From ebea5397b9e763b5e2d3d5df824eae74ca7bbc62 Mon Sep 17 00:00:00 2001 From: Johannesd3 Date: Mon, 1 Mar 2021 00:10:13 +0100 Subject: [PATCH 01/36] Add librespot-discovery crate --- Cargo.lock | 31 +++-- Cargo.toml | 6 +- connect/Cargo.toml | 18 +-- connect/src/discovery.rs | 263 --------------------------------------- connect/src/lib.rs | 1 - core/src/config.rs | 57 +++++---- discovery/Cargo.toml | 33 +++++ discovery/src/lib.rs | 134 ++++++++++++++++++++ discovery/src/server.rs | 236 +++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 7 +- 11 files changed, 473 insertions(+), 314 deletions(-) delete mode 100644 connect/src/discovery.rs create mode 100644 discovery/Cargo.toml create mode 100644 discovery/src/lib.rs create mode 100644 discovery/src/server.rs diff --git a/Cargo.lock b/Cargo.lock index 54ba474e..955044b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1143,6 +1143,7 @@ dependencies = [ "librespot-audio", "librespot-connect", "librespot-core", + "librespot-discovery", "librespot-metadata", "librespot-playback", "librespot-protocol", @@ -1172,15 +1173,8 @@ dependencies = [ name = "librespot-connect" version = "0.2.0" dependencies = [ - "aes-ctr", - "base64", - "dns-sd", "form_urlencoded", - "futures-core", "futures-util", - "hmac", - "hyper", - "libmdns", "librespot-core", "librespot-playback", "librespot-protocol", @@ -1189,10 +1183,8 @@ dependencies = [ "rand", "serde", "serde_json", - "sha-1", "tokio", "tokio-stream", - "url", ] [[package]] @@ -1235,6 +1227,27 @@ dependencies = [ "vergen", ] +[[package]] +name = "librespot-discovery" +version = "0.2.0" +dependencies = [ + "aes-ctr", + "base64", + "cfg-if 1.0.0", + "dns-sd", + "form_urlencoded", + "futures-core", + "hmac", + "hyper", + "libmdns", + "librespot-core", + "log", + "rand", + "serde_json", + "sha-1", + "tokio", +] + [[package]] name = "librespot-metadata" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 081cacae..80903698 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,10 @@ path = "core" version = "0.2.0" features = ["apresolve"] +[dependencies.librespot-discovery] +path = "discovery" +version = "0.2.0" + [dependencies.librespot-metadata] path = "metadata" version = "0.2.0" @@ -72,7 +76,7 @@ gstreamer-backend = ["librespot-playback/gstreamer-backend"] with-tremor = ["librespot-playback/with-tremor"] with-vorbis = ["librespot-playback/with-vorbis"] -with-dns-sd = ["librespot-connect/with-dns-sd"] +with-dns-sd = ["librespot-discovery/with-dns-sd"] default = ["rodio-backend"] diff --git a/connect/Cargo.toml b/connect/Cargo.toml index 8e9589fc..a46b70e4 100644 --- a/connect/Cargo.toml +++ b/connect/Cargo.toml @@ -8,25 +8,15 @@ repository = "https://github.com/librespot-org/librespot" edition = "2018" [dependencies] -aes-ctr = "0.6" -base64 = "0.13" form_urlencoded = "1.0" -futures-core = "0.3" futures-util = { version = "0.3.5", default_features = false } -hmac = "0.11" -hyper = { version = "0.14", features = ["server", "http1", "tcp"] } -libmdns = "0.6" log = "0.4" protobuf = "2.14.0" rand = "0.8" serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0.25" -sha-1 = "0.9" -tokio = { version = "1.0", features = ["macros", "rt", "sync"] } +serde_json = "1.0" +tokio = { version = "1.0", features = ["macros", "sync"] } tokio-stream = "0.1.1" -url = "2.1" - -dns-sd = { version = "0.1.3", optional = true } [dependencies.librespot-core] path = "../core" @@ -39,7 +29,3 @@ version = "0.2.0" [dependencies.librespot-protocol] path = "../protocol" version = "0.2.0" - -[features] -with-dns-sd = ["dns-sd"] - diff --git a/connect/src/discovery.rs b/connect/src/discovery.rs deleted file mode 100644 index 7d559f0a..00000000 --- a/connect/src/discovery.rs +++ /dev/null @@ -1,263 +0,0 @@ -use aes_ctr::cipher::generic_array::GenericArray; -use aes_ctr::cipher::{NewStreamCipher, SyncStreamCipher}; -use aes_ctr::Aes128Ctr; -use futures_core::Stream; -use hmac::{Hmac, Mac, NewMac}; -use hyper::service::{make_service_fn, service_fn}; -use hyper::{Body, Method, Request, Response, StatusCode}; -use serde_json::json; -use sha1::{Digest, Sha1}; -use tokio::sync::{mpsc, oneshot}; - -#[cfg(feature = "with-dns-sd")] -use dns_sd::DNSService; - -use librespot_core::authentication::Credentials; -use librespot_core::config::ConnectConfig; -use librespot_core::diffie_hellman::DhLocalKeys; - -use std::borrow::Cow; -use std::collections::BTreeMap; -use std::convert::Infallible; -use std::io; -use std::net::{Ipv4Addr, SocketAddr}; -use std::pin::Pin; -use std::sync::Arc; -use std::task::{Context, Poll}; - -type HmacSha1 = Hmac; - -#[derive(Clone)] -struct Discovery(Arc); -struct DiscoveryInner { - config: ConnectConfig, - device_id: String, - keys: DhLocalKeys, - tx: mpsc::UnboundedSender, -} - -impl Discovery { - fn new( - config: ConnectConfig, - device_id: String, - ) -> (Discovery, mpsc::UnboundedReceiver) { - let (tx, rx) = mpsc::unbounded_channel(); - - let discovery = Discovery(Arc::new(DiscoveryInner { - config, - device_id, - keys: DhLocalKeys::random(&mut rand::thread_rng()), - tx, - })); - - (discovery, rx) - } - - fn handle_get_info(&self, _: BTreeMap, Cow<'_, str>>) -> Response { - let public_key = base64::encode(&self.0.keys.public_key()); - - let result = json!({ - "status": 101, - "statusString": "ERROR-OK", - "spotifyError": 0, - "version": "2.7.1", - "deviceID": (self.0.device_id), - "remoteName": (self.0.config.name), - "activeUser": "", - "publicKey": (public_key), - "deviceType": (self.0.config.device_type.to_string().to_uppercase()), - "libraryVersion": "0.1.0", - "accountReq": "PREMIUM", - "brandDisplayName": "librespot", - "modelDisplayName": "librespot", - "resolverVersion": "0", - "groupStatus": "NONE", - "voiceSupport": "NO", - }); - - let body = result.to_string(); - Response::new(Body::from(body)) - } - - fn handle_add_user( - &self, - params: BTreeMap, Cow<'_, str>>, - ) -> Response { - let username = params.get("userName").unwrap().as_ref(); - let encrypted_blob = params.get("blob").unwrap(); - let client_key = params.get("clientKey").unwrap(); - - let encrypted_blob = base64::decode(encrypted_blob.as_bytes()).unwrap(); - - let shared_key = self - .0 - .keys - .shared_secret(&base64::decode(client_key.as_bytes()).unwrap()); - - let iv = &encrypted_blob[0..16]; - let encrypted = &encrypted_blob[16..encrypted_blob.len() - 20]; - let cksum = &encrypted_blob[encrypted_blob.len() - 20..encrypted_blob.len()]; - - let base_key = Sha1::digest(&shared_key); - let base_key = &base_key[..16]; - - let checksum_key = { - let mut h = HmacSha1::new_from_slice(base_key).expect("HMAC can take key of any size"); - h.update(b"checksum"); - h.finalize().into_bytes() - }; - - let encryption_key = { - let mut h = HmacSha1::new_from_slice(&base_key).expect("HMAC can take key of any size"); - h.update(b"encryption"); - h.finalize().into_bytes() - }; - - let mut h = HmacSha1::new_from_slice(&checksum_key).expect("HMAC can take key of any size"); - h.update(encrypted); - if h.verify(cksum).is_err() { - warn!("Login error for user {:?}: MAC mismatch", username); - let result = json!({ - "status": 102, - "spotifyError": 1, - "statusString": "ERROR-MAC" - }); - - let body = result.to_string(); - return Response::new(Body::from(body)); - } - - let decrypted = { - let mut data = encrypted.to_vec(); - let mut cipher = Aes128Ctr::new( - &GenericArray::from_slice(&encryption_key[0..16]), - &GenericArray::from_slice(iv), - ); - cipher.apply_keystream(&mut data); - String::from_utf8(data).unwrap() - }; - - let credentials = - Credentials::with_blob(username.to_string(), &decrypted, &self.0.device_id); - - self.0.tx.send(credentials).unwrap(); - - let result = json!({ - "status": 101, - "spotifyError": 0, - "statusString": "ERROR-OK" - }); - - let body = result.to_string(); - Response::new(Body::from(body)) - } - - fn not_found(&self) -> Response { - let mut res = Response::default(); - *res.status_mut() = StatusCode::NOT_FOUND; - res - } - - async fn call(self, request: Request) -> hyper::Result> { - let mut params = BTreeMap::new(); - - let (parts, body) = request.into_parts(); - - if let Some(query) = parts.uri.query() { - let query_params = url::form_urlencoded::parse(query.as_bytes()); - params.extend(query_params); - } - - if parts.method != Method::GET { - debug!("{:?} {:?} {:?}", parts.method, parts.uri.path(), params); - } - - let body = hyper::body::to_bytes(body).await?; - - params.extend(url::form_urlencoded::parse(&body)); - - Ok( - match (parts.method, params.get("action").map(AsRef::as_ref)) { - (Method::GET, Some("getInfo")) => self.handle_get_info(params), - (Method::POST, Some("addUser")) => self.handle_add_user(params), - _ => self.not_found(), - }, - ) - } -} - -#[cfg(feature = "with-dns-sd")] -pub struct DiscoveryStream { - credentials: mpsc::UnboundedReceiver, - _svc: DNSService, - _close_tx: oneshot::Sender, -} - -#[cfg(not(feature = "with-dns-sd"))] -pub struct DiscoveryStream { - credentials: mpsc::UnboundedReceiver, - _svc: libmdns::Service, - _close_tx: oneshot::Sender, -} - -pub fn discovery( - config: ConnectConfig, - device_id: String, - port: u16, -) -> io::Result { - let (discovery, creds_rx) = Discovery::new(config.clone(), device_id); - let (close_tx, close_rx) = oneshot::channel(); - - let address = SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), port); - - let make_service = make_service_fn(move |_| { - let discovery = discovery.clone(); - async move { Ok::<_, hyper::Error>(service_fn(move |request| discovery.clone().call(request))) } - }); - - let server = hyper::Server::bind(&address).serve(make_service); - - let s_port = server.local_addr().port(); - debug!("Zeroconf server listening on 0.0.0.0:{}", s_port); - - tokio::spawn(server.with_graceful_shutdown(async { - close_rx.await.unwrap_err(); - debug!("Shutting down discovery server"); - })); - - #[cfg(feature = "with-dns-sd")] - let svc = DNSService::register( - Some(&*config.name), - "_spotify-connect._tcp", - None, - None, - s_port, - &["VERSION=1.0", "CPath=/"], - ) - .unwrap(); - - #[cfg(not(feature = "with-dns-sd"))] - let responder = libmdns::Responder::spawn(&tokio::runtime::Handle::current())?; - - #[cfg(not(feature = "with-dns-sd"))] - let svc = responder.register( - "_spotify-connect._tcp".to_owned(), - config.name, - s_port, - &["VERSION=1.0", "CPath=/"], - ); - - Ok(DiscoveryStream { - credentials: creds_rx, - _svc: svc, - _close_tx: close_tx, - }) -} - -impl Stream for DiscoveryStream { - type Item = Credentials; - - fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - self.credentials.poll_recv(cx) - } -} diff --git a/connect/src/lib.rs b/connect/src/lib.rs index 600dd033..5ddfeba9 100644 --- a/connect/src/lib.rs +++ b/connect/src/lib.rs @@ -6,5 +6,4 @@ use librespot_playback as playback; use librespot_protocol as protocol; pub mod context; -pub mod discovery; pub mod spirc; diff --git a/core/src/config.rs b/core/src/config.rs index 9c70c25b..4ab81c6a 100644 --- a/core/src/config.rs +++ b/core/src/config.rs @@ -71,30 +71,43 @@ impl FromStr for DeviceType { } } +impl From<&DeviceType> for &str { + fn from(d: &DeviceType) -> &'static str { + use self::DeviceType::*; + match d { + Unknown => "Unknown", + Computer => "Computer", + Tablet => "Tablet", + Smartphone => "Smartphone", + Speaker => "Speaker", + Tv => "TV", + Avr => "AVR", + Stb => "STB", + AudioDongle => "AudioDongle", + GameConsole => "GameConsole", + CastAudio => "CastAudio", + CastVideo => "CastVideo", + Automobile => "Automobile", + Smartwatch => "Smartwatch", + Chromebook => "Chromebook", + UnknownSpotify => "UnknownSpotify", + CarThing => "CarThing", + Observer => "Observer", + HomeThing => "HomeThing", + } + } +} + +impl From for &str { + fn from(d: DeviceType) -> &'static str { + (&d).into() + } +} + impl fmt::Display for DeviceType { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - use self::DeviceType::*; - match *self { - Unknown => f.write_str("Unknown"), - Computer => f.write_str("Computer"), - Tablet => f.write_str("Tablet"), - Smartphone => f.write_str("Smartphone"), - Speaker => f.write_str("Speaker"), - Tv => f.write_str("TV"), - Avr => f.write_str("AVR"), - Stb => f.write_str("STB"), - AudioDongle => f.write_str("AudioDongle"), - GameConsole => f.write_str("GameConsole"), - CastAudio => f.write_str("CastAudio"), - CastVideo => f.write_str("CastVideo"), - Automobile => f.write_str("Automobile"), - Smartwatch => f.write_str("Smartwatch"), - Chromebook => f.write_str("Chromebook"), - UnknownSpotify => f.write_str("UnknownSpotify"), - CarThing => f.write_str("CarThing"), - Observer => f.write_str("Observer"), - HomeThing => f.write_str("HomeThing"), - } + let str: &str = self.into(); + f.write_str(str) } } diff --git a/discovery/Cargo.toml b/discovery/Cargo.toml new file mode 100644 index 00000000..1946c710 --- /dev/null +++ b/discovery/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "librespot-discovery" +version = "0.2.0" +authors = ["Paul Lietar "] +description = "The discovery logic for librespot" +license = "MIT" +repository = "https://github.com/librespot-org/librespot" +edition = "2018" + +[dependencies] +aes-ctr = "0.6" +base64 = "0.13" +cfg-if = "1.0" +form_urlencoded = "1.0" +futures-core = "0.3" +hmac = "0.11" +hyper = { version = "0.14", features = ["server", "http1", "tcp"] } +libmdns = "0.6" +log = "0.4" +rand = "0.8" +serde_json = "1.0.25" +sha-1 = "0.9" +tokio = { version = "1.0", features = ["sync", "rt"] } + +dns-sd = { version = "0.1.3", optional = true } + +[dependencies.librespot-core] +path = "../core" +default_features = false +version = "0.2.0" + +[features] +with-dns-sd = ["dns-sd"] diff --git a/discovery/src/lib.rs b/discovery/src/lib.rs new file mode 100644 index 00000000..cd78345d --- /dev/null +++ b/discovery/src/lib.rs @@ -0,0 +1,134 @@ +//! Advertises this device to Spotify clients in the local network. +//! +//! This device will show up in the list of "available devices". +//! Once it is selected from the list, [`Credentials`] are received. +//! Those can be used to establish a new Session with [`librespot_core`]. + +#![warn(clippy::all, missing_docs, rust_2018_idioms)] + +mod server; + +use std::io; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use cfg_if::cfg_if; +use futures_core::Stream; +use librespot_core as core; + +use self::server::DiscoveryServer; + +/// Credentials to be used in [`librespot`](`librespot_core`). +pub use crate::core::authentication::Credentials; + +/// Determining the icon in the list of available devices. +pub use crate::core::config::DeviceType; + +/// Makes this device visible to Spotify clients in the local network. +/// +/// `Discovery` implements the [`Stream`] trait. Every time this device +/// is selected in the list of available devices, it yields [`Credentials`]. +pub struct Discovery { + server: DiscoveryServer, + + #[cfg(not(feature = "with-dns-sd"))] + _svc: libmdns::Service, + #[cfg(feature = "with-dns-sd")] + _svc: dns_sd::DNSService, +} + +/// A builder for [`Discovery`]. +pub struct Builder { + server_config: server::Config, + port: u16, +} + +impl Builder { + /// Starts a new builder using the provided device id. + pub fn new(device_id: String) -> Self { + Self { + server_config: server::Config { + name: "Librespot".into(), + device_type: DeviceType::default(), + device_id, + }, + port: 0, + } + } + + /// Sets the name to be displayed. Default is `"Librespot"`. + pub fn name(mut self, name: String) -> Self { + self.server_config.name = name.into(); + self + } + + /// Sets the device type which is visible as icon in other Spotify clients. Default is `Speaker`. + pub fn device_type(mut self, device_type: DeviceType) -> Self { + self.server_config.device_type = device_type; + self + } + + /// Sets the port on which it should listen to incoming connections. + /// The default value `0` means any port. + pub fn port(mut self, port: u16) -> Self { + self.port = port; + self + } + + /// Sets up the [`Discovery`] instance. + /// + /// # Errors + /// If setting up the mdns service or creating the server fails, this function returns an error. + pub fn launch(self) -> io::Result { + Discovery::new(self) + } +} + +impl Discovery { + /// Starts a [`Builder`] with the provided device id. + pub fn builder(device_id: String) -> Builder { + Builder::new(device_id) + } + + fn new(builder: Builder) -> io::Result { + let name = builder.server_config.name.clone(); + let mut port = builder.port; + let server = DiscoveryServer::new(builder.server_config, &mut port) + .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + + let svc; + + cfg_if! { + if #[cfg(feature = "with-dns-sd")] { + svc = dns_sd::DNSService::register( + Some(name.as_ref()), + "_spotify-connect._tcp", + None, + None, + port, + &["VERSION=1.0", "CPath=/"], + ) + .unwrap(); + + } else { + let responder = libmdns::Responder::spawn(&tokio::runtime::Handle::current())?; + svc = responder.register( + "_spotify-connect._tcp".to_owned(), + name.into_owned(), + port, + &["VERSION=1.0", "CPath=/"], + ) + } + }; + + Ok(Self { server, _svc: svc }) + } +} + +impl Stream for Discovery { + type Item = Credentials; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.server).poll_next(cx) + } +} diff --git a/discovery/src/server.rs b/discovery/src/server.rs new file mode 100644 index 00000000..867ac3c9 --- /dev/null +++ b/discovery/src/server.rs @@ -0,0 +1,236 @@ +use std::borrow::Cow; +use std::collections::BTreeMap; +use std::convert::Infallible; +use std::net::{Ipv4Addr, SocketAddr}; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; + +use aes_ctr::cipher::generic_array::GenericArray; +use aes_ctr::cipher::{NewStreamCipher, SyncStreamCipher}; +use aes_ctr::Aes128Ctr; +use futures_core::Stream; +use hmac::{Hmac, Mac, NewMac}; +use hyper::service::{make_service_fn, service_fn}; +use hyper::{Body, Method, Request, Response, StatusCode}; +use log::{debug, warn}; +use serde_json::json; +use sha1::{Digest, Sha1}; +use tokio::sync::{mpsc, oneshot}; + +use crate::core::authentication::Credentials; +use crate::core::config::DeviceType; +use crate::core::diffie_hellman::DhLocalKeys; + +type Params<'a> = BTreeMap, Cow<'a, str>>; + +pub struct Config { + pub name: Cow<'static, str>, + pub device_type: DeviceType, + pub device_id: String, +} + +struct RequestHandler { + config: Config, + keys: DhLocalKeys, + tx: mpsc::UnboundedSender, +} + +impl RequestHandler { + fn new(config: Config) -> (Self, mpsc::UnboundedReceiver) { + let (tx, rx) = mpsc::unbounded_channel(); + + let discovery = Self { + config, + keys: DhLocalKeys::random(&mut rand::thread_rng()), + tx, + }; + + (discovery, rx) + } + + fn handle_get_info(&self) -> Response { + let public_key = base64::encode(&self.keys.public_key()); + let device_type: &str = self.config.device_type.into(); + + let body = json!({ + "status": 101, + "statusString": "ERROR-OK", + "spotifyError": 0, + "version": "2.7.1", + "deviceID": (self.config.device_id), + "remoteName": (self.config.name), + "activeUser": "", + "publicKey": (public_key), + "deviceType": (device_type), + "libraryVersion": "0.1.0", + "accountReq": "PREMIUM", + "brandDisplayName": "librespot", + "modelDisplayName": "librespot", + "resolverVersion": "0", + "groupStatus": "NONE", + "voiceSupport": "NO", + }) + .to_string(); + + Response::new(Body::from(body)) + } + + fn handle_add_user(&self, params: &Params<'_>) -> Response { + let username = params.get("userName").unwrap().as_ref(); + let encrypted_blob = params.get("blob").unwrap(); + let client_key = params.get("clientKey").unwrap(); + + let encrypted_blob = base64::decode(encrypted_blob.as_bytes()).unwrap(); + + let client_key = base64::decode(client_key.as_bytes()).unwrap(); + let shared_key = self.keys.shared_secret(&client_key); + + let iv = &encrypted_blob[0..16]; + let encrypted = &encrypted_blob[16..encrypted_blob.len() - 20]; + let cksum = &encrypted_blob[encrypted_blob.len() - 20..encrypted_blob.len()]; + + let base_key = Sha1::digest(&shared_key); + let base_key = &base_key[..16]; + + let checksum_key = { + let mut h = + Hmac::::new_from_slice(base_key).expect("HMAC can take key of any size"); + h.update(b"checksum"); + h.finalize().into_bytes() + }; + + let encryption_key = { + let mut h = + Hmac::::new_from_slice(base_key).expect("HMAC can take key of any size"); + h.update(b"encryption"); + h.finalize().into_bytes() + }; + + let mut h = + Hmac::::new_from_slice(&checksum_key).expect("HMAC can take key of any size"); + h.update(encrypted); + if h.verify(cksum).is_err() { + warn!("Login error for user {:?}: MAC mismatch", username); + let result = json!({ + "status": 102, + "spotifyError": 1, + "statusString": "ERROR-MAC" + }); + + let body = result.to_string(); + return Response::new(Body::from(body)); + } + + let decrypted = { + let mut data = encrypted.to_vec(); + let mut cipher = Aes128Ctr::new( + GenericArray::from_slice(&encryption_key[0..16]), + GenericArray::from_slice(iv), + ); + cipher.apply_keystream(&mut data); + String::from_utf8(data).unwrap() + }; + + let credentials = + Credentials::with_blob(username.to_string(), &decrypted, &self.config.device_id); + + self.tx.send(credentials).unwrap(); + + let result = json!({ + "status": 101, + "spotifyError": 0, + "statusString": "ERROR-OK" + }); + + let body = result.to_string(); + Response::new(Body::from(body)) + } + + fn not_found(&self) -> Response { + let mut res = Response::default(); + *res.status_mut() = StatusCode::NOT_FOUND; + res + } + + async fn handle(self: Arc, request: Request) -> hyper::Result> { + let mut params = Params::new(); + + let (parts, body) = request.into_parts(); + + if let Some(query) = parts.uri.query() { + let query_params = form_urlencoded::parse(query.as_bytes()); + params.extend(query_params); + } + + if parts.method != Method::GET { + debug!("{:?} {:?} {:?}", parts.method, parts.uri.path(), params); + } + + let body = hyper::body::to_bytes(body).await?; + + params.extend(form_urlencoded::parse(&body)); + + let action = params.get("action").map(Cow::as_ref); + + Ok(match (parts.method, action) { + (Method::GET, Some("getInfo")) => self.handle_get_info(), + (Method::POST, Some("addUser")) => self.handle_add_user(¶ms), + _ => self.not_found(), + }) + } +} + +pub struct DiscoveryServer { + cred_rx: mpsc::UnboundedReceiver, + _close_tx: oneshot::Sender, +} + +impl DiscoveryServer { + pub fn new(config: Config, port: &mut u16) -> hyper::Result { + let (discovery, cred_rx) = RequestHandler::new(config); + let discovery = Arc::new(discovery); + + let (close_tx, close_rx) = oneshot::channel(); + + let address = SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), *port); + + let make_service = make_service_fn(move |_| { + let discovery = discovery.clone(); + async move { + Ok::<_, hyper::Error>(service_fn(move |request| discovery.clone().handle(request))) + } + }); + + let server = hyper::Server::try_bind(&address)?.serve(make_service); + + *port = server.local_addr().port(); + debug!("Zeroconf server listening on 0.0.0.0:{}", *port); + + tokio::spawn(async { + let result = server + .with_graceful_shutdown(async { + close_rx.await.unwrap_err(); + debug!("Shutting down discovery server"); + }) + .await; + + if let Err(e) = result { + warn!("Discovery server failed: {}", e); + } + }); + + Ok(Self { + cred_rx, + _close_tx: close_tx, + }) + } +} + +impl Stream for DiscoveryServer { + type Item = Credentials; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.cred_rx.poll_recv(cx) + } +} diff --git a/src/lib.rs b/src/lib.rs index 7722e93e..75211282 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub use librespot_audio as audio; pub use librespot_connect as connect; pub use librespot_core as core; +pub use librespot_discovery as discovery; pub use librespot_metadata as metadata; pub use librespot_playback as playback; pub use librespot_protocol as protocol; diff --git a/src/main.rs b/src/main.rs index a5106af2..b401f9b1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -645,11 +645,14 @@ async fn main() { let mut connecting: Pin>> = Box::pin(future::pending()); if setup.enable_discovery { - let config = setup.connect_config.clone(); let device_id = setup.session_config.device_id.clone(); discovery = Some( - librespot_connect::discovery::discovery(config, device_id, setup.zeroconf_port) + librespot::discovery::Discovery::builder(device_id) + .name(setup.connect_config.name.clone()) + .device_type(setup.connect_config.device_type) + .port(setup.zeroconf_port) + .launch() .unwrap(), ); } From 1ec5dd21fa833d1c44805c29e807a0308d689c4a Mon Sep 17 00:00:00 2001 From: johannesd3 Date: Fri, 12 Mar 2021 16:39:58 +0100 Subject: [PATCH 02/36] Add discovery example --- Cargo.lock | 27 +++++++++++++++++++++++++++ discovery/Cargo.toml | 6 ++++++ discovery/examples/discovery.rs | 25 +++++++++++++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 discovery/examples/discovery.rs diff --git a/Cargo.lock b/Cargo.lock index 955044b6..3f62136d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -237,6 +237,17 @@ dependencies = [ "libloading 0.7.0", ] +[[package]] +name = "colored" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ffc801dacf156c5854b9df4f425a626539c3a6ef7893cc0c5084a23f0b6c59" +dependencies = [ + "atty", + "lazy_static", + "winapi", +] + [[package]] name = "combine" version = "4.5.2" @@ -1236,7 +1247,9 @@ dependencies = [ "cfg-if 1.0.0", "dns-sd", "form_urlencoded", + "futures", "futures-core", + "hex", "hmac", "hyper", "libmdns", @@ -1245,6 +1258,7 @@ dependencies = [ "rand", "serde_json", "sha-1", + "simple_logger", "tokio", ] @@ -2094,6 +2108,19 @@ dependencies = [ "libc", ] +[[package]] +name = "simple_logger" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd57f17c093ead1d4a1499dc9acaafdd71240908d64775465543b8d9a9f1d198" +dependencies = [ + "atty", + "chrono", + "colored", + "log", + "winapi", +] + [[package]] name = "slab" version = "0.4.3" diff --git a/discovery/Cargo.toml b/discovery/Cargo.toml index 1946c710..ddb382c1 100644 --- a/discovery/Cargo.toml +++ b/discovery/Cargo.toml @@ -29,5 +29,11 @@ path = "../core" default_features = false version = "0.2.0" +[dev-dependencies] +futures = "0.3" +hex = "0.4" +simple_logger = "1.11" +tokio = { version = "1.0", features = ["macros", "rt"] } + [features] with-dns-sd = ["dns-sd"] diff --git a/discovery/examples/discovery.rs b/discovery/examples/discovery.rs new file mode 100644 index 00000000..267cee4f --- /dev/null +++ b/discovery/examples/discovery.rs @@ -0,0 +1,25 @@ +use futures::StreamExt; +use librespot_discovery::DeviceType; +use sha1::{Digest, Sha1}; +use simple_logger::SimpleLogger; + +#[tokio::main(flavor = "current_thread")] +async fn main() { + SimpleLogger::new() + .with_level(log::LevelFilter::Debug) + .init() + .unwrap(); + + let name = "Librespot".to_string(); + let device_id = hex::encode(Sha1::digest(name.as_bytes())); + + let mut server = librespot_discovery::Discovery::builder(device_id) + .name(name) + .device_type(DeviceType::Computer) + .launch() + .unwrap(); + + while let Some(x) = server.next().await { + println!("Received {:?}", x); + } +} From a7f9e0a20b512dcb7bb28e4eb31bd2c559c1b2d0 Mon Sep 17 00:00:00 2001 From: johannesd3 Date: Wed, 17 Mar 2021 18:47:27 +0100 Subject: [PATCH 03/36] Add an error type to librespot_discovery --- Cargo.lock | 1 + discovery/Cargo.toml | 1 + discovery/src/lib.rs | 51 ++++++++++++++++++++++++++++---------------- 3 files changed, 35 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3f62136d..23eb4485 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1259,6 +1259,7 @@ dependencies = [ "serde_json", "sha-1", "simple_logger", + "thiserror", "tokio", ] diff --git a/discovery/Cargo.toml b/discovery/Cargo.toml index ddb382c1..9ea9df48 100644 --- a/discovery/Cargo.toml +++ b/discovery/Cargo.toml @@ -20,6 +20,7 @@ log = "0.4" rand = "0.8" serde_json = "1.0.25" sha-1 = "0.9" +thiserror = "1.0" tokio = { version = "1.0", features = ["sync", "rt"] } dns-sd = { version = "0.1.3", optional = true } diff --git a/discovery/src/lib.rs b/discovery/src/lib.rs index cd78345d..3625d504 100644 --- a/discovery/src/lib.rs +++ b/discovery/src/lib.rs @@ -3,6 +3,9 @@ //! This device will show up in the list of "available devices". //! Once it is selected from the list, [`Credentials`] are received. //! Those can be used to establish a new Session with [`librespot_core`]. +//! +//! This library uses mDNS and DNS-SD so that other devices can find it, +//! and spawns an http server to answer requests of Spotify clients. #![warn(clippy::all, missing_docs, rust_2018_idioms)] @@ -15,6 +18,7 @@ use std::task::{Context, Poll}; use cfg_if::cfg_if; use futures_core::Stream; use librespot_core as core; +use thiserror::Error; use self::server::DiscoveryServer; @@ -43,6 +47,17 @@ pub struct Builder { port: u16, } +/// Errors that can occur while setting up a [`Discovery`] instance. +#[derive(Debug, Error)] +pub enum Error { + /// Setting up service discovery via DNS-SD failed. + #[error("Setting up dns-sd failed: {0}")] + DnsSdError(#[from] io::Error), + /// Setting up the http server failed. + #[error("Setting up the http server failed: {0}")] + HttpServerError(#[from] hyper::Error), +} + impl Builder { /// Starts a new builder using the provided device id. pub fn new(device_id: String) -> Self { @@ -79,22 +94,10 @@ impl Builder { /// /// # Errors /// If setting up the mdns service or creating the server fails, this function returns an error. - pub fn launch(self) -> io::Result { - Discovery::new(self) - } -} - -impl Discovery { - /// Starts a [`Builder`] with the provided device id. - pub fn builder(device_id: String) -> Builder { - Builder::new(device_id) - } - - fn new(builder: Builder) -> io::Result { - let name = builder.server_config.name.clone(); - let mut port = builder.port; - let server = DiscoveryServer::new(builder.server_config, &mut port) - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + pub fn launch(self) -> Result { + let mut port = self.port; + let name = self.server_config.name.clone().into_owned(); + let server = DiscoveryServer::new(self.server_config, &mut port)?; let svc; @@ -114,14 +117,26 @@ impl Discovery { let responder = libmdns::Responder::spawn(&tokio::runtime::Handle::current())?; svc = responder.register( "_spotify-connect._tcp".to_owned(), - name.into_owned(), + name, port, &["VERSION=1.0", "CPath=/"], ) } }; - Ok(Self { server, _svc: svc }) + Ok(Discovery { server, _svc: svc }) + } +} + +impl Discovery { + /// Starts a [`Builder`] with the provided device id. + pub fn builder(device_id: String) -> Builder { + Builder::new(device_id) + } + + /// Create a new instance with the specified device id and default paramaters. + pub fn new(device_id: String) -> Result { + Self::builder(device_id).launch() } } From 16de6a7f6876cf3fbc2f637f98272485eb0de9d5 Mon Sep 17 00:00:00 2001 From: johannesd3 Date: Fri, 2 Apr 2021 16:15:37 +0200 Subject: [PATCH 04/36] Improve api of discovery crate's builder --- discovery/examples/discovery.rs | 2 +- discovery/src/lib.rs | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/discovery/examples/discovery.rs b/discovery/examples/discovery.rs index 267cee4f..cd913fd2 100644 --- a/discovery/examples/discovery.rs +++ b/discovery/examples/discovery.rs @@ -10,7 +10,7 @@ async fn main() { .init() .unwrap(); - let name = "Librespot".to_string(); + let name = "Librespot"; let device_id = hex::encode(Sha1::digest(name.as_bytes())); let mut server = librespot_discovery::Discovery::builder(device_id) diff --git a/discovery/src/lib.rs b/discovery/src/lib.rs index 3625d504..b1249a0d 100644 --- a/discovery/src/lib.rs +++ b/discovery/src/lib.rs @@ -11,6 +11,7 @@ mod server; +use std::borrow::Cow; use std::io; use std::pin::Pin; use std::task::{Context, Poll}; @@ -60,19 +61,19 @@ pub enum Error { impl Builder { /// Starts a new builder using the provided device id. - pub fn new(device_id: String) -> Self { + pub fn new(device_id: impl Into) -> Self { Self { server_config: server::Config { name: "Librespot".into(), device_type: DeviceType::default(), - device_id, + device_id: device_id.into(), }, port: 0, } } /// Sets the name to be displayed. Default is `"Librespot"`. - pub fn name(mut self, name: String) -> Self { + pub fn name(mut self, name: impl Into>) -> Self { self.server_config.name = name.into(); self } @@ -130,12 +131,12 @@ impl Builder { impl Discovery { /// Starts a [`Builder`] with the provided device id. - pub fn builder(device_id: String) -> Builder { + pub fn builder(device_id: impl Into) -> Builder { Builder::new(device_id) } /// Create a new instance with the specified device id and default paramaters. - pub fn new(device_id: String) -> Result { + pub fn new(device_id: impl Into) -> Result { Self::builder(device_id).launch() } } From c49e1320d41d52375ebd65db813e3cff6a1bcce4 Mon Sep 17 00:00:00 2001 From: johannesd3 Date: Tue, 13 Apr 2021 11:34:41 +0200 Subject: [PATCH 05/36] Update workflow --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 825fc936..5f7a74c5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -99,8 +99,8 @@ jobs: - run: cargo hack --workspace --remove-dev-deps - run: cargo build -p librespot-core --no-default-features - run: cargo build -p librespot-core - - run: cargo build -p librespot-connect - - run: cargo build -p librespot-connect --no-default-features --features with-dns-sd + - run: cargo hack build --each-feature -p librespot-discovery + - run: cargo hack build --each-feature -p librespot-playback - run: cargo hack build --each-feature test-windows: From 9b6ba4902665ea9a908a7f14121f3c7ef3cb7216 Mon Sep 17 00:00:00 2001 From: johannesd3 Date: Sun, 9 May 2021 10:50:15 +0200 Subject: [PATCH 06/36] Add "discovery" compat layer to "connect" --- Cargo.lock | 1 + connect/Cargo.toml | 7 +++++++ connect/src/discovery.rs | 31 +++++++++++++++++++++++++++++++ connect/src/lib.rs | 5 +++++ 4 files changed, 44 insertions(+) create mode 100644 connect/src/discovery.rs diff --git a/Cargo.lock b/Cargo.lock index 23eb4485..78f9237e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1187,6 +1187,7 @@ dependencies = [ "form_urlencoded", "futures-util", "librespot-core", + "librespot-discovery", "librespot-playback", "librespot-protocol", "log", diff --git a/connect/Cargo.toml b/connect/Cargo.toml index a46b70e4..89d185ab 100644 --- a/connect/Cargo.toml +++ b/connect/Cargo.toml @@ -29,3 +29,10 @@ version = "0.2.0" [dependencies.librespot-protocol] path = "../protocol" version = "0.2.0" + +[dependencies.librespot-discovery] +path = "../discovery" +version = "0.2.0" + +[features] +with-dns-sd = ["librespot-discovery/with-dns-sd"] diff --git a/connect/src/discovery.rs b/connect/src/discovery.rs new file mode 100644 index 00000000..8ce3f4f0 --- /dev/null +++ b/connect/src/discovery.rs @@ -0,0 +1,31 @@ +use std::io; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use futures_util::Stream; +use librespot_core::authentication::Credentials; +use librespot_core::config::ConnectConfig; + +pub struct DiscoveryStream(librespot_discovery::Discovery); + +impl Stream for DiscoveryStream { + type Item = Credentials; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.0).poll_next(cx) + } +} + +pub fn discovery( + config: ConnectConfig, + device_id: String, + port: u16, +) -> io::Result { + librespot_discovery::Discovery::builder(device_id) + .device_type(config.device_type) + .port(port) + .name(config.name) + .launch() + .map(DiscoveryStream) + .map_err(|e| io::Error::new(io::ErrorKind::Other, e)) +} diff --git a/connect/src/lib.rs b/connect/src/lib.rs index 5ddfeba9..267bf1b8 100644 --- a/connect/src/lib.rs +++ b/connect/src/lib.rs @@ -6,4 +6,9 @@ use librespot_playback as playback; use librespot_protocol as protocol; pub mod context; +#[deprecated( + since = "0.2.1", + note = "Please use the crate `librespot_discovery` instead." +)] +pub mod discovery; pub mod spirc; From ceab3634299b5e0e64482c030a808ae1afc63108 Mon Sep 17 00:00:00 2001 From: johannesd3 Date: Sun, 9 May 2021 11:02:32 +0200 Subject: [PATCH 07/36] Update changelog --- CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a775d4c..461f3666 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +* [connect], [discovery] The crate `librespot-discovery` for discovery in LAN was created. Its functionality was previously part of `librespot-connect`. + ### Removed * [librespot-audio] Removed `VorbisDecoder`, `VorbisError`, `AudioPacket`, `PassthroughDecoder`, `PassthroughError`, `AudioError`, `AudioDecoder` and the `convert` module from `librespot_audio`. The underlying crates `vorbis`, `librespot-tremor`, `lewton` and `ogg` should be used directly. ### Fixed -* [librespot-playback] Incorrect `PlayerConfig::default().normalisation_threshold` caused distortion when using dynamic volume normalisation downstream +* [librespot-playback] Incorrect `PlayerConfig::default().normalisation_threshold` caused distortion when using dynamic volume normalisation downstream in librespot-connect was deprecated in favor of the `librespot-discovery` crate. + +### Deprecated +* [connect] The `discovery` module. ## [0.2.0] - 2021-05-04 From 4448ce0c098252e509140c6cef0a4c7cbcac9669 Mon Sep 17 00:00:00 2001 From: Johannesd3 <51954457+Johannesd3@users.noreply.github.com> Date: Mon, 24 May 2021 11:54:01 +0200 Subject: [PATCH 08/36] Set correct libraryVersion --- discovery/src/server.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discovery/src/server.rs b/discovery/src/server.rs index 867ac3c9..53b849f7 100644 --- a/discovery/src/server.rs +++ b/discovery/src/server.rs @@ -63,7 +63,7 @@ impl RequestHandler { "activeUser": "", "publicKey": (public_key), "deviceType": (device_type), - "libraryVersion": "0.1.0", + "libraryVersion": crate::core::version::SEMVER, "accountReq": "PREMIUM", "brandDisplayName": "librespot", "modelDisplayName": "librespot", From eca505c3873e3e0a87b7dd4d56ab25d5d5d67cf2 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Mon, 24 May 2021 15:53:32 +0200 Subject: [PATCH 09/36] Improve volume controls This is a squashed commit featuring the following: Connect: - Synchronize player volume with mixer volume on playback - Fix step size on volume up/down events - Remove no-op mixer started/stopped logic Playback: - Move from `connect` to `playback` crate - Make cubic volume control available to all mixers with `--volume-ctrl cubic` - Normalize volumes to `[0.0..1.0]` instead of `[0..65535]` for greater precision and performance (breaking) - Add `--volume-range` option to set dB range and control `log` and `cubic` volume control curves - Fix `log` and `cubic` volume controls to be mute at zero volume Alsa mixer: - Complete rewrite (breaking) - Query card dB range for the `log` volume control unless specified otherwise - Query dB range from Alsa softvol (previously only from hardware) - Use `--device` name for `--mixer-card` unless specified otherwise - Fix consistency for `cubic` between cards that report minimum volume as mute, and cards that report some dB value - Fix `--volume-ctrl {linear|log}` to work as expected - Removed `--mixer-linear-volume` option; use `--volume-ctrl linear` instead --- CHANGELOG.md | 23 +- connect/src/spirc.rs | 97 ++----- core/src/config.rs | 30 +-- playback/src/config.rs | 52 +++- playback/src/mixer/alsamixer.rs | 438 ++++++++++++++++++-------------- playback/src/mixer/mappings.rs | 163 ++++++++++++ playback/src/mixer/mod.rs | 31 ++- playback/src/mixer/softmixer.rs | 39 ++- playback/src/player.rs | 24 +- src/main.rs | 230 ++++++++++------- 10 files changed, 689 insertions(+), 438 deletions(-) create mode 100644 playback/src/mixer/mappings.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a775d4c..d549c71f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) since v0.2.0. ## [Unreleased] +### Added +- [playback] Add `--volume-range` option to set dB range and control `log` and `cubic` volume control curves +- [playback] `alsamixer`: support for querying dB range from Alsa softvol + +### Changed +* [audio, playback] Moved `VorbisDecoder`, `VorbisError`, `AudioPacket`, `PassthroughDecoder`, `PassthroughError`, `AudioError`, `AudioDecoder` and the `convert` module from `librespot-audio` to `librespot-playback`. The underlying crates `vorbis`, `librespot-tremor`, `lewton` and `ogg` should be used directly. (breaking) +- [connect, playback] Moved volume controls from `connect` to `playback` crate +* [connect] Synchronize player volume with mixer volume on playback +- [playback] Make cubic volume control available to all mixers with `--volume-ctrl cubic` +- [playback] Normalize volumes to `[0.0..1.0]` instead of `[0..65535]` for greater precision and performance (breaking) +- [playback] `alsamixer`: complete rewrite (breaking) +- [playback] `alsamixer`: query card dB range for the `log` volume control unless specified otherwise +- [playback] `alsamixer`: use `--device` name for `--mixer-card` unless specified otherwise + +### Fixed +- [connect] Fix step size on volume up/down events +- [playback] Fix `log` and `cubic` volume controls to be mute at zero volume +- [playback] `alsamixer`: make `cubic` consistent between cards that report minimum volume as mute, and cards that report some dB value +- [playback] `alsamixer`: make `--volume-ctrl {linear|log}` work as expected ### Removed - -* [librespot-audio] Removed `VorbisDecoder`, `VorbisError`, `AudioPacket`, `PassthroughDecoder`, `PassthroughError`, `AudioError`, `AudioDecoder` and the `convert` module from `librespot_audio`. The underlying crates `vorbis`, `librespot-tremor`, `lewton` and `ogg` should be used directly. +- [connect] Removed no-op mixer started/stopped logic +- [playback] `alsamixer`: removed `--mixer-linear-volume` option; use `--volume-ctrl linear` instead ### Fixed diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index eeb840d2..76cf7054 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -3,7 +3,7 @@ use std::pin::Pin; use std::time::{SystemTime, UNIX_EPOCH}; use crate::context::StationContext; -use crate::core::config::{ConnectConfig, VolumeCtrl}; +use crate::core::config::ConnectConfig; use crate::core::mercury::{MercuryError, MercurySender}; use crate::core::session::Session; use crate::core::spotify_id::{SpotifyAudioType, SpotifyId, SpotifyIdError}; @@ -54,7 +54,6 @@ struct SpircTask { device: DeviceState, state: State, play_request_id: Option, - mixer_started: bool, play_status: SpircPlayStatus, subscription: BoxedStream, @@ -82,13 +81,15 @@ pub enum SpircCommand { } struct SpircTaskConfig { - volume_ctrl: VolumeCtrl, autoplay: bool, } const CONTEXT_TRACKS_HISTORY: usize = 10; const CONTEXT_FETCH_THRESHOLD: u32 = 5; +const VOLUME_STEPS: i64 = 64; +const VOLUME_STEP_SIZE: u16 = 1024; // (std::u16::MAX + 1) / VOLUME_STEPS + pub struct Spirc { commands: mpsc::UnboundedSender, } @@ -163,10 +164,10 @@ fn initial_device_state(config: ConnectConfig) -> DeviceState { msg.set_typ(protocol::spirc::CapabilityType::kVolumeSteps); { let repeated = msg.mut_intValue(); - if let VolumeCtrl::Fixed = config.volume_ctrl { - repeated.push(0) + if config.has_volume_ctrl { + repeated.push(VOLUME_STEPS) } else { - repeated.push(64) + repeated.push(0) } }; msg @@ -214,36 +215,6 @@ fn initial_device_state(config: ConnectConfig) -> DeviceState { } } -fn calc_logarithmic_volume(volume: u16) -> u16 { - // Volume conversion taken from https://www.dr-lex.be/info-stuff/volumecontrols.html#ideal2 - // Convert the given volume [0..0xffff] to a dB gain - // We assume a dB range of 60dB. - // Use the equation: a * exp(b * x) - // in which a = IDEAL_FACTOR, b = 1/1000 - const IDEAL_FACTOR: f64 = 6.908; - let normalized_volume = volume as f64 / std::u16::MAX as f64; // To get a value between 0 and 1 - - let mut val = std::u16::MAX; - // Prevent val > std::u16::MAX due to rounding errors - if normalized_volume < 0.999 { - let new_volume = (normalized_volume * IDEAL_FACTOR).exp() / 1000.0; - val = (new_volume * std::u16::MAX as f64) as u16; - } - - debug!("input volume:{} to mixer: {}", volume, val); - - // return the scale factor (0..0xffff) (equivalent to a voltage multiplier). - val -} - -fn volume_to_mixer(volume: u16, volume_ctrl: &VolumeCtrl) -> u16 { - match volume_ctrl { - VolumeCtrl::Linear => volume, - VolumeCtrl::Log => calc_logarithmic_volume(volume), - VolumeCtrl::Fixed => volume, - } -} - fn url_encode(bytes: impl AsRef<[u8]>) -> String { form_urlencoded::byte_serialize(bytes.as_ref()).collect() } @@ -280,9 +251,8 @@ impl Spirc { let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); - let volume = config.volume; + let initial_volume = config.initial_volume; let task_config = SpircTaskConfig { - volume_ctrl: config.volume_ctrl.to_owned(), autoplay: config.autoplay, }; @@ -302,7 +272,6 @@ impl Spirc { device, state: initial_state(), play_request_id: None, - mixer_started: false, play_status: SpircPlayStatus::Stopped, subscription, @@ -318,7 +287,12 @@ impl Spirc { context: None, }; - task.set_volume(volume); + if let Some(volume) = initial_volume { + task.set_volume(volume); + } else { + let current_volume = task.mixer.volume(); + task.set_volume(current_volume); + } let spirc = Spirc { commands: cmd_tx }; @@ -437,20 +411,6 @@ impl SpircTask { dur.as_millis() as i64 + 1000 * self.session.time_delta() } - fn ensure_mixer_started(&mut self) { - if !self.mixer_started { - self.mixer.start(); - self.mixer_started = true; - } - } - - fn ensure_mixer_stopped(&mut self) { - if self.mixer_started { - self.mixer.stop(); - self.mixer_started = false; - } - } - fn update_state_position(&mut self, position_ms: u32) { let now = self.now_ms(); self.state.set_position_measured_at(now as u64); @@ -600,7 +560,6 @@ impl SpircTask { _ => { warn!("The player has stopped unexpectedly."); self.state.set_status(PlayStatus::kPlayStatusStop); - self.ensure_mixer_stopped(); self.notify(None, true); self.play_status = SpircPlayStatus::Stopped; } @@ -659,7 +618,6 @@ impl SpircTask { info!("No more tracks left in queue"); self.state.set_status(PlayStatus::kPlayStatusStop); self.player.stop(); - self.mixer.stop(); self.play_status = SpircPlayStatus::Stopped; } @@ -767,7 +725,6 @@ impl SpircTask { self.device.set_is_active(false); self.state.set_status(PlayStatus::kPlayStatusStop); self.player.stop(); - self.ensure_mixer_stopped(); self.play_status = SpircPlayStatus::Stopped; } } @@ -782,7 +739,11 @@ impl SpircTask { position_ms, preloading_of_next_track_triggered, } => { - self.ensure_mixer_started(); + // Synchronize the volume from the mixer. This is useful on + // systems that can switch sources from and back to librespot. + let current_volume = self.mixer.volume(); + self.set_volume(current_volume); + self.player.play(); self.state.set_status(PlayStatus::kPlayStatusPlay); self.update_state_position(position_ms); @@ -792,7 +753,6 @@ impl SpircTask { }; } SpircPlayStatus::LoadingPause { position_ms } => { - self.ensure_mixer_started(); self.player.play(); self.play_status = SpircPlayStatus::LoadingPlay { position_ms }; } @@ -962,7 +922,6 @@ impl SpircTask { self.state.set_playing_track_index(0); self.state.set_status(PlayStatus::kPlayStatusStop); self.player.stop(); - self.ensure_mixer_stopped(); self.play_status = SpircPlayStatus::Stopped; } } @@ -1007,19 +966,13 @@ impl SpircTask { } fn handle_volume_up(&mut self) { - let mut volume: u32 = self.device.get_volume() as u32 + 4096; - if volume > 0xFFFF { - volume = 0xFFFF; - } - self.set_volume(volume as u16); + let volume = (self.device.get_volume() as u16).saturating_add(VOLUME_STEP_SIZE); + self.set_volume(volume); } fn handle_volume_down(&mut self) { - let mut volume: i32 = self.device.get_volume() as i32 - 4096; - if volume < 0 { - volume = 0; - } - self.set_volume(volume as u16); + let volume = (self.device.get_volume() as u16).saturating_sub(VOLUME_STEP_SIZE); + self.set_volume(volume); } fn handle_end_of_track(&mut self) { @@ -1243,7 +1196,6 @@ impl SpircTask { None => { self.state.set_status(PlayStatus::kPlayStatusStop); self.player.stop(); - self.ensure_mixer_stopped(); self.play_status = SpircPlayStatus::Stopped; } } @@ -1273,8 +1225,7 @@ impl SpircTask { fn set_volume(&mut self, volume: u16) { self.device.set_volume(volume as u32); - self.mixer - .set_volume(volume_to_mixer(volume, &self.config.volume_ctrl)); + self.mixer.set_volume(volume); if let Some(cache) = self.session.cache() { cache.save_volume(volume) } diff --git a/core/src/config.rs b/core/src/config.rs index 9c70c25b..db990419 100644 --- a/core/src/config.rs +++ b/core/src/config.rs @@ -108,33 +108,7 @@ impl Default for DeviceType { pub struct ConnectConfig { pub name: String, pub device_type: DeviceType, - pub volume: u16, - pub volume_ctrl: VolumeCtrl, + pub initial_volume: Option, + pub has_volume_ctrl: bool, pub autoplay: bool, } - -#[derive(Clone, Debug)] -pub enum VolumeCtrl { - Linear, - Log, - Fixed, -} - -impl FromStr for VolumeCtrl { - type Err = (); - fn from_str(s: &str) -> Result { - use self::VolumeCtrl::*; - match s.to_lowercase().as_ref() { - "linear" => Ok(Linear), - "log" => Ok(Log), - "fixed" => Ok(Fixed), - _ => Err(()), - } - } -} - -impl Default for VolumeCtrl { - fn default() -> VolumeCtrl { - VolumeCtrl::Log - } -} diff --git a/playback/src/config.rs b/playback/src/config.rs index feb1d61e..9f8d97e1 100644 --- a/playback/src/config.rs +++ b/playback/src/config.rs @@ -1,4 +1,4 @@ -use super::player::NormalisationData; +use super::player::db_to_ratio; use crate::convert::i24; use std::convert::TryFrom; @@ -80,7 +80,7 @@ pub enum NormalisationType { impl FromStr for NormalisationType { type Err = (); fn from_str(s: &str) -> Result { - match s { + match s.to_lowercase().as_ref() { "album" => Ok(Self::Album), "track" => Ok(Self::Track), _ => Err(()), @@ -103,7 +103,7 @@ pub enum NormalisationMethod { impl FromStr for NormalisationMethod { type Err = (); fn from_str(s: &str) -> Result { - match s { + match s.to_lowercase().as_ref() { "basic" => Ok(Self::Basic), "dynamic" => Ok(Self::Dynamic), _ => Err(()), @@ -120,6 +120,7 @@ impl Default for NormalisationMethod { #[derive(Clone, Debug)] pub struct PlayerConfig { pub bitrate: Bitrate, + pub gapless: bool, pub normalisation: bool, pub normalisation_type: NormalisationType, pub normalisation_method: NormalisationMethod, @@ -128,7 +129,6 @@ pub struct PlayerConfig { pub normalisation_attack: f32, pub normalisation_release: f32, pub normalisation_knee: f32, - pub gapless: bool, pub passthrough: bool, } @@ -136,16 +136,56 @@ impl Default for PlayerConfig { fn default() -> PlayerConfig { PlayerConfig { bitrate: Bitrate::default(), + gapless: true, normalisation: false, normalisation_type: NormalisationType::default(), normalisation_method: NormalisationMethod::default(), normalisation_pregain: 0.0, - normalisation_threshold: NormalisationData::db_to_ratio(-1.0), + normalisation_threshold: db_to_ratio(-1.0), normalisation_attack: 0.005, normalisation_release: 0.1, normalisation_knee: 1.0, - gapless: true, passthrough: false, } } } + +// fields are intended for volume control range in dB +#[derive(Clone, Copy, Debug)] +pub enum VolumeCtrl { + Cubic(f32), + Fixed, + Linear, + Log(f32), +} + +impl FromStr for VolumeCtrl { + type Err = (); + fn from_str(s: &str) -> Result { + Self::from_str_with_range(s, Self::DEFAULT_DB_RANGE) + } +} + +impl Default for VolumeCtrl { + fn default() -> VolumeCtrl { + VolumeCtrl::Log(Self::DEFAULT_DB_RANGE) + } +} + +impl VolumeCtrl { + pub const MAX_VOLUME: u16 = std::u16::MAX; + + // Taken from: https://www.dr-lex.be/info-stuff/volumecontrols.html + pub const DEFAULT_DB_RANGE: f32 = 60.0; + + pub fn from_str_with_range(s: &str, db_range: f32) -> Result::Err> { + use self::VolumeCtrl::*; + match s.to_lowercase().as_ref() { + "cubic" => Ok(Cubic(db_range)), + "fixed" => Ok(Fixed), + "linear" => Ok(Linear), + "log" => Ok(Log(db_range)), + _ => Err(()), + } + } +} diff --git a/playback/src/mixer/alsamixer.rs b/playback/src/mixer/alsamixer.rs index 5e0a963f..62009184 100644 --- a/playback/src/mixer/alsamixer.rs +++ b/playback/src/mixer/alsamixer.rs @@ -1,218 +1,264 @@ -use super::AudioFilter; -use super::{Mixer, MixerConfig}; -use std::error::Error; +use crate::player::{db_to_ratio, ratio_to_db}; -const SND_CTL_TLV_DB_GAIN_MUTE: i64 = -9999999; +use super::mappings::{LogMapping, MappedCtrl, VolumeMapping}; +use super::{Mixer, MixerConfig, VolumeCtrl}; -#[derive(Clone)] -struct AlsaMixerVolumeParams { - min: i64, - max: i64, - range: f64, - min_db: alsa::mixer::MilliBel, - max_db: alsa::mixer::MilliBel, - has_switch: bool, -} +use alsa::ctl::{ElemId, ElemIface}; +use alsa::mixer::{MilliBel, SelemChannelId, SelemId}; +use alsa::{Ctl, Round}; + +use std::ffi::CString; #[derive(Clone)] pub struct AlsaMixer { config: MixerConfig, - params: AlsaMixerVolumeParams, + min: i64, + max: i64, + range: i64, + min_db: f32, + max_db: f32, + db_range: f32, + has_switch: bool, + is_softvol: bool, + use_linear_in_db: bool, } -impl AlsaMixer { - fn pvol(&self, vol: T, min: T, max: T) -> f64 - where - T: std::ops::Sub + Copy, - f64: std::convert::From<::Output>, - { - f64::from(vol - min) / f64::from(max - min) - } - - fn init_mixer(mut config: MixerConfig) -> Result> { - let mixer = alsa::mixer::Mixer::new(&config.card, false)?; - let sid = alsa::mixer::SelemId::new(&config.mixer, config.index); - - let selem = mixer.find_selem(&sid).unwrap_or_else(|| { - panic!( - "Couldn't find simple mixer control for {},{}", - &config.mixer, &config.index, - ) - }); - let (min, max) = selem.get_playback_volume_range(); - let (min_db, max_db) = selem.get_playback_db_range(); - let hw_mix = selem - .get_playback_vol_db(alsa::mixer::SelemChannelId::mono()) - .is_ok(); - let has_switch = selem.has_playback_switch(); - if min_db != alsa::mixer::MilliBel(SND_CTL_TLV_DB_GAIN_MUTE) { - warn!("Alsa min-db is not SND_CTL_TLV_DB_GAIN_MUTE!!"); - } - info!( - "Alsa Mixer info min: {} ({:?}[dB]) -- max: {} ({:?}[dB]) HW: {:?}", - min, min_db, max, max_db, hw_mix - ); - - if config.mapped_volume && (max_db - min_db <= alsa::mixer::MilliBel(24)) { - warn!( - "Switching to linear volume mapping, control range: {:?}", - max_db - min_db - ); - config.mapped_volume = false; - } else if !config.mapped_volume { - info!("Using Alsa linear volume"); - } - - if min_db != alsa::mixer::MilliBel(SND_CTL_TLV_DB_GAIN_MUTE) { - debug!("Alsa min-db is not SND_CTL_TLV_DB_GAIN_MUTE!!"); - } - - Ok(AlsaMixer { - config, - params: AlsaMixerVolumeParams { - min, - max, - range: (max - min) as f64, - min_db, - max_db, - has_switch, - }, - }) - } - - fn map_volume(&self, set_volume: Option) -> Result> { - let mixer = alsa::mixer::Mixer::new(&self.config.card, false)?; - let sid = alsa::mixer::SelemId::new(&*self.config.mixer, self.config.index); - - let selem = mixer.find_selem(&sid).unwrap(); - let cur_vol = selem - .get_playback_volume(alsa::mixer::SelemChannelId::mono()) - .expect("Couldn't get current volume"); - let cur_vol_db = selem - .get_playback_vol_db(alsa::mixer::SelemChannelId::mono()) - .unwrap_or(alsa::mixer::MilliBel(-SND_CTL_TLV_DB_GAIN_MUTE)); - - let mut new_vol: u16 = 0; - trace!("Current alsa volume: {}{:?}", cur_vol, cur_vol_db); - - match set_volume { - Some(vol) => { - if self.params.has_switch { - let is_muted = selem - .get_playback_switch(alsa::mixer::SelemChannelId::mono()) - .map(|b| b == 0) - .unwrap_or(false); - if vol == 0 { - debug!("Toggling mute::True"); - selem.set_playback_switch_all(0).expect("Can't switch mute"); - - return Ok(vol); - } else if is_muted { - debug!("Toggling mute::False"); - selem.set_playback_switch_all(1).expect("Can't reset mute"); - } - } - - if self.config.mapped_volume { - // Cubic mapping ala alsamixer - // https://linux.die.net/man/1/alsamixer - // In alsamixer, the volume is mapped to a value that is more natural for a - // human ear. The mapping is designed so that the position in the interval is - // proportional to the volume as a human ear would perceive it, i.e. the - // position is the cubic root of the linear sample multiplication factor. For - // controls with a small range (24 dB or less), the mapping is linear in the dB - // values so that each step has the same size visually. TODO - // TODO: Check if min is not mute! - let vol_db = (self.pvol(vol, 0x0000, 0xFFFF).log10() * 6000.0).floor() as i64 - + self.params.max_db.0; - selem - .set_playback_db_all(alsa::mixer::MilliBel(vol_db), alsa::Round::Floor) - .expect("Couldn't set alsa dB volume"); - debug!( - "Mapping volume [{:.3}%] {:?} [u16] ->> Alsa [{:.3}%] {:?} [dB] - {} [i64]", - self.pvol(vol, 0x0000, 0xFFFF) * 100.0, - vol, - self.pvol( - vol_db as f64, - self.params.min as f64, - self.params.max as f64 - ) * 100.0, - vol_db as f64 / 100.0, - vol_db - ); - } else { - // Linear mapping - let alsa_volume = - ((vol as f64 / 0xFFFF as f64) * self.params.range) as i64 + self.params.min; - selem - .set_playback_volume_all(alsa_volume) - .expect("Couldn't set alsa raw volume"); - debug!( - "Mapping volume [{:.3}%] {:?} [u16] ->> Alsa [{:.3}%] {:?} [i64]", - self.pvol(vol, 0x0000, 0xFFFF) * 100.0, - vol, - self.pvol( - alsa_volume as f64, - self.params.min as f64, - self.params.max as f64 - ) * 100.0, - alsa_volume - ); - }; - } - None => { - new_vol = (((cur_vol - self.params.min) as f64 / self.params.range) * 0xFFFF as f64) - as u16; - debug!( - "Mapping volume [{:.3}%] {:?} [u16] <<- Alsa [{:.3}%] {:?} [i64]", - self.pvol(new_vol, 0x0000, 0xFFFF), - new_vol, - self.pvol( - cur_vol as f64, - self.params.min as f64, - self.params.max as f64 - ), - cur_vol - ); - } - } - - Ok(new_vol) - } -} +// min_db cannot be depended on to be mute. Also note that contrary to +// its name copied verbatim from Alsa, this is in millibel scale. +const SND_CTL_TLV_DB_GAIN_MUTE: MilliBel = MilliBel(-9999999); +const ZERO_DB: MilliBel = MilliBel(0); impl Mixer for AlsaMixer { - fn open(config: Option) -> AlsaMixer { - let config = config.unwrap_or_default(); + fn open(config: MixerConfig) -> Self { info!( - "Setting up new mixer: card:{} mixer:{} index:{}", - config.card, config.mixer, config.index + "Mixing with alsa and volume control: {:?} for card: {} with mixer control: {},{}", + config.volume_ctrl, config.card, config.control, config.index, ); - AlsaMixer::init_mixer(config).expect("Error setting up mixer!") + + let mut config = config; // clone + + let mixer = + alsa::mixer::Mixer::new(&config.card, false).expect("Could not open Alsa mixer"); + let simple_element = mixer + .find_selem(&SelemId::new(&config.control, config.index)) + .expect("Could not find Alsa mixer control"); + + // Query capabilities + let has_switch = simple_element.has_playback_switch(); + let is_softvol = simple_element + .get_playback_vol_db(SelemChannelId::mono()) + .is_err(); + + // Query raw volume range + let (min, max) = simple_element.get_playback_volume_range(); + let range = i64::abs(max - min); + + // Query dB volume range -- note that Alsa exposes a different + // API for hardware and software mixers + let (min_millibel, max_millibel) = if is_softvol { + let control = + Ctl::new(&config.card, false).expect("Could not open Alsa softvol with that card"); + let mut element_id = ElemId::new(ElemIface::Mixer); + element_id.set_name( + &CString::new(config.control.as_str()) + .expect("Could not open Alsa softvol with that name"), + ); + element_id.set_index(config.index); + let (min_millibel, mut max_millibel) = control + .get_db_range(&element_id) + .expect("Could not get Alsa softvol dB range"); + + // Alsa can report incorrect maximum volumes due to rounding + // errors. e.g. Alsa rounds [-60.0..0.0] in range [0..255] to + // step size 0.23. Then multiplying 0.23 by 255 incorrectly + // returns a dB range of 58.65 instead of 60 dB, from + // [-60.00..-1.35]. This workaround checks the default case + // where the maximum dB volume is expected to be 0, and cannot + // cover all cases. + if max_millibel != ZERO_DB { + warn!("Alsa mixer reported maximum dB != 0, which is suspect"); + let reported_step_size = (max_millibel - min_millibel).0 / range; + let assumed_step_size = (ZERO_DB - min_millibel).0 / range; + if reported_step_size == assumed_step_size { + warn!("Alsa rounding error detected, setting maximum dB to {:.2} instead of {:.2}", ZERO_DB.to_db(), max_millibel.to_db()); + max_millibel = ZERO_DB; + } else { + warn!("Please manually set with `--volume-ctrl` if this is incorrect"); + } + } + (min_millibel, max_millibel) + } else { + let (mut min_millibel, max_millibel) = simple_element.get_playback_db_range(); + + // Some controls report that their minimum volume is mute, instead + // of their actual lowest dB setting before that. + if min_millibel == SND_CTL_TLV_DB_GAIN_MUTE && min < max { + debug!("Alsa mixer reported minimum dB as mute, trying workaround"); + min_millibel = simple_element + .ask_playback_vol_db(min + 1) + .expect("Could not convert Alsa raw volume to dB volume"); + } + (min_millibel, max_millibel) + }; + + let min_db = min_millibel.to_db(); + let max_db = max_millibel.to_db(); + let db_range = f32::abs(max_db - min_db); + + // Synchronize the volume control dB range with the mixer control, + // unless it was already set with a command line option. + if !config.volume_ctrl.range_ok() { + config.volume_ctrl.set_db_range(db_range); + } + + // For hardware controls with a small range (24 dB or less), + // force using the dB API with a linear mapping. + let mut use_linear_in_db = false; + if !is_softvol && db_range <= 24.0 { + use_linear_in_db = true; + config.volume_ctrl = VolumeCtrl::Linear; + } + + debug!("Alsa mixer control is softvol: {}", is_softvol); + debug!("Alsa support for playback (mute) switch: {}", has_switch); + debug!("Alsa raw volume range: [{}..{}] ({})", min, max, range); + debug!( + "Alsa dB volume range: [{:.2}..{:.2}] ({:.2})", + min_db, max_db, db_range + ); + debug!("Alsa forcing linear dB mapping: {}", use_linear_in_db); + + Self { + config, + min, + max, + range, + min_db, + max_db, + db_range, + has_switch, + is_softvol, + use_linear_in_db, + } } - fn start(&self) {} - - fn stop(&self) {} - fn volume(&self) -> u16 { - match self.map_volume(None) { - Ok(vol) => vol, - Err(e) => { - error!("Error getting volume for <{}>, {:?}", self.config.card, e); - 0 - } + let mixer = + alsa::mixer::Mixer::new(&self.config.card, false).expect("Could not open Alsa mixer"); + let simple_element = mixer + .find_selem(&SelemId::new(&self.config.control, self.config.index)) + .expect("Could not find Alsa mixer control"); + + if self.switched_off() { + return 0; } + + let mut mapped_volume = if self.is_softvol { + let raw_volume = simple_element + .get_playback_volume(SelemChannelId::mono()) + .expect("Could not get raw Alsa volume"); + + raw_volume as f32 / self.range as f32 - self.min as f32 + } else { + let db_volume = simple_element + .get_playback_vol_db(SelemChannelId::mono()) + .expect("Could not get Alsa dB volume") + .to_db(); + + if self.use_linear_in_db { + (db_volume - self.min_db) / self.db_range + } else if f32::abs(db_volume - SND_CTL_TLV_DB_GAIN_MUTE.to_db()) <= f32::EPSILON { + 0.0 + } else { + db_to_ratio(db_volume - self.max_db) + } + }; + + // see comment in `set_volume` why we are handling an antilog volume + if mapped_volume > 0.0 && self.is_some_linear() { + mapped_volume = LogMapping::linear_to_mapped(mapped_volume, self.db_range); + } + + self.config.volume_ctrl.from_mapped(mapped_volume) } fn set_volume(&self, volume: u16) { - match self.map_volume(Some(volume)) { - Ok(_) => (), - Err(e) => error!("Error setting volume for <{}>, {:?}", self.config.card, e), - } - } + let mixer = + alsa::mixer::Mixer::new(&self.config.card, false).expect("Could not open Alsa mixer"); + let simple_element = mixer + .find_selem(&SelemId::new(&self.config.control, self.config.index)) + .expect("Could not find Alsa mixer control"); - fn get_audio_filter(&self) -> Option> { - None + if self.has_switch { + if volume == 0 { + debug!("Disabling playback (setting mute) on Alsa"); + simple_element + .set_playback_switch_all(0) + .expect("Could not disable playback (set mute) on Alsa"); + } else if self.switched_off() { + debug!("Enabling playback (unsetting mute) on Alsa"); + simple_element + .set_playback_switch_all(1) + .expect("Could not enable playback (unset mute) on Alsa"); + } + } + + let mut mapped_volume = self.config.volume_ctrl.to_mapped(volume); + + // Alsa's linear algorithms map everything onto log. Alsa softvol does + // this internally. In the case of `use_linear_in_db` this happens + // automatically by virtue of the dB scale. This means that linear + // controls become log, log becomes log-on-log, and so on. To make + // the controls work as expected, perform an antilog calculation to + // counteract what Alsa will be doing to the set volume. + if mapped_volume > 0.0 && self.is_some_linear() { + mapped_volume = LogMapping::mapped_to_linear(mapped_volume, self.db_range); + } + + if self.is_softvol { + let scaled_volume = (self.min as f32 + mapped_volume * self.range as f32) as i64; + debug!("Setting Alsa raw volume to {}", scaled_volume); + simple_element + .set_playback_volume_all(scaled_volume) + .expect("Could not set Alsa raw volume"); + return; + } + + let db_volume = if self.use_linear_in_db { + self.min_db + mapped_volume * self.db_range + } else if volume == 0 { + // prevent ratio_to_db(0.0) from returning -inf + SND_CTL_TLV_DB_GAIN_MUTE.to_db() + } else { + ratio_to_db(mapped_volume) + self.max_db + }; + + debug!("Setting Alsa volume to {:.2} dB", db_volume); + simple_element + .set_playback_db_all(MilliBel::from_db(db_volume), Round::Floor) + .expect("Could not set Alsa dB volume"); + } +} + +impl AlsaMixer { + fn switched_off(&self) -> bool { + if !self.has_switch { + return false; + } + + let mixer = + alsa::mixer::Mixer::new(&self.config.card, false).expect("Could not open Alsa mixer"); + let simple_element = mixer + .find_selem(&SelemId::new(&self.config.control, self.config.index)) + .expect("Could not find Alsa mixer control"); + + simple_element + .get_playback_switch(SelemChannelId::mono()) + .map(|playback| playback == 0) + .unwrap_or(false) + } + + fn is_some_linear(&self) -> bool { + self.is_softvol || self.use_linear_in_db } } diff --git a/playback/src/mixer/mappings.rs b/playback/src/mixer/mappings.rs new file mode 100644 index 00000000..6a274442 --- /dev/null +++ b/playback/src/mixer/mappings.rs @@ -0,0 +1,163 @@ +use super::VolumeCtrl; +use crate::player::db_to_ratio; + +pub trait MappedCtrl { + fn to_mapped(&self, volume: u16) -> f32; + fn from_mapped(&self, mapped_volume: f32) -> u16; + + fn db_range(&self) -> f32; + fn set_db_range(&mut self, new_db_range: f32); + fn range_ok(&self) -> bool; +} + +impl MappedCtrl for VolumeCtrl { + fn to_mapped(&self, volume: u16) -> f32 { + // More than just an optimization, this ensures that zero volume is + // really mute (both the log and cubic equations would otherwise not + // reach zero). + if volume == 0 { + return 0.0; + } else if volume == 1 { + // And limit in case of rounding errors (as is the case for log). + return 1.0; + } + + let normalized_volume = volume as f32 / Self::MAX_VOLUME as f32; + let mapped_volume = if self.range_ok() { + match *self { + Self::Cubic(db_range) => { + CubicMapping::linear_to_mapped(normalized_volume, db_range) + } + Self::Log(db_range) => LogMapping::linear_to_mapped(normalized_volume, db_range), + _ => normalized_volume, + } + } else { + // Ensure not to return -inf or NaN due to division by zero. + error!( + "{:?} does not work with 0 dB range, using linear mapping instead", + self + ); + normalized_volume + }; + + debug!( + "Input volume {} mapped to: {:.2}%", + volume, + mapped_volume * 100.0 + ); + + mapped_volume + } + + fn from_mapped(&self, mapped_volume: f32) -> u16 { + // More than just an optimization, this ensures that zero mapped volume + // is unmapped to non-negative real numbers (otherwise the log and cubic + // equations would respectively return -inf and -1/9.) + if f32::abs(mapped_volume - 0.0) <= f32::EPSILON { + return 0; + } else if f32::abs(mapped_volume - 1.0) <= f32::EPSILON { + return Self::MAX_VOLUME; + } + + let unmapped_volume = if self.range_ok() { + match *self { + Self::Cubic(db_range) => CubicMapping::mapped_to_linear(mapped_volume, db_range), + Self::Log(db_range) => LogMapping::mapped_to_linear(mapped_volume, db_range), + _ => mapped_volume, + } + } else { + // Ensure not to return -inf or NaN due to division by zero. + error!( + "{:?} does not work with 0 dB range, using linear mapping instead", + self + ); + mapped_volume + }; + + (unmapped_volume * Self::MAX_VOLUME as f32) as u16 + } + + fn db_range(&self) -> f32 { + match *self { + Self::Fixed => 0.0, + Self::Linear => Self::DEFAULT_DB_RANGE, // arbitrary, could be anything > 0 + Self::Log(db_range) | Self::Cubic(db_range) => db_range, + } + } + + fn set_db_range(&mut self, new_db_range: f32) { + match self { + Self::Cubic(ref mut db_range) | Self::Log(ref mut db_range) => *db_range = new_db_range, + _ => error!("Invalid to set dB range for volume control type {:?}", self), + } + + debug!("Volume control is now {:?}", self) + } + + fn range_ok(&self) -> bool { + self.db_range() > 0.0 || matches!(self, Self::Fixed | Self::Linear) + } +} + +pub trait VolumeMapping { + fn linear_to_mapped(unmapped_volume: f32, db_range: f32) -> f32; + fn mapped_to_linear(mapped_volume: f32, db_range: f32) -> f32; +} + +// Volume conversion taken from: https://www.dr-lex.be/info-stuff/volumecontrols.html#ideal2 +// +// As the human auditory system has a logarithmic sensitivity curve, this +// mapping results in a near linear loudness experience with the listener. +pub struct LogMapping {} +impl VolumeMapping for LogMapping { + fn linear_to_mapped(normalized_volume: f32, db_range: f32) -> f32 { + let (db_ratio, ideal_factor) = Self::coefficients(db_range); + f32::exp(ideal_factor * normalized_volume) / db_ratio + } + + fn mapped_to_linear(mapped_volume: f32, db_range: f32) -> f32 { + let (db_ratio, ideal_factor) = Self::coefficients(db_range); + f32::ln(db_ratio * mapped_volume) / ideal_factor + } +} + +impl LogMapping { + fn coefficients(db_range: f32) -> (f32, f32) { + let db_ratio = db_to_ratio(db_range); + let ideal_factor = f32::ln(db_ratio); + (db_ratio, ideal_factor) + } +} + +// Ported from: https://github.com/alsa-project/alsa-utils/blob/master/alsamixer/volume_mapping.c +// which in turn was inspired by: https://www.robotplanet.dk/audio/audio_gui_design/ +// +// Though this mapping is computationally less expensive than the logarithmic +// mapping, it really does not matter as librespot memoizes the mapped value. +// Use this mapping if you have some reason to mimic Alsa's native mixer or +// prefer a more granular control in the upper volume range. +// +// Note: https://www.dr-lex.be/info-stuff/volumecontrols.html#ideal3 shows +// better approximations to the logarithmic curve but because we only intend +// to mimic Alsa here, we do not implement them. If your desire is to use a +// logarithmic mapping, then use that volume control. +pub struct CubicMapping {} +impl VolumeMapping for CubicMapping { + fn linear_to_mapped(normalized_volume: f32, db_range: f32) -> f32 { + let min_norm = Self::min_norm(db_range); + f32::powi(normalized_volume * (1.0 - min_norm) + min_norm, 3) + } + + fn mapped_to_linear(mapped_volume: f32, db_range: f32) -> f32 { + let min_norm = Self::min_norm(db_range); + (mapped_volume.powf(1.0 / 3.0) - min_norm) / (1.0 - min_norm) + } +} + +impl CubicMapping { + fn min_norm(db_range: f32) -> f32 { + // Note that this 60.0 is unrelated to DEFAULT_DB_RANGE. + // Instead, it's the cubic voltage to dB ratio. + f32::powf(10.0, -1.0 * db_range / 60.0) + } +} diff --git a/playback/src/mixer/mod.rs b/playback/src/mixer/mod.rs index af41c6f4..3c3bed2e 100644 --- a/playback/src/mixer/mod.rs +++ b/playback/src/mixer/mod.rs @@ -1,11 +1,16 @@ +use crate::config::VolumeCtrl; + +pub mod mappings; +use self::mappings::MappedCtrl; + pub trait Mixer: Send { - fn open(_: Option) -> Self + fn open(config: MixerConfig) -> Self where Self: Sized; - fn start(&self); - fn stop(&self); + fn set_volume(&self, volume: u16); fn volume(&self) -> u16; + fn get_audio_filter(&self) -> Option> { None } @@ -15,6 +20,9 @@ pub trait AudioFilter { fn modify_stream(&self, data: &mut [f32]); } +pub mod softmixer; +use self::softmixer::SoftMixer; + #[cfg(feature = "alsa-backend")] pub mod alsamixer; #[cfg(feature = "alsa-backend")] @@ -23,29 +31,26 @@ use self::alsamixer::AlsaMixer; #[derive(Debug, Clone)] pub struct MixerConfig { pub card: String, - pub mixer: String, + pub control: String, pub index: u32, - pub mapped_volume: bool, + pub volume_ctrl: VolumeCtrl, } impl Default for MixerConfig { fn default() -> MixerConfig { MixerConfig { card: String::from("default"), - mixer: String::from("PCM"), + control: String::from("PCM"), index: 0, - mapped_volume: true, + volume_ctrl: VolumeCtrl::default(), } } } -pub mod softmixer; -use self::softmixer::SoftMixer; +pub type MixerFn = fn(MixerConfig) -> Box; -type MixerFn = fn(Option) -> Box; - -fn mk_sink(device: Option) -> Box { - Box::new(M::open(device)) +fn mk_sink(config: MixerConfig) -> Box { + Box::new(M::open(config)) } pub fn find>(name: Option) -> Option { diff --git a/playback/src/mixer/softmixer.rs b/playback/src/mixer/softmixer.rs index ec8ed6b2..0be161ad 100644 --- a/playback/src/mixer/softmixer.rs +++ b/playback/src/mixer/softmixer.rs @@ -1,28 +1,40 @@ -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::Arc; use super::AudioFilter; +use super::{MappedCtrl, VolumeCtrl}; use super::{Mixer, MixerConfig}; #[derive(Clone)] pub struct SoftMixer { - volume: Arc, + // There is no AtomicF32, so we store the f32 as bits in a u32 field. + // It's much faster than a Mutex. + volume: Arc, + volume_ctrl: VolumeCtrl, } impl Mixer for SoftMixer { - fn open(_: Option) -> SoftMixer { - SoftMixer { - volume: Arc::new(AtomicUsize::new(0xFFFF)), + fn open(config: MixerConfig) -> Self { + let volume_ctrl = config.volume_ctrl; + info!("Mixing with softvol and volume control: {:?}", volume_ctrl); + + Self { + volume: Arc::new(AtomicU32::new(f32::to_bits(0.5))), + volume_ctrl, } } - fn start(&self) {} - fn stop(&self) {} + fn volume(&self) -> u16 { - self.volume.load(Ordering::Relaxed) as u16 + let mapped_volume = f32::from_bits(self.volume.load(Ordering::Relaxed)); + self.volume_ctrl.from_mapped(mapped_volume) } + fn set_volume(&self, volume: u16) { - self.volume.store(volume as usize, Ordering::Relaxed); + let mapped_volume = self.volume_ctrl.to_mapped(volume); + self.volume + .store(mapped_volume.to_bits(), Ordering::Relaxed) } + fn get_audio_filter(&self) -> Option> { Some(Box::new(SoftVolumeApplier { volume: self.volume.clone(), @@ -31,16 +43,15 @@ impl Mixer for SoftMixer { } struct SoftVolumeApplier { - volume: Arc, + volume: Arc, } impl AudioFilter for SoftVolumeApplier { fn modify_stream(&self, data: &mut [f32]) { - let volume = self.volume.load(Ordering::Relaxed) as u16; - if volume != 0xFFFF { - let volume_factor = volume as f64 / 0xFFFF as f64; + let volume = f32::from_bits(self.volume.load(Ordering::Relaxed)); + if volume < 1.0 { for x in data.iter_mut() { - *x = (*x as f64 * volume_factor) as f32; + *x = (*x as f64 * volume as f64) as f32; } } } diff --git a/playback/src/player.rs b/playback/src/player.rs index 8cbb4372..659804f8 100644 --- a/playback/src/player.rs +++ b/playback/src/player.rs @@ -30,7 +30,7 @@ pub const NUM_CHANNELS: u8 = 2; pub const SAMPLES_PER_SECOND: u32 = SAMPLE_RATE as u32 * NUM_CHANNELS as u32; const PRELOAD_NEXT_TRACK_BEFORE_END_DURATION_MS: u32 = 30000; -const DB_VOLTAGE_RATIO: f32 = 20.0; +pub const DB_VOLTAGE_RATIO: f32 = 20.0; pub struct Player { commands: Option>, @@ -196,6 +196,14 @@ impl PlayerEvent { pub type PlayerEventChannel = mpsc::UnboundedReceiver; +pub fn db_to_ratio(db: f32) -> f32 { + f32::powf(10.0, db / DB_VOLTAGE_RATIO) +} + +pub fn ratio_to_db(ratio: f32) -> f32 { + ratio.log10() * DB_VOLTAGE_RATIO +} + #[derive(Clone, Copy, Debug)] pub struct NormalisationData { track_gain_db: f32, @@ -205,14 +213,6 @@ pub struct NormalisationData { } impl NormalisationData { - pub fn db_to_ratio(db: f32) -> f32 { - f32::powf(10.0, db / DB_VOLTAGE_RATIO) - } - - pub fn ratio_to_db(ratio: f32) -> f32 { - ratio.log10() * DB_VOLTAGE_RATIO - } - fn parse_from_file(mut file: T) -> io::Result { const SPOTIFY_NORMALIZATION_HEADER_START_OFFSET: u64 = 144; file.seek(SeekFrom::Start(SPOTIFY_NORMALIZATION_HEADER_START_OFFSET))?; @@ -243,11 +243,11 @@ impl NormalisationData { }; let normalisation_power = gain_db + config.normalisation_pregain; - let mut normalisation_factor = Self::db_to_ratio(normalisation_power); + let mut normalisation_factor = db_to_ratio(normalisation_power); if normalisation_factor * gain_peak > config.normalisation_threshold { let limited_normalisation_factor = config.normalisation_threshold / gain_peak; - let limited_normalisation_power = Self::ratio_to_db(limited_normalisation_factor); + let limited_normalisation_power = ratio_to_db(limited_normalisation_factor); if config.normalisation_method == NormalisationMethod::Basic { warn!("Limiting gain to {:.2} dB for the duration of this track to stay under normalisation threshold.", limited_normalisation_power); @@ -266,7 +266,7 @@ impl NormalisationData { debug!("Normalisation Type: {:?}", config.normalisation_type); debug!( "Normalisation Threshold: {:.1}", - Self::ratio_to_db(config.normalisation_threshold) + ratio_to_db(config.normalisation_threshold) ); debug!("Normalisation Method: {:?}", config.normalisation_method); debug!("Normalisation Factor: {}", normalisation_factor); diff --git a/src/main.rs b/src/main.rs index a5106af2..6f41db9f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,15 +9,16 @@ use url::Url; use librespot::connect::spirc::Spirc; use librespot::core::authentication::Credentials; use librespot::core::cache::Cache; -use librespot::core::config::{ConnectConfig, DeviceType, SessionConfig, VolumeCtrl}; +use librespot::core::config::{ConnectConfig, DeviceType, SessionConfig}; use librespot::core::session::Session; use librespot::core::version; -use librespot::playback::audio_backend::{self, Sink, BACKENDS}; +use librespot::playback::audio_backend::{self, SinkBuilder, BACKENDS}; use librespot::playback::config::{ - AudioFormat, Bitrate, NormalisationMethod, NormalisationType, PlayerConfig, + AudioFormat, Bitrate, NormalisationMethod, NormalisationType, PlayerConfig, VolumeCtrl, }; -use librespot::playback::mixer::{self, Mixer, MixerConfig}; -use librespot::playback::player::{NormalisationData, Player}; +use librespot::playback::mixer::mappings::MappedCtrl; +use librespot::playback::mixer::{self, MixerConfig, MixerFn}; +use librespot::playback::player::{db_to_ratio, Player}; mod player_event_handler; use player_event_handler::{emit_sink_event, run_program_on_events}; @@ -66,7 +67,7 @@ fn setup_logging(verbose: bool) { } fn list_backends() { - println!("Available Backends : "); + println!("Available backends : "); for (&(name, _), idx) in BACKENDS.iter().zip(0..) { if idx == 0 { println!("- {} (default)", name); @@ -172,11 +173,9 @@ fn print_version() { #[derive(Clone)] struct Setup { format: AudioFormat, - backend: fn(Option, AudioFormat) -> Box, + backend: SinkBuilder, device: Option, - - mixer: fn(Option) -> Box, - + mixer: MixerFn, cache: Option, player_config: PlayerConfig, session_config: SessionConfig, @@ -266,11 +265,6 @@ fn get_setup(args: &[String]) -> Setup { "Alsa mixer index, Index of the cards mixer. Defaults to 0", "MIXER_INDEX", ) - .optflag( - "", - "mixer-linear-volume", - "Disable alsa's mapped volume scale (cubic). Default false", - ) .optopt( "", "initial-volume", @@ -333,8 +327,14 @@ fn get_setup(args: &[String]) -> Setup { .optopt( "", "volume-ctrl", - "Volume control type - [linear, log, fixed]. Default is logarithmic", + "Volume control type - [cubic, fixed, linear, log]. Default is log.", "VOLUME_CTRL" + ) + .optopt( + "", + "volume-range", + "Range of the volume control (dB). Defaults to 60 for softvol and for alsa what the mixer supports.", + "RANGE", ) .optflag( "", @@ -399,18 +399,55 @@ fn get_setup(args: &[String]) -> Setup { let mixer_name = matches.opt_str("mixer"); let mixer = mixer::find(mixer_name.as_ref()).expect("Invalid mixer"); - let mixer_config = MixerConfig { - card: matches - .opt_str("mixer-card") - .unwrap_or_else(|| String::from("default")), - mixer: matches - .opt_str("mixer-name") - .unwrap_or_else(|| String::from("PCM")), - index: matches + let mixer_config = { + let card = matches.opt_str("mixer-card").unwrap_or_else(|| { + if let Some(ref device_name) = device { + device_name.to_string() + } else { + String::from("default") + } + }); + let index = matches .opt_str("mixer-index") .map(|index| index.parse::().unwrap()) - .unwrap_or(0), - mapped_volume: !matches.opt_present("mixer-linear-volume"), + .unwrap_or(0); + let control = matches + .opt_str("mixer-name") + .unwrap_or_else(|| String::from("PCM")); + let mut volume_range = matches + .opt_str("volume-range") + .map(|range| range.parse::().unwrap()) + .unwrap_or_else(|| match mixer_name.as_ref().map(AsRef::as_ref) { + Some("alsa") => 0.0, // let Alsa query the control + _ => VolumeCtrl::DEFAULT_DB_RANGE, + }); + if volume_range < 0.0 { + // User might have specified range as minimum dB volume. + volume_range *= -1.0; + warn!( + "Please enter positive volume ranges only, assuming {:.2} dB", + volume_range + ); + } + let volume_ctrl = matches + .opt_str("volume-ctrl") + .as_ref() + .map(|volume_ctrl| { + VolumeCtrl::from_str_with_range(volume_ctrl, volume_range) + .expect("Invalid volume control type") + }) + .unwrap_or_else(|| { + let mut volume_ctrl = VolumeCtrl::default(); + volume_ctrl.set_db_range(volume_range); + volume_ctrl + }); + + MixerConfig { + card, + control, + index, + volume_ctrl, + } }; let cache = { @@ -459,15 +496,18 @@ fn get_setup(args: &[String]) -> Setup { let initial_volume = matches .opt_str("initial-volume") - .map(|volume| { - let volume = volume.parse::().unwrap(); + .map(|initial_volume| { + let volume = initial_volume.parse::().unwrap(); if volume > 100 { - panic!("Initial volume must be in the range 0-100"); + error!("Initial volume must be in the range 0-100."); + // the cast will saturate, not necessary to take further action } - (volume as i32 * 0xFFFF / 100) as u16 + (volume as f32 / 100.0 * VolumeCtrl::MAX_VOLUME as f32) as u16 }) - .or_else(|| cache.as_ref().and_then(Cache::volume)) - .unwrap_or(0x8000); + .or_else(|| match mixer_name.as_ref().map(AsRef::as_ref) { + Some("alsa") => None, + _ => cache.as_ref().and_then(Cache::volume), + }); let zeroconf_port = matches .opt_str("zeroconf-port") @@ -506,15 +546,15 @@ fn get_setup(args: &[String]) -> Setup { match Url::parse(&s) { Ok(url) => { if url.host().is_none() || url.port_or_known_default().is_none() { - panic!("Invalid proxy url, only urls on the format \"http://host:port\" are allowed"); + panic!("Invalid proxy url, only URLs on the format \"http://host:port\" are allowed"); } if url.scheme() != "http" { - panic!("Only unsecure http:// proxies are supported"); + panic!("Only insecure http:// proxies are supported"); } url }, - Err(err) => panic!("Invalid proxy url: {}, only urls on the format \"http://host:port\" are allowed", err) + Err(err) => panic!("Invalid proxy URL: {}, only URLs in the format \"http://host:port\" are allowed", err) } }, ), @@ -524,21 +564,14 @@ fn get_setup(args: &[String]) -> Setup { } }; - let passthrough = matches.opt_present("passthrough"); - let player_config = { let bitrate = matches .opt_str("b") .as_ref() .map(|bitrate| Bitrate::from_str(bitrate).expect("Invalid bitrate")) .unwrap_or_default(); - let gain_type = matches - .opt_str("normalisation-gain-type") - .as_ref() - .map(|gain_type| { - NormalisationType::from_str(gain_type).expect("Invalid normalisation type") - }) - .unwrap_or_default(); + let gapless = !matches.opt_present("disable-gapless"); + let normalisation = matches.opt_present("enable-volume-normalisation"); let normalisation_method = matches .opt_str("normalisation-method") .as_ref() @@ -546,41 +579,52 @@ fn get_setup(args: &[String]) -> Setup { NormalisationMethod::from_str(gain_type).expect("Invalid normalisation method") }) .unwrap_or_default(); + let normalisation_type = matches + .opt_str("normalisation-gain-type") + .as_ref() + .map(|gain_type| { + NormalisationType::from_str(gain_type).expect("Invalid normalisation type") + }) + .unwrap_or_default(); + let normalisation_pregain = matches + .opt_str("normalisation-pregain") + .map(|pregain| pregain.parse::().expect("Invalid pregain float value")) + .unwrap_or(PlayerConfig::default().normalisation_pregain); + let normalisation_threshold = matches + .opt_str("normalisation-threshold") + .map(|threshold| { + db_to_ratio( + threshold + .parse::() + .expect("Invalid threshold float value"), + ) + }) + .unwrap_or(PlayerConfig::default().normalisation_threshold); + let normalisation_attack = matches + .opt_str("normalisation-attack") + .map(|attack| attack.parse::().expect("Invalid attack float value") / MILLIS) + .unwrap_or(PlayerConfig::default().normalisation_attack); + let normalisation_release = matches + .opt_str("normalisation-release") + .map(|release| release.parse::().expect("Invalid release float value") / MILLIS) + .unwrap_or(PlayerConfig::default().normalisation_release); + let normalisation_knee = matches + .opt_str("normalisation-knee") + .map(|knee| knee.parse::().expect("Invalid knee float value")) + .unwrap_or(PlayerConfig::default().normalisation_knee); + let passthrough = matches.opt_present("passthrough"); PlayerConfig { bitrate, - gapless: !matches.opt_present("disable-gapless"), - normalisation: matches.opt_present("enable-volume-normalisation"), + gapless, + normalisation, + normalisation_type, normalisation_method, - normalisation_type: gain_type, - normalisation_pregain: matches - .opt_str("normalisation-pregain") - .map(|pregain| pregain.parse::().expect("Invalid pregain float value")) - .unwrap_or(PlayerConfig::default().normalisation_pregain), - normalisation_threshold: matches - .opt_str("normalisation-threshold") - .map(|threshold| { - NormalisationData::db_to_ratio( - threshold - .parse::() - .expect("Invalid threshold float value"), - ) - }) - .unwrap_or(PlayerConfig::default().normalisation_threshold), - normalisation_attack: matches - .opt_str("normalisation-attack") - .map(|attack| attack.parse::().expect("Invalid attack float value") / MILLIS) - .unwrap_or(PlayerConfig::default().normalisation_attack), - normalisation_release: matches - .opt_str("normalisation-release") - .map(|release| { - release.parse::().expect("Invalid release float value") / MILLIS - }) - .unwrap_or(PlayerConfig::default().normalisation_release), - normalisation_knee: matches - .opt_str("normalisation-knee") - .map(|knee| knee.parse::().expect("Invalid knee float value")) - .unwrap_or(PlayerConfig::default().normalisation_knee), + normalisation_pregain, + normalisation_threshold, + normalisation_attack, + normalisation_release, + normalisation_knee, passthrough, } }; @@ -591,39 +635,37 @@ fn get_setup(args: &[String]) -> Setup { .as_ref() .map(|device_type| DeviceType::from_str(device_type).expect("Invalid device type")) .unwrap_or_default(); - - let volume_ctrl = matches - .opt_str("volume-ctrl") - .as_ref() - .map(|volume_ctrl| VolumeCtrl::from_str(volume_ctrl).expect("Invalid volume ctrl type")) - .unwrap_or_default(); + let has_volume_ctrl = !matches!(mixer_config.volume_ctrl, VolumeCtrl::Fixed); + let autoplay = matches.opt_present("autoplay"); ConnectConfig { name, device_type, - volume: initial_volume, - volume_ctrl, - autoplay: matches.opt_present("autoplay"), + initial_volume, + has_volume_ctrl, + autoplay, } }; let enable_discovery = !matches.opt_present("disable-discovery"); + let player_event_program = matches.opt_str("onevent"); + let emit_sink_events = matches.opt_present("emit-sink-events"); Setup { format, backend, - cache, - session_config, - player_config, - connect_config, - credentials, device, + mixer, + cache, + player_config, + session_config, + connect_config, + mixer_config, + credentials, enable_discovery, zeroconf_port, - mixer, - mixer_config, - player_event_program: matches.opt_str("onevent"), - emit_sink_events: matches.opt_present("emit-sink-events"), + player_event_program, + emit_sink_events, } } @@ -697,7 +739,7 @@ async fn main() { session = &mut connecting, if !connecting.is_terminated() => match session { Ok(session) => { let mixer_config = setup.mixer_config.clone(); - let mixer = (setup.mixer)(Some(mixer_config)); + let mixer = (setup.mixer)(mixer_config); let player_config = setup.player_config.clone(); let connect_config = setup.connect_config.clone(); From 9efd886e9132c18667372ed0eaae80eff1656c17 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Tue, 25 May 2021 20:17:28 +0200 Subject: [PATCH 10/36] Describe new `mixer-card` getopts behavior Also remove some other getopts and string changes to a separate PR. --- src/main.rs | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/main.rs b/src/main.rs index 6f41db9f..5b8d1d1a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -250,13 +250,13 @@ fn get_setup(args: &[String]) -> Setup { .optopt( "m", "mixer-name", - "Alsa mixer name, e.g \"PCM\" or \"Master\". Defaults to 'PCM'", + "Alsa mixer control, e.g. 'PCM' or 'Master'. Defaults to 'PCM'.", "MIXER_NAME", ) .optopt( "", "mixer-card", - "Alsa mixer card, e.g \"hw:0\" or similar from `aplay -l`. Defaults to 'default' ", + "Alsa mixer card, e.g 'hw:0' or similar from `aplay -l`. Defaults to DEVICE if specified, 'default' otherwise.", "MIXER_CARD", ) .optopt( @@ -268,7 +268,7 @@ fn get_setup(args: &[String]) -> Setup { .optopt( "", "initial-volume", - "Initial volume in %, once connected (must be from 0 to 100)", + "Initial volume (%) once connected {0..100}. Defaults to 50 for softvol and for Alsa mixer the current volume.", "VOLUME", ) .optopt( @@ -327,14 +327,14 @@ fn get_setup(args: &[String]) -> Setup { .optopt( "", "volume-ctrl", - "Volume control type - [cubic, fixed, linear, log]. Default is log.", + "Volume control type {cubic|fixed|linear|log}. Defaults to log.", "VOLUME_CTRL" ) - .optopt( - "", - "volume-range", - "Range of the volume control (dB). Defaults to 60 for softvol and for alsa what the mixer supports.", - "RANGE", + .optopt( + "", + "volume-range", + "Range of the volume control (dB). Defaults to 60 for softvol and for Alsa mixer what the mixer supports.", + "RANGE", ) .optflag( "", @@ -423,7 +423,7 @@ fn get_setup(args: &[String]) -> Setup { }); if volume_range < 0.0 { // User might have specified range as minimum dB volume. - volume_range *= -1.0; + volume_range = -volume_range; warn!( "Please enter positive volume ranges only, assuming {:.2} dB", volume_range @@ -546,15 +546,15 @@ fn get_setup(args: &[String]) -> Setup { match Url::parse(&s) { Ok(url) => { if url.host().is_none() || url.port_or_known_default().is_none() { - panic!("Invalid proxy url, only URLs on the format \"http://host:port\" are allowed"); + panic!("Invalid proxy url, only urls on the format \"http://host:port\" are allowed"); } if url.scheme() != "http" { - panic!("Only insecure http:// proxies are supported"); + panic!("Only unsecure http:// proxies are supported"); } url }, - Err(err) => panic!("Invalid proxy URL: {}, only URLs in the format \"http://host:port\" are allowed", err) + Err(err) => panic!("Invalid proxy url: {}, only urls on the format \"http://host:port\" are allowed", err) } }, ), @@ -575,8 +575,8 @@ fn get_setup(args: &[String]) -> Setup { let normalisation_method = matches .opt_str("normalisation-method") .as_ref() - .map(|gain_type| { - NormalisationMethod::from_str(gain_type).expect("Invalid normalisation method") + .map(|method| { + NormalisationMethod::from_str(method).expect("Invalid normalisation method") }) .unwrap_or_default(); let normalisation_type = matches From db71f6d87e0b62f2a3c492dd27dff1e8c2455432 Mon Sep 17 00:00:00 2001 From: Johannesd3 <51954457+Johannesd3@users.noreply.github.com> Date: Wed, 26 May 2021 21:03:06 +0200 Subject: [PATCH 11/36] Fix changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57d50727..c632c959 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [playback] `alsamixer`: use `--device` name for `--mixer-card` unless specified otherwise ### Deprecated -- [connect] The `discovery` module +- [connect] The `discovery` module was deprecated in favor of the `librespot-discovery` crate ### Removed - [connect] Removed no-op mixer started/stopped logic (breaking) @@ -30,7 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - [connect] Fix step size on volume up/down events -- [playback] Incorrect `PlayerConfig::default().normalisation_threshold` caused distortion when using dynamic volume normalisation downstream in librespot-connect was deprecated in favor of the `librespot-discovery` crate. +- [playback] Incorrect `PlayerConfig::default().normalisation_threshold` caused distortion when using dynamic volume normalisation downstream - [playback] Fix `log` and `cubic` volume controls to be mute at zero volume - [playback] `alsamixer`: make `cubic` consistent between cards that report minimum volume as mute, and cards that report some dB value - [playback] `alsamixer`: make `--volume-ctrl {linear|log}` work as expected From bb3dd64c87c3e1cd8286029f00fc94a64c6ef719 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Wed, 26 May 2021 21:19:17 +0200 Subject: [PATCH 12/36] Implement dithering (#694) Dithering lowers digital-to-analog conversion ("requantization") error, linearizing output, lowering distortion and replacing it with a constant, fixed noise level, which is more pleasant to the ear than the distortion. Guidance: - On S24, S24_3 and S24, the default is to use triangular dithering. Depending on personal preference you may use Gaussian dithering instead; it's not as good objectively, but it may be preferred subjectively if you are looking for a more "analog" sound akin to tape hiss. - Advanced users who know that they have a DAC without noise shaping have a third option: high-passed dithering, which is like triangular dithering except that it moves dithering noise up in frequency where it is less audible. Note: 99% of DACs are of delta-sigma design with noise shaping, so unless you have a multibit / R2R DAC, or otherwise know what you are doing, this is not for you. - Don't dither or shape noise on S32 or F32. On F32 it's not supported anyway (there are no integer conversions and so no rounding errors) and on S32 the noise level is so far down that it is simply inaudible even after volume normalisation and control. New command line option: --dither DITHER Specify the dither algorithm to use - [none, gpdf, tpdf, tpdf_hp]. Defaults to 'tpdf' for formats S16 S24, S24_3 and 'none' for other formats. Notes: This PR also features some opportunistic improvements. Worthy of mention are: - matching reference Vorbis sample conversion techniques for lower noise - a cleanup of the convert API --- CHANGELOG.md | 1 + Cargo.lock | 21 +++- playback/Cargo.toml | 4 + playback/src/audio_backend/alsa.rs | 1 + playback/src/audio_backend/gstreamer.rs | 5 +- playback/src/audio_backend/jackaudio.rs | 5 +- playback/src/audio_backend/mod.rs | 38 +++--- playback/src/audio_backend/pipe.rs | 2 +- playback/src/audio_backend/portaudio.rs | 19 ++- playback/src/audio_backend/pulseaudio.rs | 1 + playback/src/audio_backend/rodio.rs | 8 +- playback/src/audio_backend/sdl.rs | 8 +- playback/src/audio_backend/subprocess.rs | 1 + playback/src/config.rs | 15 ++- playback/src/convert.rs | 125 ++++++++++++++------ playback/src/decoder/libvorbis_decoder.rs | 5 +- playback/src/dither.rs | 138 ++++++++++++++++++++++ playback/src/lib.rs | 1 + playback/src/player.rs | 7 +- src/main.rs | 34 +++++- 20 files changed, 339 insertions(+), 100 deletions(-) create mode 100644 playback/src/dither.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 57d50727..4ff1b8ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added - [discovery] The crate `librespot-discovery` for discovery in LAN was created. Its functionality was previously part of `librespot-connect`. +- [playback] Add support for dithering with `--dither` for lower requantization error (breaking) - [playback] Add `--volume-range` option to set dB range and control `log` and `cubic` volume control curves - [playback] `alsamixer`: support for querying dB range from Alsa softvol diff --git a/Cargo.lock b/Cargo.lock index 5dc36ce2..d22a7e36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,7 +1,5 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 - [[package]] name = "aes" version = "0.6.0" @@ -1075,6 +1073,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "libm" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d73b3f436185384286bd8098d17ec07c9a7d2388a6599f824d8502b529702a" + [[package]] name = "libmdns" version = "0.6.1" @@ -1300,6 +1304,8 @@ dependencies = [ "log", "ogg", "portaudio-rs", + "rand", + "rand_distr", "rodio", "sdl2", "shell-words", @@ -1545,6 +1551,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1896,6 +1903,16 @@ dependencies = [ "getrandom", ] +[[package]] +name = "rand_distr" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da9e8f32ad24fb80d07d2323a9a2ce8b30d68a62b8cb4df88119ff49a698f038" +dependencies = [ + "num-traits", + "rand", +] + [[package]] name = "rand_hc" version = "0.3.0" diff --git a/playback/Cargo.toml b/playback/Cargo.toml index 37806062..6970e7a8 100644 --- a/playback/Cargo.toml +++ b/playback/Cargo.toml @@ -48,6 +48,10 @@ librespot-tremor = { version = "0.2", optional = true } ogg = "0.8" vorbis = { version ="0.0", optional = true } +# Dithering +rand = "0.8" +rand_distr = "0.4" + [features] alsa-backend = ["alsa"] portaudio-backend = ["portaudio-rs"] diff --git a/playback/src/audio_backend/alsa.rs b/playback/src/audio_backend/alsa.rs index c7bc4e55..98939668 100644 --- a/playback/src/audio_backend/alsa.rs +++ b/playback/src/audio_backend/alsa.rs @@ -1,5 +1,6 @@ use super::{Open, Sink, SinkAsBytes}; use crate::config::AudioFormat; +use crate::convert::Converter; use crate::decoder::AudioPacket; use crate::player::{NUM_CHANNELS, SAMPLES_PER_SECOND, SAMPLE_RATE}; use alsa::device_name::HintIter; diff --git a/playback/src/audio_backend/gstreamer.rs b/playback/src/audio_backend/gstreamer.rs index e31c66ae..b5273102 100644 --- a/playback/src/audio_backend/gstreamer.rs +++ b/playback/src/audio_backend/gstreamer.rs @@ -1,5 +1,6 @@ use super::{Open, Sink, SinkAsBytes}; use crate::config::AudioFormat; +use crate::convert::Converter; use crate::decoder::AudioPacket; use crate::player::{NUM_CHANNELS, SAMPLE_RATE}; @@ -37,7 +38,8 @@ impl Open for GstreamerSink { "appsrc caps=\"audio/x-raw,format={}LE,layout=interleaved,channels={},rate={}\" block=true max-bytes={} name=appsrc0 ", gst_format, NUM_CHANNELS, SAMPLE_RATE, gst_bytes ); - let pipeline_str_rest = r#" ! audioconvert ! autoaudiosink"#; + // no need to dither twice; use librespot dithering instead + let pipeline_str_rest = r#" ! audioconvert dithering=none ! autoaudiosink"#; let pipeline_str: String = match device { Some(x) => format!("{}{}", pipeline_str_preamble, x), None => format!("{}{}", pipeline_str_preamble, pipeline_str_rest), @@ -120,7 +122,6 @@ impl Open for GstreamerSink { } impl Sink for GstreamerSink { - start_stop_noop!(); sink_as_bytes!(); } diff --git a/playback/src/audio_backend/jackaudio.rs b/playback/src/audio_backend/jackaudio.rs index 816147ff..ab75fff2 100644 --- a/playback/src/audio_backend/jackaudio.rs +++ b/playback/src/audio_backend/jackaudio.rs @@ -1,5 +1,6 @@ use super::{Open, Sink}; use crate::config::AudioFormat; +use crate::convert::Converter; use crate::decoder::AudioPacket; use crate::player::NUM_CHANNELS; use jack::{ @@ -69,9 +70,7 @@ impl Open for JackSink { } impl Sink for JackSink { - start_stop_noop!(); - - fn write(&mut self, packet: &AudioPacket) -> io::Result<()> { + fn write(&mut self, packet: &AudioPacket, _: &mut Converter) -> io::Result<()> { for s in packet.samples().iter() { let res = self.send.send(*s); if res.is_err() { diff --git a/playback/src/audio_backend/mod.rs b/playback/src/audio_backend/mod.rs index 84e35634..e4653f17 100644 --- a/playback/src/audio_backend/mod.rs +++ b/playback/src/audio_backend/mod.rs @@ -1,4 +1,5 @@ use crate::config::AudioFormat; +use crate::convert::Converter; use crate::decoder::AudioPacket; use std::io; @@ -7,9 +8,13 @@ pub trait Open { } pub trait Sink { - fn start(&mut self) -> io::Result<()>; - fn stop(&mut self) -> io::Result<()>; - fn write(&mut self, packet: &AudioPacket) -> io::Result<()>; + fn start(&mut self) -> io::Result<()> { + Ok(()) + } + fn stop(&mut self) -> io::Result<()> { + Ok(()) + } + fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> io::Result<()>; } pub type SinkBuilder = fn(Option, AudioFormat) -> Box; @@ -25,26 +30,26 @@ fn mk_sink(device: Option, format: AudioFormat // reuse code for various backends macro_rules! sink_as_bytes { () => { - fn write(&mut self, packet: &AudioPacket) -> io::Result<()> { - use crate::convert::{self, i24}; + fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> io::Result<()> { + use crate::convert::i24; use zerocopy::AsBytes; match packet { AudioPacket::Samples(samples) => match self.format { AudioFormat::F32 => self.write_bytes(samples.as_bytes()), AudioFormat::S32 => { - let samples_s32: &[i32] = &convert::to_s32(samples); + let samples_s32: &[i32] = &converter.f32_to_s32(samples); self.write_bytes(samples_s32.as_bytes()) } AudioFormat::S24 => { - let samples_s24: &[i32] = &convert::to_s24(samples); + let samples_s24: &[i32] = &converter.f32_to_s24(samples); self.write_bytes(samples_s24.as_bytes()) } AudioFormat::S24_3 => { - let samples_s24_3: &[i24] = &convert::to_s24_3(samples); + let samples_s24_3: &[i24] = &converter.f32_to_s24_3(samples); self.write_bytes(samples_s24_3.as_bytes()) } AudioFormat::S16 => { - let samples_s16: &[i16] = &convert::to_s16(samples); + let samples_s16: &[i16] = &converter.f32_to_s16(samples); self.write_bytes(samples_s16.as_bytes()) } }, @@ -54,17 +59,6 @@ macro_rules! sink_as_bytes { }; } -macro_rules! start_stop_noop { - () => { - fn start(&mut self) -> io::Result<()> { - Ok(()) - } - fn stop(&mut self) -> io::Result<()> { - Ok(()) - } - }; -} - #[cfg(feature = "alsa-backend")] mod alsa; #[cfg(feature = "alsa-backend")] @@ -105,6 +99,8 @@ mod subprocess; use self::subprocess::SubprocessSink; pub const BACKENDS: &[(&str, SinkBuilder)] = &[ + #[cfg(feature = "rodio-backend")] + ("rodio", rodio::mk_rodio), // default goes first #[cfg(feature = "alsa-backend")] ("alsa", mk_sink::), #[cfg(feature = "portaudio-backend")] @@ -115,8 +111,6 @@ pub const BACKENDS: &[(&str, SinkBuilder)] = &[ ("jackaudio", mk_sink::), #[cfg(feature = "gstreamer-backend")] ("gstreamer", mk_sink::), - #[cfg(feature = "rodio-backend")] - ("rodio", rodio::mk_rodio), #[cfg(feature = "rodiojack-backend")] ("rodiojack", rodio::mk_rodiojack), #[cfg(feature = "sdl-backend")] diff --git a/playback/src/audio_backend/pipe.rs b/playback/src/audio_backend/pipe.rs index df3e6c0f..6ad2773b 100644 --- a/playback/src/audio_backend/pipe.rs +++ b/playback/src/audio_backend/pipe.rs @@ -1,5 +1,6 @@ use super::{Open, Sink, SinkAsBytes}; use crate::config::AudioFormat; +use crate::convert::Converter; use crate::decoder::AudioPacket; use std::fs::OpenOptions; use std::io::{self, Write}; @@ -23,7 +24,6 @@ impl Open for StdoutSink { } impl Sink for StdoutSink { - start_stop_noop!(); sink_as_bytes!(); } diff --git a/playback/src/audio_backend/portaudio.rs b/playback/src/audio_backend/portaudio.rs index 4fe471a9..0bcd1aa5 100644 --- a/playback/src/audio_backend/portaudio.rs +++ b/playback/src/audio_backend/portaudio.rs @@ -1,6 +1,6 @@ use super::{Open, Sink}; use crate::config::AudioFormat; -use crate::convert; +use crate::convert::Converter; use crate::decoder::AudioPacket; use crate::player::{NUM_CHANNELS, SAMPLE_RATE}; use portaudio_rs::device::{get_default_output_index, DeviceIndex, DeviceInfo}; @@ -55,9 +55,6 @@ impl<'a> Open for PortAudioSink<'a> { fn open(device: Option, format: AudioFormat) -> PortAudioSink<'a> { info!("Using PortAudio sink with format: {:?}", format); - warn!("This backend is known to panic on several platforms."); - warn!("Consider using some other backend, or better yet, contributing a fix."); - portaudio_rs::initialize().unwrap(); let device_idx = match device.as_ref().map(AsRef::as_ref) { @@ -109,7 +106,7 @@ impl<'a> Sink for PortAudioSink<'a> { Some(*$parameters), SAMPLE_RATE as f64, FRAMES_PER_BUFFER_UNSPECIFIED, - StreamFlags::empty(), + StreamFlags::DITHER_OFF, // no need to dither twice; use librespot dithering instead None, ) .unwrap(), @@ -136,15 +133,15 @@ impl<'a> Sink for PortAudioSink<'a> { }}; } match self { - Self::F32(stream, _parameters) => stop_sink!(ref mut stream), - Self::S32(stream, _parameters) => stop_sink!(ref mut stream), - Self::S16(stream, _parameters) => stop_sink!(ref mut stream), + Self::F32(stream, _) => stop_sink!(ref mut stream), + Self::S32(stream, _) => stop_sink!(ref mut stream), + Self::S16(stream, _) => stop_sink!(ref mut stream), }; Ok(()) } - fn write(&mut self, packet: &AudioPacket) -> io::Result<()> { + fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> io::Result<()> { macro_rules! write_sink { (ref mut $stream: expr, $samples: expr) => { $stream.as_mut().unwrap().write($samples) @@ -157,11 +154,11 @@ impl<'a> Sink for PortAudioSink<'a> { write_sink!(ref mut stream, samples) } Self::S32(stream, _parameters) => { - let samples_s32: &[i32] = &convert::to_s32(samples); + let samples_s32: &[i32] = &converter.f32_to_s32(samples); write_sink!(ref mut stream, samples_s32) } Self::S16(stream, _parameters) => { - let samples_s16: &[i16] = &convert::to_s16(samples); + let samples_s16: &[i16] = &converter.f32_to_s16(samples); write_sink!(ref mut stream, samples_s16) } }; diff --git a/playback/src/audio_backend/pulseaudio.rs b/playback/src/audio_backend/pulseaudio.rs index 90a4a67a..57c9b8bc 100644 --- a/playback/src/audio_backend/pulseaudio.rs +++ b/playback/src/audio_backend/pulseaudio.rs @@ -1,5 +1,6 @@ use super::{Open, Sink, SinkAsBytes}; use crate::config::AudioFormat; +use crate::convert::Converter; use crate::decoder::AudioPacket; use crate::player::{NUM_CHANNELS, SAMPLE_RATE}; use libpulse_binding::{self as pulse, stream::Direction}; diff --git a/playback/src/audio_backend/rodio.rs b/playback/src/audio_backend/rodio.rs index 9399a309..52e9bc91 100644 --- a/playback/src/audio_backend/rodio.rs +++ b/playback/src/audio_backend/rodio.rs @@ -6,7 +6,7 @@ use thiserror::Error; use super::Sink; use crate::config::AudioFormat; -use crate::convert; +use crate::convert::Converter; use crate::decoder::AudioPacket; use crate::player::{NUM_CHANNELS, SAMPLE_RATE}; @@ -174,9 +174,7 @@ pub fn open(host: cpal::Host, device: Option, format: AudioFormat) -> Ro } impl Sink for RodioSink { - start_stop_noop!(); - - fn write(&mut self, packet: &AudioPacket) -> io::Result<()> { + fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> io::Result<()> { let samples = packet.samples(); match self.format { AudioFormat::F32 => { @@ -185,7 +183,7 @@ impl Sink for RodioSink { self.rodio_sink.append(source); } AudioFormat::S16 => { - let samples_s16: &[i16] = &convert::to_s16(samples); + let samples_s16: &[i16] = &converter.f32_to_s16(samples); let source = rodio::buffer::SamplesBuffer::new( NUM_CHANNELS as u16, SAMPLE_RATE, diff --git a/playback/src/audio_backend/sdl.rs b/playback/src/audio_backend/sdl.rs index a3a608d9..ab7c7ecc 100644 --- a/playback/src/audio_backend/sdl.rs +++ b/playback/src/audio_backend/sdl.rs @@ -1,6 +1,6 @@ use super::{Open, Sink}; use crate::config::AudioFormat; -use crate::convert; +use crate::convert::Converter; use crate::decoder::AudioPacket; use crate::player::{NUM_CHANNELS, SAMPLE_RATE}; use sdl2::audio::{AudioQueue, AudioSpecDesired}; @@ -81,7 +81,7 @@ impl Sink for SdlSink { Ok(()) } - fn write(&mut self, packet: &AudioPacket) -> io::Result<()> { + fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> io::Result<()> { macro_rules! drain_sink { ($queue: expr, $size: expr) => {{ // sleep and wait for sdl thread to drain the queue a bit @@ -98,12 +98,12 @@ impl Sink for SdlSink { queue.queue(samples) } Self::S32(queue) => { - let samples_s32: &[i32] = &convert::to_s32(samples); + let samples_s32: &[i32] = &converter.f32_to_s32(samples); drain_sink!(queue, AudioFormat::S32.size()); queue.queue(samples_s32) } Self::S16(queue) => { - let samples_s16: &[i16] = &convert::to_s16(samples); + let samples_s16: &[i16] = &converter.f32_to_s16(samples); drain_sink!(queue, AudioFormat::S16.size()); queue.queue(samples_s16) } diff --git a/playback/src/audio_backend/subprocess.rs b/playback/src/audio_backend/subprocess.rs index f493e7a7..785fb3d2 100644 --- a/playback/src/audio_backend/subprocess.rs +++ b/playback/src/audio_backend/subprocess.rs @@ -1,5 +1,6 @@ use super::{Open, Sink, SinkAsBytes}; use crate::config::AudioFormat; +use crate::convert::Converter; use crate::decoder::AudioPacket; use shell_words::split; diff --git a/playback/src/config.rs b/playback/src/config.rs index 9f8d97e1..4b9a74f0 100644 --- a/playback/src/config.rs +++ b/playback/src/config.rs @@ -1,5 +1,6 @@ use super::player::db_to_ratio; use crate::convert::i24; +pub use crate::dither::{mk_ditherer, DithererBuilder, TriangularDitherer}; use std::convert::TryFrom; use std::mem; @@ -117,10 +118,12 @@ impl Default for NormalisationMethod { } } -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct PlayerConfig { pub bitrate: Bitrate, pub gapless: bool, + pub passthrough: bool, + pub normalisation: bool, pub normalisation_type: NormalisationType, pub normalisation_method: NormalisationMethod, @@ -129,12 +132,15 @@ pub struct PlayerConfig { pub normalisation_attack: f32, pub normalisation_release: f32, pub normalisation_knee: f32, - pub passthrough: bool, + + // pass function pointers so they can be lazily instantiated *after* spawning a thread + // (thereby circumventing Send bounds that they might not satisfy) + pub ditherer: Option, } impl Default for PlayerConfig { - fn default() -> PlayerConfig { - PlayerConfig { + fn default() -> Self { + Self { bitrate: Bitrate::default(), gapless: true, normalisation: false, @@ -146,6 +152,7 @@ impl Default for PlayerConfig { normalisation_release: 0.1, normalisation_knee: 1.0, passthrough: false, + ditherer: Some(mk_ditherer::), } } } diff --git a/playback/src/convert.rs b/playback/src/convert.rs index 450910b0..c344d3e3 100644 --- a/playback/src/convert.rs +++ b/playback/src/convert.rs @@ -1,3 +1,4 @@ +use crate::dither::{Ditherer, DithererBuilder}; use zerocopy::AsBytes; #[derive(AsBytes, Copy, Clone, Debug)] @@ -5,52 +6,98 @@ use zerocopy::AsBytes; #[repr(transparent)] pub struct i24([u8; 3]); impl i24 { - fn pcm_from_i32(sample: i32) -> Self { - // drop the least significant byte - let [a, b, c, _d] = (sample >> 8).to_le_bytes(); + fn from_s24(sample: i32) -> Self { + // trim the padding in the most significant byte + let [a, b, c, _d] = sample.to_le_bytes(); i24([a, b, c]) } } -// Losslessly represent [-1.0, 1.0] to [$type::MIN, $type::MAX] while maintaining DC linearity. -macro_rules! convert_samples_to { - ($type: ident, $samples: expr) => { - convert_samples_to!($type, $samples, 0) - }; - ($type: ident, $samples: expr, $drop_bits: expr) => { - $samples +pub struct Converter { + ditherer: Option>, +} + +impl Converter { + pub fn new(dither_config: Option) -> Self { + if let Some(ref ditherer_builder) = dither_config { + let ditherer = (ditherer_builder)(); + info!("Converting with ditherer: {}", ditherer.name()); + Self { + ditherer: Some(ditherer), + } + } else { + Self { ditherer: None } + } + } + + // Denormalize and dither + pub fn scale(&mut self, sample: f32, factor: i64) -> f32 { + // From the many float to int conversion methods available, match what + // the reference Vorbis implementation uses: sample * 32768 (for 16 bit) + let int_value = sample * factor as f32; + + match self.ditherer { + Some(ref mut d) => int_value + d.noise(int_value), + None => int_value, + } + } + + // Special case for samples packed in a word of greater bit depth (e.g. + // S24): clamp between min and max to ensure that the most significant + // byte is zero. Otherwise, dithering may cause an overflow. This is not + // necessary for other formats, because casting to integer will saturate + // to the bounds of the primitive. + pub fn clamping_scale(&mut self, sample: f32, factor: i64) -> f32 { + let int_value = self.scale(sample, factor); + + // In two's complement, there are more negative than positive values. + let min = -factor as f32; + let max = (factor - 1) as f32; + + if int_value < min { + return min; + } else if int_value > max { + return max; + } + int_value + } + + // https://doc.rust-lang.org/nomicon/casts.html: casting float to integer + // rounds towards zero, then saturates. Ideally halves should round to even to + // prevent any bias, but since it is extremely unlikely that a float has + // *exactly* .5 as fraction, this should be more than precise enough. + pub fn f32_to_s32(&mut self, samples: &[f32]) -> Vec { + samples + .iter() + .map(|sample| self.scale(*sample, 0x80000000) as i32) + .collect() + } + + // S24 is 24-bit PCM packed in an upper 32-bit word + pub fn f32_to_s24(&mut self, samples: &[f32]) -> Vec { + samples + .iter() + .map(|sample| self.clamping_scale(*sample, 0x800000) as i32) + .collect() + } + + // S24_3 is 24-bit PCM in a 3-byte array + pub fn f32_to_s24_3(&mut self, samples: &[f32]) -> Vec { + samples .iter() .map(|sample| { - // Losslessly represent [-1.0, 1.0] to [$type::MIN, $type::MAX] - // while maintaining DC linearity. There is nothing to be gained - // by doing this in f64, as the significand of a f32 is 24 bits, - // just like the maximum bit depth we are converting to. - let int_value = *sample * (std::$type::MAX as f32 + 0.5) - 0.5; - - // Casting floats to ints truncates by default, which results - // in larger quantization error than rounding arithmetically. - // Flooring is faster, but again with larger error. - int_value.round() as $type >> $drop_bits + // Not as DRY as calling f32_to_s24 first, but this saves iterating + // over all samples twice. + let int_value = self.clamping_scale(*sample, 0x800000) as i32; + i24::from_s24(int_value) }) .collect() - }; -} + } -pub fn to_s32(samples: &[f32]) -> Vec { - convert_samples_to!(i32, samples) -} - -pub fn to_s24(samples: &[f32]) -> Vec { - convert_samples_to!(i32, samples, 8) -} - -pub fn to_s24_3(samples: &[f32]) -> Vec { - to_s32(samples) - .iter() - .map(|sample| i24::pcm_from_i32(*sample)) - .collect() -} - -pub fn to_s16(samples: &[f32]) -> Vec { - convert_samples_to!(i16, samples) + pub fn f32_to_s16(&mut self, samples: &[f32]) -> Vec { + samples + .iter() + .map(|sample| self.scale(*sample, 0x8000) as i16) + .collect() + } } diff --git a/playback/src/decoder/libvorbis_decoder.rs b/playback/src/decoder/libvorbis_decoder.rs index 6f9a68a3..23e66583 100644 --- a/playback/src/decoder/libvorbis_decoder.rs +++ b/playback/src/decoder/libvorbis_decoder.rs @@ -38,14 +38,11 @@ where loop { match self.0.packets().next() { Some(Ok(packet)) => { - // Losslessly represent [-32768, 32767] to [-1.0, 1.0] while maintaining DC linearity. return Ok(Some(AudioPacket::Samples( packet .data .iter() - .map(|sample| { - ((*sample as f64 + 0.5) / (std::i16::MAX as f64 + 0.5)) as f32 - }) + .map(|sample| (*sample as f64 / 0x8000 as f64) as f32) .collect(), ))); } diff --git a/playback/src/dither.rs b/playback/src/dither.rs new file mode 100644 index 00000000..86aca6e2 --- /dev/null +++ b/playback/src/dither.rs @@ -0,0 +1,138 @@ +use rand::rngs::ThreadRng; +use rand_distr::{Distribution, Normal, Triangular, Uniform}; +use std::fmt; + +const NUM_CHANNELS: usize = 2; + +// Dithering lowers digital-to-analog conversion ("requantization") error, +// linearizing output, lowering distortion and replacing it with a constant, +// fixed noise level, which is more pleasant to the ear than the distortion. +// +// Guidance: +// +// * On S24, S24_3 and S24, the default is to use triangular dithering. +// Depending on personal preference you may use Gaussian dithering instead; +// it's not as good objectively, but it may be preferred subjectively if +// you are looking for a more "analog" sound akin to tape hiss. +// +// * Advanced users who know that they have a DAC without noise shaping have +// a third option: high-passed dithering, which is like triangular dithering +// except that it moves dithering noise up in frequency where it is less +// audible. Note: 99% of DACs are of delta-sigma design with noise shaping, +// so unless you have a multibit / R2R DAC, or otherwise know what you are +// doing, this is not for you. +// +// * Don't dither or shape noise on S32 or F32. On F32 it's not supported +// anyway (there are no integer conversions and so no rounding errors) and +// on S32 the noise level is so far down that it is simply inaudible even +// after volume normalisation and control. +// +pub trait Ditherer { + fn new() -> Self + where + Self: Sized; + fn name(&self) -> &'static str; + fn noise(&mut self, sample: f32) -> f32; +} + +impl fmt::Display for dyn Ditherer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.name()) + } +} + +// Implementation note: we save the handle to ThreadRng so it doesn't require +// a lookup on each call (which is on each sample!). This is ~2.5x as fast. +// Downside is that it is not Send so we cannot move it around player threads. +// + +pub struct TriangularDitherer { + cached_rng: ThreadRng, + distribution: Triangular, +} + +impl Ditherer for TriangularDitherer { + fn new() -> Self { + Self { + cached_rng: rand::thread_rng(), + // 2 LSB peak-to-peak needed to linearize the response: + distribution: Triangular::new(-1.0, 1.0, 0.0).unwrap(), + } + } + + fn name(&self) -> &'static str { + "Triangular" + } + + fn noise(&mut self, _sample: f32) -> f32 { + self.distribution.sample(&mut self.cached_rng) + } +} + +pub struct GaussianDitherer { + cached_rng: ThreadRng, + distribution: Normal, +} + +impl Ditherer for GaussianDitherer { + fn new() -> Self { + Self { + cached_rng: rand::thread_rng(), + // 1/2 LSB RMS needed to linearize the response: + distribution: Normal::new(0.0, 0.5).unwrap(), + } + } + + fn name(&self) -> &'static str { + "Gaussian" + } + + fn noise(&mut self, _sample: f32) -> f32 { + self.distribution.sample(&mut self.cached_rng) + } +} + +pub struct HighPassDitherer { + active_channel: usize, + previous_noises: [f32; NUM_CHANNELS], + cached_rng: ThreadRng, + distribution: Uniform, +} + +impl Ditherer for HighPassDitherer { + fn new() -> Self { + Self { + active_channel: 0, + previous_noises: [0.0; NUM_CHANNELS], + cached_rng: rand::thread_rng(), + distribution: Uniform::new_inclusive(-0.5, 0.5), // 1 LSB +/- 1 LSB (previous) = 2 LSB + } + } + + fn name(&self) -> &'static str { + "Triangular, High Passed" + } + + fn noise(&mut self, _sample: f32) -> f32 { + let new_noise = self.distribution.sample(&mut self.cached_rng); + let high_passed_noise = new_noise - self.previous_noises[self.active_channel]; + self.previous_noises[self.active_channel] = new_noise; + self.active_channel ^= 1; + high_passed_noise + } +} + +pub fn mk_ditherer() -> Box { + Box::new(D::new()) +} + +pub type DithererBuilder = fn() -> Box; + +pub fn find_ditherer(name: Option) -> Option { + match name.as_deref() { + Some("tpdf") => Some(mk_ditherer::), + Some("gpdf") => Some(mk_ditherer::), + Some("tpdf_hp") => Some(mk_ditherer::), + _ => None, + } +} diff --git a/playback/src/lib.rs b/playback/src/lib.rs index 58423380..31dadc44 100644 --- a/playback/src/lib.rs +++ b/playback/src/lib.rs @@ -9,5 +9,6 @@ pub mod audio_backend; pub mod config; mod convert; mod decoder; +pub mod dither; pub mod mixer; pub mod player; diff --git a/playback/src/player.rs b/playback/src/player.rs index 659804f8..ba159946 100644 --- a/playback/src/player.rs +++ b/playback/src/player.rs @@ -18,6 +18,7 @@ use crate::audio::{ }; use crate::audio_backend::Sink; use crate::config::{Bitrate, NormalisationMethod, NormalisationType, PlayerConfig}; +use crate::convert::Converter; use crate::core::session::Session; use crate::core::spotify_id::SpotifyId; use crate::core::util::SeqGenerator; @@ -59,6 +60,7 @@ struct PlayerInternal { sink_event_callback: Option, audio_filter: Option>, event_senders: Vec>, + converter: Converter, limiter_active: bool, limiter_attack_counter: u32, @@ -297,6 +299,8 @@ impl Player { let handle = thread::spawn(move || { debug!("new Player[{}]", session.session_id()); + let converter = Converter::new(config.ditherer); + let internal = PlayerInternal { session, config, @@ -309,6 +313,7 @@ impl Player { sink_event_callback: None, audio_filter, event_senders: [event_sender].to_vec(), + converter, limiter_active: false, limiter_attack_counter: 0, @@ -1283,7 +1288,7 @@ impl PlayerInternal { } } - if let Err(err) = self.sink.write(&packet) { + if let Err(err) = self.sink.write(&packet, &mut self.converter) { error!("Could not write audio: {}", err); self.ensure_sink_stopped(false); } diff --git a/src/main.rs b/src/main.rs index 739336bb..ab47605e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ use librespot::playback::audio_backend::{self, SinkBuilder, BACKENDS}; use librespot::playback::config::{ AudioFormat, Bitrate, NormalisationMethod, NormalisationType, PlayerConfig, VolumeCtrl, }; +use librespot::playback::dither; use librespot::playback::mixer::mappings::MappedCtrl; use librespot::playback::mixer::{self, MixerConfig, MixerFn}; use librespot::playback::player::{db_to_ratio, Player}; @@ -170,7 +171,6 @@ fn print_version() { ); } -#[derive(Clone)] struct Setup { format: AudioFormat, backend: SinkBuilder, @@ -246,6 +246,12 @@ fn get_setup(args: &[String]) -> Setup { "Output format (F32, S32, S24, S24_3 or S16). Defaults to S16", "FORMAT", ) + .optopt( + "", + "dither", + "Specify the dither algorithm to use - [none, gpdf, tpdf, tpdf_hp]. Defaults to 'tpdf' for formats S16, S24, S24_3 and 'none' for other formats.", + "DITHER", + ) .optopt("", "mixer", "Mixer to use (alsa or softvol)", "MIXER") .optopt( "m", @@ -570,7 +576,9 @@ fn get_setup(args: &[String]) -> Setup { .as_ref() .map(|bitrate| Bitrate::from_str(bitrate).expect("Invalid bitrate")) .unwrap_or_default(); + let gapless = !matches.opt_present("disable-gapless"); + let normalisation = matches.opt_present("enable-volume-normalisation"); let normalisation_method = matches .opt_str("normalisation-method") @@ -612,11 +620,33 @@ fn get_setup(args: &[String]) -> Setup { .opt_str("normalisation-knee") .map(|knee| knee.parse::().expect("Invalid knee float value")) .unwrap_or(PlayerConfig::default().normalisation_knee); + + let ditherer_name = matches.opt_str("dither"); + let ditherer = match ditherer_name.as_deref() { + // explicitly disabled on command line + Some("none") => None, + // explicitly set on command line + Some(_) => { + if format == AudioFormat::F32 { + unimplemented!("Dithering is not available on format {:?}", format); + } + Some(dither::find_ditherer(ditherer_name).expect("Invalid ditherer")) + } + // nothing set on command line => use default + None => match format { + AudioFormat::S16 | AudioFormat::S24 | AudioFormat::S24_3 => { + PlayerConfig::default().ditherer + } + _ => None, + }, + }; + let passthrough = matches.opt_present("passthrough"); PlayerConfig { bitrate, gapless, + passthrough, normalisation, normalisation_type, normalisation_method, @@ -625,7 +655,7 @@ fn get_setup(args: &[String]) -> Setup { normalisation_attack, normalisation_release, normalisation_knee, - passthrough, + ditherer, } }; From 11dfedea3b7e53549739b00116c8b1d0e9f65e56 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Wed, 26 May 2021 21:43:20 +0200 Subject: [PATCH 13/36] Remove `with-vorbis` and `with-tremor` features (#750) --- CHANGELOG.md | 1 + Cargo.lock | 69 ------------------------------------- Cargo.toml | 3 -- playback/Cargo.toml | 15 +++----- playback/src/decoder/mod.rs | 13 ++----- 5 files changed, 8 insertions(+), 93 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ff1b8ab..cf108575 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed - [connect] Removed no-op mixer started/stopped logic (breaking) +- [playback] Removed `with-vorbis` and `with-tremor` features - [playback] `alsamixer`: removed `--mixer-linear-volume` option; use `--volume-ctrl linear` instead ### Fixed diff --git a/Cargo.lock b/Cargo.lock index d22a7e36..dc9876bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -529,12 +529,6 @@ dependencies = [ "slab", ] -[[package]] -name = "gcc" -version = "0.3.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f5f3913fa0bfe7ee1fd8248b6b9f42a5af4b9d65ec2dd2c3c26132b950ecfc2" - [[package]] name = "generic-array" version = "0.14.4" @@ -1286,7 +1280,6 @@ version = "0.2.0" dependencies = [ "alsa", "byteorder", - "cfg-if 1.0.0", "cpal", "futures-executor", "futures-util", @@ -1300,7 +1293,6 @@ dependencies = [ "librespot-audio", "librespot-core", "librespot-metadata", - "librespot-tremor", "log", "ogg", "portaudio-rs", @@ -1311,7 +1303,6 @@ dependencies = [ "shell-words", "thiserror", "tokio", - "vorbis", "zerocopy", ] @@ -1324,18 +1315,6 @@ dependencies = [ "protobuf-codegen-pure", ] -[[package]] -name = "librespot-tremor" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97f525bff915d478a76940a7b988e5ea34911ba7280c97bd3a7673f54d68b4fe" -dependencies = [ - "cc", - "libc", - "ogg-sys", - "pkg-config", -] - [[package]] name = "lock_api" version = "0.4.4" @@ -1618,17 +1597,6 @@ dependencies = [ "byteorder", ] -[[package]] -name = "ogg-sys" -version = "0.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a95b8c172e17df1a41bf8d666301d3b2c4efeb90d9d0415e2a4dc0668b35fdb2" -dependencies = [ - "gcc", - "libc", - "pkg-config", -] - [[package]] name = "once_cell" version = "1.7.2" @@ -2492,43 +2460,6 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" -[[package]] -name = "vorbis" -version = "0.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e8a194457075360557b82dac78f7ca2d65bbb6679bccfabae5f7c8c706cc776" -dependencies = [ - "libc", - "ogg-sys", - "vorbis-sys", - "vorbisfile-sys", -] - -[[package]] -name = "vorbis-sys" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9ed6ef5361a85e68ccc005961d995c2d44e31f0816f142025f2ca2383dfbfd" -dependencies = [ - "cc", - "libc", - "ogg-sys", - "pkg-config", -] - -[[package]] -name = "vorbisfile-sys" -version = "0.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f4306d7e1ac4699b55e20de9483750b90c250913188efd7484db6bfbe9042d1" -dependencies = [ - "gcc", - "libc", - "ogg-sys", - "pkg-config", - "vorbis-sys", -] - [[package]] name = "walkdir" version = "2.3.2" diff --git a/Cargo.toml b/Cargo.toml index 80903698..5ea5bf1f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,9 +73,6 @@ rodiojack-backend = ["librespot-playback/rodiojack-backend"] sdl-backend = ["librespot-playback/sdl-backend"] gstreamer-backend = ["librespot-playback/gstreamer-backend"] -with-tremor = ["librespot-playback/with-tremor"] -with-vorbis = ["librespot-playback/with-vorbis"] - with-dns-sd = ["librespot-discovery/with-dns-sd"] default = ["rodio-backend"] diff --git a/playback/Cargo.toml b/playback/Cargo.toml index 6970e7a8..2af1436b 100644 --- a/playback/Cargo.toml +++ b/playback/Cargo.toml @@ -18,15 +18,15 @@ path = "../metadata" version = "0.2.0" [dependencies] -cfg-if = "1.0" futures-executor = "0.3" futures-util = { version = "0.3", default_features = false, features = ["alloc"] } log = "0.4" byteorder = "1.4" shell-words = "1.0.0" tokio = { version = "1", features = ["sync"] } -zerocopy = { version = "0.3" } +zerocopy = { version = "0.3" } +# Backends alsa = { version = "0.5", optional = true } portaudio-rs = { version = "0.3", optional = true } libpulse-binding = { version = "2", optional = true, default-features = false } @@ -42,11 +42,9 @@ rodio = { version = "0.14", optional = true, default-features = false cpal = { version = "0.13", optional = true } thiserror = { version = "1", optional = true } -# Decoders -lewton = "0.10" # Currently not optional because of limitations of cargo features -librespot-tremor = { version = "0.2", optional = true } +# Decoder +lewton = "0.10" ogg = "0.8" -vorbis = { version ="0.0", optional = true } # Dithering rand = "0.8" @@ -60,7 +58,4 @@ jackaudio-backend = ["jack"] rodio-backend = ["rodio", "cpal", "thiserror"] rodiojack-backend = ["rodio", "cpal/jack", "thiserror"] sdl-backend = ["sdl2"] -gstreamer-backend = ["gstreamer", "gstreamer-app", "glib"] - -with-tremor = ["librespot-tremor"] -with-vorbis = ["vorbis"] \ No newline at end of file +gstreamer-backend = ["gstreamer", "gstreamer-app", "glib"] \ No newline at end of file diff --git a/playback/src/decoder/mod.rs b/playback/src/decoder/mod.rs index 6108f00f..46c39212 100644 --- a/playback/src/decoder/mod.rs +++ b/playback/src/decoder/mod.rs @@ -1,16 +1,7 @@ use std::fmt; -use cfg_if::cfg_if; - -cfg_if! { - if #[cfg(any(feature = "with-tremor", feature = "with-vorbis"))] { - mod libvorbis_decoder; - pub use libvorbis_decoder::{VorbisDecoder, VorbisError}; - } else { - mod lewton_decoder; - pub use lewton_decoder::{VorbisDecoder, VorbisError}; - } -} +mod lewton_decoder; +pub use lewton_decoder::{VorbisDecoder, VorbisError}; mod passthrough_decoder; pub use passthrough_decoder::{PassthroughDecoder, PassthroughError}; From 8abc0becaf6523bf191df86db9e1a5919114aa52 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Wed, 26 May 2021 22:03:52 +0200 Subject: [PATCH 14/36] Print normalisation setup once and add units (#759) --- playback/src/player.rs | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/playback/src/player.rs b/playback/src/player.rs index ba159946..7cc505b7 100644 --- a/playback/src/player.rs +++ b/playback/src/player.rs @@ -265,19 +265,7 @@ impl NormalisationData { } debug!("Normalisation Data: {:?}", data); - debug!("Normalisation Type: {:?}", config.normalisation_type); - debug!( - "Normalisation Threshold: {:.1}", - ratio_to_db(config.normalisation_threshold) - ); - debug!("Normalisation Method: {:?}", config.normalisation_method); - debug!("Normalisation Factor: {}", normalisation_factor); - - if config.normalisation_method == NormalisationMethod::Dynamic { - debug!("Normalisation Attack: {:?}", config.normalisation_attack); - debug!("Normalisation Release: {:?}", config.normalisation_release); - debug!("Normalisation Knee: {:?}", config.normalisation_knee); - } + debug!("Normalisation Factor: {:.2}%", normalisation_factor * 100.0); normalisation_factor } @@ -296,6 +284,27 @@ impl Player { let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); let (event_sender, event_receiver) = mpsc::unbounded_channel(); + if config.normalisation { + debug!("Normalisation Type: {:?}", config.normalisation_type); + debug!( + "Normalisation Threshold: {:.1} dBFS", + ratio_to_db(config.normalisation_threshold) + ); + debug!("Normalisation Method: {:?}", config.normalisation_method); + + if config.normalisation_method == NormalisationMethod::Dynamic { + debug!( + "Normalisation Attack: {:.0} ms", + config.normalisation_attack * 1000.0 + ); + debug!( + "Normalisation Release: {:.0} ms", + config.normalisation_release * 1000.0 + ); + debug!("Normalisation Knee: {:?}", config.normalisation_knee); + } + } + let handle = thread::spawn(move || { debug!("new Player[{}]", session.session_id()); From 87743394d98fc4e04747c740ac23f6d2fda00dae Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Wed, 26 May 2021 22:30:32 +0200 Subject: [PATCH 15/36] Improve getopts and stderr message consistency in main (#757) --- src/main.rs | 355 ++++++++++++++++++++++++++++------------------------ 1 file changed, 188 insertions(+), 167 deletions(-) diff --git a/src/main.rs b/src/main.rs index ab47605e..81988136 100644 --- a/src/main.rs +++ b/src/main.rs @@ -190,182 +190,195 @@ struct Setup { fn get_setup(args: &[String]) -> Setup { let mut opts = getopts::Options::new(); - opts.optopt( + opts.optflag( + "h", + "help", + "Print this help menu.", + ).optopt( "c", "cache", "Path to a directory where files will be cached.", - "CACHE", + "PATH", ).optopt( "", "system-cache", - "Path to a directory where system files (credentials, volume) will be cached. Can be different from cache option value", - "SYTEMCACHE", + "Path to a directory where system files (credentials, volume) will be cached. Can be different from cache option value.", + "PATH", ).optopt( "", "cache-size-limit", "Limits the size of the cache for audio files.", - "CACHE_SIZE_LIMIT" + "SIZE" ).optflag("", "disable-audio-cache", "Disable caching of the audio data.") - .optopt("n", "name", "Device name", "NAME") - .optopt("", "device-type", "Displayed device type", "DEVICE_TYPE") - .optopt( - "b", - "bitrate", - "Bitrate (96, 160 or 320). Defaults to 160", - "BITRATE", - ) - .optopt( - "", - "onevent", - "Run PROGRAM when playback is about to begin.", - "PROGRAM", - ) - .optflag("", "emit-sink-events", "Run program set by --onevent before sink is opened and after it is closed.") - .optflag("v", "verbose", "Enable verbose output") - .optflag("V", "version", "Display librespot version string") - .optopt("u", "username", "Username to sign in with", "USERNAME") - .optopt("p", "password", "Password", "PASSWORD") - .optopt("", "proxy", "HTTP proxy to use when connecting", "PROXY") - .optopt("", "ap-port", "Connect to AP with specified port. If no AP with that port are present fallback AP will be used. Available ports are usually 80, 443 and 4070", "AP_PORT") - .optflag("", "disable-discovery", "Disable discovery mode") - .optopt( - "", - "backend", - "Audio backend to use. Use '?' to list options", - "BACKEND", - ) - .optopt( - "", - "device", - "Audio device to use. Use '?' to list options if using portaudio or alsa", - "DEVICE", - ) - .optopt( - "", - "format", - "Output format (F32, S32, S24, S24_3 or S16). Defaults to S16", - "FORMAT", - ) - .optopt( - "", - "dither", - "Specify the dither algorithm to use - [none, gpdf, tpdf, tpdf_hp]. Defaults to 'tpdf' for formats S16, S24, S24_3 and 'none' for other formats.", - "DITHER", - ) - .optopt("", "mixer", "Mixer to use (alsa or softvol)", "MIXER") - .optopt( - "m", - "mixer-name", - "Alsa mixer control, e.g. 'PCM' or 'Master'. Defaults to 'PCM'.", - "MIXER_NAME", - ) - .optopt( - "", - "mixer-card", - "Alsa mixer card, e.g 'hw:0' or similar from `aplay -l`. Defaults to DEVICE if specified, 'default' otherwise.", - "MIXER_CARD", - ) - .optopt( - "", - "mixer-index", - "Alsa mixer index, Index of the cards mixer. Defaults to 0", - "MIXER_INDEX", - ) - .optopt( - "", - "initial-volume", - "Initial volume (%) once connected {0..100}. Defaults to 50 for softvol and for Alsa mixer the current volume.", - "VOLUME", - ) - .optopt( - "", - "zeroconf-port", - "The port the internal server advertised over zeroconf uses.", - "ZEROCONF_PORT", - ) - .optflag( - "", - "enable-volume-normalisation", - "Play all tracks at the same volume", - ) - .optopt( - "", - "normalisation-method", - "Specify the normalisation method to use - [basic, dynamic]. Default is dynamic.", - "NORMALISATION_METHOD", - ) - .optopt( - "", - "normalisation-gain-type", - "Specify the normalisation gain type to use - [track, album]. Default is album.", - "GAIN_TYPE", - ) - .optopt( - "", - "normalisation-pregain", - "Pregain (dB) applied by volume normalisation", - "PREGAIN", - ) - .optopt( - "", - "normalisation-threshold", - "Threshold (dBFS) to prevent clipping. Default is -1.0.", - "THRESHOLD", - ) - .optopt( - "", - "normalisation-attack", - "Attack time (ms) in which the dynamic limiter is reducing gain. Default is 5.", - "ATTACK", - ) - .optopt( - "", - "normalisation-release", - "Release or decay time (ms) in which the dynamic limiter is restoring gain. Default is 100.", - "RELEASE", - ) - .optopt( - "", - "normalisation-knee", - "Knee steepness of the dynamic limiter. Default is 1.0.", - "KNEE", - ) - .optopt( - "", - "volume-ctrl", - "Volume control type {cubic|fixed|linear|log}. Defaults to log.", - "VOLUME_CTRL" - ) - .optopt( - "", - "volume-range", - "Range of the volume control (dB). Defaults to 60 for softvol and for Alsa mixer what the mixer supports.", - "RANGE", - ) - .optflag( - "", - "autoplay", - "autoplay similar songs when your music ends.", - ) - .optflag( - "", - "disable-gapless", - "disable gapless playback.", - ) - .optflag( - "", - "passthrough", - "Pass raw stream to output, only works for \"pipe\"." - ); + .optopt("n", "name", "Device name.", "NAME") + .optopt("", "device-type", "Displayed device type.", "TYPE") + .optopt( + "b", + "bitrate", + "Bitrate (kbps) {96|160|320}. Defaults to 160.", + "BITRATE", + ) + .optopt( + "", + "onevent", + "Run PROGRAM when a playback event occurs.", + "PROGRAM", + ) + .optflag("", "emit-sink-events", "Run program set by --onevent before sink is opened and after it is closed.") + .optflag("v", "verbose", "Enable verbose output.") + .optflag("V", "version", "Display librespot version string.") + .optopt("u", "username", "Username to sign in with.", "USERNAME") + .optopt("p", "password", "Password", "PASSWORD") + .optopt("", "proxy", "HTTP proxy to use when connecting.", "URL") + .optopt("", "ap-port", "Connect to AP with specified port. If no AP with that port are present fallback AP will be used. Available ports are usually 80, 443 and 4070.", "PORT") + .optflag("", "disable-discovery", "Disable discovery mode.") + .optopt( + "", + "backend", + "Audio backend to use. Use '?' to list options.", + "NAME", + ) + .optopt( + "", + "device", + "Audio device to use. Use '?' to list options if using alsa, portaudio or rodio.", + "NAME", + ) + .optopt( + "", + "format", + "Output format {F32|S32|S24|S24_3|S16}. Defaults to S16.", + "FORMAT", + ) + .optopt( + "", + "dither", + "Specify the dither algorithm to use - [none, gpdf, tpdf, tpdf_hp]. Defaults to 'tpdf' for formats S16, S24, S24_3 and 'none' for other formats.", + "DITHER", + ) + .optopt("", "mixer", "Mixer to use {alsa|softvol}.", "MIXER") + .optopt( + "m", + "mixer-name", + "Alsa mixer control, e.g. 'PCM' or 'Master'. Defaults to 'PCM'.", + "NAME", + ) + .optopt( + "", + "mixer-card", + "Alsa mixer card, e.g 'hw:0' or similar from `aplay -l`. Defaults to DEVICE if specified, 'default' otherwise.", + "MIXER_CARD", + ) + .optopt( + "", + "mixer-index", + "Alsa index of the cards mixer. Defaults to 0.", + "INDEX", + ) + .optopt( + "", + "initial-volume", + "Initial volume in % from 0-100. Default for softvol: '50'. For the Alsa mixer: the current volume.", + "VOLUME", + ) + .optopt( + "", + "zeroconf-port", + "The port the internal server advertised over zeroconf uses.", + "PORT", + ) + .optflag( + "", + "enable-volume-normalisation", + "Play all tracks at the same volume.", + ) + .optopt( + "", + "normalisation-method", + "Specify the normalisation method to use {basic|dynamic}. Defaults to dynamic.", + "METHOD", + ) + .optopt( + "", + "normalisation-gain-type", + "Specify the normalisation gain type to use {track|album}. Defaults to album.", + "TYPE", + ) + .optopt( + "", + "normalisation-pregain", + "Pregain (dB) applied by volume normalisation. Defaults to 0.", + "PREGAIN", + ) + .optopt( + "", + "normalisation-threshold", + "Threshold (dBFS) to prevent clipping. Defaults to -1.0.", + "THRESHOLD", + ) + .optopt( + "", + "normalisation-attack", + "Attack time (ms) in which the dynamic limiter is reducing gain. Defaults to 5.", + "TIME", + ) + .optopt( + "", + "normalisation-release", + "Release or decay time (ms) in which the dynamic limiter is restoring gain. Defaults to 100.", + "TIME", + ) + .optopt( + "", + "normalisation-knee", + "Knee steepness of the dynamic limiter. Defaults to 1.0.", + "KNEE", + ) + .optopt( + "", + "volume-ctrl", + "Volume control type {cubic|fixed|linear|log}. Defaults to log.", + "VOLUME_CTRL" + ) + .optopt( + "", + "volume-range", + "Range of the volume control (dB). Default for softvol: 60. For the Alsa mixer: what the control supports.", + "RANGE", + ) + .optflag( + "", + "autoplay", + "Automatically play similar songs when your music ends.", + ) + .optflag( + "", + "disable-gapless", + "Disable gapless playback.", + ) + .optflag( + "", + "passthrough", + "Pass raw stream to output, only works for pipe and subprocess.", + ); let matches = match opts.parse(&args[1..]) { Ok(m) => m, Err(f) => { - eprintln!("error: {}\n{}", f.to_string(), usage(&args[0], &opts)); + eprintln!( + "Error parsing command line options: {}\n{}", + f, + usage(&args[0], &opts) + ); exit(1); } }; + if matches.opt_present("h") { + println!("{}", usage(&args[0], &opts)); + exit(0); + } + if matches.opt_present("version") { print_version(); exit(0); @@ -552,7 +565,7 @@ fn get_setup(args: &[String]) -> Setup { match Url::parse(&s) { Ok(url) => { if url.host().is_none() || url.port_or_known_default().is_none() { - panic!("Invalid proxy url, only urls on the format \"http://host:port\" are allowed"); + panic!("Invalid proxy url, only URLs on the format \"http://host:port\" are allowed"); } if url.scheme() != "http" { @@ -560,7 +573,7 @@ fn get_setup(args: &[String]) -> Setup { } url }, - Err(err) => panic!("Invalid proxy url: {}, only urls on the format \"http://host:port\" are allowed", err) + Err(err) => panic!("Invalid proxy URL: {}, only URLs in the format \"http://host:port\" are allowed", err) } }, ), @@ -792,14 +805,14 @@ async fn main() { Ok(e) if e.success() => (), Ok(e) => { if let Some(code) = e.code() { - warn!("Sink event prog returned exit code {}", code); + warn!("Sink event program returned exit code {}", code); } else { - warn!("Sink event prog returned failure"); + warn!("Sink event program returned failure"); } - } + }, Err(e) => { warn!("Emitting sink event failed: {}", e); - } + }, } }))); } @@ -849,13 +862,21 @@ async fn main() { tokio::spawn(async move { match child.wait().await { - Ok(status) if !status.success() => error!("child exited with status {:?}", status.code()), - Err(e) => error!("failed to wait on child process: {}", e), - _ => {} + Ok(e) if e.success() => (), + Ok(e) => { + if let Some(code) = e.code() { + warn!("On event program returned exit code {}", code); + } else { + warn!("On event program returned failure"); + } + }, + Err(e) => { + warn!("On event program failed: {}", e); + }, } }); } else { - error!("program failed to start"); + warn!("On event program failed to start"); } } } From f2d31b73bb6ab1f4f1368f82208166687da215e9 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Wed, 26 May 2021 23:14:24 +0200 Subject: [PATCH 16/36] Print normalisation pregain in verbose mode --- playback/src/player.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/playback/src/player.rs b/playback/src/player.rs index 7cc505b7..254908eb 100644 --- a/playback/src/player.rs +++ b/playback/src/player.rs @@ -286,6 +286,10 @@ impl Player { if config.normalisation { debug!("Normalisation Type: {:?}", config.normalisation_type); + debug!( + "Normalisation Pregain: {:.1} dB", + config.normalisation_pregain + ); debug!( "Normalisation Threshold: {:.1} dBFS", ratio_to_db(config.normalisation_threshold) From 049bcf616f8881cf6be224ff424dde8bb9707f62 Mon Sep 17 00:00:00 2001 From: johannesd3 Date: Thu, 27 May 2021 15:28:14 +0200 Subject: [PATCH 17/36] Fix integer overflow in spotify_id --- core/src/spotify_id.rs | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/core/src/spotify_id.rs b/core/src/spotify_id.rs index 3372572a..e79a247c 100644 --- a/core/src/spotify_id.rs +++ b/core/src/spotify_id.rs @@ -116,17 +116,20 @@ impl SpotifyId { /// /// [Spotify URI]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids pub fn from_uri(src: &str) -> Result { - // We expect the ID to be the last colon-delimited item in the URI. - let b = src.as_bytes(); - let id_i = b.len() - SpotifyId::SIZE_BASE62; - if b[id_i - 1] != b':' { + let src = src.strip_prefix("spotify:").ok_or(SpotifyIdError)?; + + if src.len() <= SpotifyId::SIZE_BASE62 { return Err(SpotifyIdError); } - let mut id = SpotifyId::from_base62(&src[id_i..])?; + let colon_index = src.len() - SpotifyId::SIZE_BASE62 - 1; - // Slice offset by 8 as we are skipping the "spotify:" prefix. - id.audio_type = src[8..id_i - 1].into(); + if src.as_bytes()[colon_index] != b':' { + return Err(SpotifyIdError); + } + + let mut id = SpotifyId::from_base62(&src[colon_index + 1..])?; + id.audio_type = src[..colon_index].into(); Ok(id) } @@ -305,7 +308,7 @@ mod tests { }, ]; - static CONV_INVALID: [ConversionCase; 2] = [ + static CONV_INVALID: [ConversionCase; 3] = [ ConversionCase { id: 0, kind: SpotifyAudioType::NonPlayable, @@ -330,6 +333,18 @@ mod tests { 154, 27, 28, 251, ], }, + ConversionCase { + id: 0, + kind: SpotifyAudioType::NonPlayable, + // Uri too short + uri: "spotify:azb:aRS48xBl0tH", + base16: "--------------------", + base62: "....................", + raw: &[ + // Invalid length. + 154, 27, 28, 251, + ], + }, ]; #[test] From 3be50fd90f94771f6894d7c6cd74772e61e2fe75 Mon Sep 17 00:00:00 2001 From: johannesd3 Date: Thu, 27 May 2021 17:20:49 +0200 Subject: [PATCH 18/36] Corrected documentation --- core/src/spotify_id.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/spotify_id.rs b/core/src/spotify_id.rs index e79a247c..e6e2bae0 100644 --- a/core/src/spotify_id.rs +++ b/core/src/spotify_id.rs @@ -134,7 +134,7 @@ impl SpotifyId { Ok(id) } - /// Returns the `SpotifyId` as a base16 (hex) encoded, `SpotifyId::SIZE_BASE62` (22) + /// Returns the `SpotifyId` as a base16 (hex) encoded, `SpotifyId::SIZE_BASE16` (32) /// character long `String`. pub fn to_base16(&self) -> String { to_base16(&self.to_raw(), &mut [0u8; SpotifyId::SIZE_BASE16]) From 19f0555e7c04dcf686767a8128f1b69bc2f5320d Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Thu, 27 May 2021 23:44:45 +0200 Subject: [PATCH 19/36] Fix leftovers from merging diverging branches --- playback/src/convert.rs | 8 +-- playback/src/decoder/libvorbis_decoder.rs | 86 ----------------------- playback/src/mixer/mappings.rs | 2 +- 3 files changed, 5 insertions(+), 91 deletions(-) delete mode 100644 playback/src/decoder/libvorbis_decoder.rs diff --git a/playback/src/convert.rs b/playback/src/convert.rs index c344d3e3..a5d5a0bb 100644 --- a/playback/src/convert.rs +++ b/playback/src/convert.rs @@ -36,6 +36,10 @@ impl Converter { // the reference Vorbis implementation uses: sample * 32768 (for 16 bit) let int_value = sample * factor as f32; + // https://doc.rust-lang.org/nomicon/casts.html: casting float to integer + // rounds towards zero, then saturates. Ideally halves should round to even to + // prevent any bias, but since it is extremely unlikely that a float has + // *exactly* .5 as fraction, this should be more than precise enough. match self.ditherer { Some(ref mut d) => int_value + d.noise(int_value), None => int_value, @@ -62,10 +66,6 @@ impl Converter { int_value } - // https://doc.rust-lang.org/nomicon/casts.html: casting float to integer - // rounds towards zero, then saturates. Ideally halves should round to even to - // prevent any bias, but since it is extremely unlikely that a float has - // *exactly* .5 as fraction, this should be more than precise enough. pub fn f32_to_s32(&mut self, samples: &[f32]) -> Vec { samples .iter() diff --git a/playback/src/decoder/libvorbis_decoder.rs b/playback/src/decoder/libvorbis_decoder.rs deleted file mode 100644 index 23e66583..00000000 --- a/playback/src/decoder/libvorbis_decoder.rs +++ /dev/null @@ -1,86 +0,0 @@ -#[cfg(feature = "with-tremor")] -use librespot_tremor as vorbis; - -use super::{AudioDecoder, AudioError, AudioPacket}; -use std::error; -use std::fmt; -use std::io::{Read, Seek}; - -pub struct VorbisDecoder(vorbis::Decoder); -pub struct VorbisError(vorbis::VorbisError); - -impl VorbisDecoder -where - R: Read + Seek, -{ - pub fn new(input: R) -> Result, VorbisError> { - Ok(VorbisDecoder(vorbis::Decoder::new(input)?)) - } -} - -impl AudioDecoder for VorbisDecoder -where - R: Read + Seek, -{ - #[cfg(not(feature = "with-tremor"))] - fn seek(&mut self, ms: i64) -> Result<(), AudioError> { - self.0.time_seek(ms as f64 / 1000f64)?; - Ok(()) - } - - #[cfg(feature = "with-tremor")] - fn seek(&mut self, ms: i64) -> Result<(), AudioError> { - self.0.time_seek(ms)?; - Ok(()) - } - - fn next_packet(&mut self) -> Result, AudioError> { - loop { - match self.0.packets().next() { - Some(Ok(packet)) => { - return Ok(Some(AudioPacket::Samples( - packet - .data - .iter() - .map(|sample| (*sample as f64 / 0x8000 as f64) as f32) - .collect(), - ))); - } - None => return Ok(None), - - Some(Err(vorbis::VorbisError::Hole)) => (), - Some(Err(err)) => return Err(err.into()), - } - } - } -} - -impl From for VorbisError { - fn from(err: vorbis::VorbisError) -> VorbisError { - VorbisError(err) - } -} - -impl fmt::Debug for VorbisError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - fmt::Debug::fmt(&self.0, f) - } -} - -impl fmt::Display for VorbisError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - fmt::Display::fmt(&self.0, f) - } -} - -impl error::Error for VorbisError { - fn source(&self) -> Option<&(dyn error::Error + 'static)> { - error::Error::source(&self.0) - } -} - -impl From for AudioError { - fn from(err: vorbis::VorbisError) -> AudioError { - AudioError::VorbisError(VorbisError(err)) - } -} diff --git a/playback/src/mixer/mappings.rs b/playback/src/mixer/mappings.rs index 6a274442..d47744d3 100644 --- a/playback/src/mixer/mappings.rs +++ b/playback/src/mixer/mappings.rs @@ -17,7 +17,7 @@ impl MappedCtrl for VolumeCtrl { // reach zero). if volume == 0 { return 0.0; - } else if volume == 1 { + } else if volume == Self::MAX_VOLUME { // And limit in case of rounding errors (as is the case for log). return 1.0; } From a2fde0a1d6ddc5dd5f33fd1b823bef8a7dd54706 Mon Sep 17 00:00:00 2001 From: johannesd3 Date: Fri, 28 May 2021 10:55:05 +0200 Subject: [PATCH 20/36] Run `cargo update` --- Cargo.lock | 80 +++++++++++++++++++++++++++--------------------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dc9876bd..e7bd92dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -168,9 +168,9 @@ checksum = "b700ce4376041dcd0a327fd0097c41095743c4c8af8887265942faf1100bd040" [[package]] name = "cc" -version = "1.0.67" +version = "1.0.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c69b077ad434294d3ce9f1f6143a2a4b89a8a2d54ef813d85003a4fd1137fd" +checksum = "4a72c244c1ff497a746a7e1fb3d14bd08420ecda70c8f25c7112f2781652d787" dependencies = [ "jobserver", ] @@ -309,9 +309,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.1.1" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec1028182c380cc45a2e2c5ec841134f2dfd0f8f5f0a5bcd68004f81b5efdf4" +checksum = "ed00c67cb5d0a7d64a44f6ad2668db7e7530311dd53ea79bcd4fb022c64911c8" dependencies = [ "libc", ] @@ -437,9 +437,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.14" +version = "0.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d5813545e459ad3ca1bff9915e9ad7f1a47dc6a91b627ce321d5863b7dd253" +checksum = "0e7e43a803dae2fa37c1f6a8fe121e1f7bf9548b4dfc0522a42f34145dadfc27" dependencies = [ "futures-channel", "futures-core", @@ -550,9 +550,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9495705279e7140bf035dde1f6e750c162df8b625267cd52cc44e0b156732c8" +checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753" dependencies = [ "cfg-if 1.0.0", "libc", @@ -819,15 +819,15 @@ dependencies = [ [[package]] name = "httparse" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1ce40d6fc9764887c2fdc7305c3dcc429ba11ff981c1509416afd5697e4437" +checksum = "f3a87b616e37e93c22fb19bcd386f02f3af5ea98a25670ad0fce773de23c5e68" [[package]] name = "httpdate" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05842d0d43232b23ccb7060ecb0f0626922c21f30012e97b767b30afd4a5d4b9" +checksum = "6456b8a6c8f33fee7d958fcd1b60d55b11940a79e63ae87013e6d22e26034440" [[package]] name = "humantime" @@ -837,9 +837,9 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" [[package]] name = "hyper" -version = "0.14.7" +version = "0.14.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e5f105c494081baa3bf9e200b279e27ec1623895cd504c7dbef8d0b080fcf54" +checksum = "d3f71a7eea53a3f8257a7b4795373ff886397178cd634430ea94e12d7fe4fe34" dependencies = [ "bytes", "futures-channel", @@ -1043,9 +1043,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.94" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18794a8ad5b29321f790b55d93dfba91e125cb1a9edbd4f8e3150acc771c1a5e" +checksum = "789da6d93f1b866ffe175afc5322a4d76c038605a1c3319bb57b06967ca98a36" [[package]] name = "libloading" @@ -1567,9 +1567,9 @@ dependencies = [ [[package]] name = "oboe" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfb2390bddb9546c0f7448fd1d2abdd39e6075206f960991eb28c7fa7f126c4" +checksum = "dfa187b38ae20374617b7ad418034ed3dc90ac980181d211518bd03537ae8f8d" dependencies = [ "jni", "ndk", @@ -1581,9 +1581,9 @@ dependencies = [ [[package]] name = "oboe-sys" -version = "0.4.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe069264d082fc820dfa172f79be3f2e088ecfece9b1c47b0c9fd838d2bef103" +checksum = "b88e64835aa3f579c08d182526dc34e3907343d5b97e87b71a40ba5bca7aca9e" dependencies = [ "cc", ] @@ -1799,9 +1799,9 @@ checksum = "bc881b2c22681370c6a780e47af9840ef841837bc98118431d4e1868bd0c1086" [[package]] name = "proc-macro2" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a152013215dca273577e18d2bf00fa862b89b24169fb78c4c95aeb07992c9cec" +checksum = "f0d8caf72986c1a598726adc988bb5984792ef84f5ee5aa50209145ee8077038" dependencies = [ "unicode-xid", ] @@ -2023,18 +2023,18 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.125" +version = "1.0.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "558dc50e1a5a5fa7112ca2ce4effcb321b0300c0d4ccf0776a9f60cd89031171" +checksum = "ec7505abeacaec74ae4778d9d9328fe5a5d04253220a85c4ee022239fc996d03" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.125" +version = "1.0.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b093b7a2bb58203b5da3056c05b4ec1fed827dcfdb37347a8841695263b3d06d" +checksum = "963a7dbc9895aeac7ac90e74f34a5d5261828f79df35cbed41e10189d3804d43" dependencies = [ "proc-macro2", "quote", @@ -2229,18 +2229,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.24" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0f4a65597094d4483ddaed134f409b2cb7c1beccf25201a9f73c719254fa98e" +checksum = "fa6f76457f59514c7eeb4e59d891395fab0b2fd1d40723ae737d64153392e9c6" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.24" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7765189610d8241a44529806d6fd1f2e0a08734313a35d5b3a556f92b381f3c0" +checksum = "8a36768c0fbf1bb15eca10defa29526bda730a2376c2ab4393ccfa16fb1a318d" dependencies = [ "proc-macro2", "quote", @@ -2274,9 +2274,9 @@ checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" [[package]] name = "tokio" -version = "1.5.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83f0c8e7c0addab50b663055baf787d0af7f413a46e6e7fb9559a4e4db7137a5" +checksum = "bd3076b5c8cc18138b8f8814895c11eb4de37114a5d127bafdc5e55798ceef37" dependencies = [ "autocfg", "bytes", @@ -2293,9 +2293,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf7b11a536f46a809a8a9f0bb4237020f70ecbf115b842360afb127ea2fda57" +checksum = "c49e3df43841dafb86046472506755d8501c5615673955f6aa17181125d13c37" dependencies = [ "proc-macro2", "quote", @@ -2304,9 +2304,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e177a5d8c3bf36de9ebe6d58537d8879e964332f93fb3339e43f618c81361af0" +checksum = "f8864d706fdb3cc0843a49647ac892720dac98a6eeb818b77190592cf4994066" dependencies = [ "futures-core", "pin-project-lite", @@ -2315,9 +2315,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.6.6" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940a12c99365c31ea8dd9ba04ec1be183ffe4920102bb7122c2f515437601e8e" +checksum = "1caa0b0c8d94a049db56b5acf8cba99dc0623aab1b26d5b5f5e2d945846b3592" dependencies = [ "bytes", "futures-core", @@ -2543,9 +2543,9 @@ checksum = "d7cff876b8f18eed75a66cf49b65e7f967cb354a7aa16003fb55dbfd25b44b4f" [[package]] name = "web-sys" -version = "0.3.50" +version = "0.3.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a905d57e488fec8861446d3393670fb50d27a262344013181c2cdf9fff5481be" +checksum = "e828417b379f3df7111d3a2a9e5753706cae29c41f7c4029ee9fd77f3e09e582" dependencies = [ "js-sys", "wasm-bindgen", From 8062bd25185c0205a890cadce37661167a187b5a Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sat, 29 May 2021 22:53:19 +0200 Subject: [PATCH 21/36] Improve sample rounding and clean up noise shaping leftovers (#771) --- Cargo.lock | 85 +++++++++++++++++++++++++++++++++++------ playback/Cargo.toml | 1 + playback/src/convert.rs | 19 ++++----- playback/src/dither.rs | 8 ++-- 4 files changed, 89 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e7bd92dc..a4e3ef9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -435,6 +435,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fuchsia-cprng" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" + [[package]] name = "futures" version = "0.3.15" @@ -1073,6 +1079,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7d73b3f436185384286bd8098d17ec07c9a7d2388a6599f824d8502b529702a" +[[package]] +name = "libmath" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfd3416934a853ae80d5c3b006f632dfcbaf320300c5167e88a469e9ac214502" +dependencies = [ + "rand 0.3.23", +] + [[package]] name = "libmdns" version = "0.6.1" @@ -1085,7 +1100,7 @@ dependencies = [ "if-addrs", "log", "multimap", - "rand", + "rand 0.8.3", "socket2", "thiserror", "tokio", @@ -1190,7 +1205,7 @@ dependencies = [ "librespot-protocol", "log", "protobuf", - "rand", + "rand 0.8.3", "serde", "serde_json", "tokio", @@ -1223,7 +1238,7 @@ dependencies = [ "pbkdf2", "priority-queue", "protobuf", - "rand", + "rand 0.8.3", "serde", "serde_json", "sha-1", @@ -1254,7 +1269,7 @@ dependencies = [ "libmdns", "librespot-core", "log", - "rand", + "rand 0.8.3", "serde_json", "sha-1", "simple_logger", @@ -1288,6 +1303,7 @@ dependencies = [ "gstreamer-app", "jack 0.7.1", "lewton", + "libmath", "libpulse-binding", "libpulse-simple-binding", "librespot-audio", @@ -1296,7 +1312,7 @@ dependencies = [ "log", "ogg", "portaudio-rs", - "rand", + "rand 0.8.3", "rand_distr", "rodio", "sdl2", @@ -1488,7 +1504,7 @@ dependencies = [ "autocfg", "num-integer", "num-traits", - "rand", + "rand 0.8.3", ] [[package]] @@ -1840,6 +1856,29 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ac302d8f83c0c1974bf758f6b041c6c8ada916fbb44a609158ca8b064cc76c" +dependencies = [ + "libc", + "rand 0.4.6", +] + +[[package]] +name = "rand" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293" +dependencies = [ + "fuchsia-cprng", + "libc", + "rand_core 0.3.1", + "rdrand", + "winapi", +] + [[package]] name = "rand" version = "0.8.3" @@ -1848,7 +1887,7 @@ checksum = "0ef9e7e66b4468674bfcb0c81af8b7fa0bb154fa9f28eb840da5c447baeb8d7e" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.2", "rand_hc", ] @@ -1859,9 +1898,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e12735cf05c9e10bf21534da50a147b924d555dc7a547c42e6bb2d5b6017ae0d" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.2", ] +[[package]] +name = "rand_core" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b" +dependencies = [ + "rand_core 0.4.2", +] + +[[package]] +name = "rand_core" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" + [[package]] name = "rand_core" version = "0.6.2" @@ -1878,7 +1932,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da9e8f32ad24fb80d07d2323a9a2ce8b30d68a62b8cb4df88119ff49a698f038" dependencies = [ "num-traits", - "rand", + "rand 0.8.3", ] [[package]] @@ -1887,7 +1941,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3190ef7066a446f2e7f42e239d161e905420ccab01eb967c9eb27d21b2322a73" dependencies = [ - "rand_core", + "rand_core 0.6.2", +] + +[[package]] +name = "rdrand" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2" +dependencies = [ + "rand_core 0.3.1", ] [[package]] @@ -2212,7 +2275,7 @@ checksum = "dac1c663cfc93810f88aed9b8941d48cabf856a1b111c29a40439018d870eb22" dependencies = [ "cfg-if 1.0.0", "libc", - "rand", + "rand 0.8.3", "redox_syscall", "remove_dir_all", "winapi", diff --git a/playback/Cargo.toml b/playback/Cargo.toml index 2af1436b..bf55b5a5 100644 --- a/playback/Cargo.toml +++ b/playback/Cargo.toml @@ -20,6 +20,7 @@ version = "0.2.0" [dependencies] futures-executor = "0.3" futures-util = { version = "0.3", default_features = false, features = ["alloc"] } +libmath = "0.2" log = "0.4" byteorder = "1.4" shell-words = "1.0.0" diff --git a/playback/src/convert.rs b/playback/src/convert.rs index a5d5a0bb..91fa0e96 100644 --- a/playback/src/convert.rs +++ b/playback/src/convert.rs @@ -32,18 +32,19 @@ impl Converter { // Denormalize and dither pub fn scale(&mut self, sample: f32, factor: i64) -> f32 { + let dither = match self.ditherer { + Some(ref mut d) => d.noise(), + None => 0.0, + }; + // From the many float to int conversion methods available, match what // the reference Vorbis implementation uses: sample * 32768 (for 16 bit) - let int_value = sample * factor as f32; + let int_value = sample * factor as f32 + dither; - // https://doc.rust-lang.org/nomicon/casts.html: casting float to integer - // rounds towards zero, then saturates. Ideally halves should round to even to - // prevent any bias, but since it is extremely unlikely that a float has - // *exactly* .5 as fraction, this should be more than precise enough. - match self.ditherer { - Some(ref mut d) => int_value + d.noise(int_value), - None => int_value, - } + // Casting float to integer rounds towards zero by default, i.e. it + // truncates, and that generates larger error than rounding to nearest. + // Absolute lowest error is gained from rounding ties to even. + math::round::half_to_even(int_value.into(), 0) as f32 } // Special case for samples packed in a word of greater bit depth (e.g. diff --git a/playback/src/dither.rs b/playback/src/dither.rs index 86aca6e2..972cca2d 100644 --- a/playback/src/dither.rs +++ b/playback/src/dither.rs @@ -32,7 +32,7 @@ pub trait Ditherer { where Self: Sized; fn name(&self) -> &'static str; - fn noise(&mut self, sample: f32) -> f32; + fn noise(&mut self) -> f32; } impl fmt::Display for dyn Ditherer { @@ -64,7 +64,7 @@ impl Ditherer for TriangularDitherer { "Triangular" } - fn noise(&mut self, _sample: f32) -> f32 { + fn noise(&mut self) -> f32 { self.distribution.sample(&mut self.cached_rng) } } @@ -87,7 +87,7 @@ impl Ditherer for GaussianDitherer { "Gaussian" } - fn noise(&mut self, _sample: f32) -> f32 { + fn noise(&mut self) -> f32 { self.distribution.sample(&mut self.cached_rng) } } @@ -113,7 +113,7 @@ impl Ditherer for HighPassDitherer { "Triangular, High Passed" } - fn noise(&mut self, _sample: f32) -> f32 { + fn noise(&mut self) -> f32 { let new_noise = self.distribution.sample(&mut self.cached_rng); let high_passed_noise = new_noise - self.previous_noises[self.active_channel]; self.previous_noises[self.active_channel] = new_noise; From fe2d5ca7c654cd5981969ae880ccd69e6d1066d3 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 30 May 2021 20:09:39 +0200 Subject: [PATCH 22/36] Store and process samples in 64 bit (#773) --- CHANGELOG.md | 2 + playback/src/audio_backend/alsa.rs | 1 + playback/src/audio_backend/jackaudio.rs | 7 +-- playback/src/audio_backend/mod.rs | 14 +++-- playback/src/audio_backend/portaudio.rs | 7 +-- playback/src/audio_backend/pulseaudio.rs | 3 ++ playback/src/audio_backend/rodio.rs | 10 ++-- playback/src/audio_backend/sdl.rs | 7 +-- playback/src/config.rs | 22 ++++---- playback/src/convert.rs | 37 +++++++------ playback/src/decoder/lewton_decoder.rs | 8 ++- playback/src/decoder/mod.rs | 9 +++- playback/src/dither.rs | 16 +++--- playback/src/mixer/alsamixer.rs | 26 ++++----- playback/src/mixer/mappings.rs | 50 +++++++++--------- playback/src/mixer/mod.rs | 2 +- playback/src/mixer/softmixer.rs | 20 +++---- playback/src/player.rs | 67 +++++++++++------------- src/main.rs | 18 +++---- 19 files changed, 177 insertions(+), 149 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 499bebce..83fd100f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,11 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [playback] Add support for dithering with `--dither` for lower requantization error (breaking) - [playback] Add `--volume-range` option to set dB range and control `log` and `cubic` volume control curves - [playback] `alsamixer`: support for querying dB range from Alsa softvol +- [playback] Add `--format F64` (supported by Alsa and GStreamer only) ### Changed - [audio, playback] Moved `VorbisDecoder`, `VorbisError`, `AudioPacket`, `PassthroughDecoder`, `PassthroughError`, `AudioError`, `AudioDecoder` and the `convert` module from `librespot-audio` to `librespot-playback`. The underlying crates `vorbis`, `librespot-tremor`, `lewton` and `ogg` should be used directly. (breaking) - [connect, playback] Moved volume controls from `librespot-connect` to `librespot-playback` crate - [connect] Synchronize player volume with mixer volume on playback +- [playback] Store and pass samples in 64-bit floating point - [playback] Make cubic volume control available to all mixers with `--volume-ctrl cubic` - [playback] Normalize volumes to `[0.0..1.0]` instead of `[0..65535]` for greater precision and performance (breaking) - [playback] `alsamixer`: complete rewrite (breaking) diff --git a/playback/src/audio_backend/alsa.rs b/playback/src/audio_backend/alsa.rs index 98939668..b5deb2eb 100644 --- a/playback/src/audio_backend/alsa.rs +++ b/playback/src/audio_backend/alsa.rs @@ -41,6 +41,7 @@ fn list_outputs() { fn open_device(dev_name: &str, format: AudioFormat) -> Result<(PCM, Frames), Box> { let pcm = PCM::new(dev_name, Direction::Playback, false)?; let alsa_format = match format { + AudioFormat::F64 => Format::float64(), AudioFormat::F32 => Format::float(), AudioFormat::S32 => Format::s32(), AudioFormat::S24 => Format::s24(), diff --git a/playback/src/audio_backend/jackaudio.rs b/playback/src/audio_backend/jackaudio.rs index ab75fff2..75c4eb9f 100644 --- a/playback/src/audio_backend/jackaudio.rs +++ b/playback/src/audio_backend/jackaudio.rs @@ -70,9 +70,10 @@ impl Open for JackSink { } impl Sink for JackSink { - fn write(&mut self, packet: &AudioPacket, _: &mut Converter) -> io::Result<()> { - for s in packet.samples().iter() { - let res = self.send.send(*s); + fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> io::Result<()> { + let samples_f32: &[f32] = &converter.f64_to_f32(packet.samples()); + for sample in samples_f32.iter() { + let res = self.send.send(*sample); if res.is_err() { error!("cannot write to channel"); } diff --git a/playback/src/audio_backend/mod.rs b/playback/src/audio_backend/mod.rs index e4653f17..0996bfb6 100644 --- a/playback/src/audio_backend/mod.rs +++ b/playback/src/audio_backend/mod.rs @@ -35,21 +35,25 @@ macro_rules! sink_as_bytes { use zerocopy::AsBytes; match packet { AudioPacket::Samples(samples) => match self.format { - AudioFormat::F32 => self.write_bytes(samples.as_bytes()), + AudioFormat::F64 => self.write_bytes(samples.as_bytes()), + AudioFormat::F32 => { + let samples_f32: &[f32] = &converter.f64_to_f32(samples); + self.write_bytes(samples_f32.as_bytes()) + } AudioFormat::S32 => { - let samples_s32: &[i32] = &converter.f32_to_s32(samples); + let samples_s32: &[i32] = &converter.f64_to_s32(samples); self.write_bytes(samples_s32.as_bytes()) } AudioFormat::S24 => { - let samples_s24: &[i32] = &converter.f32_to_s24(samples); + let samples_s24: &[i32] = &converter.f64_to_s24(samples); self.write_bytes(samples_s24.as_bytes()) } AudioFormat::S24_3 => { - let samples_s24_3: &[i24] = &converter.f32_to_s24_3(samples); + let samples_s24_3: &[i24] = &converter.f64_to_s24_3(samples); self.write_bytes(samples_s24_3.as_bytes()) } AudioFormat::S16 => { - let samples_s16: &[i16] = &converter.f32_to_s16(samples); + let samples_s16: &[i16] = &converter.f64_to_s16(samples); self.write_bytes(samples_s16.as_bytes()) } }, diff --git a/playback/src/audio_backend/portaudio.rs b/playback/src/audio_backend/portaudio.rs index 0bcd1aa5..ccebcfdf 100644 --- a/playback/src/audio_backend/portaudio.rs +++ b/playback/src/audio_backend/portaudio.rs @@ -151,14 +151,15 @@ impl<'a> Sink for PortAudioSink<'a> { let samples = packet.samples(); let result = match self { Self::F32(stream, _parameters) => { - write_sink!(ref mut stream, samples) + let samples_f32: &[f32] = &converter.f64_to_f32(samples); + write_sink!(ref mut stream, samples_f32) } Self::S32(stream, _parameters) => { - let samples_s32: &[i32] = &converter.f32_to_s32(samples); + let samples_s32: &[i32] = &converter.f64_to_s32(samples); write_sink!(ref mut stream, samples_s32) } Self::S16(stream, _parameters) => { - let samples_s16: &[i16] = &converter.f32_to_s16(samples); + let samples_s16: &[i16] = &converter.f64_to_s16(samples); write_sink!(ref mut stream, samples_s16) } }; diff --git a/playback/src/audio_backend/pulseaudio.rs b/playback/src/audio_backend/pulseaudio.rs index 57c9b8bc..1298cd7c 100644 --- a/playback/src/audio_backend/pulseaudio.rs +++ b/playback/src/audio_backend/pulseaudio.rs @@ -28,6 +28,9 @@ impl Open for PulseAudioSink { AudioFormat::S24 => pulse::sample::Format::S24_32le, AudioFormat::S24_3 => pulse::sample::Format::S24le, AudioFormat::S16 => pulse::sample::Format::S16le, + _ => { + unimplemented!("PulseAudio currently does not support {:?} output", format) + } }; let ss = pulse::sample::Spec { diff --git a/playback/src/audio_backend/rodio.rs b/playback/src/audio_backend/rodio.rs index 52e9bc91..2951560a 100644 --- a/playback/src/audio_backend/rodio.rs +++ b/playback/src/audio_backend/rodio.rs @@ -178,12 +178,16 @@ impl Sink for RodioSink { let samples = packet.samples(); match self.format { AudioFormat::F32 => { - let source = - rodio::buffer::SamplesBuffer::new(NUM_CHANNELS as u16, SAMPLE_RATE, samples); + let samples_f32: &[f32] = &converter.f64_to_f32(samples); + let source = rodio::buffer::SamplesBuffer::new( + NUM_CHANNELS as u16, + SAMPLE_RATE, + samples_f32, + ); self.rodio_sink.append(source); } AudioFormat::S16 => { - let samples_s16: &[i16] = &converter.f32_to_s16(samples); + let samples_s16: &[i16] = &converter.f64_to_s16(samples); let source = rodio::buffer::SamplesBuffer::new( NUM_CHANNELS as u16, SAMPLE_RATE, diff --git a/playback/src/audio_backend/sdl.rs b/playback/src/audio_backend/sdl.rs index ab7c7ecc..d07e562f 100644 --- a/playback/src/audio_backend/sdl.rs +++ b/playback/src/audio_backend/sdl.rs @@ -94,16 +94,17 @@ impl Sink for SdlSink { let samples = packet.samples(); match self { Self::F32(queue) => { + let samples_f32: &[f32] = &converter.f64_to_f32(samples); drain_sink!(queue, AudioFormat::F32.size()); - queue.queue(samples) + queue.queue(samples_f32) } Self::S32(queue) => { - let samples_s32: &[i32] = &converter.f32_to_s32(samples); + let samples_s32: &[i32] = &converter.f64_to_s32(samples); drain_sink!(queue, AudioFormat::S32.size()); queue.queue(samples_s32) } Self::S16(queue) => { - let samples_s16: &[i16] = &converter.f32_to_s16(samples); + let samples_s16: &[i16] = &converter.f64_to_s16(samples); drain_sink!(queue, AudioFormat::S16.size()); queue.queue(samples_s16) } diff --git a/playback/src/config.rs b/playback/src/config.rs index 4b9a74f0..a2e1c6c7 100644 --- a/playback/src/config.rs +++ b/playback/src/config.rs @@ -33,6 +33,7 @@ impl Default for Bitrate { #[derive(Clone, Copy, Debug, Hash, PartialOrd, Ord, PartialEq, Eq)] pub enum AudioFormat { + F64, F32, S32, S24, @@ -44,6 +45,7 @@ impl TryFrom<&String> for AudioFormat { type Error = (); fn try_from(s: &String) -> Result { match s.to_uppercase().as_str() { + "F64" => Ok(Self::F64), "F32" => Ok(Self::F32), "S32" => Ok(Self::S32), "S24" => Ok(Self::S24), @@ -65,6 +67,8 @@ impl AudioFormat { #[allow(dead_code)] pub fn size(&self) -> usize { match self { + Self::F64 => mem::size_of::(), + Self::F32 => mem::size_of::(), Self::S24_3 => mem::size_of::(), Self::S16 => mem::size_of::(), _ => mem::size_of::(), // S32 and S24 are both stored in i32 @@ -127,11 +131,11 @@ pub struct PlayerConfig { pub normalisation: bool, pub normalisation_type: NormalisationType, pub normalisation_method: NormalisationMethod, - pub normalisation_pregain: f32, - pub normalisation_threshold: f32, - pub normalisation_attack: f32, - pub normalisation_release: f32, - pub normalisation_knee: f32, + pub normalisation_pregain: f64, + pub normalisation_threshold: f64, + pub normalisation_attack: f64, + pub normalisation_release: f64, + pub normalisation_knee: f64, // pass function pointers so they can be lazily instantiated *after* spawning a thread // (thereby circumventing Send bounds that they might not satisfy) @@ -160,10 +164,10 @@ impl Default for PlayerConfig { // fields are intended for volume control range in dB #[derive(Clone, Copy, Debug)] pub enum VolumeCtrl { - Cubic(f32), + Cubic(f64), Fixed, Linear, - Log(f32), + Log(f64), } impl FromStr for VolumeCtrl { @@ -183,9 +187,9 @@ impl VolumeCtrl { pub const MAX_VOLUME: u16 = std::u16::MAX; // Taken from: https://www.dr-lex.be/info-stuff/volumecontrols.html - pub const DEFAULT_DB_RANGE: f32 = 60.0; + pub const DEFAULT_DB_RANGE: f64 = 60.0; - pub fn from_str_with_range(s: &str, db_range: f32) -> Result::Err> { + pub fn from_str_with_range(s: &str, db_range: f64) -> Result::Err> { use self::VolumeCtrl::*; match s.to_lowercase().as_ref() { "cubic" => Ok(Cubic(db_range)), diff --git a/playback/src/convert.rs b/playback/src/convert.rs index 91fa0e96..ddeff2fc 100644 --- a/playback/src/convert.rs +++ b/playback/src/convert.rs @@ -30,8 +30,11 @@ impl Converter { } } - // Denormalize and dither - pub fn scale(&mut self, sample: f32, factor: i64) -> f32 { + const SCALE_S32: f64 = 2147483648.; + const SCALE_S24: f64 = 8388608.; + const SCALE_S16: f64 = 32768.; + + pub fn scale(&mut self, sample: f64, factor: f64) -> f64 { let dither = match self.ditherer { Some(ref mut d) => d.noise(), None => 0.0, @@ -39,12 +42,12 @@ impl Converter { // From the many float to int conversion methods available, match what // the reference Vorbis implementation uses: sample * 32768 (for 16 bit) - let int_value = sample * factor as f32 + dither; + let int_value = sample * factor + dither; // Casting float to integer rounds towards zero by default, i.e. it // truncates, and that generates larger error than rounding to nearest. // Absolute lowest error is gained from rounding ties to even. - math::round::half_to_even(int_value.into(), 0) as f32 + math::round::half_to_even(int_value, 0) } // Special case for samples packed in a word of greater bit depth (e.g. @@ -52,12 +55,12 @@ impl Converter { // byte is zero. Otherwise, dithering may cause an overflow. This is not // necessary for other formats, because casting to integer will saturate // to the bounds of the primitive. - pub fn clamping_scale(&mut self, sample: f32, factor: i64) -> f32 { + pub fn clamping_scale(&mut self, sample: f64, factor: f64) -> f64 { let int_value = self.scale(sample, factor); // In two's complement, there are more negative than positive values. - let min = -factor as f32; - let max = (factor - 1) as f32; + let min = -factor; + let max = factor - 1.0; if int_value < min { return min; @@ -67,38 +70,42 @@ impl Converter { int_value } - pub fn f32_to_s32(&mut self, samples: &[f32]) -> Vec { + pub fn f64_to_f32(&mut self, samples: &[f64]) -> Vec { + samples.iter().map(|sample| *sample as f32).collect() + } + + pub fn f64_to_s32(&mut self, samples: &[f64]) -> Vec { samples .iter() - .map(|sample| self.scale(*sample, 0x80000000) as i32) + .map(|sample| self.scale(*sample, Self::SCALE_S32) as i32) .collect() } // S24 is 24-bit PCM packed in an upper 32-bit word - pub fn f32_to_s24(&mut self, samples: &[f32]) -> Vec { + pub fn f64_to_s24(&mut self, samples: &[f64]) -> Vec { samples .iter() - .map(|sample| self.clamping_scale(*sample, 0x800000) as i32) + .map(|sample| self.clamping_scale(*sample, Self::SCALE_S24) as i32) .collect() } // S24_3 is 24-bit PCM in a 3-byte array - pub fn f32_to_s24_3(&mut self, samples: &[f32]) -> Vec { + pub fn f64_to_s24_3(&mut self, samples: &[f64]) -> Vec { samples .iter() .map(|sample| { // Not as DRY as calling f32_to_s24 first, but this saves iterating // over all samples twice. - let int_value = self.clamping_scale(*sample, 0x800000) as i32; + let int_value = self.clamping_scale(*sample, Self::SCALE_S24) as i32; i24::from_s24(int_value) }) .collect() } - pub fn f32_to_s16(&mut self, samples: &[f32]) -> Vec { + pub fn f64_to_s16(&mut self, samples: &[f64]) -> Vec { samples .iter() - .map(|sample| self.scale(*sample, 0x8000) as i16) + .map(|sample| self.scale(*sample, Self::SCALE_S16) as i16) .collect() } } diff --git a/playback/src/decoder/lewton_decoder.rs b/playback/src/decoder/lewton_decoder.rs index 528d9344..64a49e57 100644 --- a/playback/src/decoder/lewton_decoder.rs +++ b/playback/src/decoder/lewton_decoder.rs @@ -1,6 +1,7 @@ use super::{AudioDecoder, AudioError, AudioPacket}; use lewton::inside_ogg::OggStreamReader; +use lewton::samples::InterleavedSamples; use std::error; use std::fmt; @@ -35,11 +36,8 @@ where use lewton::OggReadError::NoCapturePatternFound; use lewton::VorbisError::{BadAudio, OggError}; loop { - match self - .0 - .read_dec_packet_generic::>() - { - Ok(Some(packet)) => return Ok(Some(AudioPacket::Samples(packet.samples))), + match self.0.read_dec_packet_generic::>() { + Ok(Some(packet)) => return Ok(Some(AudioPacket::samples_from_f32(packet.samples))), Ok(None) => return Ok(None), Err(BadAudio(AudioIsHeader)) => (), diff --git a/playback/src/decoder/mod.rs b/playback/src/decoder/mod.rs index 46c39212..9641e8b3 100644 --- a/playback/src/decoder/mod.rs +++ b/playback/src/decoder/mod.rs @@ -7,12 +7,17 @@ mod passthrough_decoder; pub use passthrough_decoder::{PassthroughDecoder, PassthroughError}; pub enum AudioPacket { - Samples(Vec), + Samples(Vec), OggData(Vec), } impl AudioPacket { - pub fn samples(&self) -> &[f32] { + pub fn samples_from_f32(f32_samples: Vec) -> Self { + let f64_samples = f32_samples.iter().map(|sample| *sample as f64).collect(); + AudioPacket::Samples(f64_samples) + } + + pub fn samples(&self) -> &[f64] { match self { AudioPacket::Samples(s) => s, AudioPacket::OggData(_) => panic!("can't return OggData on samples"), diff --git a/playback/src/dither.rs b/playback/src/dither.rs index 972cca2d..63447ce8 100644 --- a/playback/src/dither.rs +++ b/playback/src/dither.rs @@ -32,7 +32,7 @@ pub trait Ditherer { where Self: Sized; fn name(&self) -> &'static str; - fn noise(&mut self) -> f32; + fn noise(&mut self) -> f64; } impl fmt::Display for dyn Ditherer { @@ -48,7 +48,7 @@ impl fmt::Display for dyn Ditherer { pub struct TriangularDitherer { cached_rng: ThreadRng, - distribution: Triangular, + distribution: Triangular, } impl Ditherer for TriangularDitherer { @@ -64,14 +64,14 @@ impl Ditherer for TriangularDitherer { "Triangular" } - fn noise(&mut self) -> f32 { + fn noise(&mut self) -> f64 { self.distribution.sample(&mut self.cached_rng) } } pub struct GaussianDitherer { cached_rng: ThreadRng, - distribution: Normal, + distribution: Normal, } impl Ditherer for GaussianDitherer { @@ -87,16 +87,16 @@ impl Ditherer for GaussianDitherer { "Gaussian" } - fn noise(&mut self) -> f32 { + fn noise(&mut self) -> f64 { self.distribution.sample(&mut self.cached_rng) } } pub struct HighPassDitherer { active_channel: usize, - previous_noises: [f32; NUM_CHANNELS], + previous_noises: [f64; NUM_CHANNELS], cached_rng: ThreadRng, - distribution: Uniform, + distribution: Uniform, } impl Ditherer for HighPassDitherer { @@ -113,7 +113,7 @@ impl Ditherer for HighPassDitherer { "Triangular, High Passed" } - fn noise(&mut self) -> f32 { + fn noise(&mut self) -> f64 { let new_noise = self.distribution.sample(&mut self.cached_rng); let high_passed_noise = new_noise - self.previous_noises[self.active_channel]; self.previous_noises[self.active_channel] = new_noise; diff --git a/playback/src/mixer/alsamixer.rs b/playback/src/mixer/alsamixer.rs index 62009184..fb6853bb 100644 --- a/playback/src/mixer/alsamixer.rs +++ b/playback/src/mixer/alsamixer.rs @@ -15,9 +15,9 @@ pub struct AlsaMixer { min: i64, max: i64, range: i64, - min_db: f32, - max_db: f32, - db_range: f32, + min_db: f64, + max_db: f64, + db_range: f64, has_switch: bool, is_softvol: bool, use_linear_in_db: bool, @@ -101,9 +101,9 @@ impl Mixer for AlsaMixer { (min_millibel, max_millibel) }; - let min_db = min_millibel.to_db(); - let max_db = max_millibel.to_db(); - let db_range = f32::abs(max_db - min_db); + let min_db = min_millibel.to_db() as f64; + let max_db = max_millibel.to_db() as f64; + let db_range = f64::abs(max_db - min_db); // Synchronize the volume control dB range with the mixer control, // unless it was already set with a command line option. @@ -157,17 +157,17 @@ impl Mixer for AlsaMixer { let raw_volume = simple_element .get_playback_volume(SelemChannelId::mono()) .expect("Could not get raw Alsa volume"); - - raw_volume as f32 / self.range as f32 - self.min as f32 + raw_volume as f64 / self.range as f64 - self.min as f64 } else { let db_volume = simple_element .get_playback_vol_db(SelemChannelId::mono()) .expect("Could not get Alsa dB volume") - .to_db(); + .to_db() as f64; if self.use_linear_in_db { (db_volume - self.min_db) / self.db_range - } else if f32::abs(db_volume - SND_CTL_TLV_DB_GAIN_MUTE.to_db()) <= f32::EPSILON { + } else if f64::abs(db_volume - SND_CTL_TLV_DB_GAIN_MUTE.to_db() as f64) <= f64::EPSILON + { 0.0 } else { db_to_ratio(db_volume - self.max_db) @@ -216,7 +216,7 @@ impl Mixer for AlsaMixer { } if self.is_softvol { - let scaled_volume = (self.min as f32 + mapped_volume * self.range as f32) as i64; + let scaled_volume = (self.min as f64 + mapped_volume * self.range as f64) as i64; debug!("Setting Alsa raw volume to {}", scaled_volume); simple_element .set_playback_volume_all(scaled_volume) @@ -228,14 +228,14 @@ impl Mixer for AlsaMixer { self.min_db + mapped_volume * self.db_range } else if volume == 0 { // prevent ratio_to_db(0.0) from returning -inf - SND_CTL_TLV_DB_GAIN_MUTE.to_db() + SND_CTL_TLV_DB_GAIN_MUTE.to_db() as f64 } else { ratio_to_db(mapped_volume) + self.max_db }; debug!("Setting Alsa volume to {:.2} dB", db_volume); simple_element - .set_playback_db_all(MilliBel::from_db(db_volume), Round::Floor) + .set_playback_db_all(MilliBel::from_db(db_volume as f32), Round::Floor) .expect("Could not set Alsa dB volume"); } } diff --git a/playback/src/mixer/mappings.rs b/playback/src/mixer/mappings.rs index d47744d3..04cef439 100644 --- a/playback/src/mixer/mappings.rs +++ b/playback/src/mixer/mappings.rs @@ -2,16 +2,16 @@ use super::VolumeCtrl; use crate::player::db_to_ratio; pub trait MappedCtrl { - fn to_mapped(&self, volume: u16) -> f32; - fn from_mapped(&self, mapped_volume: f32) -> u16; + fn to_mapped(&self, volume: u16) -> f64; + fn from_mapped(&self, mapped_volume: f64) -> u16; - fn db_range(&self) -> f32; - fn set_db_range(&mut self, new_db_range: f32); + fn db_range(&self) -> f64; + fn set_db_range(&mut self, new_db_range: f64); fn range_ok(&self) -> bool; } impl MappedCtrl for VolumeCtrl { - fn to_mapped(&self, volume: u16) -> f32 { + fn to_mapped(&self, volume: u16) -> f64 { // More than just an optimization, this ensures that zero volume is // really mute (both the log and cubic equations would otherwise not // reach zero). @@ -22,7 +22,7 @@ impl MappedCtrl for VolumeCtrl { return 1.0; } - let normalized_volume = volume as f32 / Self::MAX_VOLUME as f32; + let normalized_volume = volume as f64 / Self::MAX_VOLUME as f64; let mapped_volume = if self.range_ok() { match *self { Self::Cubic(db_range) => { @@ -49,13 +49,13 @@ impl MappedCtrl for VolumeCtrl { mapped_volume } - fn from_mapped(&self, mapped_volume: f32) -> u16 { + fn from_mapped(&self, mapped_volume: f64) -> u16 { // More than just an optimization, this ensures that zero mapped volume // is unmapped to non-negative real numbers (otherwise the log and cubic // equations would respectively return -inf and -1/9.) - if f32::abs(mapped_volume - 0.0) <= f32::EPSILON { + if f64::abs(mapped_volume - 0.0) <= f64::EPSILON { return 0; - } else if f32::abs(mapped_volume - 1.0) <= f32::EPSILON { + } else if f64::abs(mapped_volume - 1.0) <= f64::EPSILON { return Self::MAX_VOLUME; } @@ -74,10 +74,10 @@ impl MappedCtrl for VolumeCtrl { mapped_volume }; - (unmapped_volume * Self::MAX_VOLUME as f32) as u16 + (unmapped_volume * Self::MAX_VOLUME as f64) as u16 } - fn db_range(&self) -> f32 { + fn db_range(&self) -> f64 { match *self { Self::Fixed => 0.0, Self::Linear => Self::DEFAULT_DB_RANGE, // arbitrary, could be anything > 0 @@ -85,7 +85,7 @@ impl MappedCtrl for VolumeCtrl { } } - fn set_db_range(&mut self, new_db_range: f32) { + fn set_db_range(&mut self, new_db_range: f64) { match self { Self::Cubic(ref mut db_range) | Self::Log(ref mut db_range) => *db_range = new_db_range, _ => error!("Invalid to set dB range for volume control type {:?}", self), @@ -100,8 +100,8 @@ impl MappedCtrl for VolumeCtrl { } pub trait VolumeMapping { - fn linear_to_mapped(unmapped_volume: f32, db_range: f32) -> f32; - fn mapped_to_linear(mapped_volume: f32, db_range: f32) -> f32; + fn linear_to_mapped(unmapped_volume: f64, db_range: f64) -> f64; + fn mapped_to_linear(mapped_volume: f64, db_range: f64) -> f64; } // Volume conversion taken from: https://www.dr-lex.be/info-stuff/volumecontrols.html#ideal2 @@ -110,21 +110,21 @@ pub trait VolumeMapping { // mapping results in a near linear loudness experience with the listener. pub struct LogMapping {} impl VolumeMapping for LogMapping { - fn linear_to_mapped(normalized_volume: f32, db_range: f32) -> f32 { + fn linear_to_mapped(normalized_volume: f64, db_range: f64) -> f64 { let (db_ratio, ideal_factor) = Self::coefficients(db_range); - f32::exp(ideal_factor * normalized_volume) / db_ratio + f64::exp(ideal_factor * normalized_volume) / db_ratio } - fn mapped_to_linear(mapped_volume: f32, db_range: f32) -> f32 { + fn mapped_to_linear(mapped_volume: f64, db_range: f64) -> f64 { let (db_ratio, ideal_factor) = Self::coefficients(db_range); - f32::ln(db_ratio * mapped_volume) / ideal_factor + f64::ln(db_ratio * mapped_volume) / ideal_factor } } impl LogMapping { - fn coefficients(db_range: f32) -> (f32, f32) { + fn coefficients(db_range: f64) -> (f64, f64) { let db_ratio = db_to_ratio(db_range); - let ideal_factor = f32::ln(db_ratio); + let ideal_factor = f64::ln(db_ratio); (db_ratio, ideal_factor) } } @@ -143,21 +143,21 @@ impl LogMapping { // logarithmic mapping, then use that volume control. pub struct CubicMapping {} impl VolumeMapping for CubicMapping { - fn linear_to_mapped(normalized_volume: f32, db_range: f32) -> f32 { + fn linear_to_mapped(normalized_volume: f64, db_range: f64) -> f64 { let min_norm = Self::min_norm(db_range); - f32::powi(normalized_volume * (1.0 - min_norm) + min_norm, 3) + f64::powi(normalized_volume * (1.0 - min_norm) + min_norm, 3) } - fn mapped_to_linear(mapped_volume: f32, db_range: f32) -> f32 { + fn mapped_to_linear(mapped_volume: f64, db_range: f64) -> f64 { let min_norm = Self::min_norm(db_range); (mapped_volume.powf(1.0 / 3.0) - min_norm) / (1.0 - min_norm) } } impl CubicMapping { - fn min_norm(db_range: f32) -> f32 { + fn min_norm(db_range: f64) -> f64 { // Note that this 60.0 is unrelated to DEFAULT_DB_RANGE. // Instead, it's the cubic voltage to dB ratio. - f32::powf(10.0, -1.0 * db_range / 60.0) + f64::powf(10.0, -1.0 * db_range / 60.0) } } diff --git a/playback/src/mixer/mod.rs b/playback/src/mixer/mod.rs index 3c3bed2e..aaecd779 100644 --- a/playback/src/mixer/mod.rs +++ b/playback/src/mixer/mod.rs @@ -17,7 +17,7 @@ pub trait Mixer: Send { } pub trait AudioFilter { - fn modify_stream(&self, data: &mut [f32]); + fn modify_stream(&self, data: &mut [f64]); } pub mod softmixer; diff --git a/playback/src/mixer/softmixer.rs b/playback/src/mixer/softmixer.rs index 0be161ad..d1c6eb20 100644 --- a/playback/src/mixer/softmixer.rs +++ b/playback/src/mixer/softmixer.rs @@ -1,4 +1,4 @@ -use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use super::AudioFilter; @@ -7,9 +7,9 @@ use super::{Mixer, MixerConfig}; #[derive(Clone)] pub struct SoftMixer { - // There is no AtomicF32, so we store the f32 as bits in a u32 field. - // It's much faster than a Mutex. - volume: Arc, + // There is no AtomicF64, so we store the f64 as bits in a u64 field. + // It's much faster than a Mutex. + volume: Arc, volume_ctrl: VolumeCtrl, } @@ -19,13 +19,13 @@ impl Mixer for SoftMixer { info!("Mixing with softvol and volume control: {:?}", volume_ctrl); Self { - volume: Arc::new(AtomicU32::new(f32::to_bits(0.5))), + volume: Arc::new(AtomicU64::new(f64::to_bits(0.5))), volume_ctrl, } } fn volume(&self) -> u16 { - let mapped_volume = f32::from_bits(self.volume.load(Ordering::Relaxed)); + let mapped_volume = f64::from_bits(self.volume.load(Ordering::Relaxed)); self.volume_ctrl.from_mapped(mapped_volume) } @@ -43,15 +43,15 @@ impl Mixer for SoftMixer { } struct SoftVolumeApplier { - volume: Arc, + volume: Arc, } impl AudioFilter for SoftVolumeApplier { - fn modify_stream(&self, data: &mut [f32]) { - let volume = f32::from_bits(self.volume.load(Ordering::Relaxed)); + fn modify_stream(&self, data: &mut [f64]) { + let volume = f64::from_bits(self.volume.load(Ordering::Relaxed)); if volume < 1.0 { for x in data.iter_mut() { - *x = (*x as f64 * volume as f64) as f32; + *x *= volume; } } } diff --git a/playback/src/player.rs b/playback/src/player.rs index 254908eb..f5af69f8 100644 --- a/playback/src/player.rs +++ b/playback/src/player.rs @@ -31,7 +31,7 @@ pub const NUM_CHANNELS: u8 = 2; pub const SAMPLES_PER_SECOND: u32 = SAMPLE_RATE as u32 * NUM_CHANNELS as u32; const PRELOAD_NEXT_TRACK_BEFORE_END_DURATION_MS: u32 = 30000; -pub const DB_VOLTAGE_RATIO: f32 = 20.0; +pub const DB_VOLTAGE_RATIO: f64 = 20.0; pub struct Player { commands: Option>, @@ -65,9 +65,9 @@ struct PlayerInternal { limiter_active: bool, limiter_attack_counter: u32, limiter_release_counter: u32, - limiter_peak_sample: f32, - limiter_factor: f32, - limiter_strength: f32, + limiter_peak_sample: f64, + limiter_factor: f64, + limiter_strength: f64, } enum PlayerCommand { @@ -198,11 +198,11 @@ impl PlayerEvent { pub type PlayerEventChannel = mpsc::UnboundedReceiver; -pub fn db_to_ratio(db: f32) -> f32 { - f32::powf(10.0, db / DB_VOLTAGE_RATIO) +pub fn db_to_ratio(db: f64) -> f64 { + f64::powf(10.0, db / DB_VOLTAGE_RATIO) } -pub fn ratio_to_db(ratio: f32) -> f32 { +pub fn ratio_to_db(ratio: f64) -> f64 { ratio.log10() * DB_VOLTAGE_RATIO } @@ -234,7 +234,7 @@ impl NormalisationData { Ok(r) } - fn get_factor(config: &PlayerConfig, data: NormalisationData) -> f32 { + fn get_factor(config: &PlayerConfig, data: NormalisationData) -> f64 { if !config.normalisation { return 1.0; } @@ -244,11 +244,11 @@ impl NormalisationData { NormalisationType::Track => [data.track_gain_db, data.track_peak], }; - let normalisation_power = gain_db + config.normalisation_pregain; + let normalisation_power = gain_db as f64 + config.normalisation_pregain; let mut normalisation_factor = db_to_ratio(normalisation_power); - if normalisation_factor * gain_peak > config.normalisation_threshold { - let limited_normalisation_factor = config.normalisation_threshold / gain_peak; + if normalisation_factor * gain_peak as f64 > config.normalisation_threshold { + let limited_normalisation_factor = config.normalisation_threshold / gain_peak as f64; let limited_normalisation_power = ratio_to_db(limited_normalisation_factor); if config.normalisation_method == NormalisationMethod::Basic { @@ -267,7 +267,7 @@ impl NormalisationData { debug!("Normalisation Data: {:?}", data); debug!("Normalisation Factor: {:.2}%", normalisation_factor * 100.0); - normalisation_factor + normalisation_factor as f64 } } @@ -430,7 +430,7 @@ impl Drop for Player { struct PlayerLoadedTrackData { decoder: Decoder, - normalisation_factor: f32, + normalisation_factor: f64, stream_loader_controller: StreamLoaderController, bytes_per_second: usize, duration_ms: u32, @@ -463,7 +463,7 @@ enum PlayerState { track_id: SpotifyId, play_request_id: u64, decoder: Decoder, - normalisation_factor: f32, + normalisation_factor: f64, stream_loader_controller: StreamLoaderController, bytes_per_second: usize, duration_ms: u32, @@ -474,7 +474,7 @@ enum PlayerState { track_id: SpotifyId, play_request_id: u64, decoder: Decoder, - normalisation_factor: f32, + normalisation_factor: f64, stream_loader_controller: StreamLoaderController, bytes_per_second: usize, duration_ms: u32, @@ -789,7 +789,7 @@ impl PlayerTrackLoader { } Err(_) => { warn!("Unable to extract normalisation data, using default value."); - 1.0_f32 + 1.0 } }; @@ -1178,7 +1178,7 @@ impl PlayerInternal { } } - fn handle_packet(&mut self, packet: Option, normalisation_factor: f32) { + fn handle_packet(&mut self, packet: Option, normalisation_factor: f64) { match packet { Some(mut packet) => { if !packet.is_empty() { @@ -1188,7 +1188,7 @@ impl PlayerInternal { } if self.config.normalisation - && !(f32::abs(normalisation_factor - 1.0) <= f32::EPSILON + && !(f64::abs(normalisation_factor - 1.0) <= f64::EPSILON && self.config.normalisation_method == NormalisationMethod::Basic) { for sample in data.iter_mut() { @@ -1208,10 +1208,10 @@ impl PlayerInternal { { shaped_limiter_strength = 1.0 / (1.0 - + f32::powf( + + f64::powf( shaped_limiter_strength / (1.0 - shaped_limiter_strength), - -1.0 * self.config.normalisation_knee, + -self.config.normalisation_knee, )); } actual_normalisation_factor = @@ -1222,18 +1222,16 @@ impl PlayerInternal { // Always check for peaks, even when the limiter is already active. // There may be even higher peaks than we initially targeted. // Check against the normalisation factor that would be applied normally. - let abs_sample = - ((*sample as f64 * normalisation_factor as f64) as f32) - .abs(); + let abs_sample = f64::abs(*sample * normalisation_factor); if abs_sample > self.config.normalisation_threshold { self.limiter_active = true; if self.limiter_release_counter > 0 { // A peak was encountered while releasing the limiter; // synchronize with the current release limiter strength. self.limiter_attack_counter = (((SAMPLES_PER_SECOND - as f32 + as f64 * self.config.normalisation_release) - - self.limiter_release_counter as f32) + - self.limiter_release_counter as f64) / (self.config.normalisation_release / self.config.normalisation_attack)) as u32; @@ -1242,8 +1240,8 @@ impl PlayerInternal { self.limiter_attack_counter = self.limiter_attack_counter.saturating_add(1); - self.limiter_strength = self.limiter_attack_counter as f32 - / (SAMPLES_PER_SECOND as f32 + self.limiter_strength = self.limiter_attack_counter as f64 + / (SAMPLES_PER_SECOND as f64 * self.config.normalisation_attack); if abs_sample > self.limiter_peak_sample { @@ -1259,9 +1257,9 @@ impl PlayerInternal { // start the release by synchronizing with the current // attack limiter strength. self.limiter_release_counter = (((SAMPLES_PER_SECOND - as f32 + as f64 * self.config.normalisation_attack) - - self.limiter_attack_counter as f32) + - self.limiter_attack_counter as f64) * (self.config.normalisation_release / self.config.normalisation_attack)) as u32; @@ -1272,23 +1270,22 @@ impl PlayerInternal { self.limiter_release_counter.saturating_add(1); if self.limiter_release_counter - > (SAMPLES_PER_SECOND as f32 + > (SAMPLES_PER_SECOND as f64 * self.config.normalisation_release) as u32 { self.reset_limiter(); } else { - self.limiter_strength = ((SAMPLES_PER_SECOND as f32 + self.limiter_strength = ((SAMPLES_PER_SECOND as f64 * self.config.normalisation_release) - - self.limiter_release_counter as f32) - / (SAMPLES_PER_SECOND as f32 + - self.limiter_release_counter as f64) + / (SAMPLES_PER_SECOND as f64 * self.config.normalisation_release); } } } - *sample = - (*sample as f64 * actual_normalisation_factor as f64) as f32; + *sample *= actual_normalisation_factor; // Extremely sharp attacks, however unlikely, *may* still clip and provide // undefined results, so strictly enforce output within [-1.0, 1.0]. diff --git a/src/main.rs b/src/main.rs index 81988136..4f8b8f1b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,7 +34,7 @@ use std::{ pin::Pin, }; -const MILLIS: f32 = 1000.0; +const MILLIS: f64 = 1000.0; fn device_id(name: &str) -> String { hex::encode(Sha1::digest(name.as_bytes())) @@ -247,7 +247,7 @@ fn get_setup(args: &[String]) -> Setup { .optopt( "", "format", - "Output format {F32|S32|S24|S24_3|S16}. Defaults to S16.", + "Output format {F64|F32|S32|S24|S24_3|S16}. Defaults to S16.", "FORMAT", ) .optopt( @@ -435,7 +435,7 @@ fn get_setup(args: &[String]) -> Setup { .unwrap_or_else(|| String::from("PCM")); let mut volume_range = matches .opt_str("volume-range") - .map(|range| range.parse::().unwrap()) + .map(|range| range.parse::().unwrap()) .unwrap_or_else(|| match mixer_name.as_ref().map(AsRef::as_ref) { Some("alsa") => 0.0, // let Alsa query the control _ => VolumeCtrl::DEFAULT_DB_RANGE, @@ -609,29 +609,29 @@ fn get_setup(args: &[String]) -> Setup { .unwrap_or_default(); let normalisation_pregain = matches .opt_str("normalisation-pregain") - .map(|pregain| pregain.parse::().expect("Invalid pregain float value")) + .map(|pregain| pregain.parse::().expect("Invalid pregain float value")) .unwrap_or(PlayerConfig::default().normalisation_pregain); let normalisation_threshold = matches .opt_str("normalisation-threshold") .map(|threshold| { db_to_ratio( threshold - .parse::() + .parse::() .expect("Invalid threshold float value"), ) }) .unwrap_or(PlayerConfig::default().normalisation_threshold); let normalisation_attack = matches .opt_str("normalisation-attack") - .map(|attack| attack.parse::().expect("Invalid attack float value") / MILLIS) + .map(|attack| attack.parse::().expect("Invalid attack float value") / MILLIS) .unwrap_or(PlayerConfig::default().normalisation_attack); let normalisation_release = matches .opt_str("normalisation-release") - .map(|release| release.parse::().expect("Invalid release float value") / MILLIS) + .map(|release| release.parse::().expect("Invalid release float value") / MILLIS) .unwrap_or(PlayerConfig::default().normalisation_release); let normalisation_knee = matches .opt_str("normalisation-knee") - .map(|knee| knee.parse::().expect("Invalid knee float value")) + .map(|knee| knee.parse::().expect("Invalid knee float value")) .unwrap_or(PlayerConfig::default().normalisation_knee); let ditherer_name = matches.opt_str("dither"); @@ -640,7 +640,7 @@ fn get_setup(args: &[String]) -> Setup { Some("none") => None, // explicitly set on command line Some(_) => { - if format == AudioFormat::F32 { + if format == AudioFormat::F64 || format == AudioFormat::F32 { unimplemented!("Dithering is not available on format {:?}", format); } Some(dither::find_ditherer(ditherer_name).expect("Invalid ditherer")) From bae18349887eb8bf6ae2fa6a04c86b70579b366d Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 30 May 2021 20:57:46 +0200 Subject: [PATCH 23/36] Fix output on big-endian systems (#778) --- CHANGELOG.md | 2 ++ playback/src/audio_backend/alsa.rs | 6 +++++- playback/src/audio_backend/gstreamer.rs | 9 +++++++-- playback/src/audio_backend/pulseaudio.rs | 10 +++++----- playback/src/convert.rs | 8 ++++++-- 5 files changed, 25 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83fd100f..bf75382a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,8 +36,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [connect] Fix step size on volume up/down events - [playback] Incorrect `PlayerConfig::default().normalisation_threshold` caused distortion when using dynamic volume normalisation downstream - [playback] Fix `log` and `cubic` volume controls to be mute at zero volume +- [playback] Fix `S24_3` format on big-endian systems - [playback] `alsamixer`: make `cubic` consistent between cards that report minimum volume as mute, and cards that report some dB value - [playback] `alsamixer`: make `--volume-ctrl {linear|log}` work as expected +- [playback] `alsa`, `gstreamer`, `pulseaudio`: always output in native endianness ## [0.2.0] - 2021-05-04 diff --git a/playback/src/audio_backend/alsa.rs b/playback/src/audio_backend/alsa.rs index b5deb2eb..8a590c6f 100644 --- a/playback/src/audio_backend/alsa.rs +++ b/playback/src/audio_backend/alsa.rs @@ -45,8 +45,12 @@ fn open_device(dev_name: &str, format: AudioFormat) -> Result<(PCM, Frames), Box AudioFormat::F32 => Format::float(), AudioFormat::S32 => Format::s32(), AudioFormat::S24 => Format::s24(), - AudioFormat::S24_3 => Format::S243LE, AudioFormat::S16 => Format::s16(), + + #[cfg(target_endian = "little")] + AudioFormat::S24_3 => Format::S243LE, + #[cfg(target_endian = "big")] + AudioFormat::S24_3 => Format::S243BE, }; // http://www.linuxjournal.com/article/6735?page=0,1#N0x19ab2890.0x19ba78d8 diff --git a/playback/src/audio_backend/gstreamer.rs b/playback/src/audio_backend/gstreamer.rs index b5273102..bd76863c 100644 --- a/playback/src/audio_backend/gstreamer.rs +++ b/playback/src/audio_backend/gstreamer.rs @@ -34,9 +34,14 @@ impl Open for GstreamerSink { let sample_size = format.size(); let gst_bytes = 2048 * sample_size; + #[cfg(target_endian = "little")] + const ENDIANNESS: &str = "LE"; + #[cfg(target_endian = "big")] + const ENDIANNESS: &str = "BE"; + let pipeline_str_preamble = format!( - "appsrc caps=\"audio/x-raw,format={}LE,layout=interleaved,channels={},rate={}\" block=true max-bytes={} name=appsrc0 ", - gst_format, NUM_CHANNELS, SAMPLE_RATE, gst_bytes + "appsrc caps=\"audio/x-raw,format={}{},layout=interleaved,channels={},rate={}\" block=true max-bytes={} name=appsrc0 ", + gst_format, ENDIANNESS, NUM_CHANNELS, SAMPLE_RATE, gst_bytes ); // no need to dither twice; use librespot dithering instead let pipeline_str_rest = r#" ! audioconvert dithering=none ! autoaudiosink"#; diff --git a/playback/src/audio_backend/pulseaudio.rs b/playback/src/audio_backend/pulseaudio.rs index 1298cd7c..75bd49de 100644 --- a/playback/src/audio_backend/pulseaudio.rs +++ b/playback/src/audio_backend/pulseaudio.rs @@ -23,11 +23,11 @@ impl Open for PulseAudioSink { // PulseAudio calls S24 and S24_3 different from the rest of the world let pulse_format = match format { - AudioFormat::F32 => pulse::sample::Format::F32le, - AudioFormat::S32 => pulse::sample::Format::S32le, - AudioFormat::S24 => pulse::sample::Format::S24_32le, - AudioFormat::S24_3 => pulse::sample::Format::S24le, - AudioFormat::S16 => pulse::sample::Format::S16le, + AudioFormat::F32 => pulse::sample::Format::FLOAT32NE, + AudioFormat::S32 => pulse::sample::Format::S32NE, + AudioFormat::S24 => pulse::sample::Format::S24_32NE, + AudioFormat::S24_3 => pulse::sample::Format::S24NE, + AudioFormat::S16 => pulse::sample::Format::S16NE, _ => { unimplemented!("PulseAudio currently does not support {:?} output", format) } diff --git a/playback/src/convert.rs b/playback/src/convert.rs index ddeff2fc..37e53fc0 100644 --- a/playback/src/convert.rs +++ b/playback/src/convert.rs @@ -8,8 +8,12 @@ pub struct i24([u8; 3]); impl i24 { fn from_s24(sample: i32) -> Self { // trim the padding in the most significant byte - let [a, b, c, _d] = sample.to_le_bytes(); - i24([a, b, c]) + #[allow(unused_variables)] + let [a, b, c, d] = sample.to_ne_bytes(); + #[cfg(target_endian = "little")] + return Self([a, b, c]); + #[cfg(target_endian = "big")] + return Self([b, c, d]); } } From ad19b69bfbd06a9e1a8005f47d6a83bc1f9e0bd4 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Mon, 31 May 2021 22:32:39 +0200 Subject: [PATCH 24/36] Various code improvements (#777) * Remove deprecated use of std::u16::MAX * Use `FromStr` for fallible `&str` conversions * DRY up strings into constants * Change `as_ref().map()` into `as_deref()` * Use `Duration` for time constants and functions * Optimize `Vec` with response times * Move comments for `rustdoc` to parse --- CHANGELOG.md | 1 + audio/src/fetch/mod.rs | 136 +++++----- audio/src/fetch/receive.rs | 70 +++--- audio/src/lib.rs | 4 +- connect/src/spirc.rs | 2 +- core/src/channel.rs | 9 +- playback/src/audio_backend/alsa.rs | 12 +- playback/src/audio_backend/gstreamer.rs | 6 +- playback/src/audio_backend/jackaudio.rs | 6 +- playback/src/audio_backend/mod.rs | 20 +- playback/src/audio_backend/pipe.rs | 4 + playback/src/audio_backend/portaudio.rs | 8 +- playback/src/audio_backend/pulseaudio.rs | 8 +- playback/src/audio_backend/rodio.rs | 11 +- playback/src/audio_backend/sdl.rs | 11 +- playback/src/audio_backend/subprocess.rs | 4 + playback/src/config.rs | 20 +- playback/src/convert.rs | 13 + playback/src/decoder/lewton_decoder.rs | 3 +- playback/src/decoder/passthrough_decoder.rs | 9 +- playback/src/dither.rs | 24 +- playback/src/lib.rs | 4 + playback/src/mixer/alsamixer.rs | 2 + playback/src/mixer/mod.rs | 8 +- playback/src/mixer/softmixer.rs | 4 + playback/src/player.rs | 83 +++---- src/main.rs | 260 ++++++++++++-------- 27 files changed, 433 insertions(+), 309 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf75382a..c00e884b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - [audio, playback] Moved `VorbisDecoder`, `VorbisError`, `AudioPacket`, `PassthroughDecoder`, `PassthroughError`, `AudioError`, `AudioDecoder` and the `convert` module from `librespot-audio` to `librespot-playback`. The underlying crates `vorbis`, `librespot-tremor`, `lewton` and `ogg` should be used directly. (breaking) +- [audio, playback] Use `Duration` for time constants and functions (breaking) - [connect, playback] Moved volume controls from `librespot-connect` to `librespot-playback` crate - [connect] Synchronize player volume with mixer volume on playback - [playback] Store and pass samples in 64-bit floating point diff --git a/audio/src/fetch/mod.rs b/audio/src/fetch/mod.rs index 8e076ebc..636194a8 100644 --- a/audio/src/fetch/mod.rs +++ b/audio/src/fetch/mod.rs @@ -18,70 +18,70 @@ use tokio::sync::{mpsc, oneshot}; use self::receive::{audio_file_fetch, request_range}; use crate::range_set::{Range, RangeSet}; +/// The minimum size of a block that is requested from the Spotify servers in one request. +/// This is the block size that is typically requested while doing a `seek()` on a file. +/// Note: smaller requests can happen if part of the block is downloaded already. const MINIMUM_DOWNLOAD_SIZE: usize = 1024 * 16; -// The minimum size of a block that is requested from the Spotify servers in one request. -// This is the block size that is typically requested while doing a seek() on a file. -// Note: smaller requests can happen if part of the block is downloaded already. +/// The amount of data that is requested when initially opening a file. +/// Note: if the file is opened to play from the beginning, the amount of data to +/// read ahead is requested in addition to this amount. If the file is opened to seek to +/// another position, then only this amount is requested on the first request. const INITIAL_DOWNLOAD_SIZE: usize = 1024 * 16; -// The amount of data that is requested when initially opening a file. -// Note: if the file is opened to play from the beginning, the amount of data to -// read ahead is requested in addition to this amount. If the file is opened to seek to -// another position, then only this amount is requested on the first request. -const INITIAL_PING_TIME_ESTIMATE_SECONDS: f64 = 0.5; -// The pig time that is used for calculations before a ping time was actually measured. +/// The ping time that is used for calculations before a ping time was actually measured. +const INITIAL_PING_TIME_ESTIMATE: Duration = Duration::from_millis(500); -const MAXIMUM_ASSUMED_PING_TIME_SECONDS: f64 = 1.5; -// If the measured ping time to the Spotify server is larger than this value, it is capped -// to avoid run-away block sizes and pre-fetching. +/// If the measured ping time to the Spotify server is larger than this value, it is capped +/// to avoid run-away block sizes and pre-fetching. +const MAXIMUM_ASSUMED_PING_TIME: Duration = Duration::from_millis(1500); -pub const READ_AHEAD_BEFORE_PLAYBACK_SECONDS: f64 = 1.0; -// Before playback starts, this many seconds of data must be present. -// Note: the calculations are done using the nominal bitrate of the file. The actual amount -// of audio data may be larger or smaller. +/// Before playback starts, this many seconds of data must be present. +/// Note: the calculations are done using the nominal bitrate of the file. The actual amount +/// of audio data may be larger or smaller. +pub const READ_AHEAD_BEFORE_PLAYBACK: Duration = Duration::from_secs(1); -pub const READ_AHEAD_BEFORE_PLAYBACK_ROUNDTRIPS: f64 = 2.0; -// Same as READ_AHEAD_BEFORE_PLAYBACK_SECONDS, but the time is taken as a factor of the ping -// time to the Spotify server. -// Both, READ_AHEAD_BEFORE_PLAYBACK_SECONDS and READ_AHEAD_BEFORE_PLAYBACK_ROUNDTRIPS are -// obeyed. -// Note: the calculations are done using the nominal bitrate of the file. The actual amount -// of audio data may be larger or smaller. +/// Same as `READ_AHEAD_BEFORE_PLAYBACK`, but the time is taken as a factor of the ping +/// time to the Spotify server. Both `READ_AHEAD_BEFORE_PLAYBACK` and +/// `READ_AHEAD_BEFORE_PLAYBACK_ROUNDTRIPS` are obeyed. +/// Note: the calculations are done using the nominal bitrate of the file. The actual amount +/// of audio data may be larger or smaller. +pub const READ_AHEAD_BEFORE_PLAYBACK_ROUNDTRIPS: f32 = 2.0; -pub const READ_AHEAD_DURING_PLAYBACK_SECONDS: f64 = 5.0; -// While playing back, this many seconds of data ahead of the current read position are -// requested. -// Note: the calculations are done using the nominal bitrate of the file. The actual amount -// of audio data may be larger or smaller. +/// While playing back, this many seconds of data ahead of the current read position are +/// requested. +/// Note: the calculations are done using the nominal bitrate of the file. The actual amount +/// of audio data may be larger or smaller. +pub const READ_AHEAD_DURING_PLAYBACK: Duration = Duration::from_secs(5); -pub const READ_AHEAD_DURING_PLAYBACK_ROUNDTRIPS: f64 = 10.0; -// Same as READ_AHEAD_DURING_PLAYBACK_SECONDS, but the time is taken as a factor of the ping -// time to the Spotify server. -// Note: the calculations are done using the nominal bitrate of the file. The actual amount -// of audio data may be larger or smaller. +/// Same as `READ_AHEAD_DURING_PLAYBACK`, but the time is taken as a factor of the ping +/// time to the Spotify server. +/// Note: the calculations are done using the nominal bitrate of the file. The actual amount +/// of audio data may be larger or smaller. +pub const READ_AHEAD_DURING_PLAYBACK_ROUNDTRIPS: f32 = 10.0; -const PREFETCH_THRESHOLD_FACTOR: f64 = 4.0; -// If the amount of data that is pending (requested but not received) is less than a certain amount, -// data is pre-fetched in addition to the read ahead settings above. The threshold for requesting more -// data is calculated as -// < PREFETCH_THRESHOLD_FACTOR * * +/// If the amount of data that is pending (requested but not received) is less than a certain amount, +/// data is pre-fetched in addition to the read ahead settings above. The threshold for requesting more +/// data is calculated as ` < PREFETCH_THRESHOLD_FACTOR * * ` +const PREFETCH_THRESHOLD_FACTOR: f32 = 4.0; -const FAST_PREFETCH_THRESHOLD_FACTOR: f64 = 1.5; -// Similar to PREFETCH_THRESHOLD_FACTOR, but it also takes the current download rate into account. -// The formula used is -// < FAST_PREFETCH_THRESHOLD_FACTOR * * -// This mechanism allows for fast downloading of the remainder of the file. The number should be larger -// than 1 so the download rate ramps up until the bandwidth is saturated. The larger the value, the faster -// the download rate ramps up. However, this comes at the cost that it might hurt ping-time if a seek is -// performed while downloading. Values smaller than 1 cause the download rate to collapse and effectively -// only PREFETCH_THRESHOLD_FACTOR is in effect. Thus, set to zero if bandwidth saturation is not wanted. +/// Similar to `PREFETCH_THRESHOLD_FACTOR`, but it also takes the current download rate into account. +/// The formula used is ` < FAST_PREFETCH_THRESHOLD_FACTOR * * ` +/// This mechanism allows for fast downloading of the remainder of the file. The number should be larger +/// than `1.0` so the download rate ramps up until the bandwidth is saturated. The larger the value, the faster +/// the download rate ramps up. However, this comes at the cost that it might hurt ping time if a seek is +/// performed while downloading. Values smaller than `1.0` cause the download rate to collapse and effectively +/// only `PREFETCH_THRESHOLD_FACTOR` is in effect. Thus, set to `0.0` if bandwidth saturation is not wanted. +const FAST_PREFETCH_THRESHOLD_FACTOR: f32 = 1.5; +/// Limit the number of requests that are pending simultaneously before pre-fetching data. Pending +/// requests share bandwidth. Thus, havint too many requests can lead to the one that is needed next +/// for playback to be delayed leading to a buffer underrun. This limit has the effect that a new +/// pre-fetch request is only sent if less than `MAX_PREFETCH_REQUESTS` are pending. const MAX_PREFETCH_REQUESTS: usize = 4; -// Limit the number of requests that are pending simultaneously before pre-fetching data. Pending -// requests share bandwidth. Thus, havint too many requests can lead to the one that is needed next -// for playback to be delayed leading to a buffer underrun. This limit has the effect that a new -// pre-fetch request is only sent if less than MAX_PREFETCH_REQUESTS are pending. + +/// The time we will wait to obtain status updates on downloading. +const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(1); pub enum AudioFile { Cached(fs::File), @@ -131,10 +131,10 @@ impl StreamLoaderController { }) } - pub fn ping_time_ms(&self) -> usize { - self.stream_shared.as_ref().map_or(0, |shared| { - shared.ping_time_ms.load(atomic::Ordering::Relaxed) - }) + pub fn ping_time(&self) -> Duration { + Duration::from_millis(self.stream_shared.as_ref().map_or(0, |shared| { + shared.ping_time_ms.load(atomic::Ordering::Relaxed) as u64 + })) } fn send_stream_loader_command(&self, command: StreamLoaderCommand) { @@ -170,7 +170,7 @@ impl StreamLoaderController { { download_status = shared .cond - .wait_timeout(download_status, Duration::from_millis(1000)) + .wait_timeout(download_status, DOWNLOAD_TIMEOUT) .unwrap() .0; if range.length @@ -271,10 +271,10 @@ impl AudioFile { let mut initial_data_length = if play_from_beginning { INITIAL_DOWNLOAD_SIZE + max( - (READ_AHEAD_DURING_PLAYBACK_SECONDS * bytes_per_second as f64) as usize, - (INITIAL_PING_TIME_ESTIMATE_SECONDS + (READ_AHEAD_DURING_PLAYBACK.as_secs_f32() * bytes_per_second as f32) as usize, + (INITIAL_PING_TIME_ESTIMATE.as_secs_f32() * READ_AHEAD_DURING_PLAYBACK_ROUNDTRIPS - * bytes_per_second as f64) as usize, + * bytes_per_second as f32) as usize, ) } else { INITIAL_DOWNLOAD_SIZE @@ -368,7 +368,7 @@ impl AudioFileStreaming { let read_file = write_file.reopen().unwrap(); - //let (seek_tx, seek_rx) = mpsc::unbounded(); + // let (seek_tx, seek_rx) = mpsc::unbounded(); let (stream_loader_command_tx, stream_loader_command_rx) = mpsc::unbounded_channel::(); @@ -405,17 +405,19 @@ impl Read for AudioFileStreaming { let length_to_request = match *(self.shared.download_strategy.lock().unwrap()) { DownloadStrategy::RandomAccess() => length, DownloadStrategy::Streaming() => { - // Due to the read-ahead stuff, we potentially request more than the actual reqeust demanded. - let ping_time_seconds = - 0.0001 * self.shared.ping_time_ms.load(atomic::Ordering::Relaxed) as f64; + // Due to the read-ahead stuff, we potentially request more than the actual request demanded. + let ping_time_seconds = Duration::from_millis( + self.shared.ping_time_ms.load(atomic::Ordering::Relaxed) as u64, + ) + .as_secs_f32(); let length_to_request = length + max( - (READ_AHEAD_DURING_PLAYBACK_SECONDS * self.shared.stream_data_rate as f64) - as usize, + (READ_AHEAD_DURING_PLAYBACK.as_secs_f32() + * self.shared.stream_data_rate as f32) as usize, (READ_AHEAD_DURING_PLAYBACK_ROUNDTRIPS * ping_time_seconds - * self.shared.stream_data_rate as f64) as usize, + * self.shared.stream_data_rate as f32) as usize, ); min(length_to_request, self.shared.file_size - offset) } @@ -449,7 +451,7 @@ impl Read for AudioFileStreaming { download_status = self .shared .cond - .wait_timeout(download_status, Duration::from_millis(1000)) + .wait_timeout(download_status, DOWNLOAD_TIMEOUT) .unwrap() .0; } diff --git a/audio/src/fetch/receive.rs b/audio/src/fetch/receive.rs index 0f056c96..64becc23 100644 --- a/audio/src/fetch/receive.rs +++ b/audio/src/fetch/receive.rs @@ -1,8 +1,9 @@ use std::cmp::{max, min}; use std::io::{Seek, SeekFrom, Write}; use std::sync::{atomic, Arc}; -use std::time::Instant; +use std::time::{Duration, Instant}; +use atomic::Ordering; use byteorder::{BigEndian, WriteBytesExt}; use bytes::Bytes; use futures_util::StreamExt; @@ -16,7 +17,7 @@ use crate::range_set::{Range, RangeSet}; use super::{AudioFileShared, DownloadStrategy, StreamLoaderCommand}; use super::{ - FAST_PREFETCH_THRESHOLD_FACTOR, MAXIMUM_ASSUMED_PING_TIME_SECONDS, MAX_PREFETCH_REQUESTS, + FAST_PREFETCH_THRESHOLD_FACTOR, MAXIMUM_ASSUMED_PING_TIME, MAX_PREFETCH_REQUESTS, MINIMUM_DOWNLOAD_SIZE, PREFETCH_THRESHOLD_FACTOR, }; @@ -57,7 +58,7 @@ struct PartialFileData { } enum ReceivedData { - ResponseTimeMs(usize), + ResponseTime(Duration), Data(PartialFileData), } @@ -74,7 +75,7 @@ async fn receive_data( let old_number_of_request = shared .number_of_open_requests - .fetch_add(1, atomic::Ordering::SeqCst); + .fetch_add(1, Ordering::SeqCst); let mut measure_ping_time = old_number_of_request == 0; @@ -86,14 +87,11 @@ async fn receive_data( }; if measure_ping_time { - let duration = Instant::now() - request_sent_time; - let duration_ms: u64; - if 0.001 * (duration.as_millis() as f64) > MAXIMUM_ASSUMED_PING_TIME_SECONDS { - duration_ms = (MAXIMUM_ASSUMED_PING_TIME_SECONDS * 1000.0) as u64; - } else { - duration_ms = duration.as_millis() as u64; + let mut duration = Instant::now() - request_sent_time; + if duration > MAXIMUM_ASSUMED_PING_TIME { + duration = MAXIMUM_ASSUMED_PING_TIME; } - let _ = file_data_tx.send(ReceivedData::ResponseTimeMs(duration_ms as usize)); + let _ = file_data_tx.send(ReceivedData::ResponseTime(duration)); measure_ping_time = false; } let data_size = data.len(); @@ -127,7 +125,7 @@ async fn receive_data( shared .number_of_open_requests - .fetch_sub(1, atomic::Ordering::SeqCst); + .fetch_sub(1, Ordering::SeqCst); if result.is_err() { warn!( @@ -149,7 +147,7 @@ struct AudioFileFetch { file_data_tx: mpsc::UnboundedSender, complete_tx: Option>, - network_response_times_ms: Vec, + network_response_times: Vec, } // Might be replaced by enum from std once stable @@ -237,7 +235,7 @@ impl AudioFileFetch { // download data from after the current read position first let mut tail_end = RangeSet::new(); - let read_position = self.shared.read_position.load(atomic::Ordering::Relaxed); + let read_position = self.shared.read_position.load(Ordering::Relaxed); tail_end.add_range(&Range::new( read_position, self.shared.file_size - read_position, @@ -267,26 +265,23 @@ impl AudioFileFetch { fn handle_file_data(&mut self, data: ReceivedData) -> ControlFlow { match data { - ReceivedData::ResponseTimeMs(response_time_ms) => { - trace!("Ping time estimated as: {} ms.", response_time_ms); + ReceivedData::ResponseTime(response_time) => { + trace!("Ping time estimated as: {}ms", response_time.as_millis()); - // record the response time - self.network_response_times_ms.push(response_time_ms); - - // prune old response times. Keep at most three. - while self.network_response_times_ms.len() > 3 { - self.network_response_times_ms.remove(0); + // prune old response times. Keep at most two so we can push a third. + while self.network_response_times.len() >= 3 { + self.network_response_times.remove(0); } + // record the response time + self.network_response_times.push(response_time); + // stats::median is experimental. So we calculate the median of up to three ourselves. - let ping_time_ms: usize = match self.network_response_times_ms.len() { - 1 => self.network_response_times_ms[0] as usize, - 2 => { - ((self.network_response_times_ms[0] + self.network_response_times_ms[1]) - / 2) as usize - } + let ping_time = match self.network_response_times.len() { + 1 => self.network_response_times[0], + 2 => (self.network_response_times[0] + self.network_response_times[1]) / 2, 3 => { - let mut times = self.network_response_times_ms.clone(); + let mut times = self.network_response_times.clone(); times.sort_unstable(); times[1] } @@ -296,7 +291,7 @@ impl AudioFileFetch { // store our new estimate for everyone to see self.shared .ping_time_ms - .store(ping_time_ms, atomic::Ordering::Relaxed); + .store(ping_time.as_millis() as usize, Ordering::Relaxed); } ReceivedData::Data(data) => { self.output @@ -390,7 +385,7 @@ pub(super) async fn audio_file_fetch( file_data_tx, complete_tx: Some(complete_tx), - network_response_times_ms: Vec::new(), + network_response_times: Vec::with_capacity(3), }; loop { @@ -408,10 +403,8 @@ pub(super) async fn audio_file_fetch( } if fetch.get_download_strategy() == DownloadStrategy::Streaming() { - let number_of_open_requests = fetch - .shared - .number_of_open_requests - .load(atomic::Ordering::SeqCst); + let number_of_open_requests = + fetch.shared.number_of_open_requests.load(Ordering::SeqCst); if number_of_open_requests < MAX_PREFETCH_REQUESTS { let max_requests_to_send = MAX_PREFETCH_REQUESTS - number_of_open_requests; @@ -424,14 +417,15 @@ pub(super) async fn audio_file_fetch( }; let ping_time_seconds = - 0.001 * fetch.shared.ping_time_ms.load(atomic::Ordering::Relaxed) as f64; + Duration::from_millis(fetch.shared.ping_time_ms.load(Ordering::Relaxed) as u64) + .as_secs_f32(); let download_rate = fetch.session.channel().get_download_rate_estimate(); let desired_pending_bytes = max( (PREFETCH_THRESHOLD_FACTOR * ping_time_seconds - * fetch.shared.stream_data_rate as f64) as usize, - (FAST_PREFETCH_THRESHOLD_FACTOR * ping_time_seconds * download_rate as f64) + * fetch.shared.stream_data_rate as f32) as usize, + (FAST_PREFETCH_THRESHOLD_FACTOR * ping_time_seconds * download_rate as f32) as usize, ); diff --git a/audio/src/lib.rs b/audio/src/lib.rs index e43cf728..4b486bbe 100644 --- a/audio/src/lib.rs +++ b/audio/src/lib.rs @@ -11,6 +11,6 @@ mod range_set; pub use decrypt::AudioDecrypt; pub use fetch::{AudioFile, StreamLoaderController}; pub use fetch::{ - READ_AHEAD_BEFORE_PLAYBACK_ROUNDTRIPS, READ_AHEAD_BEFORE_PLAYBACK_SECONDS, - READ_AHEAD_DURING_PLAYBACK_ROUNDTRIPS, READ_AHEAD_DURING_PLAYBACK_SECONDS, + READ_AHEAD_BEFORE_PLAYBACK, READ_AHEAD_BEFORE_PLAYBACK_ROUNDTRIPS, READ_AHEAD_DURING_PLAYBACK, + READ_AHEAD_DURING_PLAYBACK_ROUNDTRIPS, }; diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 76cf7054..57dc4cdd 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -88,7 +88,7 @@ const CONTEXT_TRACKS_HISTORY: usize = 10; const CONTEXT_FETCH_THRESHOLD: u32 = 5; const VOLUME_STEPS: i64 = 64; -const VOLUME_STEP_SIZE: u16 = 1024; // (std::u16::MAX + 1) / VOLUME_STEPS +const VOLUME_STEP_SIZE: u16 = 1024; // (u16::MAX + 1) / VOLUME_STEPS pub struct Spirc { commands: mpsc::UnboundedSender, diff --git a/core/src/channel.rs b/core/src/channel.rs index 4a78a4aa..29c3c8aa 100644 --- a/core/src/channel.rs +++ b/core/src/channel.rs @@ -23,6 +23,8 @@ component! { } } +const ONE_SECOND_IN_MS: usize = 1000; + #[derive(Debug, Hash, PartialEq, Eq, Copy, Clone)] pub struct ChannelError; @@ -74,8 +76,11 @@ impl ChannelManager { self.lock(|inner| { let current_time = Instant::now(); if let Some(download_measurement_start) = inner.download_measurement_start { - if (current_time - download_measurement_start).as_millis() > 1000 { - inner.download_rate_estimate = 1000 * inner.download_measurement_bytes + if (current_time - download_measurement_start).as_millis() + > ONE_SECOND_IN_MS as u128 + { + inner.download_rate_estimate = ONE_SECOND_IN_MS + * inner.download_measurement_bytes / (current_time - download_measurement_start).as_millis() as usize; inner.download_measurement_start = Some(current_time); inner.download_measurement_bytes = 0; diff --git a/playback/src/audio_backend/alsa.rs b/playback/src/audio_backend/alsa.rs index 8a590c6f..b2f020f1 100644 --- a/playback/src/audio_backend/alsa.rs +++ b/playback/src/audio_backend/alsa.rs @@ -2,7 +2,7 @@ use super::{Open, Sink, SinkAsBytes}; use crate::config::AudioFormat; use crate::convert::Converter; use crate::decoder::AudioPacket; -use crate::player::{NUM_CHANNELS, SAMPLES_PER_SECOND, SAMPLE_RATE}; +use crate::{NUM_CHANNELS, SAMPLES_PER_SECOND, SAMPLE_RATE}; use alsa::device_name::HintIter; use alsa::pcm::{Access, Format, Frames, HwParams, PCM}; use alsa::{Direction, Error, ValueOr}; @@ -10,8 +10,9 @@ use std::cmp::min; use std::ffi::CString; use std::io; use std::process::exit; +use std::time::Duration; -const BUFFERED_LATENCY: f32 = 0.125; // seconds +const BUFFERED_LATENCY: Duration = Duration::from_millis(125); const BUFFERED_PERIODS: Frames = 4; pub struct AlsaSink { @@ -57,7 +58,8 @@ fn open_device(dev_name: &str, format: AudioFormat) -> Result<(PCM, Frames), Box // latency = period_size * periods / (rate * bytes_per_frame) // For stereo samples encoded as 32-bit float, one frame has a length of eight bytes. let mut period_size = ((SAMPLES_PER_SECOND * format.size() as u32) as f32 - * (BUFFERED_LATENCY / BUFFERED_PERIODS as f32)) as Frames; + * (BUFFERED_LATENCY.as_secs_f32() / BUFFERED_PERIODS as f32)) + as Frames; { let hwp = HwParams::any(&pcm)?; hwp.set_access(Access::RWInterleaved)?; @@ -80,7 +82,7 @@ impl Open for AlsaSink { fn open(device: Option, format: AudioFormat) -> Self { info!("Using Alsa sink with format: {:?}", format); - let name = match device.as_ref().map(AsRef::as_ref) { + let name = match device.as_deref() { Some("?") => { println!("Listing available Alsa outputs:"); list_outputs(); @@ -162,6 +164,8 @@ impl SinkAsBytes for AlsaSink { } impl AlsaSink { + pub const NAME: &'static str = "alsa"; + fn write_buf(&mut self) { let pcm = self.pcm.as_mut().unwrap(); let io = pcm.io_bytes(); diff --git a/playback/src/audio_backend/gstreamer.rs b/playback/src/audio_backend/gstreamer.rs index bd76863c..58f6cbc9 100644 --- a/playback/src/audio_backend/gstreamer.rs +++ b/playback/src/audio_backend/gstreamer.rs @@ -2,7 +2,7 @@ use super::{Open, Sink, SinkAsBytes}; use crate::config::AudioFormat; use crate::convert::Converter; use crate::decoder::AudioPacket; -use crate::player::{NUM_CHANNELS, SAMPLE_RATE}; +use crate::{NUM_CHANNELS, SAMPLE_RATE}; use gstreamer as gst; use gstreamer_app as gst_app; @@ -139,3 +139,7 @@ impl SinkAsBytes for GstreamerSink { Ok(()) } } + +impl GstreamerSink { + pub const NAME: &'static str = "gstreamer"; +} diff --git a/playback/src/audio_backend/jackaudio.rs b/playback/src/audio_backend/jackaudio.rs index 75c4eb9f..f55f20a8 100644 --- a/playback/src/audio_backend/jackaudio.rs +++ b/playback/src/audio_backend/jackaudio.rs @@ -2,7 +2,7 @@ use super::{Open, Sink}; use crate::config::AudioFormat; use crate::convert::Converter; use crate::decoder::AudioPacket; -use crate::player::NUM_CHANNELS; +use crate::NUM_CHANNELS; use jack::{ AsyncClient, AudioOut, Client, ClientOptions, Control, Port, ProcessHandler, ProcessScope, }; @@ -81,3 +81,7 @@ impl Sink for JackSink { Ok(()) } } + +impl JackSink { + pub const NAME: &'static str = "jackaudio"; +} diff --git a/playback/src/audio_backend/mod.rs b/playback/src/audio_backend/mod.rs index 0996bfb6..31fb847c 100644 --- a/playback/src/audio_backend/mod.rs +++ b/playback/src/audio_backend/mod.rs @@ -90,6 +90,8 @@ use self::gstreamer::GstreamerSink; #[cfg(any(feature = "rodio-backend", feature = "rodiojack-backend"))] mod rodio; +#[cfg(any(feature = "rodio-backend", feature = "rodiojack-backend"))] +use self::rodio::RodioSink; #[cfg(feature = "sdl-backend")] mod sdl; @@ -104,23 +106,23 @@ use self::subprocess::SubprocessSink; pub const BACKENDS: &[(&str, SinkBuilder)] = &[ #[cfg(feature = "rodio-backend")] - ("rodio", rodio::mk_rodio), // default goes first + (RodioSink::NAME, rodio::mk_rodio), // default goes first #[cfg(feature = "alsa-backend")] - ("alsa", mk_sink::), + (AlsaSink::NAME, mk_sink::), #[cfg(feature = "portaudio-backend")] - ("portaudio", mk_sink::), + (PortAudioSink::NAME, mk_sink::), #[cfg(feature = "pulseaudio-backend")] - ("pulseaudio", mk_sink::), + (PulseAudioSink::NAME, mk_sink::), #[cfg(feature = "jackaudio-backend")] - ("jackaudio", mk_sink::), + (JackSink::NAME, mk_sink::), #[cfg(feature = "gstreamer-backend")] - ("gstreamer", mk_sink::), + (GstreamerSink::NAME, mk_sink::), #[cfg(feature = "rodiojack-backend")] ("rodiojack", rodio::mk_rodiojack), #[cfg(feature = "sdl-backend")] - ("sdl", mk_sink::), - ("pipe", mk_sink::), - ("subprocess", mk_sink::), + (SdlSink::NAME, mk_sink::), + (StdoutSink::NAME, mk_sink::), + (SubprocessSink::NAME, mk_sink::), ]; pub fn find(name: Option) -> Option { diff --git a/playback/src/audio_backend/pipe.rs b/playback/src/audio_backend/pipe.rs index 6ad2773b..926219f9 100644 --- a/playback/src/audio_backend/pipe.rs +++ b/playback/src/audio_backend/pipe.rs @@ -34,3 +34,7 @@ impl SinkAsBytes for StdoutSink { Ok(()) } } + +impl StdoutSink { + pub const NAME: &'static str = "pipe"; +} diff --git a/playback/src/audio_backend/portaudio.rs b/playback/src/audio_backend/portaudio.rs index ccebcfdf..378deb48 100644 --- a/playback/src/audio_backend/portaudio.rs +++ b/playback/src/audio_backend/portaudio.rs @@ -2,7 +2,7 @@ use super::{Open, Sink}; use crate::config::AudioFormat; use crate::convert::Converter; use crate::decoder::AudioPacket; -use crate::player::{NUM_CHANNELS, SAMPLE_RATE}; +use crate::{NUM_CHANNELS, SAMPLE_RATE}; use portaudio_rs::device::{get_default_output_index, DeviceIndex, DeviceInfo}; use portaudio_rs::stream::*; use std::io; @@ -57,7 +57,7 @@ impl<'a> Open for PortAudioSink<'a> { portaudio_rs::initialize().unwrap(); - let device_idx = match device.as_ref().map(AsRef::as_ref) { + let device_idx = match device.as_deref() { Some("?") => { list_outputs(); exit(0) @@ -178,3 +178,7 @@ impl<'a> Drop for PortAudioSink<'a> { portaudio_rs::terminate().unwrap(); } } + +impl<'a> PortAudioSink<'a> { + pub const NAME: &'static str = "portaudio"; +} diff --git a/playback/src/audio_backend/pulseaudio.rs b/playback/src/audio_backend/pulseaudio.rs index 75bd49de..e36941ea 100644 --- a/playback/src/audio_backend/pulseaudio.rs +++ b/playback/src/audio_backend/pulseaudio.rs @@ -2,7 +2,7 @@ use super::{Open, Sink, SinkAsBytes}; use crate::config::AudioFormat; use crate::convert::Converter; use crate::decoder::AudioPacket; -use crate::player::{NUM_CHANNELS, SAMPLE_RATE}; +use crate::{NUM_CHANNELS, SAMPLE_RATE}; use libpulse_binding::{self as pulse, stream::Direction}; use libpulse_simple_binding::Simple; use std::io; @@ -55,7 +55,7 @@ impl Sink for PulseAudioSink { return Ok(()); } - let device = self.device.as_ref().map(|s| (*s).as_str()); + let device = self.device.as_deref(); let result = Simple::new( None, // Use the default server. APP_NAME, // Our application's name. @@ -104,3 +104,7 @@ impl SinkAsBytes for PulseAudioSink { } } } + +impl PulseAudioSink { + pub const NAME: &'static str = "pulseaudio"; +} diff --git a/playback/src/audio_backend/rodio.rs b/playback/src/audio_backend/rodio.rs index 2951560a..1e999938 100644 --- a/playback/src/audio_backend/rodio.rs +++ b/playback/src/audio_backend/rodio.rs @@ -1,5 +1,6 @@ use std::process::exit; -use std::{io, thread, time}; +use std::time::Duration; +use std::{io, thread}; use cpal::traits::{DeviceTrait, HostTrait}; use thiserror::Error; @@ -8,7 +9,7 @@ use super::Sink; use crate::config::AudioFormat; use crate::convert::Converter; use crate::decoder::AudioPacket; -use crate::player::{NUM_CHANNELS, SAMPLE_RATE}; +use crate::{NUM_CHANNELS, SAMPLE_RATE}; #[cfg(all( feature = "rodiojack-backend", @@ -203,8 +204,12 @@ impl Sink for RodioSink { // 44100 elements --> about 27 chunks while self.rodio_sink.len() > 26 { // sleep and wait for rodio to drain a bit - thread::sleep(time::Duration::from_millis(10)); + thread::sleep(Duration::from_millis(10)); } Ok(()) } } + +impl RodioSink { + pub const NAME: &'static str = "rodio"; +} diff --git a/playback/src/audio_backend/sdl.rs b/playback/src/audio_backend/sdl.rs index d07e562f..28d140e8 100644 --- a/playback/src/audio_backend/sdl.rs +++ b/playback/src/audio_backend/sdl.rs @@ -2,9 +2,10 @@ use super::{Open, Sink}; use crate::config::AudioFormat; use crate::convert::Converter; use crate::decoder::AudioPacket; -use crate::player::{NUM_CHANNELS, SAMPLE_RATE}; +use crate::{NUM_CHANNELS, SAMPLE_RATE}; use sdl2::audio::{AudioQueue, AudioSpecDesired}; -use std::{io, thread, time}; +use std::time::Duration; +use std::{io, thread}; pub enum SdlSink { F32(AudioQueue), @@ -86,7 +87,7 @@ impl Sink for SdlSink { ($queue: expr, $size: expr) => {{ // sleep and wait for sdl thread to drain the queue a bit while $queue.size() > (NUM_CHANNELS as u32 * $size as u32 * SAMPLE_RATE) { - thread::sleep(time::Duration::from_millis(10)); + thread::sleep(Duration::from_millis(10)); } }}; } @@ -112,3 +113,7 @@ impl Sink for SdlSink { Ok(()) } } + +impl SdlSink { + pub const NAME: &'static str = "sdl"; +} diff --git a/playback/src/audio_backend/subprocess.rs b/playback/src/audio_backend/subprocess.rs index 785fb3d2..64f04c88 100644 --- a/playback/src/audio_backend/subprocess.rs +++ b/playback/src/audio_backend/subprocess.rs @@ -62,3 +62,7 @@ impl SinkAsBytes for SubprocessSink { Ok(()) } } + +impl SubprocessSink { + pub const NAME: &'static str = "subprocess"; +} diff --git a/playback/src/config.rs b/playback/src/config.rs index a2e1c6c7..7604f59f 100644 --- a/playback/src/config.rs +++ b/playback/src/config.rs @@ -2,9 +2,9 @@ use super::player::db_to_ratio; use crate::convert::i24; pub use crate::dither::{mk_ditherer, DithererBuilder, TriangularDitherer}; -use std::convert::TryFrom; use std::mem; use std::str::FromStr; +use std::time::Duration; #[derive(Clone, Copy, Debug, Hash, PartialOrd, Ord, PartialEq, Eq)] pub enum Bitrate { @@ -41,10 +41,10 @@ pub enum AudioFormat { S16, } -impl TryFrom<&String> for AudioFormat { - type Error = (); - fn try_from(s: &String) -> Result { - match s.to_uppercase().as_str() { +impl FromStr for AudioFormat { + type Err = (); + fn from_str(s: &str) -> Result { + match s.to_uppercase().as_ref() { "F64" => Ok(Self::F64), "F32" => Ok(Self::F32), "S32" => Ok(Self::S32), @@ -133,8 +133,8 @@ pub struct PlayerConfig { pub normalisation_method: NormalisationMethod, pub normalisation_pregain: f64, pub normalisation_threshold: f64, - pub normalisation_attack: f64, - pub normalisation_release: f64, + pub normalisation_attack: Duration, + pub normalisation_release: Duration, pub normalisation_knee: f64, // pass function pointers so they can be lazily instantiated *after* spawning a thread @@ -152,8 +152,8 @@ impl Default for PlayerConfig { normalisation_method: NormalisationMethod::default(), normalisation_pregain: 0.0, normalisation_threshold: db_to_ratio(-1.0), - normalisation_attack: 0.005, - normalisation_release: 0.1, + normalisation_attack: Duration::from_millis(5), + normalisation_release: Duration::from_millis(100), normalisation_knee: 1.0, passthrough: false, ditherer: Some(mk_ditherer::), @@ -184,7 +184,7 @@ impl Default for VolumeCtrl { } impl VolumeCtrl { - pub const MAX_VOLUME: u16 = std::u16::MAX; + pub const MAX_VOLUME: u16 = u16::MAX; // Taken from: https://www.dr-lex.be/info-stuff/volumecontrols.html pub const DEFAULT_DB_RANGE: f64 = 60.0; diff --git a/playback/src/convert.rs b/playback/src/convert.rs index 37e53fc0..1f1122a7 100644 --- a/playback/src/convert.rs +++ b/playback/src/convert.rs @@ -34,8 +34,21 @@ impl Converter { } } + /// To convert PCM samples from floating point normalized as `-1.0..=1.0` + /// to 32-bit signed integer, multiply by 2147483648 (0x80000000) and + /// saturate at the bounds of `i32`. const SCALE_S32: f64 = 2147483648.; + + /// To convert PCM samples from floating point normalized as `-1.0..=1.0` + /// to 24-bit signed integer, multiply by 8388608 (0x800000) and saturate + /// at the bounds of `i24`. const SCALE_S24: f64 = 8388608.; + + /// To convert PCM samples from floating point normalized as `-1.0..=1.0` + /// to 16-bit signed integer, multiply by 32768 (0x8000) and saturate at + /// the bounds of `i16`. When the samples were encoded using the same + /// scaling factor, like the reference Vorbis encoder does, this makes + /// conversions transparent. const SCALE_S16: f64 = 32768.; pub fn scale(&mut self, sample: f64, factor: f64) -> f64 { diff --git a/playback/src/decoder/lewton_decoder.rs b/playback/src/decoder/lewton_decoder.rs index 64a49e57..adf63e2a 100644 --- a/playback/src/decoder/lewton_decoder.rs +++ b/playback/src/decoder/lewton_decoder.rs @@ -6,6 +6,7 @@ use lewton::samples::InterleavedSamples; use std::error; use std::fmt; use std::io::{Read, Seek}; +use std::time::Duration; pub struct VorbisDecoder(OggStreamReader); pub struct VorbisError(lewton::VorbisError); @@ -24,7 +25,7 @@ where R: Read + Seek, { fn seek(&mut self, ms: i64) -> Result<(), AudioError> { - let absgp = ms * 44100 / 1000; + let absgp = Duration::from_millis(ms as u64 * crate::SAMPLE_RATE as u64).as_secs(); match self.0.seek_absgp_pg(absgp as u64) { Ok(_) => Ok(()), Err(err) => Err(AudioError::VorbisError(err.into())), diff --git a/playback/src/decoder/passthrough_decoder.rs b/playback/src/decoder/passthrough_decoder.rs index e064cba3..7c1ad532 100644 --- a/playback/src/decoder/passthrough_decoder.rs +++ b/playback/src/decoder/passthrough_decoder.rs @@ -1,8 +1,10 @@ // Passthrough decoder for librespot use super::{AudioDecoder, AudioError, AudioPacket}; +use crate::SAMPLE_RATE; use ogg::{OggReadError, Packet, PacketReader, PacketWriteEndInfo, PacketWriter}; use std::fmt; use std::io::{Read, Seek}; +use std::time::Duration; use std::time::{SystemTime, UNIX_EPOCH}; fn get_header(code: u8, rdr: &mut PacketReader) -> Result, PassthroughError> @@ -12,7 +14,7 @@ where let pck: Packet = rdr.read_packet_expected()?; let pkt_type = pck.data[0]; - debug!("Vorbis header type{}", &pkt_type); + debug!("Vorbis header type {}", &pkt_type); if pkt_type != code { return Err(PassthroughError(OggReadError::InvalidData)); @@ -96,7 +98,10 @@ impl AudioDecoder for PassthroughDecoder { self.stream_serial += 1; // hard-coded to 44.1 kHz - match self.rdr.seek_absgp(None, (ms * 44100 / 1000) as u64) { + match self.rdr.seek_absgp( + None, + Duration::from_millis(ms as u64 * SAMPLE_RATE as u64).as_secs(), + ) { Ok(_) => { // need to set some offset for next_page() let pck = self.rdr.read_packet().unwrap().unwrap(); diff --git a/playback/src/dither.rs b/playback/src/dither.rs index 63447ce8..2510b886 100644 --- a/playback/src/dither.rs +++ b/playback/src/dither.rs @@ -61,7 +61,7 @@ impl Ditherer for TriangularDitherer { } fn name(&self) -> &'static str { - "Triangular" + Self::NAME } fn noise(&mut self) -> f64 { @@ -69,6 +69,10 @@ impl Ditherer for TriangularDitherer { } } +impl TriangularDitherer { + pub const NAME: &'static str = "tpdf"; +} + pub struct GaussianDitherer { cached_rng: ThreadRng, distribution: Normal, @@ -84,7 +88,7 @@ impl Ditherer for GaussianDitherer { } fn name(&self) -> &'static str { - "Gaussian" + Self::NAME } fn noise(&mut self) -> f64 { @@ -92,6 +96,10 @@ impl Ditherer for GaussianDitherer { } } +impl GaussianDitherer { + pub const NAME: &'static str = "gpdf"; +} + pub struct HighPassDitherer { active_channel: usize, previous_noises: [f64; NUM_CHANNELS], @@ -110,7 +118,7 @@ impl Ditherer for HighPassDitherer { } fn name(&self) -> &'static str { - "Triangular, High Passed" + Self::NAME } fn noise(&mut self) -> f64 { @@ -122,6 +130,10 @@ impl Ditherer for HighPassDitherer { } } +impl HighPassDitherer { + pub const NAME: &'static str = "tpdf_hp"; +} + pub fn mk_ditherer() -> Box { Box::new(D::new()) } @@ -130,9 +142,9 @@ pub type DithererBuilder = fn() -> Box; pub fn find_ditherer(name: Option) -> Option { match name.as_deref() { - Some("tpdf") => Some(mk_ditherer::), - Some("gpdf") => Some(mk_ditherer::), - Some("tpdf_hp") => Some(mk_ditherer::), + Some(TriangularDitherer::NAME) => Some(mk_ditherer::), + Some(GaussianDitherer::NAME) => Some(mk_ditherer::), + Some(HighPassDitherer::NAME) => Some(mk_ditherer::), _ => None, } } diff --git a/playback/src/lib.rs b/playback/src/lib.rs index 31dadc44..689b8470 100644 --- a/playback/src/lib.rs +++ b/playback/src/lib.rs @@ -12,3 +12,7 @@ mod decoder; pub mod dither; pub mod mixer; pub mod player; + +pub const SAMPLE_RATE: u32 = 44100; +pub const NUM_CHANNELS: u8 = 2; +pub const SAMPLES_PER_SECOND: u32 = SAMPLE_RATE as u32 * NUM_CHANNELS as u32; diff --git a/playback/src/mixer/alsamixer.rs b/playback/src/mixer/alsamixer.rs index fb6853bb..8bee9e0d 100644 --- a/playback/src/mixer/alsamixer.rs +++ b/playback/src/mixer/alsamixer.rs @@ -241,6 +241,8 @@ impl Mixer for AlsaMixer { } impl AlsaMixer { + pub const NAME: &'static str = "alsa"; + fn switched_off(&self) -> bool { if !self.has_switch { return false; diff --git a/playback/src/mixer/mod.rs b/playback/src/mixer/mod.rs index aaecd779..ed39582e 100644 --- a/playback/src/mixer/mod.rs +++ b/playback/src/mixer/mod.rs @@ -53,11 +53,11 @@ fn mk_sink(config: MixerConfig) -> Box { Box::new(M::open(config)) } -pub fn find>(name: Option) -> Option { - match name.as_ref().map(AsRef::as_ref) { - None | Some("softvol") => Some(mk_sink::), +pub fn find(name: Option<&str>) -> Option { + match name { + None | Some(SoftMixer::NAME) => Some(mk_sink::), #[cfg(feature = "alsa-backend")] - Some("alsa") => Some(mk_sink::), + Some(AlsaMixer::NAME) => Some(mk_sink::), _ => None, } } diff --git a/playback/src/mixer/softmixer.rs b/playback/src/mixer/softmixer.rs index d1c6eb20..27448237 100644 --- a/playback/src/mixer/softmixer.rs +++ b/playback/src/mixer/softmixer.rs @@ -42,6 +42,10 @@ impl Mixer for SoftMixer { } } +impl SoftMixer { + pub const NAME: &'static str = "softmixer"; +} + struct SoftVolumeApplier { volume: Arc, } diff --git a/playback/src/player.rs b/playback/src/player.rs index f5af69f8..4daac9b4 100644 --- a/playback/src/player.rs +++ b/playback/src/player.rs @@ -13,8 +13,8 @@ use tokio::sync::{mpsc, oneshot}; use crate::audio::{AudioDecrypt, AudioFile, StreamLoaderController}; use crate::audio::{ - READ_AHEAD_BEFORE_PLAYBACK_ROUNDTRIPS, READ_AHEAD_BEFORE_PLAYBACK_SECONDS, - READ_AHEAD_DURING_PLAYBACK_ROUNDTRIPS, READ_AHEAD_DURING_PLAYBACK_SECONDS, + READ_AHEAD_BEFORE_PLAYBACK, READ_AHEAD_BEFORE_PLAYBACK_ROUNDTRIPS, READ_AHEAD_DURING_PLAYBACK, + READ_AHEAD_DURING_PLAYBACK_ROUNDTRIPS, }; use crate::audio_backend::Sink; use crate::config::{Bitrate, NormalisationMethod, NormalisationType, PlayerConfig}; @@ -26,9 +26,7 @@ use crate::decoder::{AudioDecoder, AudioError, AudioPacket, PassthroughDecoder, use crate::metadata::{AudioItem, FileFormat}; use crate::mixer::AudioFilter; -pub const SAMPLE_RATE: u32 = 44100; -pub const NUM_CHANNELS: u8 = 2; -pub const SAMPLES_PER_SECOND: u32 = SAMPLE_RATE as u32 * NUM_CHANNELS as u32; +use crate::{NUM_CHANNELS, SAMPLES_PER_SECOND}; const PRELOAD_NEXT_TRACK_BEFORE_END_DURATION_MS: u32 = 30000; pub const DB_VOLTAGE_RATIO: f64 = 20.0; @@ -297,14 +295,8 @@ impl Player { debug!("Normalisation Method: {:?}", config.normalisation_method); if config.normalisation_method == NormalisationMethod::Dynamic { - debug!( - "Normalisation Attack: {:.0} ms", - config.normalisation_attack * 1000.0 - ); - debug!( - "Normalisation Release: {:.0} ms", - config.normalisation_release * 1000.0 - ); + debug!("Normalisation Attack: {:?}", config.normalisation_attack); + debug!("Normalisation Release: {:?}", config.normalisation_release); debug!("Normalisation Knee: {:?}", config.normalisation_knee); } } @@ -973,12 +965,12 @@ impl Future for PlayerInternal { let notify_about_position = match *reported_nominal_start_time { None => true, Some(reported_nominal_start_time) => { - // only notify if we're behind. If we're ahead it's probably due to a buffer of the backend and we;re actually in time. + // only notify if we're behind. If we're ahead it's probably due to a buffer of the backend and we're actually in time. let lag = (Instant::now() - reported_nominal_start_time) .as_millis() as i64 - stream_position_millis as i64; - lag > 1000 + lag > Duration::from_secs(1).as_millis() as i64 } }; if notify_about_position { @@ -1219,6 +1211,16 @@ impl PlayerInternal { + shaped_limiter_strength * self.limiter_factor; }; + // Cast the fields here for better readability + let normalisation_attack = + self.config.normalisation_attack.as_secs_f64(); + let normalisation_release = + self.config.normalisation_release.as_secs_f64(); + let limiter_release_counter = + self.limiter_release_counter as f64; + let limiter_attack_counter = self.limiter_attack_counter as f64; + let samples_per_second = SAMPLES_PER_SECOND as f64; + // Always check for peaks, even when the limiter is already active. // There may be even higher peaks than we initially targeted. // Check against the normalisation factor that would be applied normally. @@ -1228,21 +1230,19 @@ impl PlayerInternal { if self.limiter_release_counter > 0 { // A peak was encountered while releasing the limiter; // synchronize with the current release limiter strength. - self.limiter_attack_counter = (((SAMPLES_PER_SECOND - as f64 - * self.config.normalisation_release) - - self.limiter_release_counter as f64) - / (self.config.normalisation_release - / self.config.normalisation_attack)) + self.limiter_attack_counter = (((samples_per_second + * normalisation_release) + - limiter_release_counter) + / (normalisation_release / normalisation_attack)) as u32; self.limiter_release_counter = 0; } self.limiter_attack_counter = self.limiter_attack_counter.saturating_add(1); - self.limiter_strength = self.limiter_attack_counter as f64 - / (SAMPLES_PER_SECOND as f64 - * self.config.normalisation_attack); + + self.limiter_strength = limiter_attack_counter + / (samples_per_second * normalisation_attack); if abs_sample > self.limiter_peak_sample { self.limiter_peak_sample = abs_sample; @@ -1256,12 +1256,10 @@ impl PlayerInternal { // the limiter reached full strength. For that reason // start the release by synchronizing with the current // attack limiter strength. - self.limiter_release_counter = (((SAMPLES_PER_SECOND - as f64 - * self.config.normalisation_attack) - - self.limiter_attack_counter as f64) - * (self.config.normalisation_release - / self.config.normalisation_attack)) + self.limiter_release_counter = (((samples_per_second + * normalisation_attack) + - limiter_attack_counter) + * (normalisation_release / normalisation_attack)) as u32; self.limiter_attack_counter = 0; } @@ -1270,17 +1268,14 @@ impl PlayerInternal { self.limiter_release_counter.saturating_add(1); if self.limiter_release_counter - > (SAMPLES_PER_SECOND as f64 - * self.config.normalisation_release) - as u32 + > (samples_per_second * normalisation_release) as u32 { self.reset_limiter(); } else { - self.limiter_strength = ((SAMPLES_PER_SECOND as f64 - * self.config.normalisation_release) - - self.limiter_release_counter as f64) - / (SAMPLES_PER_SECOND as f64 - * self.config.normalisation_release); + self.limiter_strength = ((samples_per_second + * normalisation_release) + - limiter_release_counter) + / (samples_per_second * normalisation_release); } } } @@ -1806,18 +1801,18 @@ impl PlayerInternal { // Request our read ahead range let request_data_length = max( (READ_AHEAD_DURING_PLAYBACK_ROUNDTRIPS - * (0.001 * stream_loader_controller.ping_time_ms() as f64) - * bytes_per_second as f64) as usize, - (READ_AHEAD_DURING_PLAYBACK_SECONDS * bytes_per_second as f64) as usize, + * stream_loader_controller.ping_time().as_secs_f32() + * bytes_per_second as f32) as usize, + (READ_AHEAD_DURING_PLAYBACK.as_secs_f32() * bytes_per_second as f32) as usize, ); stream_loader_controller.fetch_next(request_data_length); // Request the part we want to wait for blocking. This effecively means we wait for the previous request to partially complete. let wait_for_data_length = max( (READ_AHEAD_BEFORE_PLAYBACK_ROUNDTRIPS - * (0.001 * stream_loader_controller.ping_time_ms() as f64) - * bytes_per_second as f64) as usize, - (READ_AHEAD_BEFORE_PLAYBACK_SECONDS * bytes_per_second as f64) as usize, + * stream_loader_controller.ping_time().as_secs_f32() + * bytes_per_second as f32) as usize, + (READ_AHEAD_BEFORE_PLAYBACK.as_secs_f32() * bytes_per_second as f32) as usize, ); stream_loader_controller.fetch_next_blocking(wait_for_data_length); } diff --git a/src/main.rs b/src/main.rs index 4f8b8f1b..a3687aaa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,8 @@ use librespot::playback::config::{ AudioFormat, Bitrate, NormalisationMethod, NormalisationType, PlayerConfig, VolumeCtrl, }; use librespot::playback::dither; +#[cfg(feature = "alsa-backend")] +use librespot::playback::mixer::alsamixer::AlsaMixer; use librespot::playback::mixer::mappings::MappedCtrl; use librespot::playback::mixer::{self, MixerConfig, MixerFn}; use librespot::playback::player::{db_to_ratio, Player}; @@ -24,17 +26,14 @@ use librespot::playback::player::{db_to_ratio, Player}; mod player_event_handler; use player_event_handler::{emit_sink_event, run_program_on_events}; -use std::convert::TryFrom; +use std::env; +use std::io::{stderr, Write}; use std::path::Path; +use std::pin::Pin; use std::process::exit; use std::str::FromStr; -use std::{env, time::Instant}; -use std::{ - io::{stderr, Write}, - pin::Pin, -}; - -const MILLIS: f64 = 1000.0; +use std::time::Duration; +use std::time::Instant; fn device_id(name: &str) -> String { hex::encode(Sha1::digest(name.as_bytes())) @@ -189,176 +188,216 @@ struct Setup { } fn get_setup(args: &[String]) -> Setup { + const AP_PORT: &str = "ap-port"; + const AUTOPLAY: &str = "autoplay"; + const BACKEND: &str = "backend"; + const BITRATE: &str = "b"; + const CACHE: &str = "c"; + const CACHE_SIZE_LIMIT: &str = "cache-size-limit"; + const DEVICE: &str = "device"; + const DEVICE_TYPE: &str = "device-type"; + const DISABLE_AUDIO_CACHE: &str = "disable-audio-cache"; + const DISABLE_DISCOVERY: &str = "disable-discovery"; + const DISABLE_GAPLESS: &str = "disable-gapless"; + const DITHER: &str = "dither"; + const EMIT_SINK_EVENTS: &str = "emit-sink-events"; + const ENABLE_VOLUME_NORMALISATION: &str = "enable-volume-normalisation"; + const FORMAT: &str = "format"; + const HELP: &str = "h"; + const INITIAL_VOLUME: &str = "initial-volume"; + const MIXER_CARD: &str = "mixer-card"; + const MIXER_INDEX: &str = "mixer-index"; + const MIXER_NAME: &str = "mixer-name"; + const NAME: &str = "name"; + const NORMALISATION_ATTACK: &str = "normalisation-attack"; + const NORMALISATION_GAIN_TYPE: &str = "normalisation-gain-type"; + const NORMALISATION_KNEE: &str = "normalisation-knee"; + const NORMALISATION_METHOD: &str = "normalisation-method"; + const NORMALISATION_PREGAIN: &str = "normalisation-pregain"; + const NORMALISATION_RELEASE: &str = "normalisation-release"; + const NORMALISATION_THRESHOLD: &str = "normalisation-threshold"; + const ONEVENT: &str = "onevent"; + const PASSTHROUGH: &str = "passthrough"; + const PASSWORD: &str = "password"; + const PROXY: &str = "proxy"; + const SYSTEM_CACHE: &str = "system-cache"; + const USERNAME: &str = "username"; + const VERBOSE: &str = "verbose"; + const VERSION: &str = "version"; + const VOLUME_CTRL: &str = "volume-ctrl"; + const VOLUME_RANGE: &str = "volume-range"; + const ZEROCONF_PORT: &str = "zeroconf-port"; + let mut opts = getopts::Options::new(); opts.optflag( - "h", + HELP, "help", "Print this help menu.", ).optopt( - "c", + CACHE, "cache", "Path to a directory where files will be cached.", "PATH", ).optopt( "", - "system-cache", + SYSTEM_CACHE, "Path to a directory where system files (credentials, volume) will be cached. Can be different from cache option value.", "PATH", ).optopt( "", - "cache-size-limit", + CACHE_SIZE_LIMIT, "Limits the size of the cache for audio files.", "SIZE" - ).optflag("", "disable-audio-cache", "Disable caching of the audio data.") - .optopt("n", "name", "Device name.", "NAME") - .optopt("", "device-type", "Displayed device type.", "TYPE") + ).optflag("", DISABLE_AUDIO_CACHE, "Disable caching of the audio data.") + .optopt("n", NAME, "Device name.", "NAME") + .optopt("", DEVICE_TYPE, "Displayed device type.", "TYPE") .optopt( - "b", + BITRATE, "bitrate", "Bitrate (kbps) {96|160|320}. Defaults to 160.", "BITRATE", ) .optopt( "", - "onevent", + ONEVENT, "Run PROGRAM when a playback event occurs.", "PROGRAM", ) - .optflag("", "emit-sink-events", "Run program set by --onevent before sink is opened and after it is closed.") - .optflag("v", "verbose", "Enable verbose output.") - .optflag("V", "version", "Display librespot version string.") - .optopt("u", "username", "Username to sign in with.", "USERNAME") - .optopt("p", "password", "Password", "PASSWORD") - .optopt("", "proxy", "HTTP proxy to use when connecting.", "URL") - .optopt("", "ap-port", "Connect to AP with specified port. If no AP with that port are present fallback AP will be used. Available ports are usually 80, 443 and 4070.", "PORT") - .optflag("", "disable-discovery", "Disable discovery mode.") + .optflag("", EMIT_SINK_EVENTS, "Run program set by --onevent before sink is opened and after it is closed.") + .optflag("v", VERBOSE, "Enable verbose output.") + .optflag("V", VERSION, "Display librespot version string.") + .optopt("u", USERNAME, "Username to sign in with.", "USERNAME") + .optopt("p", PASSWORD, "Password", "PASSWORD") + .optopt("", PROXY, "HTTP proxy to use when connecting.", "URL") + .optopt("", AP_PORT, "Connect to AP with specified port. If no AP with that port are present fallback AP will be used. Available ports are usually 80, 443 and 4070.", "PORT") + .optflag("", DISABLE_DISCOVERY, "Disable discovery mode.") .optopt( "", - "backend", + BACKEND, "Audio backend to use. Use '?' to list options.", "NAME", ) .optopt( "", - "device", + DEVICE, "Audio device to use. Use '?' to list options if using alsa, portaudio or rodio.", "NAME", ) .optopt( "", - "format", + FORMAT, "Output format {F64|F32|S32|S24|S24_3|S16}. Defaults to S16.", "FORMAT", ) .optopt( "", - "dither", + DITHER, "Specify the dither algorithm to use - [none, gpdf, tpdf, tpdf_hp]. Defaults to 'tpdf' for formats S16, S24, S24_3 and 'none' for other formats.", "DITHER", ) .optopt("", "mixer", "Mixer to use {alsa|softvol}.", "MIXER") .optopt( "m", - "mixer-name", + MIXER_NAME, "Alsa mixer control, e.g. 'PCM' or 'Master'. Defaults to 'PCM'.", "NAME", ) .optopt( "", - "mixer-card", + MIXER_CARD, "Alsa mixer card, e.g 'hw:0' or similar from `aplay -l`. Defaults to DEVICE if specified, 'default' otherwise.", "MIXER_CARD", ) .optopt( "", - "mixer-index", + MIXER_INDEX, "Alsa index of the cards mixer. Defaults to 0.", "INDEX", ) .optopt( "", - "initial-volume", + INITIAL_VOLUME, "Initial volume in % from 0-100. Default for softvol: '50'. For the Alsa mixer: the current volume.", "VOLUME", ) .optopt( "", - "zeroconf-port", + ZEROCONF_PORT, "The port the internal server advertised over zeroconf uses.", "PORT", ) .optflag( "", - "enable-volume-normalisation", + ENABLE_VOLUME_NORMALISATION, "Play all tracks at the same volume.", ) .optopt( "", - "normalisation-method", + NORMALISATION_METHOD, "Specify the normalisation method to use {basic|dynamic}. Defaults to dynamic.", "METHOD", ) .optopt( "", - "normalisation-gain-type", + NORMALISATION_GAIN_TYPE, "Specify the normalisation gain type to use {track|album}. Defaults to album.", "TYPE", ) .optopt( "", - "normalisation-pregain", + NORMALISATION_PREGAIN, "Pregain (dB) applied by volume normalisation. Defaults to 0.", "PREGAIN", ) .optopt( "", - "normalisation-threshold", + NORMALISATION_THRESHOLD, "Threshold (dBFS) to prevent clipping. Defaults to -1.0.", "THRESHOLD", ) .optopt( "", - "normalisation-attack", + NORMALISATION_ATTACK, "Attack time (ms) in which the dynamic limiter is reducing gain. Defaults to 5.", "TIME", ) .optopt( "", - "normalisation-release", + NORMALISATION_RELEASE, "Release or decay time (ms) in which the dynamic limiter is restoring gain. Defaults to 100.", "TIME", ) .optopt( "", - "normalisation-knee", + NORMALISATION_KNEE, "Knee steepness of the dynamic limiter. Defaults to 1.0.", "KNEE", ) .optopt( "", - "volume-ctrl", + VOLUME_CTRL, "Volume control type {cubic|fixed|linear|log}. Defaults to log.", "VOLUME_CTRL" ) .optopt( "", - "volume-range", + VOLUME_RANGE, "Range of the volume control (dB). Default for softvol: 60. For the Alsa mixer: what the control supports.", "RANGE", ) .optflag( "", - "autoplay", + AUTOPLAY, "Automatically play similar songs when your music ends.", ) .optflag( "", - "disable-gapless", + DISABLE_GAPLESS, "Disable gapless playback.", ) .optflag( "", - "passthrough", + PASSTHROUGH, "Pass raw stream to output, only works for pipe and subprocess.", ); @@ -374,17 +413,17 @@ fn get_setup(args: &[String]) -> Setup { } }; - if matches.opt_present("h") { + if matches.opt_present(HELP) { println!("{}", usage(&args[0], &opts)); exit(0); } - if matches.opt_present("version") { + if matches.opt_present(VERSION) { print_version(); exit(0); } - let verbose = matches.opt_present("verbose"); + let verbose = matches.opt_present(VERBOSE); setup_logging(verbose); info!( @@ -395,7 +434,7 @@ fn get_setup(args: &[String]) -> Setup { build_id = version::BUILD_ID ); - let backend_name = matches.opt_str("backend"); + let backend_name = matches.opt_str(BACKEND); if backend_name == Some("?".into()) { list_backends(); exit(0); @@ -404,40 +443,41 @@ fn get_setup(args: &[String]) -> Setup { let backend = audio_backend::find(backend_name).expect("Invalid backend"); let format = matches - .opt_str("format") - .as_ref() - .map(|format| AudioFormat::try_from(format).expect("Invalid output format")) + .opt_str(FORMAT) + .as_deref() + .map(|format| AudioFormat::from_str(format).expect("Invalid output format")) .unwrap_or_default(); - let device = matches.opt_str("device"); + let device = matches.opt_str(DEVICE); if device == Some("?".into()) { backend(device, format); exit(0); } - let mixer_name = matches.opt_str("mixer"); - let mixer = mixer::find(mixer_name.as_ref()).expect("Invalid mixer"); + let mixer_name = matches.opt_str(MIXER_NAME); + let mixer = mixer::find(mixer_name.as_deref()).expect("Invalid mixer"); let mixer_config = { - let card = matches.opt_str("mixer-card").unwrap_or_else(|| { + let card = matches.opt_str(MIXER_CARD).unwrap_or_else(|| { if let Some(ref device_name) = device { device_name.to_string() } else { - String::from("default") + MixerConfig::default().card } }); let index = matches - .opt_str("mixer-index") + .opt_str(MIXER_INDEX) .map(|index| index.parse::().unwrap()) .unwrap_or(0); let control = matches - .opt_str("mixer-name") - .unwrap_or_else(|| String::from("PCM")); + .opt_str(MIXER_NAME) + .unwrap_or_else(|| MixerConfig::default().control); let mut volume_range = matches - .opt_str("volume-range") + .opt_str(VOLUME_RANGE) .map(|range| range.parse::().unwrap()) - .unwrap_or_else(|| match mixer_name.as_ref().map(AsRef::as_ref) { - Some("alsa") => 0.0, // let Alsa query the control + .unwrap_or_else(|| match mixer_name.as_deref() { + #[cfg(feature = "alsa-backend")] + Some(AlsaMixer::NAME) => 0.0, // let Alsa query the control _ => VolumeCtrl::DEFAULT_DB_RANGE, }); if volume_range < 0.0 { @@ -449,8 +489,8 @@ fn get_setup(args: &[String]) -> Setup { ); } let volume_ctrl = matches - .opt_str("volume-ctrl") - .as_ref() + .opt_str(VOLUME_CTRL) + .as_deref() .map(|volume_ctrl| { VolumeCtrl::from_str_with_range(volume_ctrl, volume_range) .expect("Invalid volume control type") @@ -472,26 +512,26 @@ fn get_setup(args: &[String]) -> Setup { let cache = { let audio_dir; let system_dir; - if matches.opt_present("disable-audio-cache") { + if matches.opt_present(DISABLE_AUDIO_CACHE) { audio_dir = None; system_dir = matches - .opt_str("system-cache") - .or_else(|| matches.opt_str("c")) + .opt_str(SYSTEM_CACHE) + .or_else(|| matches.opt_str(CACHE)) .map(|p| p.into()); } else { - let cache_dir = matches.opt_str("c"); + let cache_dir = matches.opt_str(CACHE); audio_dir = cache_dir .as_ref() .map(|p| AsRef::::as_ref(p).join("files")); system_dir = matches - .opt_str("system-cache") + .opt_str(SYSTEM_CACHE) .or(cache_dir) .map(|p| p.into()); } let limit = if audio_dir.is_some() { matches - .opt_str("cache-size-limit") + .opt_str(CACHE_SIZE_LIMIT) .as_deref() .map(parse_file_size) .map(|e| { @@ -514,7 +554,7 @@ fn get_setup(args: &[String]) -> Setup { }; let initial_volume = matches - .opt_str("initial-volume") + .opt_str(INITIAL_VOLUME) .map(|initial_volume| { let volume = initial_volume.parse::().unwrap(); if volume > 100 { @@ -523,18 +563,19 @@ fn get_setup(args: &[String]) -> Setup { } (volume as f32 / 100.0 * VolumeCtrl::MAX_VOLUME as f32) as u16 }) - .or_else(|| match mixer_name.as_ref().map(AsRef::as_ref) { - Some("alsa") => None, + .or_else(|| match mixer_name.as_deref() { + #[cfg(feature = "alsa-backend")] + Some(AlsaMixer::NAME) => None, _ => cache.as_ref().and_then(Cache::volume), }); let zeroconf_port = matches - .opt_str("zeroconf-port") + .opt_str(ZEROCONF_PORT) .map(|port| port.parse::().unwrap()) .unwrap_or(0); let name = matches - .opt_str("name") + .opt_str(NAME) .unwrap_or_else(|| "Librespot".to_string()); let credentials = { @@ -547,8 +588,8 @@ fn get_setup(args: &[String]) -> Setup { }; get_credentials( - matches.opt_str("username"), - matches.opt_str("password"), + matches.opt_str(USERNAME), + matches.opt_str(PASSWORD), cached_credentials, password, ) @@ -560,7 +601,7 @@ fn get_setup(args: &[String]) -> Setup { SessionConfig { user_agent: version::VERSION_STRING.to_string(), device_id, - proxy: matches.opt_str("proxy").or_else(|| std::env::var("http_proxy").ok()).map( + proxy: matches.opt_str(PROXY).or_else(|| std::env::var("http_proxy").ok()).map( |s| { match Url::parse(&s) { Ok(url) => { @@ -578,41 +619,41 @@ fn get_setup(args: &[String]) -> Setup { }, ), ap_port: matches - .opt_str("ap-port") + .opt_str(AP_PORT) .map(|port| port.parse::().expect("Invalid port")), } }; let player_config = { let bitrate = matches - .opt_str("b") - .as_ref() + .opt_str(BITRATE) + .as_deref() .map(|bitrate| Bitrate::from_str(bitrate).expect("Invalid bitrate")) .unwrap_or_default(); - let gapless = !matches.opt_present("disable-gapless"); + let gapless = !matches.opt_present(DISABLE_GAPLESS); - let normalisation = matches.opt_present("enable-volume-normalisation"); + let normalisation = matches.opt_present(ENABLE_VOLUME_NORMALISATION); let normalisation_method = matches - .opt_str("normalisation-method") - .as_ref() + .opt_str(NORMALISATION_METHOD) + .as_deref() .map(|method| { NormalisationMethod::from_str(method).expect("Invalid normalisation method") }) .unwrap_or_default(); let normalisation_type = matches - .opt_str("normalisation-gain-type") - .as_ref() + .opt_str(NORMALISATION_GAIN_TYPE) + .as_deref() .map(|gain_type| { NormalisationType::from_str(gain_type).expect("Invalid normalisation type") }) .unwrap_or_default(); let normalisation_pregain = matches - .opt_str("normalisation-pregain") + .opt_str(NORMALISATION_PREGAIN) .map(|pregain| pregain.parse::().expect("Invalid pregain float value")) .unwrap_or(PlayerConfig::default().normalisation_pregain); let normalisation_threshold = matches - .opt_str("normalisation-threshold") + .opt_str(NORMALISATION_THRESHOLD) .map(|threshold| { db_to_ratio( threshold @@ -622,19 +663,23 @@ fn get_setup(args: &[String]) -> Setup { }) .unwrap_or(PlayerConfig::default().normalisation_threshold); let normalisation_attack = matches - .opt_str("normalisation-attack") - .map(|attack| attack.parse::().expect("Invalid attack float value") / MILLIS) + .opt_str(NORMALISATION_ATTACK) + .map(|attack| { + Duration::from_millis(attack.parse::().expect("Invalid attack value")) + }) .unwrap_or(PlayerConfig::default().normalisation_attack); let normalisation_release = matches - .opt_str("normalisation-release") - .map(|release| release.parse::().expect("Invalid release float value") / MILLIS) + .opt_str(NORMALISATION_RELEASE) + .map(|release| { + Duration::from_millis(release.parse::().expect("Invalid release value")) + }) .unwrap_or(PlayerConfig::default().normalisation_release); let normalisation_knee = matches - .opt_str("normalisation-knee") + .opt_str(NORMALISATION_KNEE) .map(|knee| knee.parse::().expect("Invalid knee float value")) .unwrap_or(PlayerConfig::default().normalisation_knee); - let ditherer_name = matches.opt_str("dither"); + let ditherer_name = matches.opt_str(DITHER); let ditherer = match ditherer_name.as_deref() { // explicitly disabled on command line Some("none") => None, @@ -654,7 +699,7 @@ fn get_setup(args: &[String]) -> Setup { }, }; - let passthrough = matches.opt_present("passthrough"); + let passthrough = matches.opt_present(PASSTHROUGH); PlayerConfig { bitrate, @@ -674,12 +719,12 @@ fn get_setup(args: &[String]) -> Setup { let connect_config = { let device_type = matches - .opt_str("device-type") - .as_ref() + .opt_str(DEVICE_TYPE) + .as_deref() .map(|device_type| DeviceType::from_str(device_type).expect("Invalid device type")) .unwrap_or_default(); let has_volume_ctrl = !matches!(mixer_config.volume_ctrl, VolumeCtrl::Fixed); - let autoplay = matches.opt_present("autoplay"); + let autoplay = matches.opt_present(AUTOPLAY); ConnectConfig { name, @@ -690,9 +735,9 @@ fn get_setup(args: &[String]) -> Setup { } }; - let enable_discovery = !matches.opt_present("disable-discovery"); - let player_event_program = matches.opt_str("onevent"); - let emit_sink_events = matches.opt_present("emit-sink-events"); + let enable_discovery = !matches.opt_present(DISABLE_DISCOVERY); + let player_event_program = matches.opt_str(ONEVENT); + let emit_sink_events = matches.opt_present(EMIT_SINK_EVENTS); Setup { format, @@ -714,8 +759,9 @@ fn get_setup(args: &[String]) -> Setup { #[tokio::main(flavor = "current_thread")] async fn main() { - if env::var("RUST_BACKTRACE").is_err() { - env::set_var("RUST_BACKTRACE", "full") + const RUST_BACKTRACE: &str = "RUST_BACKTRACE"; + if env::var(RUST_BACKTRACE).is_err() { + env::set_var(RUST_BACKTRACE, "full") } let args: Vec = std::env::args().collect(); From d4f466ef58b4a6128f947b1f26e7a0b020043e2a Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Mon, 31 May 2021 23:35:48 +0200 Subject: [PATCH 25/36] Revert `math::round_half_to_even` This caused quite a bump in CPU usage, which be acceptable if this actually improved sound quality. However, it turns out that this function only has one decimal precision, i.e. it would consider all values from `0.50..0.60` (exclusive) as `0.5` which is in error for our purposes. --- Cargo.lock | 85 ++++++----------------------------------- playback/Cargo.toml | 3 +- playback/src/convert.rs | 3 +- 3 files changed, 13 insertions(+), 78 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a4e3ef9b..e7bd92dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -435,12 +435,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fuchsia-cprng" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" - [[package]] name = "futures" version = "0.3.15" @@ -1079,15 +1073,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7d73b3f436185384286bd8098d17ec07c9a7d2388a6599f824d8502b529702a" -[[package]] -name = "libmath" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfd3416934a853ae80d5c3b006f632dfcbaf320300c5167e88a469e9ac214502" -dependencies = [ - "rand 0.3.23", -] - [[package]] name = "libmdns" version = "0.6.1" @@ -1100,7 +1085,7 @@ dependencies = [ "if-addrs", "log", "multimap", - "rand 0.8.3", + "rand", "socket2", "thiserror", "tokio", @@ -1205,7 +1190,7 @@ dependencies = [ "librespot-protocol", "log", "protobuf", - "rand 0.8.3", + "rand", "serde", "serde_json", "tokio", @@ -1238,7 +1223,7 @@ dependencies = [ "pbkdf2", "priority-queue", "protobuf", - "rand 0.8.3", + "rand", "serde", "serde_json", "sha-1", @@ -1269,7 +1254,7 @@ dependencies = [ "libmdns", "librespot-core", "log", - "rand 0.8.3", + "rand", "serde_json", "sha-1", "simple_logger", @@ -1303,7 +1288,6 @@ dependencies = [ "gstreamer-app", "jack 0.7.1", "lewton", - "libmath", "libpulse-binding", "libpulse-simple-binding", "librespot-audio", @@ -1312,7 +1296,7 @@ dependencies = [ "log", "ogg", "portaudio-rs", - "rand 0.8.3", + "rand", "rand_distr", "rodio", "sdl2", @@ -1504,7 +1488,7 @@ dependencies = [ "autocfg", "num-integer", "num-traits", - "rand 0.8.3", + "rand", ] [[package]] @@ -1856,29 +1840,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "rand" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64ac302d8f83c0c1974bf758f6b041c6c8ada916fbb44a609158ca8b064cc76c" -dependencies = [ - "libc", - "rand 0.4.6", -] - -[[package]] -name = "rand" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293" -dependencies = [ - "fuchsia-cprng", - "libc", - "rand_core 0.3.1", - "rdrand", - "winapi", -] - [[package]] name = "rand" version = "0.8.3" @@ -1887,7 +1848,7 @@ checksum = "0ef9e7e66b4468674bfcb0c81af8b7fa0bb154fa9f28eb840da5c447baeb8d7e" dependencies = [ "libc", "rand_chacha", - "rand_core 0.6.2", + "rand_core", "rand_hc", ] @@ -1898,24 +1859,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e12735cf05c9e10bf21534da50a147b924d555dc7a547c42e6bb2d5b6017ae0d" dependencies = [ "ppv-lite86", - "rand_core 0.6.2", + "rand_core", ] -[[package]] -name = "rand_core" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b" -dependencies = [ - "rand_core 0.4.2", -] - -[[package]] -name = "rand_core" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" - [[package]] name = "rand_core" version = "0.6.2" @@ -1932,7 +1878,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da9e8f32ad24fb80d07d2323a9a2ce8b30d68a62b8cb4df88119ff49a698f038" dependencies = [ "num-traits", - "rand 0.8.3", + "rand", ] [[package]] @@ -1941,16 +1887,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3190ef7066a446f2e7f42e239d161e905420ccab01eb967c9eb27d21b2322a73" dependencies = [ - "rand_core 0.6.2", -] - -[[package]] -name = "rdrand" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2" -dependencies = [ - "rand_core 0.3.1", + "rand_core", ] [[package]] @@ -2275,7 +2212,7 @@ checksum = "dac1c663cfc93810f88aed9b8941d48cabf856a1b111c29a40439018d870eb22" dependencies = [ "cfg-if 1.0.0", "libc", - "rand 0.8.3", + "rand", "redox_syscall", "remove_dir_all", "winapi", diff --git a/playback/Cargo.toml b/playback/Cargo.toml index bf55b5a5..e19f4ffd 100644 --- a/playback/Cargo.toml +++ b/playback/Cargo.toml @@ -20,7 +20,6 @@ version = "0.2.0" [dependencies] futures-executor = "0.3" futures-util = { version = "0.3", default_features = false, features = ["alloc"] } -libmath = "0.2" log = "0.4" byteorder = "1.4" shell-words = "1.0.0" @@ -59,4 +58,4 @@ jackaudio-backend = ["jack"] rodio-backend = ["rodio", "cpal", "thiserror"] rodiojack-backend = ["rodio", "cpal/jack", "thiserror"] sdl-backend = ["sdl2"] -gstreamer-backend = ["gstreamer", "gstreamer-app", "glib"] \ No newline at end of file +gstreamer-backend = ["gstreamer", "gstreamer-app", "glib"] diff --git a/playback/src/convert.rs b/playback/src/convert.rs index 1f1122a7..962ade66 100644 --- a/playback/src/convert.rs +++ b/playback/src/convert.rs @@ -63,8 +63,7 @@ impl Converter { // Casting float to integer rounds towards zero by default, i.e. it // truncates, and that generates larger error than rounding to nearest. - // Absolute lowest error is gained from rounding ties to even. - math::round::half_to_even(int_value, 0) + int_value.round() } // Special case for samples packed in a word of greater bit depth (e.g. From 7c975f8ed0e27d0b034997cf7bacd2cd35c9c58e Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Tue, 1 Jun 2021 20:33:10 +0200 Subject: [PATCH 26/36] Remove feature gate around `apresolve` --- Cargo.toml | 1 - core/Cargo.toml | 7 ++----- core/src/apresolve.rs | 3 +-- core/src/lib.rs | 13 +------------ 4 files changed, 4 insertions(+), 20 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5ea5bf1f..b2e590a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,6 @@ version = "0.2.0" [dependencies.librespot-core] path = "core" version = "0.2.0" -features = ["apresolve"] [dependencies.librespot-discovery] path = "discovery" diff --git a/core/Cargo.toml b/core/Cargo.toml index 80db5687..24e599a6 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -23,8 +23,8 @@ futures-util = { version = "0.3", default-features = false, features = ["alloc", hmac = "0.11" httparse = "1.3" http = "0.2" -hyper = { version = "0.14", optional = true, features = ["client", "tcp", "http1"] } -hyper-proxy = { version = "0.9.1", optional = true, default-features = false } +hyper = { version = "0.14", features = ["client", "tcp", "http1"] } +hyper-proxy = { version = "0.9.1", default-features = false } log = "0.4" num-bigint = { version = "0.4", features = ["rand"] } num-integer = "0.1" @@ -52,6 +52,3 @@ vergen = "3.0.4" [dev-dependencies] env_logger = "0.8" tokio = {version = "1.0", features = ["macros"] } - -[features] -apresolve = ["hyper", "hyper-proxy"] diff --git a/core/src/apresolve.rs b/core/src/apresolve.rs index b11e275f..759577d4 100644 --- a/core/src/apresolve.rs +++ b/core/src/apresolve.rs @@ -6,9 +6,8 @@ use hyper_proxy::{Intercept, Proxy, ProxyConnector}; use serde::Deserialize; use url::Url; -use super::AP_FALLBACK; - const APRESOLVE_ENDPOINT: &str = "http://apresolve.spotify.com:80"; +const AP_FALLBACK: &str = "ap.spotify.com:443"; #[derive(Clone, Debug, Deserialize)] struct ApResolveData { diff --git a/core/src/lib.rs b/core/src/lib.rs index bb3e21d5..9afb99a3 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -8,6 +8,7 @@ use librespot_protocol as protocol; #[macro_use] mod component; +mod apresolve; pub mod audio_key; pub mod authentication; pub mod cache; @@ -24,15 +25,3 @@ pub mod spotify_id; #[doc(hidden)] pub mod util; pub mod version; - -const AP_FALLBACK: &str = "ap.spotify.com:443"; - -#[cfg(feature = "apresolve")] -mod apresolve; - -#[cfg(not(feature = "apresolve"))] -mod apresolve { - pub async fn apresolve(_: Option<&url::Url>, _: Option) -> String { - return super::AP_FALLBACK.into(); - } -} From 4af095e741118a85c8a820368f9915ecfc5c8406 Mon Sep 17 00:00:00 2001 From: JasonLG1979 Date: Sat, 5 Jun 2021 16:23:13 -0500 Subject: [PATCH 27/36] Improve ALSA buffer size * Go back to 4 periods at 125ms. * Deal strictly in period time and periods to set ALSA buffer. * Rename `buffer` to `period_buffer`. * Add comments and change some other var names to add clarity. * Let ALSA calculate the size of `period_buffer`. --- CHANGELOG.md | 3 ++ playback/src/audio_backend/alsa.rs | 60 +++++++++++++++--------------- 2 files changed, 33 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c00e884b..b6b987b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [playback] `alsamixer`: complete rewrite (breaking) - [playback] `alsamixer`: query card dB range for the `log` volume control unless specified otherwise - [playback] `alsamixer`: use `--device` name for `--mixer-card` unless specified otherwise +- [audio_backend, alsa] Deal strictly in period time and number of periods to set ALSA buffer to be sample rate, channel count and format independent in `alsa-backend` +- [audio_backend, alsa] Use ALSA to calculate the size of internal buffer in `alsa-backend` ### Deprecated - [connect] The `discovery` module was deprecated in favor of the `librespot-discovery` crate @@ -41,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [playback] `alsamixer`: make `cubic` consistent between cards that report minimum volume as mute, and cards that report some dB value - [playback] `alsamixer`: make `--volume-ctrl {linear|log}` work as expected - [playback] `alsa`, `gstreamer`, `pulseaudio`: always output in native endianness +- [audio_backend, alsa] Return to a default buffer of approximately 0.5 secs in `alsa-backend` ## [0.2.0] - 2021-05-04 diff --git a/playback/src/audio_backend/alsa.rs b/playback/src/audio_backend/alsa.rs index b2f020f1..b69b6455 100644 --- a/playback/src/audio_backend/alsa.rs +++ b/playback/src/audio_backend/alsa.rs @@ -2,9 +2,9 @@ use super::{Open, Sink, SinkAsBytes}; use crate::config::AudioFormat; use crate::convert::Converter; use crate::decoder::AudioPacket; -use crate::{NUM_CHANNELS, SAMPLES_PER_SECOND, SAMPLE_RATE}; +use crate::{NUM_CHANNELS, SAMPLE_RATE}; use alsa::device_name::HintIter; -use alsa::pcm::{Access, Format, Frames, HwParams, PCM}; +use alsa::pcm::{Access, Format, HwParams, PCM}; use alsa::{Direction, Error, ValueOr}; use std::cmp::min; use std::ffi::CString; @@ -12,14 +12,15 @@ use std::io; use std::process::exit; use std::time::Duration; -const BUFFERED_LATENCY: Duration = Duration::from_millis(125); -const BUFFERED_PERIODS: Frames = 4; +// 125 ms Period time * 4 periods = 0.5 sec buffer. +const PERIOD_TIME: Duration = Duration::from_millis(125); +const NUM_PERIODS: u32 = 4; pub struct AlsaSink { pcm: Option, format: AudioFormat, device: String, - buffer: Vec, + period_buffer: Vec, } fn list_outputs() { @@ -39,7 +40,7 @@ fn list_outputs() { } } -fn open_device(dev_name: &str, format: AudioFormat) -> Result<(PCM, Frames), Box> { +fn open_device(dev_name: &str, format: AudioFormat) -> Result<(PCM, usize), Box> { let pcm = PCM::new(dev_name, Direction::Playback, false)?; let alsa_format = match format { AudioFormat::F64 => Format::float64(), @@ -54,28 +55,30 @@ fn open_device(dev_name: &str, format: AudioFormat) -> Result<(PCM, Frames), Box AudioFormat::S24_3 => Format::S243BE, }; - // http://www.linuxjournal.com/article/6735?page=0,1#N0x19ab2890.0x19ba78d8 - // latency = period_size * periods / (rate * bytes_per_frame) - // For stereo samples encoded as 32-bit float, one frame has a length of eight bytes. - let mut period_size = ((SAMPLES_PER_SECOND * format.size() as u32) as f32 - * (BUFFERED_LATENCY.as_secs_f32() / BUFFERED_PERIODS as f32)) - as Frames; - { + let bytes_per_period = { let hwp = HwParams::any(&pcm)?; hwp.set_access(Access::RWInterleaved)?; hwp.set_format(alsa_format)?; hwp.set_rate(SAMPLE_RATE, ValueOr::Nearest)?; hwp.set_channels(NUM_CHANNELS as u32)?; - period_size = hwp.set_period_size_near(period_size, ValueOr::Greater)?; - hwp.set_buffer_size_near(period_size * BUFFERED_PERIODS)?; + // Deal strictly in time and periods. + hwp.set_periods(NUM_PERIODS, ValueOr::Nearest)?; + hwp.set_period_time_near(PERIOD_TIME.as_micros() as u32, ValueOr::Nearest)?; pcm.hw_params(&hwp)?; let swp = pcm.sw_params_current()?; - swp.set_start_threshold(hwp.get_buffer_size()? - hwp.get_period_size()?)?; - pcm.sw_params(&swp)?; - } + // Don't assume we got what we wanted. + // Ask to make sure. + let frames_per_period = hwp.get_period_size()?; - Ok((pcm, period_size)) + swp.set_start_threshold(hwp.get_buffer_size()? - frames_per_period)?; + pcm.sw_params(&swp)?; + + // Let ALSA do the math for us. + pcm.frames_to_bytes(frames_per_period) as usize + }; + + Ok((pcm, bytes_per_period)) } impl Open for AlsaSink { @@ -97,7 +100,7 @@ impl Open for AlsaSink { pcm: None, format, device: name, - buffer: vec![], + period_buffer: vec![], } } } @@ -107,12 +110,9 @@ impl Sink for AlsaSink { if self.pcm.is_none() { let pcm = open_device(&self.device, self.format); match pcm { - Ok((p, period_size)) => { + Ok((p, bytes_per_period)) => { self.pcm = Some(p); - // Create a buffer for all samples for a full period - self.buffer = Vec::with_capacity( - period_size as usize * BUFFERED_PERIODS as usize * self.format.size(), - ); + self.period_buffer = Vec::with_capacity(bytes_per_period); } Err(e) => { error!("Alsa error PCM open {}", e); @@ -147,15 +147,15 @@ impl SinkAsBytes for AlsaSink { let mut processed_data = 0; while processed_data < data.len() { let data_to_buffer = min( - self.buffer.capacity() - self.buffer.len(), + self.period_buffer.capacity() - self.period_buffer.len(), data.len() - processed_data, ); - self.buffer + self.period_buffer .extend_from_slice(&data[processed_data..processed_data + data_to_buffer]); processed_data += data_to_buffer; - if self.buffer.len() == self.buffer.capacity() { + if self.period_buffer.len() == self.period_buffer.capacity() { self.write_buf(); - self.buffer.clear(); + self.period_buffer.clear(); } } @@ -169,7 +169,7 @@ impl AlsaSink { fn write_buf(&mut self) { let pcm = self.pcm.as_mut().unwrap(); let io = pcm.io_bytes(); - match io.writei(&self.buffer) { + match io.writei(&self.period_buffer) { Ok(_) => (), Err(err) => pcm.try_recover(err, false).unwrap(), }; From c30959212a4690b2f51664ad294772ac0781449a Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Wed, 9 Jun 2021 20:50:24 +0200 Subject: [PATCH 28/36] Update CHANGELOG.md --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6b987b2..4293b8d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,8 +24,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [playback] `alsamixer`: complete rewrite (breaking) - [playback] `alsamixer`: query card dB range for the `log` volume control unless specified otherwise - [playback] `alsamixer`: use `--device` name for `--mixer-card` unless specified otherwise -- [audio_backend, alsa] Deal strictly in period time and number of periods to set ALSA buffer to be sample rate, channel count and format independent in `alsa-backend` -- [audio_backend, alsa] Use ALSA to calculate the size of internal buffer in `alsa-backend` ### Deprecated - [connect] The `discovery` module was deprecated in favor of the `librespot-discovery` crate @@ -43,7 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [playback] `alsamixer`: make `cubic` consistent between cards that report minimum volume as mute, and cards that report some dB value - [playback] `alsamixer`: make `--volume-ctrl {linear|log}` work as expected - [playback] `alsa`, `gstreamer`, `pulseaudio`: always output in native endianness -- [audio_backend, alsa] Return to a default buffer of approximately 0.5 secs in `alsa-backend` +- [playback] `alsa`: revert buffer size to ~500 ms ## [0.2.0] - 2021-05-04 From 51a697201b4ce1c96fb3a544c9eba9da5b41bb43 Mon Sep 17 00:00:00 2001 From: Hugo Osvaldo Barrera Date: Fri, 18 Jun 2021 11:40:44 +0200 Subject: [PATCH 29/36] Include a systemd.service file to run as a user (#800) Include a systemd.service file to run as a user This new `systemd.service` file allows running and controlling the service as an unpriviledged user (generally, as part of a user session). --- Cargo.toml | 3 ++- README.md | 1 + contrib/librespot.service | 7 ++++--- contrib/librespot.user.service | 12 ++++++++++++ 4 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 contrib/librespot.user.service diff --git a/Cargo.toml b/Cargo.toml index b2e590a5..ced7d0f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,5 +90,6 @@ section = "sound" priority = "optional" assets = [ ["target/release/librespot", "usr/bin/", "755"], - ["contrib/librespot.service", "lib/systemd/system/", "644"] + ["contrib/librespot.service", "lib/systemd/system/", "644"], + ["contrib/librespot.user.service", "lib/systemd/user/", "644"] ] diff --git a/README.md b/README.md index 33b2b76e..bcf73cac 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ The above command will create a receiver named ```Librespot```, with bitrate set A full list of runtime options are available [here](https://github.com/librespot-org/librespot/wiki/Options) _Please Note: When using the cache feature, an authentication blob is stored for your account in the cache directory. For security purposes, we recommend that you set directory permissions on the cache directory to `700`._ + ## Contact Come and hang out on gitter if you need help or want to offer some. https://gitter.im/librespot-org/spotify-connect-resources diff --git a/contrib/librespot.service b/contrib/librespot.service index bd381df2..76037c8c 100644 --- a/contrib/librespot.service +++ b/contrib/librespot.service @@ -1,5 +1,7 @@ [Unit] -Description=Librespot +Description=Librespot (an open source Spotify client) +Documentation=https://github.com/librespot-org/librespot +Documentation=https://github.com/librespot-org/librespot/wiki/Options Requires=network-online.target After=network-online.target @@ -8,8 +10,7 @@ User=nobody Group=audio Restart=always RestartSec=10 -ExecStart=/usr/bin/librespot -n "%p on %H" +ExecStart=/usr/bin/librespot --name "%p@%H" [Install] WantedBy=multi-user.target - diff --git a/contrib/librespot.user.service b/contrib/librespot.user.service new file mode 100644 index 00000000..a676dde0 --- /dev/null +++ b/contrib/librespot.user.service @@ -0,0 +1,12 @@ +[Unit] +Description=Librespot (an open source Spotify client) +Documentation=https://github.com/librespot-org/librespot +Documentation=https://github.com/librespot-org/librespot/wiki/Options + +[Service] +Restart=always +RestartSec=10 +ExecStart=/usr/bin/librespot --name "%u@%H" + +[Install] +WantedBy=default.target From 0bece0d867094d50e1252be7a4842085df6aaa10 Mon Sep 17 00:00:00 2001 From: JasonLG1979 Date: Fri, 18 Jun 2021 00:32:04 -0500 Subject: [PATCH 30/36] Fix pipe backend * Move all code that can fail to `start` where errors can be returned to prevent a panic! * Replace unwrap --- playback/src/audio_backend/pipe.rs | 46 +++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/playback/src/audio_backend/pipe.rs b/playback/src/audio_backend/pipe.rs index 926219f9..65e905c3 100644 --- a/playback/src/audio_backend/pipe.rs +++ b/playback/src/audio_backend/pipe.rs @@ -6,31 +6,57 @@ use std::fs::OpenOptions; use std::io::{self, Write}; pub struct StdoutSink { - output: Box, + output: Option>, + path: Option, format: AudioFormat, } impl Open for StdoutSink { fn open(path: Option, format: AudioFormat) -> Self { info!("Using pipe sink with format: {:?}", format); - - let output: Box = match path { - Some(path) => Box::new(OpenOptions::new().write(true).open(path).unwrap()), - _ => Box::new(io::stdout()), - }; - - Self { output, format } + Self { + output: None, + path: path, + format, + } } } impl Sink for StdoutSink { + fn start(&mut self) -> io::Result<()> { + if self.output.is_none() { + let output: Box = match self.path.as_deref() { + Some(path) => { + let open_op = OpenOptions::new() + .write(true) + .open(path) + .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + Box::new(open_op) + } + None => Box::new(io::stdout()), + }; + + self.output = Some(output); + } + + Ok(()) + } + sink_as_bytes!(); } impl SinkAsBytes for StdoutSink { fn write_bytes(&mut self, data: &[u8]) -> io::Result<()> { - self.output.write_all(data)?; - self.output.flush()?; + match self.output.as_deref_mut() { + Some(output) => { + output.write_all(data)?; + output.flush()?; + } + None => { + return Err(io::Error::new(io::ErrorKind::Other, "Output is None")); + } + } + Ok(()) } } From 4c77854ffe79620fae046a047e89ca94ed814b8c Mon Sep 17 00:00:00 2001 From: Jason Gray Date: Fri, 18 Jun 2021 13:25:09 -0500 Subject: [PATCH 31/36] Better errors alsa backend (#797) Better error handling in Alsa backend * More consistent error messages * Bail on fatal errors in player * Capture and log the original error as a warning when trying to write to PCM before trying to recover --- CHANGELOG.md | 2 + playback/Cargo.toml | 2 +- playback/src/audio_backend/alsa.rs | 213 ++++++++++++++++++++++------- playback/src/player.rs | 33 +++-- 4 files changed, 188 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4293b8d6..c9ca30a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [playback] `alsamixer`: complete rewrite (breaking) - [playback] `alsamixer`: query card dB range for the `log` volume control unless specified otherwise - [playback] `alsamixer`: use `--device` name for `--mixer-card` unless specified otherwise +- [playback] `player`: consider errors in `sink.start`, `sink.stop` and `sink.write` fatal and `exit(1)` (breaking) ### Deprecated - [connect] The `discovery` module was deprecated in favor of the `librespot-discovery` crate @@ -42,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [playback] `alsamixer`: make `--volume-ctrl {linear|log}` work as expected - [playback] `alsa`, `gstreamer`, `pulseaudio`: always output in native endianness - [playback] `alsa`: revert buffer size to ~500 ms +- [playback] `alsa`: better error handling ## [0.2.0] - 2021-05-04 diff --git a/playback/Cargo.toml b/playback/Cargo.toml index e19f4ffd..0bed793c 100644 --- a/playback/Cargo.toml +++ b/playback/Cargo.toml @@ -51,7 +51,7 @@ rand = "0.8" rand_distr = "0.4" [features] -alsa-backend = ["alsa"] +alsa-backend = ["alsa", "thiserror"] portaudio-backend = ["portaudio-rs"] pulseaudio-backend = ["libpulse-binding", "libpulse-simple-binding"] jackaudio-backend = ["jack"] diff --git a/playback/src/audio_backend/alsa.rs b/playback/src/audio_backend/alsa.rs index b69b6455..3084f186 100644 --- a/playback/src/audio_backend/alsa.rs +++ b/playback/src/audio_backend/alsa.rs @@ -5,17 +5,49 @@ use crate::decoder::AudioPacket; use crate::{NUM_CHANNELS, SAMPLE_RATE}; use alsa::device_name::HintIter; use alsa::pcm::{Access, Format, HwParams, PCM}; -use alsa::{Direction, Error, ValueOr}; +use alsa::{Direction, ValueOr}; use std::cmp::min; -use std::ffi::CString; use std::io; use std::process::exit; use std::time::Duration; +use thiserror::Error; // 125 ms Period time * 4 periods = 0.5 sec buffer. const PERIOD_TIME: Duration = Duration::from_millis(125); const NUM_PERIODS: u32 = 4; +#[derive(Debug, Error)] +enum AlsaError { + #[error("AlsaSink, device {device} may be invalid or busy, {err}")] + PCMSetUpError { device: String, err: alsa::Error }, + #[error("AlsaSink, device {device} unsupported access type RWInterleaved, {err}")] + UnsupportedAccessTypeError { device: String, err: alsa::Error }, + #[error("AlsaSink, device {device} unsupported format {format:?}, {err}")] + UnsupportedFormatError { + device: String, + format: AudioFormat, + err: alsa::Error, + }, + #[error("AlsaSink, device {device} unsupported sample rate {samplerate}, {err}")] + UnsupportedSampleRateError { + device: String, + samplerate: u32, + err: alsa::Error, + }, + #[error("AlsaSink, device {device} unsupported channel count {channel_count}, {err}")] + UnsupportedChannelCountError { + device: String, + channel_count: u8, + err: alsa::Error, + }, + #[error("AlsaSink Hardware Parameters Error, {0}")] + HwParamsError(alsa::Error), + #[error("AlsaSink Software Parameters Error, {0}")] + SwParamsError(alsa::Error), + #[error("AlsaSink PCM Error, {0}")] + PCMError(alsa::Error), +} + pub struct AlsaSink { pcm: Option, format: AudioFormat, @@ -23,25 +55,40 @@ pub struct AlsaSink { period_buffer: Vec, } -fn list_outputs() { +fn list_outputs() -> io::Result<()> { + println!("Listing available Alsa outputs:"); for t in &["pcm", "ctl", "hwdep"] { println!("{} devices:", t); - let i = HintIter::new(None, &*CString::new(*t).unwrap()).unwrap(); + let i = match HintIter::new_str(None, &t) { + Ok(i) => i, + Err(e) => { + return Err(io::Error::new(io::ErrorKind::Other, e)); + } + }; for a in i { if let Some(Direction::Playback) = a.direction { // mimic aplay -L - println!( - "{}\n\t{}\n", - a.name.unwrap(), - a.desc.unwrap().replace("\n", "\n\t") - ); + let name = a + .name + .ok_or(io::Error::new(io::ErrorKind::Other, "Could not parse name"))?; + let desc = a + .desc + .ok_or(io::Error::new(io::ErrorKind::Other, "Could not parse desc"))?; + println!("{}\n\t{}\n", name, desc.replace("\n", "\n\t")); } } } + + Ok(()) } -fn open_device(dev_name: &str, format: AudioFormat) -> Result<(PCM, usize), Box> { - let pcm = PCM::new(dev_name, Direction::Playback, false)?; +fn open_device(dev_name: &str, format: AudioFormat) -> Result<(PCM, usize), AlsaError> { + let pcm = + PCM::new(dev_name, Direction::Playback, false).map_err(|e| AlsaError::PCMSetUpError { + device: dev_name.to_string(), + err: e, + })?; + let alsa_format = match format { AudioFormat::F64 => Format::float64(), AudioFormat::F32 => Format::float(), @@ -56,23 +103,64 @@ fn open_device(dev_name: &str, format: AudioFormat) -> Result<(PCM, usize), Box< }; let bytes_per_period = { - let hwp = HwParams::any(&pcm)?; - hwp.set_access(Access::RWInterleaved)?; - hwp.set_format(alsa_format)?; - hwp.set_rate(SAMPLE_RATE, ValueOr::Nearest)?; - hwp.set_channels(NUM_CHANNELS as u32)?; - // Deal strictly in time and periods. - hwp.set_periods(NUM_PERIODS, ValueOr::Nearest)?; - hwp.set_period_time_near(PERIOD_TIME.as_micros() as u32, ValueOr::Nearest)?; - pcm.hw_params(&hwp)?; + let hwp = HwParams::any(&pcm).map_err(|e| AlsaError::HwParamsError(e))?; + hwp.set_access(Access::RWInterleaved).map_err(|e| { + AlsaError::UnsupportedAccessTypeError { + device: dev_name.to_string(), + err: e, + } + })?; + + hwp.set_format(alsa_format) + .map_err(|e| AlsaError::UnsupportedFormatError { + device: dev_name.to_string(), + format: format, + err: e, + })?; + + hwp.set_rate(SAMPLE_RATE, ValueOr::Nearest).map_err(|e| { + AlsaError::UnsupportedSampleRateError { + device: dev_name.to_string(), + samplerate: SAMPLE_RATE, + err: e, + } + })?; + + hwp.set_channels(NUM_CHANNELS as u32).map_err(|e| { + AlsaError::UnsupportedChannelCountError { + device: dev_name.to_string(), + channel_count: NUM_CHANNELS, + err: e, + } + })?; + + // Deal strictly in time and periods. + hwp.set_periods(NUM_PERIODS, ValueOr::Nearest) + .map_err(|e| AlsaError::HwParamsError(e))?; + + hwp.set_period_time_near(PERIOD_TIME.as_micros() as u32, ValueOr::Nearest) + .map_err(|e| AlsaError::HwParamsError(e))?; + + pcm.hw_params(&hwp).map_err(|e| AlsaError::PCMError(e))?; + + let swp = pcm + .sw_params_current() + .map_err(|e| AlsaError::PCMError(e))?; - let swp = pcm.sw_params_current()?; // Don't assume we got what we wanted. // Ask to make sure. - let frames_per_period = hwp.get_period_size()?; + let frames_per_period = hwp + .get_period_size() + .map_err(|e| AlsaError::HwParamsError(e))?; - swp.set_start_threshold(hwp.get_buffer_size()? - frames_per_period)?; - pcm.sw_params(&swp)?; + let frames_per_buffer = hwp + .get_buffer_size() + .map_err(|e| AlsaError::HwParamsError(e))?; + + swp.set_start_threshold(frames_per_buffer - frames_per_period) + .map_err(|e| AlsaError::SwParamsError(e))?; + + pcm.sw_params(&swp).map_err(|e| AlsaError::PCMError(e))?; // Let ALSA do the math for us. pcm.frames_to_bytes(frames_per_period) as usize @@ -83,19 +171,23 @@ fn open_device(dev_name: &str, format: AudioFormat) -> Result<(PCM, usize), Box< impl Open for AlsaSink { fn open(device: Option, format: AudioFormat) -> Self { - info!("Using Alsa sink with format: {:?}", format); - let name = match device.as_deref() { - Some("?") => { - println!("Listing available Alsa outputs:"); - list_outputs(); - exit(0) - } + Some("?") => match list_outputs() { + Ok(_) => { + exit(0); + } + Err(err) => { + error!("Error listing Alsa outputs, {}", err); + exit(1); + } + }, Some(device) => device, None => "default", } .to_string(); + info!("Using AlsaSink with format: {:?}", format); + Self { pcm: None, format, @@ -108,18 +200,13 @@ impl Open for AlsaSink { impl Sink for AlsaSink { fn start(&mut self) -> io::Result<()> { if self.pcm.is_none() { - let pcm = open_device(&self.device, self.format); - match pcm { - Ok((p, bytes_per_period)) => { - self.pcm = Some(p); + match open_device(&self.device, self.format) { + Ok((pcm, bytes_per_period)) => { + self.pcm = Some(pcm); self.period_buffer = Vec::with_capacity(bytes_per_period); } Err(e) => { - error!("Alsa error PCM open {}", e); - return Err(io::Error::new( - io::ErrorKind::Other, - "Alsa error: PCM open failed", - )); + return Err(io::Error::new(io::ErrorKind::Other, e)); } } } @@ -131,9 +218,17 @@ impl Sink for AlsaSink { { // Write any leftover data in the period buffer // before draining the actual buffer - self.write_bytes(&[]).expect("could not flush buffer"); - let pcm = self.pcm.as_mut().unwrap(); - pcm.drain().unwrap(); + self.write_bytes(&[])?; + let pcm = self.pcm.as_mut().ok_or(io::Error::new( + io::ErrorKind::Other, + "Error stopping AlsaSink, PCM is None", + ))?; + pcm.drain().map_err(|e| { + io::Error::new( + io::ErrorKind::Other, + format!("Error stopping AlsaSink {}", e), + ) + })? } self.pcm = None; Ok(()) @@ -154,7 +249,7 @@ impl SinkAsBytes for AlsaSink { .extend_from_slice(&data[processed_data..processed_data + data_to_buffer]); processed_data += data_to_buffer; if self.period_buffer.len() == self.period_buffer.capacity() { - self.write_buf(); + self.write_buf()?; self.period_buffer.clear(); } } @@ -166,12 +261,30 @@ impl SinkAsBytes for AlsaSink { impl AlsaSink { pub const NAME: &'static str = "alsa"; - fn write_buf(&mut self) { - let pcm = self.pcm.as_mut().unwrap(); + fn write_buf(&mut self) -> io::Result<()> { + let pcm = self.pcm.as_mut().ok_or(io::Error::new( + io::ErrorKind::Other, + "Error writing from AlsaSink buffer to PCM, PCM is None", + ))?; let io = pcm.io_bytes(); - match io.writei(&self.period_buffer) { - Ok(_) => (), - Err(err) => pcm.try_recover(err, false).unwrap(), - }; + if let Err(err) = io.writei(&self.period_buffer) { + // Capture and log the original error as a warning, and then try to recover. + // If recovery fails then forward that error back to player. + warn!( + "Error writing from AlsaSink buffer to PCM, trying to recover {}", + err + ); + pcm.try_recover(err, false).map_err(|e| { + io::Error::new( + io::ErrorKind::Other, + format!( + "Error writing from AlsaSink buffer to PCM, recovery failed {}", + e + ), + ) + })? + } + + Ok(()) } } diff --git a/playback/src/player.rs b/playback/src/player.rs index 4daac9b4..a6e71aad 100644 --- a/playback/src/player.rs +++ b/playback/src/player.rs @@ -2,6 +2,7 @@ use std::cmp::max; use std::future::Future; use std::io::{self, Read, Seek, SeekFrom}; use std::pin::Pin; +use std::process::exit; use std::task::{Context, Poll}; use std::time::{Duration, Instant}; use std::{mem, thread}; @@ -1057,7 +1058,10 @@ impl PlayerInternal { } match self.sink.start() { Ok(()) => self.sink_status = SinkStatus::Running, - Err(err) => error!("Could not start audio: {}", err), + Err(err) => { + error!("Fatal error, could not start audio sink: {}", err); + exit(1); + } } } } @@ -1066,14 +1070,21 @@ impl PlayerInternal { match self.sink_status { SinkStatus::Running => { trace!("== Stopping sink =="); - self.sink.stop().unwrap(); - self.sink_status = if temporarily { - SinkStatus::TemporarilyClosed - } else { - SinkStatus::Closed - }; - if let Some(callback) = &mut self.sink_event_callback { - callback(self.sink_status); + match self.sink.stop() { + Ok(()) => { + self.sink_status = if temporarily { + SinkStatus::TemporarilyClosed + } else { + SinkStatus::Closed + }; + if let Some(callback) = &mut self.sink_event_callback { + callback(self.sink_status); + } + } + Err(err) => { + error!("Fatal error, could not stop audio sink: {}", err); + exit(1); + } } } SinkStatus::TemporarilyClosed => { @@ -1294,8 +1305,8 @@ impl PlayerInternal { } if let Err(err) = self.sink.write(&packet, &mut self.converter) { - error!("Could not write audio: {}", err); - self.ensure_sink_stopped(false); + error!("Fatal error, could not write audio to audio sink: {}", err); + exit(1); } } } From 5ffce0662a6f13cee0a608f1f80c61581da80444 Mon Sep 17 00:00:00 2001 From: JasonLG1979 Date: Fri, 18 Jun 2021 15:02:17 -0500 Subject: [PATCH 32/36] Fix clippy warnings Fix the clippy warnings caused by https://github.com/librespot-org/librespot/pull/797 --- playback/src/audio_backend/alsa.rs | 90 ++++++++++++++---------------- 1 file changed, 41 insertions(+), 49 deletions(-) diff --git a/playback/src/audio_backend/alsa.rs b/playback/src/audio_backend/alsa.rs index 3084f186..7101f96d 100644 --- a/playback/src/audio_backend/alsa.rs +++ b/playback/src/audio_backend/alsa.rs @@ -19,33 +19,33 @@ const NUM_PERIODS: u32 = 4; #[derive(Debug, Error)] enum AlsaError { #[error("AlsaSink, device {device} may be invalid or busy, {err}")] - PCMSetUpError { device: String, err: alsa::Error }, + PcmSetUp { device: String, err: alsa::Error }, #[error("AlsaSink, device {device} unsupported access type RWInterleaved, {err}")] - UnsupportedAccessTypeError { device: String, err: alsa::Error }, + UnsupportedAccessType { device: String, err: alsa::Error }, #[error("AlsaSink, device {device} unsupported format {format:?}, {err}")] - UnsupportedFormatError { + UnsupportedFormat { device: String, format: AudioFormat, err: alsa::Error, }, #[error("AlsaSink, device {device} unsupported sample rate {samplerate}, {err}")] - UnsupportedSampleRateError { + UnsupportedSampleRate { device: String, samplerate: u32, err: alsa::Error, }, #[error("AlsaSink, device {device} unsupported channel count {channel_count}, {err}")] - UnsupportedChannelCountError { + UnsupportedChannelCount { device: String, channel_count: u8, err: alsa::Error, }, #[error("AlsaSink Hardware Parameters Error, {0}")] - HwParamsError(alsa::Error), + HwParams(alsa::Error), #[error("AlsaSink Software Parameters Error, {0}")] - SwParamsError(alsa::Error), + SwParams(alsa::Error), #[error("AlsaSink PCM Error, {0}")] - PCMError(alsa::Error), + Pcm(alsa::Error), } pub struct AlsaSink { @@ -70,10 +70,10 @@ fn list_outputs() -> io::Result<()> { // mimic aplay -L let name = a .name - .ok_or(io::Error::new(io::ErrorKind::Other, "Could not parse name"))?; + .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "Could not parse name"))?; let desc = a .desc - .ok_or(io::Error::new(io::ErrorKind::Other, "Could not parse desc"))?; + .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "Could not parse desc"))?; println!("{}\n\t{}\n", name, desc.replace("\n", "\n\t")); } } @@ -83,11 +83,10 @@ fn list_outputs() -> io::Result<()> { } fn open_device(dev_name: &str, format: AudioFormat) -> Result<(PCM, usize), AlsaError> { - let pcm = - PCM::new(dev_name, Direction::Playback, false).map_err(|e| AlsaError::PCMSetUpError { - device: dev_name.to_string(), - err: e, - })?; + let pcm = PCM::new(dev_name, Direction::Playback, false).map_err(|e| AlsaError::PcmSetUp { + device: dev_name.to_string(), + err: e, + })?; let alsa_format = match format { AudioFormat::F64 => Format::float64(), @@ -103,64 +102,56 @@ fn open_device(dev_name: &str, format: AudioFormat) -> Result<(PCM, usize), Alsa }; let bytes_per_period = { - let hwp = HwParams::any(&pcm).map_err(|e| AlsaError::HwParamsError(e))?; - hwp.set_access(Access::RWInterleaved).map_err(|e| { - AlsaError::UnsupportedAccessTypeError { + let hwp = HwParams::any(&pcm).map_err(AlsaError::HwParams)?; + hwp.set_access(Access::RWInterleaved) + .map_err(|e| AlsaError::UnsupportedAccessType { device: dev_name.to_string(), err: e, - } - })?; + })?; hwp.set_format(alsa_format) - .map_err(|e| AlsaError::UnsupportedFormatError { + .map_err(|e| AlsaError::UnsupportedFormat { device: dev_name.to_string(), - format: format, + format, err: e, })?; hwp.set_rate(SAMPLE_RATE, ValueOr::Nearest).map_err(|e| { - AlsaError::UnsupportedSampleRateError { + AlsaError::UnsupportedSampleRate { device: dev_name.to_string(), samplerate: SAMPLE_RATE, err: e, } })?; - hwp.set_channels(NUM_CHANNELS as u32).map_err(|e| { - AlsaError::UnsupportedChannelCountError { + hwp.set_channels(NUM_CHANNELS as u32) + .map_err(|e| AlsaError::UnsupportedChannelCount { device: dev_name.to_string(), channel_count: NUM_CHANNELS, err: e, - } - })?; + })?; // Deal strictly in time and periods. hwp.set_periods(NUM_PERIODS, ValueOr::Nearest) - .map_err(|e| AlsaError::HwParamsError(e))?; + .map_err(AlsaError::HwParams)?; hwp.set_period_time_near(PERIOD_TIME.as_micros() as u32, ValueOr::Nearest) - .map_err(|e| AlsaError::HwParamsError(e))?; + .map_err(AlsaError::HwParams)?; - pcm.hw_params(&hwp).map_err(|e| AlsaError::PCMError(e))?; + pcm.hw_params(&hwp).map_err(AlsaError::Pcm)?; - let swp = pcm - .sw_params_current() - .map_err(|e| AlsaError::PCMError(e))?; + let swp = pcm.sw_params_current().map_err(AlsaError::Pcm)?; // Don't assume we got what we wanted. // Ask to make sure. - let frames_per_period = hwp - .get_period_size() - .map_err(|e| AlsaError::HwParamsError(e))?; + let frames_per_period = hwp.get_period_size().map_err(AlsaError::HwParams)?; - let frames_per_buffer = hwp - .get_buffer_size() - .map_err(|e| AlsaError::HwParamsError(e))?; + let frames_per_buffer = hwp.get_buffer_size().map_err(AlsaError::HwParams)?; swp.set_start_threshold(frames_per_buffer - frames_per_period) - .map_err(|e| AlsaError::SwParamsError(e))?; + .map_err(AlsaError::SwParams)?; - pcm.sw_params(&swp).map_err(|e| AlsaError::PCMError(e))?; + pcm.sw_params(&swp).map_err(AlsaError::Pcm)?; // Let ALSA do the math for us. pcm.frames_to_bytes(frames_per_period) as usize @@ -219,10 +210,9 @@ impl Sink for AlsaSink { // Write any leftover data in the period buffer // before draining the actual buffer self.write_bytes(&[])?; - let pcm = self.pcm.as_mut().ok_or(io::Error::new( - io::ErrorKind::Other, - "Error stopping AlsaSink, PCM is None", - ))?; + let pcm = self.pcm.as_mut().ok_or_else(|| { + io::Error::new(io::ErrorKind::Other, "Error stopping AlsaSink, PCM is None") + })?; pcm.drain().map_err(|e| { io::Error::new( io::ErrorKind::Other, @@ -262,10 +252,12 @@ impl AlsaSink { pub const NAME: &'static str = "alsa"; fn write_buf(&mut self) -> io::Result<()> { - let pcm = self.pcm.as_mut().ok_or(io::Error::new( - io::ErrorKind::Other, - "Error writing from AlsaSink buffer to PCM, PCM is None", - ))?; + let pcm = self.pcm.as_mut().ok_or_else(|| { + io::Error::new( + io::ErrorKind::Other, + "Error writing from AlsaSink buffer to PCM, PCM is None", + ) + })?; let io = pcm.io_bytes(); if let Err(err) = io.writei(&self.period_buffer) { // Capture and log the original error as a warning, and then try to recover. From 336e714dba4bb45f1e0d26fb0966efed8edca968 Mon Sep 17 00:00:00 2001 From: JasonLG1979 Date: Fri, 18 Jun 2021 15:30:22 -0500 Subject: [PATCH 33/36] Fix clippy warning --- playback/src/audio_backend/pipe.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playback/src/audio_backend/pipe.rs b/playback/src/audio_backend/pipe.rs index 65e905c3..56040384 100644 --- a/playback/src/audio_backend/pipe.rs +++ b/playback/src/audio_backend/pipe.rs @@ -16,7 +16,7 @@ impl Open for StdoutSink { info!("Using pipe sink with format: {:?}", format); Self { output: None, - path: path, + path, format, } } From 14de03e06f6ca0b20ea2957781d830548eef69a5 Mon Sep 17 00:00:00 2001 From: Jason Gray Date: Fri, 18 Jun 2021 17:20:27 -0500 Subject: [PATCH 34/36] Update tests Make CI ignore things that should not trigger build tests. --- .github/workflows/test.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5f7a74c5..8c82f0db 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,6 +11,11 @@ on: "Cargo.lock", "rustfmt.toml", ".github/workflows/*", + "!**.md", + "!contrib/*", + "!docs/*", + "!LICENSE", + "!**.sh", ] pull_request: paths: @@ -20,6 +25,11 @@ on: "Cargo.lock", "rustfmt.toml", ".github/workflows/*", + "!**.md", + "!contrib/*", + "!docs/*", + "!LICENSE", + "!**.sh", ] schedule: # Run CI every week From 586e864427162a056c58e49e114a8b78e3bc216d Mon Sep 17 00:00:00 2001 From: JasonLG1979 Date: Fri, 18 Jun 2021 17:33:49 -0500 Subject: [PATCH 35/36] Update change log --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9ca30a8..ceb63541 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,7 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [playback] `alsamixer`: make `--volume-ctrl {linear|log}` work as expected - [playback] `alsa`, `gstreamer`, `pulseaudio`: always output in native endianness - [playback] `alsa`: revert buffer size to ~500 ms -- [playback] `alsa`: better error handling +- [playback] `alsa`, `pipe`: better error handling ## [0.2.0] - 2021-05-04 From b1f1464825ba538afb1cf8157dd6f27c03f9ce1e Mon Sep 17 00:00:00 2001 From: Jason Gray Date: Sun, 20 Jun 2021 00:07:33 -0500 Subject: [PATCH 36/36] Round 2 of trying to get CI to ignore things that should not trigger a build test --- .github/workflows/test.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8c82f0db..6e447ff9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,11 +11,11 @@ on: "Cargo.lock", "rustfmt.toml", ".github/workflows/*", - "!**.md", + "!*.md", "!contrib/*", "!docs/*", "!LICENSE", - "!**.sh", + "!*.sh", ] pull_request: paths: @@ -25,11 +25,11 @@ on: "Cargo.lock", "rustfmt.toml", ".github/workflows/*", - "!**.md", + "!*.md", "!contrib/*", "!docs/*", "!LICENSE", - "!**.sh", + "!*.sh", ] schedule: # Run CI every week