Merge branch 'dev' into new-api and update crates

This commit is contained in:
Roderick van Domburg 2022-07-27 23:31:11 +02:00
commit 05b9b13cf8
No known key found for this signature in database
GPG key ID: 87F5FDE8A56219F4
41 changed files with 975 additions and 804 deletions

View file

@ -191,7 +191,7 @@ jobs:
- name: Install cross
run: cargo install cross || true
- name: Build
run: cross build --locked --target ${{ matrix.target }} --no-default-features
run: cross build --target ${{ matrix.target }} --no-default-features
clippy:
needs: [test-cross-arm, test-windows]

3
.gitignore vendored
View file

@ -5,5 +5,4 @@ spotify_appkey.key
.project
.history
*.save
*.*~

View file

@ -8,38 +8,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- [main] Enforce reasonable ranges for option values (breaking).
- [main] Don't evaluate options that would otherwise have no effect.
- [playback] `alsa`: Improve `--device ?` functionality for the alsa backend.
- [contrib] Hardened security of the systemd service units
- [main] Verbose logging mode (`-v`, `--verbose`) now logs all parsed environment variables and command line arguments (credentials are redacted).
- [playback] `Sink`: `write()` now receives ownership of the packet (breaking).
- [playback] `pipe`: create file if it doesn't already exist
- [playback] More robust dynamic limiter for very wide dynamic range (breaking)
- [playback] `subprocess`: Better error handling
- [playback] `pipe`: Better error handling
### Added
- [cache] Add `disable-credential-cache` flag (breaking).
- [main] Use different option descriptions and error messages based on what backends are enabled at build time.
- [main] Add a `-q`, `--quiet` option that changes the logging level to warn.
- [main] Add a short name for every flag and option.
- [main] Add the ability to parse environment variables.
- [playback] `pulseaudio`: set the PulseAudio name to match librespot's device name via `PULSE_PROP_application.name` environment variable (user set env var value takes precedence). (breaking)
- [playback] `pulseaudio`: set icon to `audio-x-generic` so we get an icon instead of a placeholder via `PULSE_PROP_application.icon_name` environment variable (user set env var value takes precedence). (breaking)
- [playback] `pulseaudio`: set values to: `PULSE_PROP_application.version`, `PULSE_PROP_application.process.binary`, `PULSE_PROP_stream.description`, `PULSE_PROP_media.software` and `PULSE_PROP_media.role` environment variables (user set env var values take precedence). (breaking)
- [playback] `pipe`: Implement stop
### Fixed
- [main] Prevent hang when discovery is disabled and there are no credentials or when bad credentials are given.
- [main] Don't panic when parsing options. Instead list valid values and exit.
- [main] `--alsa-mixer-device` and `--alsa-mixer-index` now fallback to the card and index specified in `--device`.
- [core] Removed unsafe code (breaking)
- [playback] Adhere to ReplayGain spec when calculating gain normalisation factor.
- [playback] `alsa`: Use `--volume-range` overrides for softvol controls
- [connect] Don't panic when activating shuffle without previous interaction.
- [playback] `alsamixer`: make `--volume-ctrl fixed` work as expected when combined with `--mixer alsa`
- [main] fix `--opt=value` line argument logging
### Removed
- [playback] `alsamixer`: previously deprecated option `mixer-card` has been removed.
- [playback] `alsamixer`: previously deprecated option `mixer-name` has been removed.
- [playback] `alsamixer`: previously deprecated option `mixer-index` has been removed.
## [0.4.1] - 2022-05-23
### Changed
- [chore] The MSRV is now 1.56
### Fixed
- [playback] Fixed dependency issues when installing from crate
## [0.4.0] - 2022-05-21
### Changed
- [chore] The MSRV is now 1.53
- [contrib] Hardened security of the `systemd` service units
- [core] `Session`: `connect()` now returns the long-term credentials
- [core] `Session`: `connect()` now accepts a flag if the credentails should be stored via the cache
- [main] Different option descriptions and error messages based on what backends are enabled at build time
- [playback] More robust dynamic limiter for very wide dynamic range (breaking)
- [playback] `alsa`: improve `--device ?` output for the Alsa backend
- [playback] `gstreamer`: create own context, set correct states and use sync handler
- [playback] `pipe`: create file if it doesn't already exist
- [playback] `Sink`: `write()` now receives ownership of the packet (breaking)
### Added
- [main] Enforce reasonable ranges for option values (breaking)
- [main] Add the ability to parse environment variables
- [main] Log now emits warning when trying to use options that would otherwise have no effect
- [main] Verbose logging now logs all parsed environment variables and command line arguments (credentials are redacted)
- [main] Add a `-q`, `--quiet` option that changes the logging level to WARN
- [main] Add `disable-credential-cache` flag (breaking)
- [main] Add a short name for every flag and option
- [playback] `pulseaudio`: set the PulseAudio name to match librespot's device name via `PULSE_PROP_application.name` environment variable (user set env var value takes precedence) (breaking)
- [playback] `pulseaudio`: set icon to `audio-x-generic` so we get an icon instead of a placeholder via `PULSE_PROP_application.icon_name` environment variable (user set env var value takes precedence) (breaking)
- [playback] `pulseaudio`: set values to: `PULSE_PROP_application.version`, `PULSE_PROP_application.process.binary`, `PULSE_PROP_stream.description`, `PULSE_PROP_media.software` and `PULSE_PROP_media.role` environment variables (user set env var values take precedence) (breaking)
### Fixed
- [connect] Don't panic when activating shuffle without previous interaction
- [core] Removed unsafe code (breaking)
- [main] Fix crash when built with Avahi support but Avahi is locally unavailable
- [main] Prevent hang when discovery is disabled and there are no credentials or when bad credentials are given
- [main] Don't panic when parsing options, instead list valid values and exit
- [main] `--alsa-mixer-device` and `--alsa-mixer-index` now fallback to the card and index specified in `--device`.
- [playback] Adhere to ReplayGain spec when calculating gain normalisation factor
- [playback] `alsa`: make `--volume-range` overrides apply to Alsa softvol controls
### Removed
- [playback] `alsamixer`: previously deprecated options `mixer-card`, `mixer-name` and `mixer-index` have been removed
## [0.3.1] - 2021-10-24
@ -114,7 +140,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.1.0] - 2019-11-06
[unreleased]: https://github.com/librespot-org/librespot/compare/v0.3.1..HEAD
[unreleased]: https://github.com/librespot-org/librespot/compare/v0.4.1..HEAD
[0.4.1]: https://github.com/librespot-org/librespot/compare/v0.4.0..v0.4.1
[0.4.0]: https://github.com/librespot-org/librespot/compare/v0.3.1..v0.4.0
[0.3.1]: https://github.com/librespot-org/librespot/compare/v0.3.0..v0.3.1
[0.3.0]: https://github.com/librespot-org/librespot/compare/v0.2.0..v0.3.0
[0.2.0]: https://github.com/librespot-org/librespot/compare/v0.1.6..v0.2.0

View file

