mirror of
https://github.com/librespot-org/librespot.git
synced 2024-12-18 17:11:53 +00:00
Port remove unsafe code and catch up with dev (#956)
This commit is contained in:
parent
ab562cc8d8
commit
47f1362453
18 changed files with 366 additions and 241 deletions
|
@ -28,6 +28,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
- [main] Prevent hang when discovery is disabled and there are no credentials or when bad credentials are given.
|
- [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] 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`.
|
- [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.
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
- [playback] `alsamixer`: previously deprecated option `mixer-card` has been removed.
|
- [playback] `alsamixer`: previously deprecated option `mixer-card` has been removed.
|
||||||
|
|
|
@ -859,15 +859,15 @@ impl SpircTask {
|
||||||
self.state.set_shuffle(update.get_state().get_shuffle());
|
self.state.set_shuffle(update.get_state().get_shuffle());
|
||||||
if self.state.get_shuffle() {
|
if self.state.get_shuffle() {
|
||||||
let current_index = self.state.get_playing_track_index();
|
let current_index = self.state.get_playing_track_index();
|
||||||
{
|
|
||||||
let tracks = self.state.mut_track();
|
let tracks = self.state.mut_track();
|
||||||
|
if !tracks.is_empty() {
|
||||||
tracks.swap(0, current_index as usize);
|
tracks.swap(0, current_index as usize);
|
||||||
if let Some((_, rest)) = tracks.split_first_mut() {
|
if let Some((_, rest)) = tracks.split_first_mut() {
|
||||||
let mut rng = rand::thread_rng();
|
let mut rng = rand::thread_rng();
|
||||||
rest.shuffle(&mut rng);
|
rest.shuffle(&mut rng);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
self.state.set_playing_track_index(0);
|
self.state.set_playing_track_index(0);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let context = self.state.get_context_uri();
|
let context = self.state.get_context_uri();
|
||||||
debug!("{:?}", context);
|
debug!("{:?}", context);
|
||||||
|
|
|
@ -368,12 +368,17 @@ impl Cache {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn file_path(&self, file: FileId) -> Option<PathBuf> {
|
pub fn file_path(&self, file: FileId) -> Option<PathBuf> {
|
||||||
self.audio_location.as_ref().map(|location| {
|
match file.to_base16() {
|
||||||
let name = file.to_base16();
|
Ok(name) => self.audio_location.as_ref().map(|location| {
|
||||||
let mut path = location.join(&name[0..2]);
|
let mut path = location.join(&name[0..2]);
|
||||||
path.push(&name[2..]);
|
path.push(&name[2..]);
|
||||||
path
|
path
|
||||||
})
|
}),
|
||||||
|
Err(e) => {
|
||||||
|
warn!("{}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn file(&self, file: FileId) -> Option<File> {
|
pub fn file(&self, file: FileId) -> Option<File> {
|
||||||
|
|
|
@ -2,7 +2,7 @@ use std::fmt;
|
||||||
|
|
||||||
use librespot_protocol as protocol;
|
use librespot_protocol as protocol;
|
||||||
|
|
||||||
use crate::spotify_id::to_base16;
|
use crate::{spotify_id::to_base16, Error};
|
||||||
|
|
||||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
pub struct FileId(pub [u8; 20]);
|
pub struct FileId(pub [u8; 20]);
|
||||||
|
@ -14,7 +14,8 @@ impl FileId {
|
||||||
FileId(dst)
|
FileId(dst)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_base16(&self) -> String {
|
#[allow(clippy::wrong_self_convention)]
|
||||||
|
pub fn to_base16(&self) -> Result<String, Error> {
|
||||||
to_base16(&self.0, &mut [0u8; 40])
|
to_base16(&self.0, &mut [0u8; 40])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -27,7 +28,7 @@ impl fmt::Debug for FileId {
|
||||||
|
|
||||||
impl fmt::Display for FileId {
|
impl fmt::Display for FileId {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
f.write_str(&self.to_base16())
|
f.write_str(&self.to_base16().unwrap_or_default())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -354,7 +354,7 @@ impl SpClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_metadata(&self, scope: &str, id: SpotifyId) -> SpClientResult {
|
pub async fn get_metadata(&self, scope: &str, id: SpotifyId) -> SpClientResult {
|
||||||
let endpoint = format!("/metadata/4/{}/{}", scope, id.to_base16());
|
let endpoint = format!("/metadata/4/{}/{}", scope, id.to_base16()?);
|
||||||
self.request(&Method::GET, &endpoint, None, None).await
|
self.request(&Method::GET, &endpoint, None, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -379,7 +379,7 @@ impl SpClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_lyrics(&self, track_id: SpotifyId) -> SpClientResult {
|
pub async fn get_lyrics(&self, track_id: SpotifyId) -> SpClientResult {
|
||||||
let endpoint = format!("/color-lyrics/v1/track/{}", track_id.to_base62());
|
let endpoint = format!("/color-lyrics/v1/track/{}", track_id.to_base62()?);
|
||||||
|
|
||||||
self.request_as_json(&Method::GET, &endpoint, None, None)
|
self.request_as_json(&Method::GET, &endpoint, None, None)
|
||||||
.await
|
.await
|
||||||
|
@ -392,7 +392,7 @@ impl SpClient {
|
||||||
) -> SpClientResult {
|
) -> SpClientResult {
|
||||||
let endpoint = format!(
|
let endpoint = format!(
|
||||||
"/color-lyrics/v2/track/{}/image/spotify:image:{}",
|
"/color-lyrics/v2/track/{}/image/spotify:image:{}",
|
||||||
track_id.to_base62(),
|
track_id.to_base62()?,
|
||||||
image_id
|
image_id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -416,7 +416,7 @@ impl SpClient {
|
||||||
pub async fn get_audio_storage(&self, file_id: FileId) -> SpClientResult {
|
pub async fn get_audio_storage(&self, file_id: FileId) -> SpClientResult {
|
||||||
let endpoint = format!(
|
let endpoint = format!(
|
||||||
"/storage-resolve/files/audio/interactive/{}",
|
"/storage-resolve/files/audio/interactive/{}",
|
||||||
file_id.to_base16()
|
file_id.to_base16()?
|
||||||
);
|
);
|
||||||
self.request(&Method::GET, &endpoint, None, None).await
|
self.request(&Method::GET, &endpoint, None, None).await
|
||||||
}
|
}
|
||||||
|
@ -459,7 +459,7 @@ impl SpClient {
|
||||||
.get_user_attribute(attribute)
|
.get_user_attribute(attribute)
|
||||||
.ok_or_else(|| SpClientError::Attribute(attribute.to_string()))?;
|
.ok_or_else(|| SpClientError::Attribute(attribute.to_string()))?;
|
||||||
|
|
||||||
let mut url = template.replace("{id}", &preview_id.to_base16());
|
let mut url = template.replace("{id}", &preview_id.to_base16()?);
|
||||||
let separator = match url.find('?') {
|
let separator = match url.find('?') {
|
||||||
Some(_) => "&",
|
Some(_) => "&",
|
||||||
None => "?",
|
None => "?",
|
||||||
|
@ -477,7 +477,7 @@ impl SpClient {
|
||||||
.get_user_attribute(attribute)
|
.get_user_attribute(attribute)
|
||||||
.ok_or_else(|| SpClientError::Attribute(attribute.to_string()))?;
|
.ok_or_else(|| SpClientError::Attribute(attribute.to_string()))?;
|
||||||
|
|
||||||
let url = template.replace("{file_id}", &file_id.to_base16());
|
let url = template.replace("{file_id}", &file_id.to_base16()?);
|
||||||
|
|
||||||
self.request_url(url).await
|
self.request_url(url).await
|
||||||
}
|
}
|
||||||
|
@ -488,7 +488,7 @@ impl SpClient {
|
||||||
.session()
|
.session()
|
||||||
.get_user_attribute(attribute)
|
.get_user_attribute(attribute)
|
||||||
.ok_or_else(|| SpClientError::Attribute(attribute.to_string()))?;
|
.ok_or_else(|| SpClientError::Attribute(attribute.to_string()))?;
|
||||||
let url = template.replace("{file_id}", &image_id.to_base16());
|
let url = template.replace("{file_id}", &image_id.to_base16()?);
|
||||||
|
|
||||||
self.request_url(url).await
|
self.request_url(url).await
|
||||||
}
|
}
|
||||||
|
|
|
@ -191,7 +191,8 @@ impl SpotifyId {
|
||||||
|
|
||||||
/// Returns the `SpotifyId` as a base16 (hex) encoded, `SpotifyId::SIZE_BASE16` (32)
|
/// Returns the `SpotifyId` as a base16 (hex) encoded, `SpotifyId::SIZE_BASE16` (32)
|
||||||
/// character long `String`.
|
/// character long `String`.
|
||||||
pub fn to_base16(&self) -> String {
|
#[allow(clippy::wrong_self_convention)]
|
||||||
|
pub fn to_base16(&self) -> Result<String, Error> {
|
||||||
to_base16(&self.to_raw(), &mut [0u8; Self::SIZE_BASE16])
|
to_base16(&self.to_raw(), &mut [0u8; Self::SIZE_BASE16])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -199,7 +200,9 @@ impl SpotifyId {
|
||||||
/// character long `String`.
|
/// character long `String`.
|
||||||
///
|
///
|
||||||
/// [canonically]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids
|
/// [canonically]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids
|
||||||
pub fn to_base62(&self) -> String {
|
|
||||||
|
#[allow(clippy::wrong_self_convention)]
|
||||||
|
pub fn to_base62(&self) -> Result<String, Error> {
|
||||||
let mut dst = [0u8; 22];
|
let mut dst = [0u8; 22];
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
let n = self.id;
|
let n = self.id;
|
||||||
|
@ -237,14 +240,12 @@ impl SpotifyId {
|
||||||
|
|
||||||
dst.reverse();
|
dst.reverse();
|
||||||
|
|
||||||
unsafe {
|
String::from_utf8(dst.to_vec()).map_err(|_| SpotifyIdError::InvalidId.into())
|
||||||
// Safety: We are only dealing with ASCII characters.
|
|
||||||
String::from_utf8_unchecked(dst.to_vec())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a copy of the `SpotifyId` as an array of `SpotifyId::SIZE` (16) bytes in
|
/// Returns a copy of the `SpotifyId` as an array of `SpotifyId::SIZE` (16) bytes in
|
||||||
/// big-endian order.
|
/// big-endian order.
|
||||||
|
#[allow(clippy::wrong_self_convention)]
|
||||||
pub fn to_raw(&self) -> [u8; Self::SIZE] {
|
pub fn to_raw(&self) -> [u8; Self::SIZE] {
|
||||||
self.id.to_be_bytes()
|
self.id.to_be_bytes()
|
||||||
}
|
}
|
||||||
|
@ -257,7 +258,9 @@ impl SpotifyId {
|
||||||
/// be encoded as `unknown`.
|
/// be encoded as `unknown`.
|
||||||
///
|
///
|
||||||
/// [Spotify URI]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids
|
/// [Spotify URI]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids
|
||||||
pub fn to_uri(&self) -> String {
|
|
||||||
|
#[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
|
// 8 chars for the "spotify:" prefix + 1 colon + 22 chars base62 encoded ID = 31
|
||||||
// + unknown size item_type.
|
// + unknown size item_type.
|
||||||
let item_type: &str = self.item_type.into();
|
let item_type: &str = self.item_type.into();
|
||||||
|
@ -265,21 +268,24 @@ impl SpotifyId {
|
||||||
dst.push_str("spotify:");
|
dst.push_str("spotify:");
|
||||||
dst.push_str(item_type);
|
dst.push_str(item_type);
|
||||||
dst.push(':');
|
dst.push(':');
|
||||||
dst.push_str(&self.to_base62());
|
let base_62 = self.to_base62()?;
|
||||||
|
dst.push_str(&base_62);
|
||||||
|
|
||||||
dst
|
Ok(dst)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Debug for SpotifyId {
|
impl fmt::Debug for SpotifyId {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
f.debug_tuple("SpotifyId").field(&self.to_uri()).finish()
|
f.debug_tuple("SpotifyId")
|
||||||
|
.field(&self.to_uri().unwrap_or_else(|_| "invalid uri".into()))
|
||||||
|
.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for SpotifyId {
|
impl fmt::Display for SpotifyId {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
f.write_str(&self.to_uri())
|
f.write_str(&self.to_uri().unwrap_or_else(|_| "invalid uri".into()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -312,16 +318,17 @@ impl NamedSpotifyId {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_uri(&self) -> String {
|
pub fn to_uri(&self) -> Result<String, Error> {
|
||||||
let item_type: &str = self.inner_id.item_type.into();
|
let item_type: &str = self.inner_id.item_type.into();
|
||||||
let mut dst = String::with_capacity(37 + self.username.len() + item_type.len());
|
let mut dst = String::with_capacity(37 + self.username.len() + item_type.len());
|
||||||
dst.push_str("spotify:user:");
|
dst.push_str("spotify:user:");
|
||||||
dst.push_str(&self.username);
|
dst.push_str(&self.username);
|
||||||
dst.push_str(item_type);
|
dst.push_str(item_type);
|
||||||
dst.push(':');
|
dst.push(':');
|
||||||
dst.push_str(&self.to_base62());
|
let base_62 = self.to_base62()?;
|
||||||
|
dst.push_str(&base_62);
|
||||||
|
|
||||||
dst
|
Ok(dst)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_spotify_id(id: SpotifyId, username: String) -> Self {
|
pub fn from_spotify_id(id: SpotifyId, username: String) -> Self {
|
||||||
|
@ -342,14 +349,24 @@ impl Deref for NamedSpotifyId {
|
||||||
impl fmt::Debug for NamedSpotifyId {
|
impl fmt::Debug for NamedSpotifyId {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
f.debug_tuple("NamedSpotifyId")
|
f.debug_tuple("NamedSpotifyId")
|
||||||
.field(&self.inner_id.to_uri())
|
.field(
|
||||||
|
&self
|
||||||
|
.inner_id
|
||||||
|
.to_uri()
|
||||||
|
.unwrap_or_else(|_| "invalid id".into()),
|
||||||
|
)
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for NamedSpotifyId {
|
impl fmt::Display for NamedSpotifyId {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
f.write_str(&self.inner_id.to_uri())
|
f.write_str(
|
||||||
|
&self
|
||||||
|
.inner_id
|
||||||
|
.to_uri()
|
||||||
|
.unwrap_or_else(|_| "invalid id".into()),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -495,7 +512,7 @@ impl TryFrom<&protocol::playlist_annotate3::TranscodedPicture> for SpotifyId {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_base16(src: &[u8], buf: &mut [u8]) -> String {
|
pub fn to_base16(src: &[u8], buf: &mut [u8]) -> Result<String, Error> {
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
for v in src {
|
for v in src {
|
||||||
buf[i] = BASE16_DIGITS[(v >> 4) as usize];
|
buf[i] = BASE16_DIGITS[(v >> 4) as usize];
|
||||||
|
@ -503,10 +520,7 @@ pub fn to_base16(src: &[u8], buf: &mut [u8]) -> String {
|
||||||
i += 2;
|
i += 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe {
|
String::from_utf8(buf.to_vec()).map_err(|_| SpotifyIdError::InvalidId.into())
|
||||||
// Safety: We are only dealing with ASCII characters.
|
|
||||||
String::from_utf8_unchecked(buf.to_vec())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
@ -623,7 +637,7 @@ mod tests {
|
||||||
item_type: c.kind,
|
item_type: c.kind,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(id.to_base62(), c.base62);
|
assert_eq!(id.to_base62().unwrap(), c.base62);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -646,7 +660,7 @@ mod tests {
|
||||||
item_type: c.kind,
|
item_type: c.kind,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(id.to_base16(), c.base16);
|
assert_eq!(id.to_base16().unwrap(), c.base16);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -672,7 +686,7 @@ mod tests {
|
||||||
item_type: c.kind,
|
item_type: c.kind,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(id.to_uri(), c.uri);
|
assert_eq!(id.to_uri().unwrap(), c.uri);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -19,11 +19,13 @@ async fn main() {
|
||||||
}
|
}
|
||||||
let credentials = Credentials::with_password(&args[1], &args[2]);
|
let credentials = Credentials::with_password(&args[1], &args[2]);
|
||||||
|
|
||||||
let uri_split = args[3].split(':');
|
let plist_uri = SpotifyId::from_uri(&args[3]).unwrap_or_else(|_| {
|
||||||
let uri_parts: Vec<&str> = uri_split.collect();
|
eprintln!(
|
||||||
println!("{}, {}, {}", uri_parts[0], uri_parts[1], uri_parts[2]);
|
"PLAYLIST should be a playlist URI such as: \
|
||||||
|
\"spotify:playlist:37i9dQZF1DXec50AjHrNTq\""
|
||||||
let plist_uri = SpotifyId::from_base62(uri_parts[2]).unwrap();
|
);
|
||||||
|
exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
let session = Session::new(session_config, None);
|
let session = Session::new(session_config, None);
|
||||||
if let Err(e) = session.connect(credentials).await {
|
if let Err(e) = session.connect(credentials).await {
|
||||||
|
|
|
@ -74,7 +74,7 @@ impl InnerAudioItem for Episode {
|
||||||
|
|
||||||
Ok(AudioItem {
|
Ok(AudioItem {
|
||||||
id,
|
id,
|
||||||
spotify_uri: id.to_uri(),
|
spotify_uri: id.to_uri()?,
|
||||||
files: episode.audio,
|
files: episode.audio,
|
||||||
name: episode.name,
|
name: episode.name,
|
||||||
duration: episode.duration,
|
duration: episode.duration,
|
||||||
|
|
|
@ -52,7 +52,7 @@ impl PlaylistAnnotation {
|
||||||
let uri = format!(
|
let uri = format!(
|
||||||
"hm://playlist-annotate/v1/annotation/user/{}/playlist/{}",
|
"hm://playlist-annotate/v1/annotation/user/{}/playlist/{}",
|
||||||
username,
|
username,
|
||||||
playlist_id.to_base62()
|
playlist_id.to_base62()?
|
||||||
);
|
);
|
||||||
<Self as MercuryRequest>::request(session, &uri).await
|
<Self as MercuryRequest>::request(session, &uri).await
|
||||||
}
|
}
|
||||||
|
|
|
@ -104,7 +104,7 @@ impl Playlist {
|
||||||
let uri = format!(
|
let uri = format!(
|
||||||
"hm://playlist/user/{}/playlist/{}",
|
"hm://playlist/user/{}/playlist/{}",
|
||||||
username,
|
username,
|
||||||
playlist_id.to_base62()
|
playlist_id.to_base62()?
|
||||||
);
|
);
|
||||||
<Self as MercuryRequest>::request(session, &uri).await
|
<Self as MercuryRequest>::request(session, &uri).await
|
||||||
}
|
}
|
||||||
|
@ -152,7 +152,7 @@ impl Metadata for Playlist {
|
||||||
type Message = protocol::playlist4_external::SelectedListContent;
|
type Message = protocol::playlist4_external::SelectedListContent;
|
||||||
|
|
||||||
async fn request(session: &Session, playlist_id: SpotifyId) -> RequestResult {
|
async fn request(session: &Session, playlist_id: SpotifyId) -> RequestResult {
|
||||||
let uri = format!("hm://playlist/v2/playlist/{}", playlist_id.to_base62());
|
let uri = format!("hm://playlist/v2/playlist/{}", playlist_id.to_base62()?);
|
||||||
<Self as MercuryRequest>::request(session, &uri).await
|
<Self as MercuryRequest>::request(session, &uri).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -88,7 +88,7 @@ impl InnerAudioItem for Track {
|
||||||
|
|
||||||
Ok(AudioItem {
|
Ok(AudioItem {
|
||||||
id,
|
id,
|
||||||
spotify_uri: id.to_uri(),
|
spotify_uri: id.to_uri()?,
|
||||||
files: track.files,
|
files: track.files,
|
||||||
name: track.name,
|
name: track.name,
|
||||||
duration: track.duration,
|
duration: track.duration,
|
||||||
|
|
|
@ -130,7 +130,7 @@ pub struct PlayerConfig {
|
||||||
pub normalisation: bool,
|
pub normalisation: bool,
|
||||||
pub normalisation_type: NormalisationType,
|
pub normalisation_type: NormalisationType,
|
||||||
pub normalisation_method: NormalisationMethod,
|
pub normalisation_method: NormalisationMethod,
|
||||||
pub normalisation_pregain_db: f32,
|
pub normalisation_pregain_db: f64,
|
||||||
pub normalisation_threshold_dbfs: f64,
|
pub normalisation_threshold_dbfs: f64,
|
||||||
pub normalisation_attack_cf: f64,
|
pub normalisation_attack_cf: f64,
|
||||||
pub normalisation_release_cf: f64,
|
pub normalisation_release_cf: f64,
|
||||||
|
|
|
@ -23,14 +23,15 @@ pub struct Converter {
|
||||||
|
|
||||||
impl Converter {
|
impl Converter {
|
||||||
pub fn new(dither_config: Option<DithererBuilder>) -> Self {
|
pub fn new(dither_config: Option<DithererBuilder>) -> Self {
|
||||||
if let Some(ref ditherer_builder) = dither_config {
|
match dither_config {
|
||||||
|
Some(ditherer_builder) => {
|
||||||
let ditherer = (ditherer_builder)();
|
let ditherer = (ditherer_builder)();
|
||||||
info!("Converting with ditherer: {}", ditherer.name());
|
info!("Converting with ditherer: {}", ditherer.name());
|
||||||
Self {
|
Self {
|
||||||
ditherer: Some(ditherer),
|
ditherer: Some(ditherer),
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
Self { ditherer: None }
|
None => Self { ditherer: None },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -52,18 +53,15 @@ impl Converter {
|
||||||
const SCALE_S16: f64 = 32768.;
|
const SCALE_S16: f64 = 32768.;
|
||||||
|
|
||||||
pub fn scale(&mut self, sample: f64, factor: f64) -> f64 {
|
pub fn scale(&mut self, sample: f64, factor: f64) -> f64 {
|
||||||
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
|
// From the many float to int conversion methods available, match what
|
||||||
// the reference Vorbis implementation uses: sample * 32768 (for 16 bit)
|
// the reference Vorbis implementation uses: sample * 32768 (for 16 bit)
|
||||||
let int_value = sample * factor + dither;
|
|
||||||
|
|
||||||
// Casting float to integer rounds towards zero by default, i.e. it
|
// Casting float to integer rounds towards zero by default, i.e. it
|
||||||
// truncates, and that generates larger error than rounding to nearest.
|
// truncates, and that generates larger error than rounding to nearest.
|
||||||
int_value.round()
|
match self.ditherer.as_mut() {
|
||||||
|
Some(d) => (sample * factor + d.noise()).round(),
|
||||||
|
None => (sample * factor).round(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special case for samples packed in a word of greater bit depth (e.g.
|
// Special case for samples packed in a word of greater bit depth (e.g.
|
||||||
|
@ -79,12 +77,13 @@ impl Converter {
|
||||||
let max = factor - 1.0;
|
let max = factor - 1.0;
|
||||||
|
|
||||||
if int_value < min {
|
if int_value < min {
|
||||||
return min;
|
min
|
||||||
} else if int_value > max {
|
} else if int_value > max {
|
||||||
return max;
|
max
|
||||||
}
|
} else {
|
||||||
int_value
|
int_value
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn f64_to_f32(&mut self, samples: &[f64]) -> Vec<f32> {
|
pub fn f64_to_f32(&mut self, samples: &[f64]) -> Vec<f32> {
|
||||||
samples.iter().map(|sample| *sample as f32).collect()
|
samples.iter().map(|sample| *sample as f32).collect()
|
||||||
|
@ -109,12 +108,7 @@ impl Converter {
|
||||||
pub fn f64_to_s24_3(&mut self, samples: &[f64]) -> Vec<i24> {
|
pub fn f64_to_s24_3(&mut self, samples: &[f64]) -> Vec<i24> {
|
||||||
samples
|
samples
|
||||||
.iter()
|
.iter()
|
||||||
.map(|sample| {
|
.map(|sample| i24::from_s24(self.clamping_scale(*sample, Self::SCALE_S24) as i32))
|
||||||
// Not as DRY as calling f32_to_s24 first, but this saves iterating
|
|
||||||
// over all samples twice.
|
|
||||||
let int_value = self.clamping_scale(*sample, Self::SCALE_S24) as i32;
|
|
||||||
i24::from_s24(int_value)
|
|
||||||
})
|
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ use rand::SeedableRng;
|
||||||
use rand_distr::{Distribution, Normal, Triangular, Uniform};
|
use rand_distr::{Distribution, Normal, Triangular, Uniform};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
const NUM_CHANNELS: usize = 2;
|
use crate::NUM_CHANNELS;
|
||||||
|
|
||||||
// Dithering lowers digital-to-analog conversion ("requantization") error,
|
// Dithering lowers digital-to-analog conversion ("requantization") error,
|
||||||
// linearizing output, lowering distortion and replacing it with a constant,
|
// linearizing output, lowering distortion and replacing it with a constant,
|
||||||
|
@ -102,7 +102,7 @@ impl GaussianDitherer {
|
||||||
|
|
||||||
pub struct HighPassDitherer {
|
pub struct HighPassDitherer {
|
||||||
active_channel: usize,
|
active_channel: usize,
|
||||||
previous_noises: [f64; NUM_CHANNELS],
|
previous_noises: [f64; NUM_CHANNELS as usize],
|
||||||
cached_rng: SmallRng,
|
cached_rng: SmallRng,
|
||||||
distribution: Uniform<f64>,
|
distribution: Uniform<f64>,
|
||||||
}
|
}
|
||||||
|
@ -111,7 +111,7 @@ impl Ditherer for HighPassDitherer {
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
active_channel: 0,
|
active_channel: 0,
|
||||||
previous_noises: [0.0; NUM_CHANNELS],
|
previous_noises: [0.0; NUM_CHANNELS as usize],
|
||||||
cached_rng: create_rng(),
|
cached_rng: create_rng(),
|
||||||
distribution: Uniform::new_inclusive(-0.5, 0.5), // 1 LSB +/- 1 LSB (previous) = 2 LSB
|
distribution: Uniform::new_inclusive(-0.5, 0.5), // 1 LSB +/- 1 LSB (previous) = 2 LSB
|
||||||
}
|
}
|
||||||
|
|
|
@ -84,7 +84,7 @@ impl Mixer for AlsaMixer {
|
||||||
warn!("Alsa rounding error detected, setting maximum dB to {:.2} instead of {:.2}", ZERO_DB.to_db(), max_millibel.to_db());
|
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;
|
max_millibel = ZERO_DB;
|
||||||
} else {
|
} else {
|
||||||
warn!("Please manually set with `--volume-ctrl` if this is incorrect");
|
warn!("Please manually set `--volume-range` if this is incorrect");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(min_millibel, max_millibel)
|
(min_millibel, max_millibel)
|
||||||
|
@ -104,12 +104,23 @@ impl Mixer for AlsaMixer {
|
||||||
|
|
||||||
let min_db = min_millibel.to_db() as f64;
|
let min_db = min_millibel.to_db() as f64;
|
||||||
let max_db = max_millibel.to_db() as f64;
|
let max_db = max_millibel.to_db() as f64;
|
||||||
let db_range = f64::abs(max_db - min_db);
|
let mut db_range = f64::abs(max_db - min_db);
|
||||||
|
|
||||||
// Synchronize the volume control dB range with the mixer control,
|
// Synchronize the volume control dB range with the mixer control,
|
||||||
// unless it was already set with a command line option.
|
// unless it was already set with a command line option.
|
||||||
if !config.volume_ctrl.range_ok() {
|
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);
|
config.volume_ctrl.set_db_range(db_range);
|
||||||
|
} else {
|
||||||
|
let db_range_override = config.volume_ctrl.db_range();
|
||||||
|
debug!(
|
||||||
|
"Alsa dB volume range was detected as {} but overridden as {}",
|
||||||
|
db_range, db_range_override
|
||||||
|
);
|
||||||
|
db_range = db_range_override;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For hardware controls with a small range (24 dB or less),
|
// For hardware controls with a small range (24 dB or less),
|
||||||
|
|
|
@ -47,6 +47,7 @@ use crate::SAMPLES_PER_SECOND;
|
||||||
|
|
||||||
const PRELOAD_NEXT_TRACK_BEFORE_END_DURATION_MS: u32 = 30000;
|
const PRELOAD_NEXT_TRACK_BEFORE_END_DURATION_MS: u32 = 30000;
|
||||||
pub const DB_VOLTAGE_RATIO: f64 = 20.0;
|
pub const DB_VOLTAGE_RATIO: f64 = 20.0;
|
||||||
|
pub const PCM_AT_0DBFS: f64 = 1.0;
|
||||||
|
|
||||||
// Spotify inserts a custom Ogg packet at the start with custom metadata values, that you would
|
// Spotify inserts a custom Ogg packet at the start with custom metadata values, that you would
|
||||||
// otherwise expect in Vorbis comments. This packet isn't well-formed and players may balk at it.
|
// otherwise expect in Vorbis comments. This packet isn't well-formed and players may balk at it.
|
||||||
|
@ -264,7 +265,6 @@ impl Default for NormalisationData {
|
||||||
impl NormalisationData {
|
impl NormalisationData {
|
||||||
fn parse_from_ogg<T: Read + Seek>(mut file: T) -> io::Result<NormalisationData> {
|
fn parse_from_ogg<T: Read + Seek>(mut file: T) -> io::Result<NormalisationData> {
|
||||||
const SPOTIFY_NORMALIZATION_HEADER_START_OFFSET: u64 = 144;
|
const SPOTIFY_NORMALIZATION_HEADER_START_OFFSET: u64 = 144;
|
||||||
|
|
||||||
let newpos = file.seek(SeekFrom::Start(SPOTIFY_NORMALIZATION_HEADER_START_OFFSET))?;
|
let newpos = file.seek(SeekFrom::Start(SPOTIFY_NORMALIZATION_HEADER_START_OFFSET))?;
|
||||||
if newpos != SPOTIFY_NORMALIZATION_HEADER_START_OFFSET {
|
if newpos != SPOTIFY_NORMALIZATION_HEADER_START_OFFSET {
|
||||||
error!(
|
error!(
|
||||||
|
@ -296,31 +296,62 @@ impl NormalisationData {
|
||||||
}
|
}
|
||||||
|
|
||||||
let (gain_db, gain_peak) = if config.normalisation_type == NormalisationType::Album {
|
let (gain_db, gain_peak) = if config.normalisation_type == NormalisationType::Album {
|
||||||
(data.album_gain_db as f64, data.album_peak as f64)
|
(data.album_gain_db, data.album_peak)
|
||||||
} else {
|
} else {
|
||||||
(data.track_gain_db as f64, data.track_peak as f64)
|
(data.track_gain_db, data.track_peak)
|
||||||
};
|
};
|
||||||
|
|
||||||
let normalisation_power = gain_db + config.normalisation_pregain_db as f64;
|
// As per the ReplayGain 1.0 & 2.0 (proposed) spec:
|
||||||
let mut normalisation_factor = db_to_ratio(normalisation_power);
|
// https://wiki.hydrogenaud.io/index.php?title=ReplayGain_1.0_specification#Clipping_prevention
|
||||||
|
// https://wiki.hydrogenaud.io/index.php?title=ReplayGain_2.0_specification#Clipping_prevention
|
||||||
|
let normalisation_factor = if config.normalisation_method == NormalisationMethod::Basic {
|
||||||
|
// For Basic Normalisation, factor = min(ratio of (ReplayGain + PreGain), 1.0 / peak level).
|
||||||
|
// https://wiki.hydrogenaud.io/index.php?title=ReplayGain_1.0_specification#Peak_amplitude
|
||||||
|
// https://wiki.hydrogenaud.io/index.php?title=ReplayGain_2.0_specification#Peak_amplitude
|
||||||
|
// We then limit that to 1.0 as not to exceed dBFS (0.0 dB).
|
||||||
|
let factor = f64::min(
|
||||||
|
db_to_ratio(gain_db + config.normalisation_pregain_db),
|
||||||
|
PCM_AT_0DBFS / gain_peak,
|
||||||
|
);
|
||||||
|
|
||||||
if normalisation_power + ratio_to_db(gain_peak) > config.normalisation_threshold_dbfs {
|
if factor > PCM_AT_0DBFS {
|
||||||
let limited_normalisation_factor =
|
info!(
|
||||||
db_to_ratio(config.normalisation_threshold_dbfs as f64) / gain_peak;
|
"Lowering gain by {:.2} dB for the duration of this track to avoid potentially exceeding dBFS.",
|
||||||
let limited_normalisation_power = ratio_to_db(limited_normalisation_factor);
|
ratio_to_db(factor)
|
||||||
|
);
|
||||||
|
|
||||||
if config.normalisation_method == NormalisationMethod::Basic {
|
PCM_AT_0DBFS
|
||||||
warn!("Limiting gain to {:.2} dB for the duration of this track to stay under normalisation threshold.", limited_normalisation_power);
|
|
||||||
normalisation_factor = limited_normalisation_factor;
|
|
||||||
} else {
|
} else {
|
||||||
|
factor
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For Dynamic Normalisation it's up to the player to decide,
|
||||||
|
// factor = ratio of (ReplayGain + PreGain).
|
||||||
|
// We then let the dynamic limiter handle gain reduction.
|
||||||
|
let factor = db_to_ratio(gain_db + config.normalisation_pregain_db);
|
||||||
|
let threshold_ratio = db_to_ratio(config.normalisation_threshold_dbfs);
|
||||||
|
|
||||||
|
if factor > PCM_AT_0DBFS {
|
||||||
|
let factor_db = gain_db + config.normalisation_pregain_db;
|
||||||
|
let limiting_db = factor_db + config.normalisation_threshold_dbfs.abs();
|
||||||
|
|
||||||
warn!(
|
warn!(
|
||||||
"This track will at its peak be subject to {:.2} dB of dynamic limiting.",
|
"This track may exceed dBFS by {:.2} dB and be subject to {:.2} dB of dynamic limiting at it's peak.",
|
||||||
normalisation_power - limited_normalisation_power
|
factor_db, limiting_db
|
||||||
|
);
|
||||||
|
} else if factor > threshold_ratio {
|
||||||
|
let limiting_db = gain_db
|
||||||
|
+ config.normalisation_pregain_db
|
||||||
|
+ config.normalisation_threshold_dbfs.abs();
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"This track may be subject to {:.2} dB of dynamic limiting at it's peak.",
|
||||||
|
limiting_db
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
warn!("Please lower pregain to avoid.");
|
factor
|
||||||
}
|
};
|
||||||
|
|
||||||
debug!("Normalisation Data: {:?}", data);
|
debug!("Normalisation Data: {:?}", data);
|
||||||
debug!(
|
debug!(
|
||||||
|
@ -792,7 +823,16 @@ impl PlayerTrackLoader {
|
||||||
position_ms: u32,
|
position_ms: u32,
|
||||||
) -> Option<PlayerLoadedTrackData> {
|
) -> Option<PlayerLoadedTrackData> {
|
||||||
let audio = match AudioItem::get_file(&self.session, spotify_id).await {
|
let audio = match AudioItem::get_file(&self.session, spotify_id).await {
|
||||||
Ok(audio) => audio,
|
Ok(audio) => match self.find_available_alternative(audio).await {
|
||||||
|
Some(audio) => audio,
|
||||||
|
None => {
|
||||||
|
warn!(
|
||||||
|
"<{}> is not available",
|
||||||
|
spotify_id.to_uri().unwrap_or_default()
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Unable to load audio item: {:?}", e);
|
error!("Unable to load audio item: {:?}", e);
|
||||||
return None;
|
return None;
|
||||||
|
@ -805,6 +845,7 @@ impl PlayerTrackLoader {
|
||||||
);
|
);
|
||||||
|
|
||||||
let is_explicit = audio.is_explicit;
|
let is_explicit = audio.is_explicit;
|
||||||
|
|
||||||
if is_explicit {
|
if is_explicit {
|
||||||
if let Some(value) = self.session.get_user_attribute("filter-explicit-content") {
|
if let Some(value) = self.session.get_user_attribute("filter-explicit-content") {
|
||||||
if &value == "1" {
|
if &value == "1" {
|
||||||
|
@ -814,22 +855,15 @@ impl PlayerTrackLoader {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let audio = match self.find_available_alternative(audio).await {
|
|
||||||
Some(audio) => audio,
|
|
||||||
None => {
|
|
||||||
error!("<{}> is not available", spotify_id.to_uri());
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if audio.duration < 0 {
|
if audio.duration < 0 {
|
||||||
error!(
|
error!(
|
||||||
"Track duration for <{}> cannot be {}",
|
"Track duration for <{}> cannot be {}",
|
||||||
spotify_id.to_uri(),
|
spotify_id.to_uri().unwrap_or_default(),
|
||||||
audio.duration
|
audio.duration
|
||||||
);
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let duration_ms = audio.duration as u32;
|
let duration_ms = audio.duration as u32;
|
||||||
|
|
||||||
// (Most) podcasts seem to support only 96 kbps Ogg Vorbis, so fall back to it
|
// (Most) podcasts seem to support only 96 kbps Ogg Vorbis, so fall back to it
|
||||||
|
@ -863,25 +897,23 @@ impl PlayerTrackLoader {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
let entry = formats.iter().find_map(|format| {
|
let (format, file_id) =
|
||||||
if let Some(&file_id) = audio.files.get(format) {
|
match formats
|
||||||
Some((*format, file_id))
|
.iter()
|
||||||
} else {
|
.find_map(|format| match audio.files.get(format) {
|
||||||
None
|
Some(&file_id) => Some((*format, file_id)),
|
||||||
}
|
_ => None,
|
||||||
});
|
}) {
|
||||||
|
|
||||||
let (format, file_id) = match entry {
|
|
||||||
Some(t) => t,
|
Some(t) => t,
|
||||||
None => {
|
None => {
|
||||||
error!("<{}> is not available in any supported format", audio.name);
|
warn!("<{}> is not available in any supported format", audio.name);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let bytes_per_second = self.stream_data_rate(format);
|
let bytes_per_second = self.stream_data_rate(format);
|
||||||
|
|
||||||
// This is only a loop to be able to reload the file if an error occured
|
// This is only a loop to be able to reload the file if an error occurred
|
||||||
// while opening a cached file.
|
// while opening a cached file.
|
||||||
loop {
|
loop {
|
||||||
let encrypted_file = AudioFile::open(&self.session, file_id, bytes_per_second);
|
let encrypted_file = AudioFile::open(&self.session, file_id, bytes_per_second);
|
||||||
|
@ -1416,9 +1448,15 @@ impl PlayerInternal {
|
||||||
// For the basic normalisation method, a normalisation factor of 1.0 indicates that
|
// 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
|
// 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.
|
// dynamic method, there may still be peaks that we want to shave off.
|
||||||
if self.config.normalisation
|
if self.config.normalisation {
|
||||||
&& !(f64::abs(normalisation_factor - 1.0) <= f64::EPSILON
|
if self.config.normalisation_method == NormalisationMethod::Basic
|
||||||
&& 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
|
// zero-cost shorthands
|
||||||
let threshold_db = self.config.normalisation_threshold_dbfs;
|
let threshold_db = self.config.normalisation_threshold_dbfs;
|
||||||
|
@ -1427,54 +1465,72 @@ impl PlayerInternal {
|
||||||
let release_cf = self.config.normalisation_release_cf;
|
let release_cf = self.config.normalisation_release_cf;
|
||||||
|
|
||||||
for sample in data.iter_mut() {
|
for sample in data.iter_mut() {
|
||||||
*sample *= normalisation_factor; // for both the basic and dynamic limiter
|
*sample *= normalisation_factor;
|
||||||
|
|
||||||
// Feedforward limiter in the log domain
|
// Feedforward limiter in the log domain
|
||||||
// After: Giannoulis, D., Massberg, M., & Reiss, J.D. (2012). Digital Dynamic
|
// After: Giannoulis, D., Massberg, M., & Reiss, J.D. (2012). Digital Dynamic
|
||||||
// Range Compressor Design—A Tutorial and Analysis. Journal of The Audio
|
// Range Compressor Design—A Tutorial and Analysis. Journal of The Audio
|
||||||
// Engineering Society, 60, 399-408.
|
// Engineering Society, 60, 399-408.
|
||||||
if self.config.normalisation_method == NormalisationMethod::Dynamic
|
|
||||||
{
|
|
||||||
// steps 1 + 2: half-wave rectification and conversion into dB
|
|
||||||
let abs_sample_db = ratio_to_db(sample.abs());
|
|
||||||
|
|
||||||
// Some tracks have samples that are precisely 0.0, but ratio_to_db(0.0)
|
// Some tracks have samples that are precisely 0.0. That's silence
|
||||||
// returns -inf and gets the peak detector stuck.
|
// and we know we don't need to limit that, in which we can spare
|
||||||
if !abs_sample_db.is_normal() {
|
// the CPU cycles.
|
||||||
continue;
|
//
|
||||||
}
|
// 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;
|
||||||
|
|
||||||
// step 3: gain computer with soft knee
|
if knee_boundary_db < -knee_db {
|
||||||
let biased_sample = abs_sample_db - threshold_db;
|
0.0
|
||||||
let limited_sample = if 2.0 * biased_sample < -knee_db {
|
} else if knee_boundary_db.abs() <= knee_db {
|
||||||
abs_sample_db
|
// The textbook equation:
|
||||||
} else if 2.0 * biased_sample.abs() <= knee_db {
|
// ratio_to_db(sample.abs()) - (ratio_to_db(sample.abs()) - (bias_db + knee_db / 2.0).powi(2) / (2.0 * knee_db))
|
||||||
abs_sample_db
|
// Simplifies to:
|
||||||
- (biased_sample + knee_db / 2.0).powi(2)
|
// ((2.0 * bias_db) + knee_db).powi(2) / (8.0 * knee_db)
|
||||||
/ (2.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 {
|
} else {
|
||||||
threshold_db as f64
|
// Textbook:
|
||||||
|
// ratio_to_db(sample.abs()) - threshold_db, which is already our bias_db.
|
||||||
|
bias_db
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
};
|
};
|
||||||
|
|
||||||
// step 4: subtractor
|
// Spare the CPU unless (1) the limiter is engaged, (2) we
|
||||||
let limiter_input = abs_sample_db - limited_sample;
|
// were in attack or (3) we were in release, and that attack/
|
||||||
|
// release wasn't finished yet.
|
||||||
// Spare the CPU unless the limiter is active or we are riding a peak.
|
if limiter_db > 0.0
|
||||||
if !(limiter_input > 0.0
|
|
||||||
|| self.normalisation_integrator > 0.0
|
|| self.normalisation_integrator > 0.0
|
||||||
|| self.normalisation_peak > 0.0)
|
|| self.normalisation_peak > 0.0
|
||||||
{
|
{
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// step 5: smooth, decoupled peak detector
|
// 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(
|
self.normalisation_integrator = f64::max(
|
||||||
limiter_input,
|
limiter_db,
|
||||||
release_cf * self.normalisation_integrator
|
release_cf * self.normalisation_integrator
|
||||||
+ (1.0 - release_cf) * limiter_input,
|
- release_cf * limiter_db
|
||||||
|
+ limiter_db,
|
||||||
);
|
);
|
||||||
self.normalisation_peak = attack_cf * self.normalisation_peak
|
// Textbook:
|
||||||
+ (1.0 - attack_cf) * self.normalisation_integrator;
|
// 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)
|
// step 6: make-up gain applied later (volume attenuation)
|
||||||
// Applying the standard normalisation factor here won't work,
|
// Applying the standard normalisation factor here won't work,
|
||||||
|
@ -1486,6 +1542,7 @@ impl PlayerInternal {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Apply volume attenuation last. TODO: make this so we can chain
|
// Apply volume attenuation last. TODO: make this so we can chain
|
||||||
// the normaliser and mixer as a processing pipeline.
|
// the normaliser and mixer as a processing pipeline.
|
||||||
|
@ -1981,15 +2038,8 @@ impl PlayerInternal {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_event(&mut self, event: PlayerEvent) {
|
fn send_event(&mut self, event: PlayerEvent) {
|
||||||
let mut index = 0;
|
self.event_senders
|
||||||
while index < self.event_senders.len() {
|
.retain(|sender| sender.send(event.clone()).is_ok());
|
||||||
match self.event_senders[index].send(event.clone()) {
|
|
||||||
Ok(_) => index += 1,
|
|
||||||
Err(_) => {
|
|
||||||
self.event_senders.remove(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_track(
|
fn load_track(
|
||||||
|
|
23
src/main.rs
23
src/main.rs
|
@ -192,7 +192,7 @@ fn get_setup() -> Setup {
|
||||||
const VALID_INITIAL_VOLUME_RANGE: RangeInclusive<u16> = 0..=100;
|
const VALID_INITIAL_VOLUME_RANGE: RangeInclusive<u16> = 0..=100;
|
||||||
const VALID_VOLUME_RANGE: RangeInclusive<f64> = 0.0..=100.0;
|
const VALID_VOLUME_RANGE: RangeInclusive<f64> = 0.0..=100.0;
|
||||||
const VALID_NORMALISATION_KNEE_RANGE: RangeInclusive<f64> = 0.0..=10.0;
|
const VALID_NORMALISATION_KNEE_RANGE: RangeInclusive<f64> = 0.0..=10.0;
|
||||||
const VALID_NORMALISATION_PREGAIN_RANGE: RangeInclusive<f32> = -10.0..=10.0;
|
const VALID_NORMALISATION_PREGAIN_RANGE: RangeInclusive<f64> = -10.0..=10.0;
|
||||||
const VALID_NORMALISATION_THRESHOLD_RANGE: RangeInclusive<f64> = -10.0..=0.0;
|
const VALID_NORMALISATION_THRESHOLD_RANGE: RangeInclusive<f64> = -10.0..=0.0;
|
||||||
const VALID_NORMALISATION_ATTACK_RANGE: RangeInclusive<u64> = 1..=500;
|
const VALID_NORMALISATION_ATTACK_RANGE: RangeInclusive<u64> = 1..=500;
|
||||||
const VALID_NORMALISATION_RELEASE_RANGE: RangeInclusive<u64> = 1..=1000;
|
const VALID_NORMALISATION_RELEASE_RANGE: RangeInclusive<u64> = 1..=1000;
|
||||||
|
@ -671,6 +671,7 @@ fn get_setup() -> Setup {
|
||||||
let opt = key.trim_start_matches('-');
|
let opt = key.trim_start_matches('-');
|
||||||
|
|
||||||
if index > 0
|
if index > 0
|
||||||
|
&& key.starts_with('-')
|
||||||
&& &args[index - 1] != key
|
&& &args[index - 1] != key
|
||||||
&& matches.opt_defined(opt)
|
&& matches.opt_defined(opt)
|
||||||
&& matches.opt_present(opt)
|
&& matches.opt_present(opt)
|
||||||
|
@ -1306,12 +1307,7 @@ fn get_setup() -> Setup {
|
||||||
normalisation_method = opt_str(NORMALISATION_METHOD)
|
normalisation_method = opt_str(NORMALISATION_METHOD)
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|method| {
|
.map(|method| {
|
||||||
warn!(
|
NormalisationMethod::from_str(method).unwrap_or_else(|_| {
|
||||||
"`--{}` / `-{}` will be deprecated in a future release.",
|
|
||||||
NORMALISATION_METHOD, NORMALISATION_METHOD_SHORT
|
|
||||||
);
|
|
||||||
|
|
||||||
let method = NormalisationMethod::from_str(method).unwrap_or_else(|_| {
|
|
||||||
invalid_error_msg(
|
invalid_error_msg(
|
||||||
NORMALISATION_METHOD,
|
NORMALISATION_METHOD,
|
||||||
NORMALISATION_METHOD_SHORT,
|
NORMALISATION_METHOD_SHORT,
|
||||||
|
@ -1321,16 +1317,7 @@ fn get_setup() -> Setup {
|
||||||
);
|
);
|
||||||
|
|
||||||
exit(1);
|
exit(1);
|
||||||
});
|
})
|
||||||
|
|
||||||
if matches!(method, NormalisationMethod::Basic) {
|
|
||||||
warn!(
|
|
||||||
"`--{}` / `-{}` {:?} will be deprecated in a future release.",
|
|
||||||
NORMALISATION_METHOD, NORMALISATION_METHOD_SHORT, method
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
method
|
|
||||||
})
|
})
|
||||||
.unwrap_or(player_default_config.normalisation_method);
|
.unwrap_or(player_default_config.normalisation_method);
|
||||||
|
|
||||||
|
@ -1352,7 +1339,7 @@ fn get_setup() -> Setup {
|
||||||
.unwrap_or(player_default_config.normalisation_type);
|
.unwrap_or(player_default_config.normalisation_type);
|
||||||
|
|
||||||
normalisation_pregain_db = opt_str(NORMALISATION_PREGAIN)
|
normalisation_pregain_db = opt_str(NORMALISATION_PREGAIN)
|
||||||
.map(|pregain| match pregain.parse::<f32>() {
|
.map(|pregain| match pregain.parse::<f64>() {
|
||||||
Ok(value) if (VALID_NORMALISATION_PREGAIN_RANGE).contains(&value) => value,
|
Ok(value) if (VALID_NORMALISATION_PREGAIN_RANGE).contains(&value) => value,
|
||||||
_ => {
|
_ => {
|
||||||
let valid_values = &format!(
|
let valid_values = &format!(
|
||||||
|
|
|
@ -1,59 +1,116 @@
|
||||||
|
use log::info;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
io,
|
io::{Error, ErrorKind, Result},
|
||||||
process::{Command, ExitStatus},
|
process::{Command, ExitStatus},
|
||||||
};
|
};
|
||||||
|
|
||||||
use log::info;
|
|
||||||
use tokio::process::{Child as AsyncChild, Command as AsyncCommand};
|
use tokio::process::{Child as AsyncChild, Command as AsyncCommand};
|
||||||
|
|
||||||
use librespot::playback::player::{PlayerEvent, SinkStatus};
|
use librespot::playback::player::{PlayerEvent, SinkStatus};
|
||||||
|
|
||||||
pub fn run_program_on_events(event: PlayerEvent, onevent: &str) -> Option<io::Result<AsyncChild>> {
|
pub fn run_program_on_events(event: PlayerEvent, onevent: &str) -> Option<Result<AsyncChild>> {
|
||||||
let mut env_vars = HashMap::new();
|
let mut env_vars = HashMap::new();
|
||||||
match event {
|
match event {
|
||||||
PlayerEvent::Changed {
|
PlayerEvent::Changed {
|
||||||
old_track_id,
|
old_track_id,
|
||||||
new_track_id,
|
new_track_id,
|
||||||
} => {
|
} => match old_track_id.to_base62() {
|
||||||
|
Err(_) => {
|
||||||
|
return Some(Err(Error::new(
|
||||||
|
ErrorKind::InvalidData,
|
||||||
|
"PlayerEvent::Changed: Invalid old track id",
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
Ok(old_id) => match new_track_id.to_base62() {
|
||||||
|
Err(_) => {
|
||||||
|
return Some(Err(Error::new(
|
||||||
|
ErrorKind::InvalidData,
|
||||||
|
"PlayerEvent::Changed: Invalid new track id",
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
Ok(new_id) => {
|
||||||
env_vars.insert("PLAYER_EVENT", "changed".to_string());
|
env_vars.insert("PLAYER_EVENT", "changed".to_string());
|
||||||
env_vars.insert("OLD_TRACK_ID", old_track_id.to_base62());
|
env_vars.insert("OLD_TRACK_ID", old_id);
|
||||||
env_vars.insert("TRACK_ID", new_track_id.to_base62());
|
env_vars.insert("TRACK_ID", new_id);
|
||||||
}
|
}
|
||||||
PlayerEvent::Started { track_id, .. } => {
|
},
|
||||||
|
},
|
||||||
|
PlayerEvent::Started { track_id, .. } => match track_id.to_base62() {
|
||||||
|
Err(_) => {
|
||||||
|
return Some(Err(Error::new(
|
||||||
|
ErrorKind::InvalidData,
|
||||||
|
"PlayerEvent::Started: Invalid track id",
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
Ok(id) => {
|
||||||
env_vars.insert("PLAYER_EVENT", "started".to_string());
|
env_vars.insert("PLAYER_EVENT", "started".to_string());
|
||||||
env_vars.insert("TRACK_ID", track_id.to_base62());
|
env_vars.insert("TRACK_ID", id);
|
||||||
}
|
}
|
||||||
PlayerEvent::Stopped { track_id, .. } => {
|
},
|
||||||
|
PlayerEvent::Stopped { track_id, .. } => match track_id.to_base62() {
|
||||||
|
Err(_) => {
|
||||||
|
return Some(Err(Error::new(
|
||||||
|
ErrorKind::InvalidData,
|
||||||
|
"PlayerEvent::Stopped: Invalid track id",
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
Ok(id) => {
|
||||||
env_vars.insert("PLAYER_EVENT", "stopped".to_string());
|
env_vars.insert("PLAYER_EVENT", "stopped".to_string());
|
||||||
env_vars.insert("TRACK_ID", track_id.to_base62());
|
env_vars.insert("TRACK_ID", id);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
PlayerEvent::Playing {
|
PlayerEvent::Playing {
|
||||||
track_id,
|
track_id,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
position_ms,
|
position_ms,
|
||||||
..
|
..
|
||||||
} => {
|
} => match track_id.to_base62() {
|
||||||
|
Err(_) => {
|
||||||
|
return Some(Err(Error::new(
|
||||||
|
ErrorKind::InvalidData,
|
||||||
|
"PlayerEvent::Playing: Invalid track id",
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
Ok(id) => {
|
||||||
env_vars.insert("PLAYER_EVENT", "playing".to_string());
|
env_vars.insert("PLAYER_EVENT", "playing".to_string());
|
||||||
env_vars.insert("TRACK_ID", track_id.to_base62());
|
env_vars.insert("TRACK_ID", id);
|
||||||
env_vars.insert("DURATION_MS", duration_ms.to_string());
|
env_vars.insert("DURATION_MS", duration_ms.to_string());
|
||||||
env_vars.insert("POSITION_MS", position_ms.to_string());
|
env_vars.insert("POSITION_MS", position_ms.to_string());
|
||||||
}
|
}
|
||||||
|
},
|
||||||
PlayerEvent::Paused {
|
PlayerEvent::Paused {
|
||||||
track_id,
|
track_id,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
position_ms,
|
position_ms,
|
||||||
..
|
..
|
||||||
} => {
|
} => match track_id.to_base62() {
|
||||||
|
Err(_) => {
|
||||||
|
return Some(Err(Error::new(
|
||||||
|
ErrorKind::InvalidData,
|
||||||
|
"PlayerEvent::Paused: Invalid track id",
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
Ok(id) => {
|
||||||
env_vars.insert("PLAYER_EVENT", "paused".to_string());
|
env_vars.insert("PLAYER_EVENT", "paused".to_string());
|
||||||
env_vars.insert("TRACK_ID", track_id.to_base62());
|
env_vars.insert("TRACK_ID", id);
|
||||||
env_vars.insert("DURATION_MS", duration_ms.to_string());
|
env_vars.insert("DURATION_MS", duration_ms.to_string());
|
||||||
env_vars.insert("POSITION_MS", position_ms.to_string());
|
env_vars.insert("POSITION_MS", position_ms.to_string());
|
||||||
}
|
}
|
||||||
PlayerEvent::Preloading { track_id, .. } => {
|
},
|
||||||
env_vars.insert("PLAYER_EVENT", "preloading".to_string());
|
PlayerEvent::Preloading { track_id, .. } => match track_id.to_base62() {
|
||||||
env_vars.insert("TRACK_ID", track_id.to_base62());
|
Err(_) => {
|
||||||
|
return Some(Err(Error::new(
|
||||||
|
ErrorKind::InvalidData,
|
||||||
|
"PlayerEvent::Preloading: Invalid track id",
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
|
Ok(id) => {
|
||||||
|
env_vars.insert("PLAYER_EVENT", "preloading".to_string());
|
||||||
|
env_vars.insert("TRACK_ID", id);
|
||||||
|
}
|
||||||
|
},
|
||||||
PlayerEvent::VolumeSet { volume } => {
|
PlayerEvent::VolumeSet { volume } => {
|
||||||
env_vars.insert("PLAYER_EVENT", "volume_set".to_string());
|
env_vars.insert("PLAYER_EVENT", "volume_set".to_string());
|
||||||
env_vars.insert("VOLUME", volume.to_string());
|
env_vars.insert("VOLUME", volume.to_string());
|
||||||
|
@ -71,7 +128,7 @@ pub fn run_program_on_events(event: PlayerEvent, onevent: &str) -> Option<io::Re
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn emit_sink_event(sink_status: SinkStatus, onevent: &str) -> io::Result<ExitStatus> {
|
pub fn emit_sink_event(sink_status: SinkStatus, onevent: &str) -> Result<ExitStatus> {
|
||||||
let mut env_vars = HashMap::new();
|
let mut env_vars = HashMap::new();
|
||||||
env_vars.insert("PLAYER_EVENT", "sink".to_string());
|
env_vars.insert("PLAYER_EVENT", "sink".to_string());
|
||||||
let sink_status = match sink_status {
|
let sink_status = match sink_status {
|
||||||
|
|
Loading…
Reference in a new issue