@ -7,7 +7,11 @@ In order to compile librespot, you will first need to set up a suitable Rust bui
### Install Rust
The easiest, and recommended way to get Rust is to use [rustup](https://rustup.rs). Once thats installed, Rust's standard tools should be set up and ready to use.
<<<<<<< HEAD
*Note: The current minimum required Rust version at the time of writing is 1.56.*
=======
*Note: The current minimum required Rust version at the time of writing is 1.56, you can find the current minimum version specified in the `.github/workflow/test.yml` file.*
>>>>>>> dev
#### Additional Rust tools - `rustfmt`
To ensure a consistent codebase, we utilise [`rustfmt`](https://github.com/rust-lang/rustfmt) and [`clippy`](https://github.com/rust-lang/rust-clippy), which are installed by default with `rustup` these days, else they can be installed manually with:

815
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[package]
name = "librespot"
version = "0.3.1"
version = "0.4.1"
authors = ["Librespot Org"]
license = "MIT"
description = "An open source client library for Spotify, with support for Spotify Connect"
@ -22,31 +22,31 @@ doc = false
[dependencies.librespot-audio]
path = "audio"
version = "0.3.1"
version = "0.4.1"
[dependencies.librespot-connect]
path = "connect"
version = "0.3.1"
version = "0.4.1"
[dependencies.librespot-core]
path = "core"
version = "0.3.1"
version = "0.4.1"
[dependencies.librespot-discovery]
path = "discovery"
version = "0.3.1"
version = "0.4.1"
[dependencies.librespot-metadata]
path = "metadata"
version = "0.3.1"
version = "0.4.1"
[dependencies.librespot-playback]
path = "playback"
version = "0.3.1"
version = "0.4.1"
[dependencies.librespot-protocol]
path = "protocol"
version = "0.3.1"
version = "0.4.1"
[dependencies]
env_logger = { version = "0.9", default-features = false, features = ["termcolor", "humantime", "atty"] }
@ -54,8 +54,8 @@ futures-util = { version = "0.3", default_features = false }
getopts = "0.2"
hex = "0.4"
log = "0.4"
rpassword = "5.0"
sha-1 = "0.10"
rpassword = "7.0"
sha1 = "0.10"
thiserror = "1.0"
tokio = { version = "1", features = ["rt", "macros", "signal", "sync", "parking_lot", "process"] }
url = "2.2"

View file

@ -2,7 +2,17 @@
## How To
The bash script in the root of the project, named `publish.sh` can be used to publish a new version of librespot and it's corresponding crates. the command should be used as follows: `./publish 0.1.0` from the project root, substituting the new version number that you wish to publish. *Note the lack of a v prefix on the version number. This is important, do not add one.* The v prefix is added where appropriate by the script.
Read through this paragraph in its entirety before running anything.
The Bash script in the root of the project, named `publish.sh` can be used to publish a new version of librespot and it's corresponding crates. the command should be used as follows from the project root: `./publish 0.1.0` from the project root, substituting the new version number that you wish to publish. *Note the lack of a v prefix on the version number. This is important, do not add one.* The v prefix is added where appropriate by the script.
Make sure that you are are starting from a clean working directory for both `dev` and `master`, completely up to date with remote and all local changes either committed and pushed or stashed.
You will want to perform a dry run first: `./publish --dry-run 0.1.0`. Please make note of any errors or warnings. In particular, you may need to explicitly inform Git which remote you want to track for the `master` branch like so: `git --track origin/master` (or whatever you have called the `librespot-org` remote `master` branch).
Depending on your system the script may fail to publish the main `librespot` crate after having published all the `librespot-xyz` sub-crates. If so then make sure the working directory is committed and pushed (watch `Cargo.toml`) and then run `cargo publish` manually after `publish.sh` finished.
To publish the crates your GitHub account needs to be authorized on `crates.io` by `librespot-org`.
## What the script does

View file

@ -1,24 +1,26 @@
[package]
name = "librespot-audio"
version = "0.3.1"
version = "0.4.1"
authors = ["Paul Lietar <paul@lietar.net>"]
description="The audio fetching and processing logic for librespot"
description = "The audio fetching logic for librespot"
license = "MIT"
repository = "https://github.com/librespot-org/librespot"
edition = "2018"
[dependencies.librespot-core]
path = "../core"
version = "0.3.1"
version = "0.4.1"
[dependencies]
aes = { version = "0.7", features = ["ctr"] }
aes = "0.8"
byteorder = "1.4"
bytes = "1"
ctr = "0.9"
futures-core = "0.3"
futures-util = "0.3"
hyper = { version = "0.14", features = ["client"] }
log = "0.4"
parking_lot = { version = "0.11", features = ["deadlock_detection"] }
parking_lot = { version = "0.12", features = ["deadlock_detection"] }
tempfile = "3"
thiserror = "1.0"
tokio = { version = "1", features = ["macros", "parking_lot", "sync"] }

View file

@ -1,9 +1,8 @@
use std::io;
use aes::{
cipher::{generic_array::GenericArray, NewCipher, StreamCipher, StreamCipherSeek},
Aes128Ctr,
};
use aes::cipher::{KeyIvInit, StreamCipher, StreamCipherSeek};
type Aes128Ctr = ctr::Ctr128BE<aes::Aes128>;
use librespot_core::audio_key::AudioKey;
@ -19,12 +18,12 @@ pub struct AudioDecrypt<T: io::Read> {
impl<T: io::Read> AudioDecrypt<T> {
pub fn new(key: Option<AudioKey>, reader: T) -> AudioDecrypt<T> {
let cipher = key.map(|key| {
Aes128Ctr::new(
GenericArray::from_slice(&key.0),
GenericArray::from_slice(&AUDIO_AESIV),
)
});
let cipher = if let Some(key) = key {
Aes128Ctr::new_from_slices(&key.0, &AUDIO_AESIV).ok()
} else {
// some files are unencrypted
None
};
AudioDecrypt { cipher, reader }
}

View file

@ -1,6 +1,6 @@
[package]
name = "librespot-connect"
version = "0.3.1"
version = "0.4.1"
authors = ["Paul Lietar <paul@lietar.net>"]
description = "The discovery and Spotify Connect logic for librespot"
license = "MIT"
@ -21,12 +21,12 @@ tokio-stream = "0.1"
[dependencies.librespot-core]
path = "../core"
version = "0.3.1"
version = "0.4.1"
[dependencies.librespot-playback]
path = "../playback"
version = "0.3.1"
version = "0.4.1"
[dependencies.librespot-protocol]
path = "../protocol"
version = "0.3.1"
version = "0.4.1"

View file

@ -331,7 +331,7 @@ impl Spirc {
);
// Connect *after* all message listeners are registered
session.connect(credentials).await?;
session.connect(credentials, true).await?;
let canonical_username = &session.username();
debug!("canonical_username: {}", canonical_username);
@ -612,6 +612,7 @@ impl SpircTask {
}
SpircCommand::Shutdown => {
CommandSender::new(self, MessageType::kMessageTypeGoodbye).send()?;
self.player.stop();
self.shutdown = true;
if let Some(rx) = self.commands.as_mut() {
rx.close()

View file

@ -1,6 +1,6 @@
[package]
name = "librespot-core"
version = "0.3.1"
version = "0.4.1"
authors = ["Paul Lietar <paul@lietar.net>"]
build = "build.rs"
description = "The core functionality provided by librespot"
@ -10,10 +10,10 @@ edition = "2018"
[dependencies.librespot-protocol]
path = "../protocol"
version = "0.3.1"
version = "0.4.1"
[dependencies]
aes = "0.7"
aes = "0.8"
base64 = "0.13"
byteorder = "1.4"
bytes = "1"
@ -22,7 +22,7 @@ form_urlencoded = "1.0"
futures-core = "0.3"
futures-util = { version = "0.3", features = ["alloc", "bilock", "sink", "unstable"] }
hmac = "0.12"
httparse = "1.5"
httparse = "1.7"
http = "0.2"
hyper = { version = "0.14", features = ["client", "http1", "http2", "tcp"] }
hyper-proxy = { version = "0.9", default-features = false, features = ["rustls"] }
@ -33,30 +33,30 @@ num-bigint = { version = "0.4", features = ["rand"] }
num-derive = "0.3"
num-integer = "0.1"
num-traits = "0.2"
once_cell = "1.9"
parking_lot = { version = "0.11", features = ["deadlock_detection"] }
pbkdf2 = { version = "0.10", default-features = false, features = ["hmac"] }
priority-queue = "1.2.1"
once_cell = "1"
parking_lot = { version = "0.12", features = ["deadlock_detection"] }
pbkdf2 = { version = "0.11", default-features = false, features = ["hmac"] }
priority-queue = "1.2"
protobuf = "2"
quick-xml = { version = "0.22", features = ["serialize"] }
quick-xml = { version = "0.23", features = ["serialize"] }
rand = "0.8"
rsa = { version = "0.5", default-features = false, features = ["alloc"] }
rsa = "0.6"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sha-1 = "0.10"
sha1 = "0.10"
shannon = "0.2"
thiserror = "1.0"
time = "0.3"
tokio = { version = "1", features = ["io-util", "macros", "net", "parking_lot", "rt", "sync", "time"] }
tokio-stream = "0.1"
tokio-tungstenite = { version = "*", default-features = false, features = ["rustls-tls-native-roots"] }
tokio-util = { version = "0.6", features = ["codec"] }
tokio-util = { version = "0.7", features = ["codec"] }
url = "2"
uuid = { version = "0.8", default-features = false, features = ["v4"] }
uuid = { version = "1", default-features = false, features = ["v4"] }
[build-dependencies]
rand = "0.8"
vergen = { version = "6", default-features = false, features = ["build", "git"] }
vergen = { version = "7", default-features = false, features = ["build", "git"] }
[dev-dependencies]
env_logger = "0.9"

View file

@ -13,11 +13,14 @@ fn main() {
vergen(config).expect("Unable to generate the cargo keys!");
let build_id: String = rand::thread_rng()
.sample_iter(Alphanumeric)
.take(8)
.map(char::from)
.collect();
let build_id = match std::env::var("SOURCE_DATE_EPOCH") {
Ok(val) => val,
Err(_) => rand::thread_rng()
.sample_iter(Alphanumeric)
.take(8)
.map(char::from)
.collect(),
};
println!("cargo:rustc-env=LIBRESPOT_BUILD_ID={}", build_id);
}

View file

@ -1,6 +1,6 @@
use std::io::{self, Read};
use aes::{Aes192, BlockDecrypt};
use aes::Aes192;
use byteorder::{BigEndian, ByteOrder};
use hmac::Hmac;
use pbkdf2::pbkdf2;
@ -106,13 +106,12 @@ impl Credentials {
// decrypt data using ECB mode without padding
let blob = {
use aes::cipher::generic_array::typenum::Unsigned;
use aes::cipher::generic_array::GenericArray;
use aes::cipher::{BlockCipher, NewBlockCipher};
use aes::cipher::{BlockDecrypt, BlockSizeUser, KeyInit};
let mut data = base64::decode(encrypted_blob)?;
let cipher = Aes192::new(GenericArray::from_slice(&key));
let block_size = <Aes192 as BlockCipher>::BlockSize::to_usize();
let block_size = Aes192::block_size();
for chunk in data.chunks_exact_mut(block_size) {
cipher.decrypt_block(GenericArray::from_mut_slice(chunk));

View file

@ -375,7 +375,7 @@ impl Cache {
path
}),
Err(e) => {
warn!("{}", e);
warn!("Invalid FileId: {}", e);
None
}
}

View file

@ -15,7 +15,7 @@ pub struct SessionConfig {
impl Default for SessionConfig {
fn default() -> SessionConfig {
let device_id = uuid::Uuid::new_v4().to_hyphenated().to_string();
let device_id = uuid::Uuid::new_v4().as_hyphenated().to_string();
SessionConfig {
client_id: KEYMASTER_CLIENT_ID.to_owned(),
device_id,

View file

@ -126,7 +126,11 @@ impl Session {
}))
}
pub async fn connect(&self, credentials: Credentials) -> Result<(), Error> {
pub async fn connect(
&self,
credentials: Credentials,
store_credentials: bool,
) -> Result<(), Error> {
let ap = self.apresolver().resolve("accesspoint").await?;
info!("Connecting to AP \"{}:{}\"", ap.0, ap.1);
let mut transport = connection::connect(&ap.0, ap.1, self.config().proxy.as_ref()).await?;
@ -136,7 +140,9 @@ impl Session {
info!("Authenticated as \"{}\" !", reusable_credentials.username);
self.set_username(&reusable_credentials.username);
if let Some(cache) = self.cache() {
cache.save_credentials(&reusable_credentials);
if store_credentials {
cache.save_credentials(&reusable_credentials);
}
}
let (tx_connection, rx_connection) = mpsc::unbounded_channel();

View file

@ -1,5 +1,6 @@
use std::{
convert::TryInto,
fmt::Write,
time::{Duration, Instant},
};
@ -273,7 +274,7 @@ impl SpClient {
Some(_) => "&",
None => "?",
};
url.push_str(&format!("{}product=0", separator));
let _ = write!(url, "{}product=0", separator);
let mut request = Request::builder()
.method(method)
@ -464,7 +465,7 @@ impl SpClient {
Some(_) => "&",
None => "?",
};
url.push_str(&format!("{}cid={}", separator, self.session().client_id()));
let _ = write!(url, "{}cid={}", separator, self.session().client_id());
self.request_url(url).await
}

View file

@ -200,7 +200,6 @@ impl SpotifyId {
/// character long `String`.
///
/// [canonically]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids
#[allow(clippy::wrong_self_convention)]
pub fn to_base62(&self) -> Result<String, Error> {
let mut dst = [0u8; 22];
@ -258,7 +257,6 @@ impl SpotifyId {
/// be encoded as `unknown`.
///
/// [Spotify URI]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids
#[allow(clippy::wrong_self_convention)]
pub fn to_uri(&self) -> Result<String, Error> {
// 8 chars for the "spotify:" prefix + 1 colon + 22 chars base62 encoded ID = 31

View file

@ -7,7 +7,7 @@ use librespot_core::{authentication::Credentials, config::SessionConfig, session
#[tokio::test]
async fn test_connection() {
timeout(Duration::from_secs(30), async {
let result = Session::new(SessionConfig::default(), None)
let result = Session::new(SessionConfig::default(), None, false)
.connect(Credentials::with_password("test", "test"))
.await;

View file

@ -1,6 +1,6 @@
[package]
name = "librespot-discovery"
version = "0.3.1"
version = "0.4.1"
authors = ["Paul Lietar <paul@lietar.net>"]
description = "The discovery logic for librespot"
license = "MIT"
@ -8,26 +8,27 @@ repository = "https://github.com/librespot-org/librespot"
edition = "2018"
[dependencies]
aes = { version = "0.7", features = ["ctr"] }
aes = "0.8"
base64 = "0.13"
cfg-if = "1.0"
ctr = "0.9"
dns-sd = { version = "0.1.3", optional = true }
form_urlencoded = "1.0"
futures-core = "0.3"
futures-util = "0.3"
hmac = "0.12"
hyper = { version = "0.14", features = ["http1", "server", "tcp"] }
libmdns = "0.6"
libmdns = "0.7"
log = "0.4"
rand = "0.8"
serde_json = "1.0"
sha-1 = "0.10"
sha1 = "0.10"
thiserror = "1.0"
tokio = { version = "1", features = ["parking_lot", "sync", "rt"] }
[dependencies.librespot-core]
path = "../core"
version = "0.3.1"
version = "0.4.1"
[dev-dependencies]
futures = "0.3"

View file

@ -52,13 +52,13 @@ pub struct Builder {
/// Errors that can occur while setting up a [`Discovery`] instance.
#[derive(Debug, Error)]
pub enum DiscoveryError {
/// Setting up service discovery via DNS-SD failed.
#[error("Creating SHA1 block cipher failed")]
AesError(#[from] aes::cipher::InvalidLength),
#[error("Setting up dns-sd failed: {0}")]
DnsSdError(#[from] io::Error),
/// Setting up the http server failed.
#[error("Creating SHA1 HMAC failed for base key {0:?}")]
HmacError(Vec<u8>),
#[error("Setting up the http server failed: {0}")]
#[error("Setting up the HTTP server failed: {0}")]
HttpServerError(#[from] hyper::Error),
#[error("Missing params for key {0}")]
ParamsError(&'static str),
@ -67,6 +67,7 @@ pub enum DiscoveryError {
impl From<DiscoveryError> for Error {
fn from(err: DiscoveryError) -> Self {
match err {
DiscoveryError::AesError(_) => Error::unavailable(err),
DiscoveryError::DnsSdError(_) => Error::unavailable(err),
DiscoveryError::HmacError(_) => Error::invalid_argument(err),
DiscoveryError::HttpServerError(_) => Error::unavailable(err),

View file

@ -8,11 +8,7 @@ use std::{
task::{Context, Poll},
};
use aes::{
cipher::generic_array::GenericArray,
cipher::{NewCipher, StreamCipher},
Aes128Ctr,
};
use aes::cipher::{KeyIvInit, StreamCipher};
use futures_core::Stream;
use futures_util::{FutureExt, TryFutureExt};
use hmac::{Hmac, Mac};
@ -32,6 +28,8 @@ use crate::{
core::{authentication::Credentials, diffie_hellman::DhLocalKeys, Error},
};
type Aes128Ctr = ctr::Ctr128BE<aes::Aes128>;
type Params<'a> = BTreeMap<Cow<'a, str>, Cow<'a, str>>;
pub struct Config {
@ -151,10 +149,8 @@ impl RequestHandler {
let decrypted = {
let mut data = encrypted.to_vec();
let mut cipher = Aes128Ctr::new(
GenericArray::from_slice(&encryption_key[0..16]),
GenericArray::from_slice(iv),
);
let mut cipher = Aes128Ctr::new_from_slices(&encryption_key[0..16], iv)
.map_err(DiscoveryError::AesError)?;
cipher.apply_keystream(&mut data);
data
};

View file

@ -19,7 +19,7 @@ async fn main() {
let credentials = Credentials::with_password(&args[1], &args[2]);
let session = Session::new(session_config, None);
match session.connect(credentials).await {
match session.connect(credentials, false).await {
Ok(()) => println!(
"Token: {:#?}",
session.token_provider().get_token(SCOPES).await.unwrap()

View file

@ -7,6 +7,7 @@ use librespot::{
playback::{
audio_backend,
config::{AudioFormat, PlayerConfig},
mixer::NoOpVolume,
player::Player,
},
};
@ -30,12 +31,12 @@ async fn main() {
println!("Connecting...");
let session = Session::new(session_config, None);
if let Err(e) = session.connect(credentials).await {
if let Err(e) = session.connect(credentials, false).await {
println!("Error connecting: {}", e);
exit(1);
}
let (mut player, _) = Player::new(player_config, session, None, move || {
let (mut player, _) = Player::new(player_config, session, Box::new(NoOpVolume), move || {
backend(None, audio_format)
});

View file

@ -28,7 +28,7 @@ async fn main() {
});
let session = Session::new(session_config, None);
if let Err(e) = session.connect(credentials).await {
if let Err(e) = session.connect(credentials, false).await {
println!("Error connecting: {}", e);
exit(1);
}

View file

@ -1,6 +1,6 @@
[package]
name = "librespot-metadata"
version = "0.3.1"
version = "0.4.1"
authors = ["Paul Lietar <paul@lietar.net>"]
description = "The metadata logic for librespot"
license = "MIT"
@ -14,12 +14,12 @@ bytes = "1"
log = "0.4"
protobuf = "2"
thiserror = "1"
uuid = { version = "0.8", default-features = false }
uuid = { version = "1", default-features = false }
[dependencies.librespot-core]
path = "../core"
version = "0.3.1"
version = "0.4.1"
[dependencies.librespot-protocol]
path = "../protocol"
version = "0.3.1"
version = "0.4.1"

View file

@ -1,3 +1,5 @@
use std::fmt::Write;
use crate::MetadataError;
use librespot_core::{Error, Session};
@ -13,10 +15,10 @@ pub trait MercuryRequest {
Some(_) => "&",
None => "?",
};
metrics_uri.push_str(&format!("{}country={}", separator, session.country()));
let _ = write!(metrics_uri, "{}country={}", separator, session.country());
if let Some(product) = session.get_user_attribute("type") {
metrics_uri.push_str(&format!("&product={}", product));
let _ = write!(metrics_uri, "&product={}", product);
}
trace!("Requesting {}", metrics_uri);

View file

@ -1,6 +1,6 @@
[package]
name = "librespot-playback"
version = "0.3.1"
version = "0.4.1"
authors = ["Sasha Hilton <sashahilton00@gmail.com>"]
description = "The audio playback logic for librespot"
license = "MIT"
@ -9,21 +9,21 @@ edition = "2018"
[dependencies.librespot-audio]
path = "../audio"
version = "0.3.1"
version = "0.4.1"
[dependencies.librespot-core]
path = "../core"
version = "0.3.1"
version = "0.4.1"
[dependencies.librespot-metadata]
path = "../metadata"
version = "0.3.1"
version = "0.4.1"
[dependencies]
byteorder = "1"
futures-util = "0.3"
log = "0.4"
parking_lot = { version = "0.11", features = ["deadlock_detection"] }
shell-words = "1.0.0"
thiserror = "1.0"
parking_lot = { version = "0.12", features = ["deadlock_detection"] }
shell-words = "1.1"
thiserror = "1"
tokio = { version = "1", features = ["parking_lot", "rt", "rt-multi-thread", "sync"] }
zerocopy = "0.6"
@ -32,11 +32,11 @@ alsa = { version = "0.6", optional = true }
portaudio-rs = { version = "0.3", optional = true }
libpulse-binding = { version = "2", optional = true, default-features = false }
libpulse-simple-binding = { version = "2", optional = true, default-features = false }
jack = { version = "0.8", optional = true }
jack = { version = "0.10", optional = true }
sdl2 = { version = "0.35", optional = true }
gst = { package = "gstreamer", version = "0.18", optional = true }
gst-app = { package = "gstreamer-app", version = "0.18", optional = true }
gst-audio = { package = "gstreamer-audio", version = "0.18", optional = true }
gstreamer = { version = "0.18", optional = true }
gstreamer-app = { version = "0.18", optional = true }
gstreamer-audio = { version = "0.18", optional = true }
glib = { version = "0.15", optional = true }
# Rodio dependencies
@ -61,6 +61,6 @@ jackaudio-backend = ["jack"]
rodio-backend = ["rodio", "cpal"]
rodiojack-backend = ["rodio", "cpal/jack"]
sdl-backend = ["sdl2"]
gstreamer-backend = ["gst", "gst-app", "gst-audio", "glib"]
gstreamer-backend = ["gstreamer", "gstreamer-app", "gstreamer-audio", "glib"]
passthrough-decoder = ["ogg"]

View file

@ -6,7 +6,6 @@ use crate::{NUM_CHANNELS, SAMPLE_RATE};
use alsa::device_name::HintIter;
use alsa::pcm::{Access, Format, Frames, HwParams, PCM};
use alsa::{Direction, ValueOr};
use std::cmp::min;
use std::process::exit;
use thiserror::Error;
@ -467,7 +466,7 @@ impl SinkAsBytes for AlsaSink {
loop {
let data_left = data_len - start_index;
let space_left = capacity - self.period_buffer.len();
let data_to_buffer = min(data_left, space_left);
let data_to_buffer = data_left.min(space_left);
let end_index = start_index + data_to_buffer;
self.period_buffer

View file

@ -1,9 +1,13 @@
use gst::{
use gstreamer::{
event::{FlushStart, FlushStop},
prelude::*,
State,
};
use gstreamer as gst;
use gstreamer_app as gst_app;
use gstreamer_audio as gst_audio;
use parking_lot::Mutex;
use std::sync::Arc;
@ -41,7 +45,7 @@ impl Open for GstreamerSink {
let gst_caps = gst_info.to_caps().expect("Failed to create GStreamer caps");
let sample_size = format.size();
let gst_bytes = NUM_CHANNELS as usize * 1024 * sample_size;
let gst_bytes = NUM_CHANNELS as usize * 2048 * sample_size;
let pipeline = gst::Pipeline::new(None);
let appsrc = gst::ElementFactory::make("appsrc", None)

View file

@ -2,9 +2,38 @@ use super::{Open, Sink, SinkAsBytes, SinkError, SinkResult};
use crate::config::AudioFormat;
use crate::convert::Converter;
use crate::decoder::AudioPacket;
use std::fs::OpenOptions;
use std::io::{self, Write};
use std::process::exit;
use thiserror::Error;
#[derive(Debug, Error)]
enum StdoutError {
#[error("<StdoutSink> {0}")]
OnWrite(std::io::Error),
#[error("<StdoutSink> File Path {file} Can Not be Opened and/or Created, {e}")]
OpenFailure { file: String, e: std::io::Error },
#[error("<StdoutSink> Failed to Flush the Output Stream, {0}")]
FlushFailure(std::io::Error),
#[error("<StdoutSink> The Output Stream is None")]
NoOutput,
}
impl From<StdoutError> for SinkError {
fn from(e: StdoutError) -> SinkError {
use StdoutError::*;
let es = e.to_string();
match e {
FlushFailure(_) | OnWrite(_) => SinkError::OnWrite(es),
OpenFailure { .. } => SinkError::ConnectionRefused(es),
NoOutput => SinkError::NotConnected(es),
}
}
}
pub struct StdoutSink {
output: Option<Box<dyn Write>>,
@ -15,13 +44,12 @@ pub struct StdoutSink {
impl Open for StdoutSink {
fn open(file: Option<String>, format: AudioFormat) -> Self {
if let Some("?") = file.as_deref() {
info!("Usage:");
println!(" Output to stdout: --backend pipe");
println!(" Output to file: --backend pipe --device {{filename}}");
println!("\nUsage:\n\nOutput to stdout:\n\n\t--backend pipe\n\nOutput to file:\n\n\t--backend pipe --device {{filename}}\n");
exit(0);
}
info!("Using pipe sink with format: {:?}", format);
info!("Using StdoutSink (pipe) with format: {:?}", format);
Self {
output: None,
file,
@ -32,21 +60,31 @@ impl Open for StdoutSink {
impl Sink for StdoutSink {
fn start(&mut self) -> SinkResult<()> {
if self.output.is_none() {
let output: Box<dyn Write> = match self.file.as_deref() {
Some(file) => {
let open_op = OpenOptions::new()
self.output.get_or_insert({
match self.file.as_deref() {
Some(file) => Box::new(
OpenOptions::new()
.write(true)
.create(true)
.open(file)
.map_err(|e| SinkError::ConnectionRefused(e.to_string()))?;
Box::new(open_op)
}
.map_err(|e| StdoutError::OpenFailure {
file: file.to_string(),
e,
})?,
),
None => Box::new(io::stdout()),
};
}
});
self.output = Some(output);
}
Ok(())
}
fn stop(&mut self) -> SinkResult<()> {
self.output
.take()
.ok_or(StdoutError::NoOutput)?
.flush()
.map_err(StdoutError::FlushFailure)?;
Ok(())
}
@ -56,19 +94,11 @@ impl Sink for StdoutSink {
impl SinkAsBytes for StdoutSink {
fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> {
match self.output.as_deref_mut() {
Some(output) => {
output
.write_all(data)
.map_err(|e| SinkError::OnWrite(e.to_string()))?;
output
.flush()
.map_err(|e| SinkError::OnWrite(e.to_string()))?;
}
None => {
return Err(SinkError::NotConnected("Output is None".to_string()));
}
}
self.output
.as_deref_mut()
.ok_or(StdoutError::NoOutput)?
.write_all(data)
.map_err(StdoutError::OnWrite)?;
Ok(())
}

View file

@ -4,30 +4,75 @@ use crate::convert::Converter;
use crate::decoder::AudioPacket;
use shell_words::split;
use std::io::Write;
use std::io::{ErrorKind, Write};
use std::process::{exit, Child, Command, Stdio};
use thiserror::Error;
#[derive(Debug, Error)]
enum SubprocessError {
#[error("<SubprocessSink> {0}")]
OnWrite(std::io::Error),
#[error("<SubprocessSink> Command {command} Can Not be Executed, {e}")]
SpawnFailure { command: String, e: std::io::Error },
#[error("<SubprocessSink> Failed to Parse Command args for {command}, {e}")]
InvalidArgs {
command: String,
e: shell_words::ParseError,
},
#[error("<SubprocessSink> Failed to Flush the Subprocess, {0}")]
FlushFailure(std::io::Error),
#[error("<SubprocessSink> Failed to Kill the Subprocess, {0}")]
KillFailure(std::io::Error),
#[error("<SubprocessSink> Failed to Wait for the Subprocess to Exit, {0}")]
WaitFailure(std::io::Error),
#[error("<SubprocessSink> The Subprocess is no longer able to accept Bytes")]
WriteZero,
#[error("<SubprocessSink> Missing Required Shell Command")]
MissingCommand,
#[error("<SubprocessSink> The Subprocess is None")]
NoChild,
#[error("<SubprocessSink> The Subprocess's stdin is None")]
NoStdin,
}
impl From<SubprocessError> for SinkError {
fn from(e: SubprocessError) -> SinkError {
use SubprocessError::*;
let es = e.to_string();
match e {
FlushFailure(_) | KillFailure(_) | WaitFailure(_) | OnWrite(_) | WriteZero => {
SinkError::OnWrite(es)
}
SpawnFailure { .. } => SinkError::ConnectionRefused(es),
MissingCommand | InvalidArgs { .. } => SinkError::InvalidParams(es),
NoChild | NoStdin => SinkError::NotConnected(es),
}
}
}
pub struct SubprocessSink {
shell_command: String,
shell_command: Option<String>,
child: Option<Child>,
format: AudioFormat,
}
impl Open for SubprocessSink {
fn open(shell_command: Option<String>, format: AudioFormat) -> Self {
let shell_command = match shell_command.as_deref() {
Some("?") => {
info!("Usage: --backend subprocess --device {{shell_command}}");
exit(0);
}
Some(cmd) => cmd.to_owned(),
None => {
error!("subprocess sink requires specifying a shell command");
exit(1);
}
};
if let Some("?") = shell_command.as_deref() {
println!("\nUsage:\n\nOutput to a Subprocess:\n\n\t--backend subprocess --device {{shell_command}}\n");
exit(0);
}
info!("Using subprocess sink with format: {:?}", format);
info!("Using SubprocessSink with format: {:?}", format);
Self {
shell_command,
@ -39,26 +84,53 @@ impl Open for SubprocessSink {
impl Sink for SubprocessSink {
fn start(&mut self) -> SinkResult<()> {
let args = split(&self.shell_command).unwrap();
let child = Command::new(&args[0])
.args(&args[1..])
.stdin(Stdio::piped())
.spawn()
.map_err(|e| SinkError::ConnectionRefused(e.to_string()))?;
self.child = Some(child);
self.child.get_or_insert({
match self.shell_command.as_deref() {
Some(command) => {
let args = split(command).map_err(|e| SubprocessError::InvalidArgs {
command: command.to_string(),
e,
})?;
Command::new(&args[0])
.args(&args[1..])
.stdin(Stdio::piped())
.spawn()
.map_err(|e| SubprocessError::SpawnFailure {
command: command.to_string(),
e,
})?
}
None => return Err(SubprocessError::MissingCommand.into()),
}
});
Ok(())
}
fn stop(&mut self) -> SinkResult<()> {
if let Some(child) = &mut self.child.take() {
child
.kill()
.map_err(|e| SinkError::OnWrite(e.to_string()))?;
child
.wait()
.map_err(|e| SinkError::OnWrite(e.to_string()))?;
let child = &mut self.child.take().ok_or(SubprocessError::NoChild)?;
match child.try_wait() {
// The process has already exited
// nothing to do.
Ok(Some(_)) => Ok(()),
Ok(_) => {
// The process Must DIE!!!
child
.stdin
.take()
.ok_or(SubprocessError::NoStdin)?
.flush()
.map_err(SubprocessError::FlushFailure)?;
child.kill().map_err(SubprocessError::KillFailure)?;
child.wait().map_err(SubprocessError::WaitFailure)?;
Ok(())
}
Err(e) => Err(SubprocessError::WaitFailure(e).into()),
}
Ok(())
}
sink_as_bytes!();
@ -66,22 +138,67 @@ impl Sink for SubprocessSink {
impl SinkAsBytes for SubprocessSink {
fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> {
if let Some(child) = &mut self.child {
let child_stdin = child
// We get one attempted restart per write.
// We don't want to get stuck in a restart loop.
let mut restarted = false;
let mut start_index = 0;
let data_len = data.len();
let mut end_index = data_len;
loop {
match self
.child
.as_ref()
.ok_or(SubprocessError::NoChild)?
.stdin
.as_mut()
.ok_or_else(|| SinkError::NotConnected("Child is None".to_string()))?;
child_stdin
.write_all(data)
.map_err(|e| SinkError::OnWrite(e.to_string()))?;
child_stdin
.flush()
.map_err(|e| SinkError::OnWrite(e.to_string()))?;
.as_ref()
.ok_or(SubprocessError::NoStdin)?
.write(&data[start_index..end_index])
{
Ok(0) => {
// Potentially fatal.
// As per the docs a return value of 0
// means we shouldn't try to write to the
// process anymore so let's try a restart
// if we haven't already.
self.try_restart(SubprocessError::WriteZero, &mut restarted)?;
continue;
}
Ok(bytes_written) => {
// What we want, a successful write.
start_index = data_len.min(start_index + bytes_written);
end_index = data_len.min(start_index + bytes_written);
if end_index == data_len {
break Ok(());
}
}
// Non-fatal, retry the write.
Err(ref e) if e.kind() == ErrorKind::Interrupted => continue,
Err(e) => {
// Very possibly fatal,
// but let's try a restart anyway if we haven't already.
self.try_restart(SubprocessError::OnWrite(e), &mut restarted)?;
continue;
}
}
}
Ok(())
}
}
impl SubprocessSink {
pub const NAME: &'static str = "subprocess";
fn try_restart(&mut self, e: SubprocessError, restarted: &mut bool) -> SinkResult<()> {
// If the restart fails throw the original error back.
if !*restarted && self.stop().is_ok() && self.start().is_ok() {
*restarted = true;
Ok(())
} else {
Err(e.into())
}
}
}

View file

@ -104,23 +104,33 @@ impl Mixer for AlsaMixer {
let min_db = min_millibel.to_db() as f64;
let max_db = max_millibel.to_db() as f64;
let mut db_range = f64::abs(max_db - min_db);
let reported_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.
if !config.volume_ctrl.range_ok() {
if db_range > 100.0 {
debug!("Alsa mixer reported dB range > 100, which is suspect");
warn!("Please manually set `--volume-range` if this is incorrect");
}
config.volume_ctrl.set_db_range(db_range);
} else {
let db_range = if config.volume_ctrl.range_ok() {
let db_range_override = config.volume_ctrl.db_range();
if db_range_override.is_normal() {
db_range_override
} else {
reported_db_range
}
} else {
config.volume_ctrl.set_db_range(reported_db_range);
reported_db_range
};
if reported_db_range == db_range {
debug!("Alsa dB volume range was reported as {}", reported_db_range);
if reported_db_range > 100.0 {
debug!("Alsa mixer reported dB range > 100, which is suspect");
debug!("Please manually set `--volume-range` if this is incorrect");
}
} else {
debug!(
"Alsa dB volume range was detected as {} but overridden as {}",
db_range, db_range_override
"Alsa dB volume range was reported as {} but overridden to {}",
reported_db_range, db_range
);
db_range = db_range_override;
}
// For hardware controls with a small range (24 dB or less),

View file

@ -3,6 +3,8 @@ use crate::config::VolumeCtrl;
pub mod mappings;
use self::mappings::MappedCtrl;
pub struct NoOpVolume;
pub trait Mixer: Send {
fn open(config: MixerConfig) -> Self
where
@ -11,13 +13,19 @@ pub trait Mixer: Send {
fn set_volume(&self, volume: u16);
fn volume(&self) -> u16;
fn get_audio_filter(&self) -> Option<Box<dyn AudioFilter + Send>> {
None
fn get_soft_volume(&self) -> Box<dyn VolumeGetter + Send> {
Box::new(NoOpVolume)
}
}
pub trait AudioFilter {
fn modify_stream(&self, data: &mut [f64]);
pub trait VolumeGetter {
fn attenuation_factor(&self) -> f64;
}
impl VolumeGetter for NoOpVolume {
fn attenuation_factor(&self) -> f64 {
1.0
}
}
pub mod softmixer;

View file

@ -1,7 +1,7 @@
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use super::AudioFilter;
use super::VolumeGetter;
use super::{MappedCtrl, VolumeCtrl};
use super::{Mixer, MixerConfig};
@ -35,10 +35,8 @@ impl Mixer for SoftMixer {
.store(mapped_volume.to_bits(), Ordering::Relaxed)
}
fn get_audio_filter(&self) -> Option<Box<dyn AudioFilter + Send>> {
Some(Box::new(SoftVolumeApplier {
volume: self.volume.clone(),
}))
fn get_soft_volume(&self) -> Box<dyn VolumeGetter + Send> {
Box::new(SoftVolume(self.volume.clone()))
}
}
@ -46,17 +44,10 @@ impl SoftMixer {
pub const NAME: &'static str = "softvol";
}
struct SoftVolumeApplier {
volume: Arc<AtomicU64>,
}
struct SoftVolume(Arc<AtomicU64>);
impl AudioFilter for SoftVolumeApplier {
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 *= volume;
}
}
impl VolumeGetter for SoftVolume {
fn attenuation_factor(&self) -> f64 {
f64::from_bits(self.0.load(Ordering::Relaxed))
}
}

View file

@ -37,7 +37,7 @@ use crate::{
core::{util::SeqGenerator, Error, Session, SpotifyId},
decoder::{AudioDecoder, AudioPacket, AudioPacketPosition, SymphoniaDecoder},
metadata::audio::{AudioFileFormat, AudioFiles, AudioItem},
mixer::AudioFilter,
mixer::VolumeGetter,
};
#[cfg(feature = "passthrough-decoder")]
@ -81,7 +81,7 @@ struct PlayerInternal {
sink: Box<dyn Sink>,
sink_status: SinkStatus,
sink_event_callback: Option<SinkEventCallback>,
audio_filter: Option<Box<dyn AudioFilter + Send>>,
volume_getter: Box<dyn VolumeGetter + Send>,
event_senders: Vec<mpsc::UnboundedSender<PlayerEvent>>,
converter: Converter,
@ -368,7 +368,7 @@ impl Player {
pub fn new<F>(
config: PlayerConfig,
session: Session,
audio_filter: Option<Box<dyn AudioFilter + Send>>,
volume_getter: Box<dyn VolumeGetter + Send>,
sink_builder: F,
) -> (Player, PlayerEventChannel)
where
@ -420,7 +420,7 @@ impl Player {
sink: sink_builder(),
sink_status: SinkStatus::Closed,
sink_event_callback: None,
audio_filter,
volume_getter,
event_senders: [event_sender].to_vec(),
converter,
@ -1445,109 +1445,111 @@ impl PlayerInternal {
Some((_, mut packet)) => {
if !packet.is_empty() {
if let AudioPacket::Samples(ref mut data) = packet {
// Get the volume for the packet.
// In the case of hardware volume control this will
// always be 1.0 (no change).
let volume = self.volume_getter.attenuation_factor();
// For the basic normalisation method, a normalisation factor of 1.0 indicates that
// there is nothing to normalise (all samples should pass unaltered). For the
// dynamic method, there may still be peaks that we want to shave off.
if self.config.normalisation {
if self.config.normalisation_method == NormalisationMethod::Basic
&& normalisation_factor < 1.0
{
for sample in data.iter_mut() {
*sample *= normalisation_factor;
}
} else if self.config.normalisation_method
== NormalisationMethod::Dynamic
{
// zero-cost shorthands
let threshold_db = self.config.normalisation_threshold_dbfs;
let knee_db = self.config.normalisation_knee_db;
let attack_cf = self.config.normalisation_attack_cf;
let release_cf = self.config.normalisation_release_cf;
for sample in data.iter_mut() {
*sample *= normalisation_factor;
// Feedforward limiter in the log domain
// After: Giannoulis, D., Massberg, M., & Reiss, J.D. (2012). Digital Dynamic
// Range Compressor Design—A Tutorial and Analysis. Journal of The Audio
// Engineering Society, 60, 399-408.
// Some tracks have samples that are precisely 0.0. That's silence
// and we know we don't need to limit that, in which we can spare
// the CPU cycles.
//
// Also, calling `ratio_to_db(0.0)` returns `inf` and would get the
// peak detector stuck. Also catch the unlikely case where a sample
// is decoded as `NaN` or some other non-normal value.
let limiter_db = if sample.is_normal() {
// step 1-4: half-wave rectification and conversion into dB
// and gain computer with soft knee and subtractor
let bias_db = ratio_to_db(sample.abs()) - threshold_db;
let knee_boundary_db = bias_db * 2.0;
if knee_boundary_db < -knee_db {
0.0
} else if knee_boundary_db.abs() <= knee_db {
// The textbook equation:
// ratio_to_db(sample.abs()) - (ratio_to_db(sample.abs()) - (bias_db + knee_db / 2.0).powi(2) / (2.0 * knee_db))
// Simplifies to:
// ((2.0 * bias_db) + knee_db).powi(2) / (8.0 * knee_db)
// Which in our case further simplifies to:
// (knee_boundary_db + knee_db).powi(2) / (8.0 * knee_db)
// because knee_boundary_db is 2.0 * bias_db.
(knee_boundary_db + knee_db).powi(2) / (8.0 * knee_db)
} else {
// Textbook:
// ratio_to_db(sample.abs()) - threshold_db, which is already our bias_db.
bias_db
}
} else {
0.0
};
// Spare the CPU unless (1) the limiter is engaged, (2) we
// were in attack or (3) we were in release, and that attack/
// release wasn't finished yet.
if limiter_db > 0.0
|| self.normalisation_integrator > 0.0
|| self.normalisation_peak > 0.0
{
// step 5: smooth, decoupled peak detector
// Textbook:
// release_cf * self.normalisation_integrator + (1.0 - release_cf) * limiter_db
// Simplifies to:
// release_cf * self.normalisation_integrator - release_cf * limiter_db + limiter_db
self.normalisation_integrator = f64::max(
limiter_db,
release_cf * self.normalisation_integrator
- release_cf * limiter_db
+ limiter_db,
);
// Textbook:
// attack_cf * self.normalisation_peak + (1.0 - attack_cf) * self.normalisation_integrator
// Simplifies to:
// attack_cf * self.normalisation_peak - attack_cf * self.normalisation_integrator + self.normalisation_integrator
self.normalisation_peak = attack_cf
* self.normalisation_peak
- attack_cf * self.normalisation_integrator
+ self.normalisation_integrator;
// step 6: make-up gain applied later (volume attenuation)
// Applying the standard normalisation factor here won't work,
// because there are tracks with peaks as high as 6 dB above
// the default threshold, so that would clip.
// steps 7-8: conversion into level and multiplication into gain stage
*sample *= db_to_ratio(-self.normalisation_peak);
}
}
// No matter the case we apply volume attenuation last if there is any.
if !self.config.normalisation && volume < 1.0 {
for sample in data.iter_mut() {
*sample *= volume;
}
}
} else if self.config.normalisation_method == NormalisationMethod::Basic
&& (normalisation_factor < 1.0 || volume < 1.0)
{
for sample in data.iter_mut() {
*sample *= normalisation_factor * volume;
}
} else if self.config.normalisation_method == NormalisationMethod::Dynamic {
// zero-cost shorthands
let threshold_db = self.config.normalisation_threshold_dbfs;
let knee_db = self.config.normalisation_knee_db;
let attack_cf = self.config.normalisation_attack_cf;
let release_cf = self.config.normalisation_release_cf;
// Apply volume attenuation last. TODO: make this so we can chain
// the normaliser and mixer as a processing pipeline.
if let Some(ref editor) = self.audio_filter {
editor.modify_stream(data)
for sample in data.iter_mut() {
*sample *= normalisation_factor;
// Feedforward limiter in the log domain
// After: Giannoulis, D., Massberg, M., & Reiss, J.D. (2012). Digital Dynamic
// Range Compressor Design—A Tutorial and Analysis. Journal of The Audio
// Engineering Society, 60, 399-408.
// Some tracks have samples that are precisely 0.0. That's silence
// and we know we don't need to limit that, in which we can spare
// the CPU cycles.
//
// Also, calling `ratio_to_db(0.0)` returns `inf` and would get the
// peak detector stuck. Also catch the unlikely case where a sample
// is decoded as `NaN` or some other non-normal value.
let limiter_db = if sample.is_normal() {
// step 1-4: half-wave rectification and conversion into dB
// and gain computer with soft knee and subtractor
let bias_db = ratio_to_db(sample.abs()) - threshold_db;
let knee_boundary_db = bias_db * 2.0;
if knee_boundary_db < -knee_db {
0.0
} else if knee_boundary_db.abs() <= knee_db {
// The textbook equation:
// ratio_to_db(sample.abs()) - (ratio_to_db(sample.abs()) - (bias_db + knee_db / 2.0).powi(2) / (2.0 * knee_db))
// Simplifies to:
// ((2.0 * bias_db) + knee_db).powi(2) / (8.0 * knee_db)
// Which in our case further simplifies to:
// (knee_boundary_db + knee_db).powi(2) / (8.0 * knee_db)
// because knee_boundary_db is 2.0 * bias_db.
(knee_boundary_db + knee_db).powi(2) / (8.0 * knee_db)
} else {
// Textbook:
// ratio_to_db(sample.abs()) - threshold_db, which is already our bias_db.
bias_db
}
} else {
0.0
};
// Spare the CPU unless (1) the limiter is engaged, (2) we
// were in attack or (3) we were in release, and that attack/
// release wasn't finished yet.
if limiter_db > 0.0
|| self.normalisation_integrator > 0.0
|| self.normalisation_peak > 0.0
{
// step 5: smooth, decoupled peak detector
// Textbook:
// release_cf * self.normalisation_integrator + (1.0 - release_cf) * limiter_db
// Simplifies to:
// release_cf * self.normalisation_integrator - release_cf * limiter_db + limiter_db
self.normalisation_integrator = f64::max(
limiter_db,
release_cf * self.normalisation_integrator
- release_cf * limiter_db
+ limiter_db,
);
// Textbook:
// attack_cf * self.normalisation_peak + (1.0 - attack_cf) * self.normalisation_integrator
// Simplifies to:
// attack_cf * self.normalisation_peak - attack_cf * self.normalisation_integrator + self.normalisation_integrator
self.normalisation_peak = attack_cf * self.normalisation_peak
- attack_cf * self.normalisation_integrator
+ self.normalisation_integrator;
// step 6: make-up gain applied later (volume attenuation)
// Applying the standard normalisation factor here won't work,
// because there are tracks with peaks as high as 6 dB above
// the default threshold, so that would clip.
// steps 7-8: conversion into level and multiplication into gain stage
*sample *= db_to_ratio(-self.normalisation_peak);
}
*sample *= volume;
}
}
}
@ -1979,7 +1981,7 @@ impl PlayerInternal {
fn handle_command(&mut self, cmd: PlayerCommand) -> PlayerResult {
debug!("command={:?}", cmd);
let result = match cmd {
match cmd {
PlayerCommand::Load {
track_id,
play_request_id,
@ -2034,7 +2036,7 @@ impl PlayerInternal {
}
};
Ok(result)
Ok(())
}
fn send_event(&mut self, event: PlayerEvent) {

View file

@ -1,6 +1,6 @@
[package]
name = "librespot-protocol"
version = "0.3.1"
version = "0.4.1"
authors = ["Paul Liétar <paul@lietar.net>"]
build = "build.rs"
description = "The protobuf logic for communicating with Spotify servers"

View file

@ -27,14 +27,17 @@ function updateVersion {
do
if [ "$CRATE" = "librespot" ]
then
CRATE=''
CRATE_DIR=''
else
CRATE_DIR=$CRATE
fi
crate_path="$WORKINGDIR/$CRATE/Cargo.toml"
crate_path="$WORKINGDIR/$CRATE_DIR/Cargo.toml"
crate_path=${crate_path//\/\///}
sed -i '' "s/^version.*/version = \"$1\"/g" "$crate_path"
echo "Path is $crate_path"
if [ "$CRATE" = "librespot" ]
then
echo "Updating lockfile"
if [ "$DRY_RUN" = 'true' ] ; then
cargo update --dry-run
git add . && git commit --dry-run -a -m "Update Cargo.lock"

View file

@ -668,7 +668,15 @@ fn get_setup() -> Setup {
trace!("Command line argument(s):");
for (index, key) in args.iter().enumerate() {
let opt = key.trim_start_matches('-');
let opt = {
let key = key.trim_start_matches('-');
if let Some((s, _)) = key.split_once('=') {
s
} else {
key
}
};
if index > 0
&& key.starts_with('-')
@ -678,13 +686,13 @@ fn get_setup() -> Setup {
{
if matches!(opt, PASSWORD | PASSWORD_SHORT | USERNAME | USERNAME_SHORT) {
// Don't log creds.
trace!("\t\t{} \"XXXXXXXX\"", key);
trace!("\t\t{} \"XXXXXXXX\"", opt);
} else {
let value = matches.opt_str(opt).unwrap_or_else(|| "".to_string());
if value.is_empty() {
trace!("\t\t{}", key);
trace!("\t\t{}", opt);
} else {
trace!("\t\t{} \"{}\"", key, value);
trace!("\t\t{} \"{}\"", opt, value);
}
}
}
@ -1072,7 +1080,7 @@ fn get_setup() -> Setup {
Some(creds) if username == creds.username => Some(creds),
_ => {
let prompt = &format!("Password for {}: ", username);
match rpassword::prompt_password_stderr(prompt) {
match rpassword::prompt_password(prompt) {
Ok(password) => {
if !password.is_empty() {
Some(Credentials::with_password(username, password))
@ -1599,24 +1607,25 @@ async fn main() {
if setup.enable_discovery {
let device_id = setup.session_config.device_id.clone();
discovery = match librespot::discovery::Discovery::builder(device_id)
match librespot::discovery::Discovery::builder(device_id)
.name(setup.connect_config.name.clone())
.device_type(setup.connect_config.device_type)
.port(setup.zeroconf_port)
.launch()
{
Ok(d) => Some(d),
Err(e) => {
error!("Discovery Error: {}", e);
exit(1);
}
}
Ok(d) => discovery = Some(d),
Err(err) => warn!("Could not initialise discovery: {}.", err),
};
}
if let Some(credentials) = setup.credentials {
last_credentials = Some(credentials);
connecting = true;
} else if discovery.is_none() {
error!(
"Discovery is unavailable and no credentials provided. Authentication is not possible."
);
exit(1);
}
loop {
@ -1656,12 +1665,12 @@ async fn main() {
let player_config = setup.player_config.clone();
let connect_config = setup.connect_config.clone();
let audio_filter = mixer.get_audio_filter();
let soft_volume = mixer.get_soft_volume();
let format = setup.format;
let backend = setup.backend;
let device = setup.device.clone();
let (player, event_channel) =
Player::new(player_config, session.clone(), audio_filter, move || {
Player::new(player_config, session.clone(), soft_volume, move || {
(backend)(device, format)
});

View file

@ -17,17 +17,17 @@ pub fn run_program_on_events(event: PlayerEvent, onevent: &str) -> Option<Result
old_track_id,
new_track_id,
} => match old_track_id.to_base62() {
Err(_) => {
Err(e) => {
return Some(Err(Error::new(
ErrorKind::InvalidData,
"PlayerEvent::Changed: Invalid old track id",
format!("PlayerEvent::Changed: Invalid old track id: {}", e),
)))
}
Ok(old_id) => match new_track_id.to_base62() {
Err(_) => {
Err(e) => {
return Some(Err(Error::new(
ErrorKind::InvalidData,
"PlayerEvent::Changed: Invalid new track id",
format!("PlayerEvent::Changed: Invalid old track id: {}", e),
)))
}
Ok(new_id) => {
@ -38,10 +38,10 @@ pub fn run_program_on_events(event: PlayerEvent, onevent: &str) -> Option<Result
},
},
PlayerEvent::Started { track_id, .. } => match track_id.to_base62() {
Err(_) => {
Err(e) => {
return Some(Err(Error::new(
ErrorKind::InvalidData,
"PlayerEvent::Started: Invalid track id",
format!("PlayerEvent::Started: Invalid track id: {}", e),
)))
}
Ok(id) => {
@ -50,10 +50,10 @@ pub fn run_program_on_events(event: PlayerEvent, onevent: &str) -> Option<Result
}
},
PlayerEvent::Stopped { track_id, .. } => match track_id.to_base62() {
Err(_) => {
Err(e) => {
return Some(Err(Error::new(
ErrorKind::InvalidData,
"PlayerEvent::Stopped: Invalid track id",
format!("PlayerEvent::Stopped: Invalid track id: {}", e),
)))
}
Ok(id) => {
@ -67,10 +67,10 @@ pub fn run_program_on_events(event: PlayerEvent, onevent: &str) -> Option<Result
position_ms,
..
} => match track_id.to_base62() {
Err(_) => {
Err(e) => {
return Some(Err(Error::new(
ErrorKind::InvalidData,
"PlayerEvent::Playing: Invalid track id",
format!("PlayerEvent::Playing: Invalid track id: {}", e),
)))
}
Ok(id) => {
@ -86,10 +86,10 @@ pub fn run_program_on_events(event: PlayerEvent, onevent: &str) -> Option<Result
position_ms,
..
} => match track_id.to_base62() {
Err(_) => {
Err(e) => {
return Some(Err(Error::new(
ErrorKind::InvalidData,
"PlayerEvent::Paused: Invalid track id",
format!("PlayerEvent::Paused: Invalid track id: {}", e),
)))
}
Ok(id) => {
@ -100,10 +100,10 @@ pub fn run_program_on_events(event: PlayerEvent, onevent: &str) -> Option<Result
}
},
PlayerEvent::Preloading { track_id, .. } => match track_id.to_base62() {
Err(_) => {
Err(e) => {
return Some(Err(Error::new(
ErrorKind::InvalidData,
"PlayerEvent::Preloading: Invalid track id",
format!("PlayerEvent::Preloading: Invalid track id: {}", e),
)))
}
Ok(id) => {