mirror of
https://github.com/librespot-org/librespot.git
synced 2024-12-18 17:11:53 +00:00
Improve volume controls
This is a squashed commit featuring the following: Connect: - Synchronize player volume with mixer volume on playback - Fix step size on volume up/down events - Remove no-op mixer started/stopped logic Playback: - Move from `connect` to `playback` crate - Make cubic volume control available to all mixers with `--volume-ctrl cubic` - Normalize volumes to `[0.0..1.0]` instead of `[0..65535]` for greater precision and performance (breaking) - Add `--volume-range` option to set dB range and control `log` and `cubic` volume control curves - Fix `log` and `cubic` volume controls to be mute at zero volume Alsa mixer: - Complete rewrite (breaking) - Query card dB range for the `log` volume control unless specified otherwise - Query dB range from Alsa softvol (previously only from hardware) - Use `--device` name for `--mixer-card` unless specified otherwise - Fix consistency for `cubic` between cards that report minimum volume as mute, and cards that report some dB value - Fix `--volume-ctrl {linear|log}` to work as expected - Removed `--mixer-linear-volume` option; use `--volume-ctrl linear` instead
This commit is contained in:
parent
68818758a2
commit
eca505c387
10 changed files with 689 additions and 438 deletions
23
CHANGELOG.md
23
CHANGELOG.md
|
@ -6,10 +6,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) since v0.2.0.
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) since v0.2.0.
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
### Added
|
||||||
|
- [playback] Add `--volume-range` option to set dB range and control `log` and `cubic` volume control curves
|
||||||
|
- [playback] `alsamixer`: support for querying dB range from Alsa softvol
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* [audio, playback] Moved `VorbisDecoder`, `VorbisError`, `AudioPacket`, `PassthroughDecoder`, `PassthroughError`, `AudioError`, `AudioDecoder` and the `convert` module from `librespot-audio` to `librespot-playback`. The underlying crates `vorbis`, `librespot-tremor`, `lewton` and `ogg` should be used directly. (breaking)
|
||||||
|
- [connect, playback] Moved volume controls from `connect` to `playback` crate
|
||||||
|
* [connect] Synchronize player volume with mixer volume on playback
|
||||||
|
- [playback] Make cubic volume control available to all mixers with `--volume-ctrl cubic`
|
||||||
|
- [playback] Normalize volumes to `[0.0..1.0]` instead of `[0..65535]` for greater precision and performance (breaking)
|
||||||
|
- [playback] `alsamixer`: complete rewrite (breaking)
|
||||||
|
- [playback] `alsamixer`: query card dB range for the `log` volume control unless specified otherwise
|
||||||
|
- [playback] `alsamixer`: use `--device` name for `--mixer-card` unless specified otherwise
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- [connect] Fix step size on volume up/down events
|
||||||
|
- [playback] Fix `log` and `cubic` volume controls to be mute at zero volume
|
||||||
|
- [playback] `alsamixer`: make `cubic` consistent between cards that report minimum volume as mute, and cards that report some dB value
|
||||||
|
- [playback] `alsamixer`: make `--volume-ctrl {linear|log}` work as expected
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
- [connect] Removed no-op mixer started/stopped logic
|
||||||
* [librespot-audio] Removed `VorbisDecoder`, `VorbisError`, `AudioPacket`, `PassthroughDecoder`, `PassthroughError`, `AudioError`, `AudioDecoder` and the `convert` module from `librespot_audio`. The underlying crates `vorbis`, `librespot-tremor`, `lewton` and `ogg` should be used directly.
|
- [playback] `alsamixer`: removed `--mixer-linear-volume` option; use `--volume-ctrl linear` instead
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ use std::pin::Pin;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use crate::context::StationContext;
|
use crate::context::StationContext;
|
||||||
use crate::core::config::{ConnectConfig, VolumeCtrl};
|
use crate::core::config::ConnectConfig;
|
||||||
use crate::core::mercury::{MercuryError, MercurySender};
|
use crate::core::mercury::{MercuryError, MercurySender};
|
||||||
use crate::core::session::Session;
|
use crate::core::session::Session;
|
||||||
use crate::core::spotify_id::{SpotifyAudioType, SpotifyId, SpotifyIdError};
|
use crate::core::spotify_id::{SpotifyAudioType, SpotifyId, SpotifyIdError};
|
||||||
|
@ -54,7 +54,6 @@ struct SpircTask {
|
||||||
device: DeviceState,
|
device: DeviceState,
|
||||||
state: State,
|
state: State,
|
||||||
play_request_id: Option<u64>,
|
play_request_id: Option<u64>,
|
||||||
mixer_started: bool,
|
|
||||||
play_status: SpircPlayStatus,
|
play_status: SpircPlayStatus,
|
||||||
|
|
||||||
subscription: BoxedStream<Frame>,
|
subscription: BoxedStream<Frame>,
|
||||||
|
@ -82,13 +81,15 @@ pub enum SpircCommand {
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SpircTaskConfig {
|
struct SpircTaskConfig {
|
||||||
volume_ctrl: VolumeCtrl,
|
|
||||||
autoplay: bool,
|
autoplay: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONTEXT_TRACKS_HISTORY: usize = 10;
|
const CONTEXT_TRACKS_HISTORY: usize = 10;
|
||||||
const CONTEXT_FETCH_THRESHOLD: u32 = 5;
|
const CONTEXT_FETCH_THRESHOLD: u32 = 5;
|
||||||
|
|
||||||
|
const VOLUME_STEPS: i64 = 64;
|
||||||
|
const VOLUME_STEP_SIZE: u16 = 1024; // (std::u16::MAX + 1) / VOLUME_STEPS
|
||||||
|
|
||||||
pub struct Spirc {
|
pub struct Spirc {
|
||||||
commands: mpsc::UnboundedSender<SpircCommand>,
|
commands: mpsc::UnboundedSender<SpircCommand>,
|
||||||
}
|
}
|
||||||
|
@ -163,10 +164,10 @@ fn initial_device_state(config: ConnectConfig) -> DeviceState {
|
||||||
msg.set_typ(protocol::spirc::CapabilityType::kVolumeSteps);
|
msg.set_typ(protocol::spirc::CapabilityType::kVolumeSteps);
|
||||||
{
|
{
|
||||||
let repeated = msg.mut_intValue();
|
let repeated = msg.mut_intValue();
|
||||||
if let VolumeCtrl::Fixed = config.volume_ctrl {
|
if config.has_volume_ctrl {
|
||||||
repeated.push(0)
|
repeated.push(VOLUME_STEPS)
|
||||||
} else {
|
} else {
|
||||||
repeated.push(64)
|
repeated.push(0)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
msg
|
msg
|
||||||
|
@ -214,36 +215,6 @@ fn initial_device_state(config: ConnectConfig) -> DeviceState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn calc_logarithmic_volume(volume: u16) -> u16 {
|
|
||||||
// Volume conversion taken from https://www.dr-lex.be/info-stuff/volumecontrols.html#ideal2
|
|
||||||
// Convert the given volume [0..0xffff] to a dB gain
|
|
||||||
// We assume a dB range of 60dB.
|
|
||||||
// Use the equation: a * exp(b * x)
|
|
||||||
// in which a = IDEAL_FACTOR, b = 1/1000
|
|
||||||
const IDEAL_FACTOR: f64 = 6.908;
|
|
||||||
let normalized_volume = volume as f64 / std::u16::MAX as f64; // To get a value between 0 and 1
|
|
||||||
|
|
||||||
let mut val = std::u16::MAX;
|
|
||||||
// Prevent val > std::u16::MAX due to rounding errors
|
|
||||||
if normalized_volume < 0.999 {
|
|
||||||
let new_volume = (normalized_volume * IDEAL_FACTOR).exp() / 1000.0;
|
|
||||||
val = (new_volume * std::u16::MAX as f64) as u16;
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!("input volume:{} to mixer: {}", volume, val);
|
|
||||||
|
|
||||||
// return the scale factor (0..0xffff) (equivalent to a voltage multiplier).
|
|
||||||
val
|
|
||||||
}
|
|
||||||
|
|
||||||
fn volume_to_mixer(volume: u16, volume_ctrl: &VolumeCtrl) -> u16 {
|
|
||||||
match volume_ctrl {
|
|
||||||
VolumeCtrl::Linear => volume,
|
|
||||||
VolumeCtrl::Log => calc_logarithmic_volume(volume),
|
|
||||||
VolumeCtrl::Fixed => volume,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn url_encode(bytes: impl AsRef<[u8]>) -> String {
|
fn url_encode(bytes: impl AsRef<[u8]>) -> String {
|
||||||
form_urlencoded::byte_serialize(bytes.as_ref()).collect()
|
form_urlencoded::byte_serialize(bytes.as_ref()).collect()
|
||||||
}
|
}
|
||||||
|
@ -280,9 +251,8 @@ impl Spirc {
|
||||||
|
|
||||||
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
|
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
let volume = config.volume;
|
let initial_volume = config.initial_volume;
|
||||||
let task_config = SpircTaskConfig {
|
let task_config = SpircTaskConfig {
|
||||||
volume_ctrl: config.volume_ctrl.to_owned(),
|
|
||||||
autoplay: config.autoplay,
|
autoplay: config.autoplay,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -302,7 +272,6 @@ impl Spirc {
|
||||||
device,
|
device,
|
||||||
state: initial_state(),
|
state: initial_state(),
|
||||||
play_request_id: None,
|
play_request_id: None,
|
||||||
mixer_started: false,
|
|
||||||
play_status: SpircPlayStatus::Stopped,
|
play_status: SpircPlayStatus::Stopped,
|
||||||
|
|
||||||
subscription,
|
subscription,
|
||||||
|
@ -318,7 +287,12 @@ impl Spirc {
|
||||||
context: None,
|
context: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
task.set_volume(volume);
|
if let Some(volume) = initial_volume {
|
||||||
|
task.set_volume(volume);
|
||||||
|
} else {
|
||||||
|
let current_volume = task.mixer.volume();
|
||||||
|
task.set_volume(current_volume);
|
||||||
|
}
|
||||||
|
|
||||||
let spirc = Spirc { commands: cmd_tx };
|
let spirc = Spirc { commands: cmd_tx };
|
||||||
|
|
||||||
|
@ -437,20 +411,6 @@ impl SpircTask {
|
||||||
dur.as_millis() as i64 + 1000 * self.session.time_delta()
|
dur.as_millis() as i64 + 1000 * self.session.time_delta()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_mixer_started(&mut self) {
|
|
||||||
if !self.mixer_started {
|
|
||||||
self.mixer.start();
|
|
||||||
self.mixer_started = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_mixer_stopped(&mut self) {
|
|
||||||
if self.mixer_started {
|
|
||||||
self.mixer.stop();
|
|
||||||
self.mixer_started = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update_state_position(&mut self, position_ms: u32) {
|
fn update_state_position(&mut self, position_ms: u32) {
|
||||||
let now = self.now_ms();
|
let now = self.now_ms();
|
||||||
self.state.set_position_measured_at(now as u64);
|
self.state.set_position_measured_at(now as u64);
|
||||||
|
@ -600,7 +560,6 @@ impl SpircTask {
|
||||||
_ => {
|
_ => {
|
||||||
warn!("The player has stopped unexpectedly.");
|
warn!("The player has stopped unexpectedly.");
|
||||||
self.state.set_status(PlayStatus::kPlayStatusStop);
|
self.state.set_status(PlayStatus::kPlayStatusStop);
|
||||||
self.ensure_mixer_stopped();
|
|
||||||
self.notify(None, true);
|
self.notify(None, true);
|
||||||
self.play_status = SpircPlayStatus::Stopped;
|
self.play_status = SpircPlayStatus::Stopped;
|
||||||
}
|
}
|
||||||
|
@ -659,7 +618,6 @@ impl SpircTask {
|
||||||
info!("No more tracks left in queue");
|
info!("No more tracks left in queue");
|
||||||
self.state.set_status(PlayStatus::kPlayStatusStop);
|
self.state.set_status(PlayStatus::kPlayStatusStop);
|
||||||
self.player.stop();
|
self.player.stop();
|
||||||
self.mixer.stop();
|
|
||||||
self.play_status = SpircPlayStatus::Stopped;
|
self.play_status = SpircPlayStatus::Stopped;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -767,7 +725,6 @@ impl SpircTask {
|
||||||
self.device.set_is_active(false);
|
self.device.set_is_active(false);
|
||||||
self.state.set_status(PlayStatus::kPlayStatusStop);
|
self.state.set_status(PlayStatus::kPlayStatusStop);
|
||||||
self.player.stop();
|
self.player.stop();
|
||||||
self.ensure_mixer_stopped();
|
|
||||||
self.play_status = SpircPlayStatus::Stopped;
|
self.play_status = SpircPlayStatus::Stopped;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -782,7 +739,11 @@ impl SpircTask {
|
||||||
position_ms,
|
position_ms,
|
||||||
preloading_of_next_track_triggered,
|
preloading_of_next_track_triggered,
|
||||||
} => {
|
} => {
|
||||||
self.ensure_mixer_started();
|
// Synchronize the volume from the mixer. This is useful on
|
||||||
|
// systems that can switch sources from and back to librespot.
|
||||||
|
let current_volume = self.mixer.volume();
|
||||||
|
self.set_volume(current_volume);
|
||||||
|
|
||||||
self.player.play();
|
self.player.play();
|
||||||
self.state.set_status(PlayStatus::kPlayStatusPlay);
|
self.state.set_status(PlayStatus::kPlayStatusPlay);
|
||||||
self.update_state_position(position_ms);
|
self.update_state_position(position_ms);
|
||||||
|
@ -792,7 +753,6 @@ impl SpircTask {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
SpircPlayStatus::LoadingPause { position_ms } => {
|
SpircPlayStatus::LoadingPause { position_ms } => {
|
||||||
self.ensure_mixer_started();
|
|
||||||
self.player.play();
|
self.player.play();
|
||||||
self.play_status = SpircPlayStatus::LoadingPlay { position_ms };
|
self.play_status = SpircPlayStatus::LoadingPlay { position_ms };
|
||||||
}
|
}
|
||||||
|
@ -962,7 +922,6 @@ impl SpircTask {
|
||||||
self.state.set_playing_track_index(0);
|
self.state.set_playing_track_index(0);
|
||||||
self.state.set_status(PlayStatus::kPlayStatusStop);
|
self.state.set_status(PlayStatus::kPlayStatusStop);
|
||||||
self.player.stop();
|
self.player.stop();
|
||||||
self.ensure_mixer_stopped();
|
|
||||||
self.play_status = SpircPlayStatus::Stopped;
|
self.play_status = SpircPlayStatus::Stopped;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1007,19 +966,13 @@ impl SpircTask {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_volume_up(&mut self) {
|
fn handle_volume_up(&mut self) {
|
||||||
let mut volume: u32 = self.device.get_volume() as u32 + 4096;
|
let volume = (self.device.get_volume() as u16).saturating_add(VOLUME_STEP_SIZE);
|
||||||
if volume > 0xFFFF {
|
self.set_volume(volume);
|
||||||
volume = 0xFFFF;
|
|
||||||
}
|
|
||||||
self.set_volume(volume as u16);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_volume_down(&mut self) {
|
fn handle_volume_down(&mut self) {
|
||||||
let mut volume: i32 = self.device.get_volume() as i32 - 4096;
|
let volume = (self.device.get_volume() as u16).saturating_sub(VOLUME_STEP_SIZE);
|
||||||
if volume < 0 {
|
self.set_volume(volume);
|
||||||
volume = 0;
|
|
||||||
}
|
|
||||||
self.set_volume(volume as u16);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_end_of_track(&mut self) {
|
fn handle_end_of_track(&mut self) {
|
||||||
|
@ -1243,7 +1196,6 @@ impl SpircTask {
|
||||||
None => {
|
None => {
|
||||||
self.state.set_status(PlayStatus::kPlayStatusStop);
|
self.state.set_status(PlayStatus::kPlayStatusStop);
|
||||||
self.player.stop();
|
self.player.stop();
|
||||||
self.ensure_mixer_stopped();
|
|
||||||
self.play_status = SpircPlayStatus::Stopped;
|
self.play_status = SpircPlayStatus::Stopped;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1273,8 +1225,7 @@ impl SpircTask {
|
||||||
|
|
||||||
fn set_volume(&mut self, volume: u16) {
|
fn set_volume(&mut self, volume: u16) {
|
||||||
self.device.set_volume(volume as u32);
|
self.device.set_volume(volume as u32);
|
||||||
self.mixer
|
self.mixer.set_volume(volume);
|
||||||
.set_volume(volume_to_mixer(volume, &self.config.volume_ctrl));
|
|
||||||
if let Some(cache) = self.session.cache() {
|
if let Some(cache) = self.session.cache() {
|
||||||
cache.save_volume(volume)
|
cache.save_volume(volume)
|
||||||
}
|
}
|
||||||
|
|
|
@ -108,33 +108,7 @@ impl Default for DeviceType {
|
||||||
pub struct ConnectConfig {
|
pub struct ConnectConfig {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub device_type: DeviceType,
|
pub device_type: DeviceType,
|
||||||
pub volume: u16,
|
pub initial_volume: Option<u16>,
|
||||||
pub volume_ctrl: VolumeCtrl,
|
pub has_volume_ctrl: bool,
|
||||||
pub autoplay: bool,
|
pub autoplay: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub enum VolumeCtrl {
|
|
||||||
Linear,
|
|
||||||
Log,
|
|
||||||
Fixed,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromStr for VolumeCtrl {
|
|
||||||
type Err = ();
|
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
||||||
use self::VolumeCtrl::*;
|
|
||||||
match s.to_lowercase().as_ref() {
|
|
||||||
"linear" => Ok(Linear),
|
|
||||||
"log" => Ok(Log),
|
|
||||||
"fixed" => Ok(Fixed),
|
|
||||||
_ => Err(()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for VolumeCtrl {
|
|
||||||
fn default() -> VolumeCtrl {
|
|
||||||
VolumeCtrl::Log
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
use super::player::NormalisationData;
|
use super::player::db_to_ratio;
|
||||||
use crate::convert::i24;
|
use crate::convert::i24;
|
||||||
|
|
||||||
use std::convert::TryFrom;
|
use std::convert::TryFrom;
|
||||||
|
@ -80,7 +80,7 @@ pub enum NormalisationType {
|
||||||
impl FromStr for NormalisationType {
|
impl FromStr for NormalisationType {
|
||||||
type Err = ();
|
type Err = ();
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
match s {
|
match s.to_lowercase().as_ref() {
|
||||||
"album" => Ok(Self::Album),
|
"album" => Ok(Self::Album),
|
||||||
"track" => Ok(Self::Track),
|
"track" => Ok(Self::Track),
|
||||||
_ => Err(()),
|
_ => Err(()),
|
||||||
|
@ -103,7 +103,7 @@ pub enum NormalisationMethod {
|
||||||
impl FromStr for NormalisationMethod {
|
impl FromStr for NormalisationMethod {
|
||||||
type Err = ();
|
type Err = ();
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
match s {
|
match s.to_lowercase().as_ref() {
|
||||||
"basic" => Ok(Self::Basic),
|
"basic" => Ok(Self::Basic),
|
||||||
"dynamic" => Ok(Self::Dynamic),
|
"dynamic" => Ok(Self::Dynamic),
|
||||||
_ => Err(()),
|
_ => Err(()),
|
||||||
|
@ -120,6 +120,7 @@ impl Default for NormalisationMethod {
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct PlayerConfig {
|
pub struct PlayerConfig {
|
||||||
pub bitrate: Bitrate,
|
pub bitrate: Bitrate,
|
||||||
|
pub gapless: bool,
|
||||||
pub normalisation: bool,
|
pub normalisation: bool,
|
||||||
pub normalisation_type: NormalisationType,
|
pub normalisation_type: NormalisationType,
|
||||||
pub normalisation_method: NormalisationMethod,
|
pub normalisation_method: NormalisationMethod,
|
||||||
|
@ -128,7 +129,6 @@ pub struct PlayerConfig {
|
||||||
pub normalisation_attack: f32,
|
pub normalisation_attack: f32,
|
||||||
pub normalisation_release: f32,
|
pub normalisation_release: f32,
|
||||||
pub normalisation_knee: f32,
|
pub normalisation_knee: f32,
|
||||||
pub gapless: bool,
|
|
||||||
pub passthrough: bool,
|
pub passthrough: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -136,16 +136,56 @@ impl Default for PlayerConfig {
|
||||||
fn default() -> PlayerConfig {
|
fn default() -> PlayerConfig {
|
||||||
PlayerConfig {
|
PlayerConfig {
|
||||||
bitrate: Bitrate::default(),
|
bitrate: Bitrate::default(),
|
||||||
|
gapless: true,
|
||||||
normalisation: false,
|
normalisation: false,
|
||||||
normalisation_type: NormalisationType::default(),
|
normalisation_type: NormalisationType::default(),
|
||||||
normalisation_method: NormalisationMethod::default(),
|
normalisation_method: NormalisationMethod::default(),
|
||||||
normalisation_pregain: 0.0,
|
normalisation_pregain: 0.0,
|
||||||
normalisation_threshold: NormalisationData::db_to_ratio(-1.0),
|
normalisation_threshold: db_to_ratio(-1.0),
|
||||||
normalisation_attack: 0.005,
|
normalisation_attack: 0.005,
|
||||||
normalisation_release: 0.1,
|
normalisation_release: 0.1,
|
||||||
normalisation_knee: 1.0,
|
normalisation_knee: 1.0,
|
||||||
gapless: true,
|
|
||||||
passthrough: false,
|
passthrough: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fields are intended for volume control range in dB
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub enum VolumeCtrl {
|
||||||
|
Cubic(f32),
|
||||||
|
Fixed,
|
||||||
|
Linear,
|
||||||
|
Log(f32),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for VolumeCtrl {
|
||||||
|
type Err = ();
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
Self::from_str_with_range(s, Self::DEFAULT_DB_RANGE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for VolumeCtrl {
|
||||||
|
fn default() -> VolumeCtrl {
|
||||||
|
VolumeCtrl::Log(Self::DEFAULT_DB_RANGE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VolumeCtrl {
|
||||||
|
pub const MAX_VOLUME: u16 = std::u16::MAX;
|
||||||
|
|
||||||
|
// Taken from: https://www.dr-lex.be/info-stuff/volumecontrols.html
|
||||||
|
pub const DEFAULT_DB_RANGE: f32 = 60.0;
|
||||||
|
|
||||||
|
pub fn from_str_with_range(s: &str, db_range: f32) -> Result<Self, <Self as FromStr>::Err> {
|
||||||
|
use self::VolumeCtrl::*;
|
||||||
|
match s.to_lowercase().as_ref() {
|
||||||
|
"cubic" => Ok(Cubic(db_range)),
|
||||||
|
"fixed" => Ok(Fixed),
|
||||||
|
"linear" => Ok(Linear),
|
||||||
|
"log" => Ok(Log(db_range)),
|
||||||
|
_ => Err(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,218 +1,264 @@
|
||||||
use super::AudioFilter;
|
use crate::player::{db_to_ratio, ratio_to_db};
|
||||||
use super::{Mixer, MixerConfig};
|
|
||||||
use std::error::Error;
|
|
||||||
|
|
||||||
const SND_CTL_TLV_DB_GAIN_MUTE: i64 = -9999999;
|
use super::mappings::{LogMapping, MappedCtrl, VolumeMapping};
|
||||||
|
use super::{Mixer, MixerConfig, VolumeCtrl};
|
||||||
|
|
||||||
#[derive(Clone)]
|
use alsa::ctl::{ElemId, ElemIface};
|
||||||
struct AlsaMixerVolumeParams {
|
use alsa::mixer::{MilliBel, SelemChannelId, SelemId};
|
||||||
min: i64,
|
use alsa::{Ctl, Round};
|
||||||
max: i64,
|
|
||||||
range: f64,
|
use std::ffi::CString;
|
||||||
min_db: alsa::mixer::MilliBel,
|
|
||||||
max_db: alsa::mixer::MilliBel,
|
|
||||||
has_switch: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AlsaMixer {
|
pub struct AlsaMixer {
|
||||||
config: MixerConfig,
|
config: MixerConfig,
|
||||||
params: AlsaMixerVolumeParams,
|
min: i64,
|
||||||
|
max: i64,
|
||||||
|
range: i64,
|
||||||
|
min_db: f32,
|
||||||
|
max_db: f32,
|
||||||
|
db_range: f32,
|
||||||
|
has_switch: bool,
|
||||||
|
is_softvol: bool,
|
||||||
|
use_linear_in_db: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AlsaMixer {
|
// min_db cannot be depended on to be mute. Also note that contrary to
|
||||||
fn pvol<T>(&self, vol: T, min: T, max: T) -> f64
|
// its name copied verbatim from Alsa, this is in millibel scale.
|
||||||
where
|
const SND_CTL_TLV_DB_GAIN_MUTE: MilliBel = MilliBel(-9999999);
|
||||||
T: std::ops::Sub + Copy,
|
const ZERO_DB: MilliBel = MilliBel(0);
|
||||||
f64: std::convert::From<<T as std::ops::Sub>::Output>,
|
|
||||||
{
|
|
||||||
f64::from(vol - min) / f64::from(max - min)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn init_mixer(mut config: MixerConfig) -> Result<AlsaMixer, Box<dyn Error>> {
|
|
||||||
let mixer = alsa::mixer::Mixer::new(&config.card, false)?;
|
|
||||||
let sid = alsa::mixer::SelemId::new(&config.mixer, config.index);
|
|
||||||
|
|
||||||
let selem = mixer.find_selem(&sid).unwrap_or_else(|| {
|
|
||||||
panic!(
|
|
||||||
"Couldn't find simple mixer control for {},{}",
|
|
||||||
&config.mixer, &config.index,
|
|
||||||
)
|
|
||||||
});
|
|
||||||
let (min, max) = selem.get_playback_volume_range();
|
|
||||||
let (min_db, max_db) = selem.get_playback_db_range();
|
|
||||||
let hw_mix = selem
|
|
||||||
.get_playback_vol_db(alsa::mixer::SelemChannelId::mono())
|
|
||||||
.is_ok();
|
|
||||||
let has_switch = selem.has_playback_switch();
|
|
||||||
if min_db != alsa::mixer::MilliBel(SND_CTL_TLV_DB_GAIN_MUTE) {
|
|
||||||
warn!("Alsa min-db is not SND_CTL_TLV_DB_GAIN_MUTE!!");
|
|
||||||
}
|
|
||||||
info!(
|
|
||||||
"Alsa Mixer info min: {} ({:?}[dB]) -- max: {} ({:?}[dB]) HW: {:?}",
|
|
||||||
min, min_db, max, max_db, hw_mix
|
|
||||||
);
|
|
||||||
|
|
||||||
if config.mapped_volume && (max_db - min_db <= alsa::mixer::MilliBel(24)) {
|
|
||||||
warn!(
|
|
||||||
"Switching to linear volume mapping, control range: {:?}",
|
|
||||||
max_db - min_db
|
|
||||||
);
|
|
||||||
config.mapped_volume = false;
|
|
||||||
} else if !config.mapped_volume {
|
|
||||||
info!("Using Alsa linear volume");
|
|
||||||
}
|
|
||||||
|
|
||||||
if min_db != alsa::mixer::MilliBel(SND_CTL_TLV_DB_GAIN_MUTE) {
|
|
||||||
debug!("Alsa min-db is not SND_CTL_TLV_DB_GAIN_MUTE!!");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(AlsaMixer {
|
|
||||||
config,
|
|
||||||
params: AlsaMixerVolumeParams {
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
range: (max - min) as f64,
|
|
||||||
min_db,
|
|
||||||
max_db,
|
|
||||||
has_switch,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_volume(&self, set_volume: Option<u16>) -> Result<u16, Box<dyn Error>> {
|
|
||||||
let mixer = alsa::mixer::Mixer::new(&self.config.card, false)?;
|
|
||||||
let sid = alsa::mixer::SelemId::new(&*self.config.mixer, self.config.index);
|
|
||||||
|
|
||||||
let selem = mixer.find_selem(&sid).unwrap();
|
|
||||||
let cur_vol = selem
|
|
||||||
.get_playback_volume(alsa::mixer::SelemChannelId::mono())
|
|
||||||
.expect("Couldn't get current volume");
|
|
||||||
let cur_vol_db = selem
|
|
||||||
.get_playback_vol_db(alsa::mixer::SelemChannelId::mono())
|
|
||||||
.unwrap_or(alsa::mixer::MilliBel(-SND_CTL_TLV_DB_GAIN_MUTE));
|
|
||||||
|
|
||||||
let mut new_vol: u16 = 0;
|
|
||||||
trace!("Current alsa volume: {}{:?}", cur_vol, cur_vol_db);
|
|
||||||
|
|
||||||
match set_volume {
|
|
||||||
Some(vol) => {
|
|
||||||
if self.params.has_switch {
|
|
||||||
let is_muted = selem
|
|
||||||
.get_playback_switch(alsa::mixer::SelemChannelId::mono())
|
|
||||||
.map(|b| b == 0)
|
|
||||||
.unwrap_or(false);
|
|
||||||
if vol == 0 {
|
|
||||||
debug!("Toggling mute::True");
|
|
||||||
selem.set_playback_switch_all(0).expect("Can't switch mute");
|
|
||||||
|
|
||||||
return Ok(vol);
|
|
||||||
} else if is_muted {
|
|
||||||
debug!("Toggling mute::False");
|
|
||||||
selem.set_playback_switch_all(1).expect("Can't reset mute");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.config.mapped_volume {
|
|
||||||
// Cubic mapping ala alsamixer
|
|
||||||
// https://linux.die.net/man/1/alsamixer
|
|
||||||
// In alsamixer, the volume is mapped to a value that is more natural for a
|
|
||||||
// human ear. The mapping is designed so that the position in the interval is
|
|
||||||
// proportional to the volume as a human ear would perceive it, i.e. the
|
|
||||||
// position is the cubic root of the linear sample multiplication factor. For
|
|
||||||
// controls with a small range (24 dB or less), the mapping is linear in the dB
|
|
||||||
// values so that each step has the same size visually. TODO
|
|
||||||
// TODO: Check if min is not mute!
|
|
||||||
let vol_db = (self.pvol(vol, 0x0000, 0xFFFF).log10() * 6000.0).floor() as i64
|
|
||||||
+ self.params.max_db.0;
|
|
||||||
selem
|
|
||||||
.set_playback_db_all(alsa::mixer::MilliBel(vol_db), alsa::Round::Floor)
|
|
||||||
.expect("Couldn't set alsa dB volume");
|
|
||||||
debug!(
|
|
||||||
"Mapping volume [{:.3}%] {:?} [u16] ->> Alsa [{:.3}%] {:?} [dB] - {} [i64]",
|
|
||||||
self.pvol(vol, 0x0000, 0xFFFF) * 100.0,
|
|
||||||
vol,
|
|
||||||
self.pvol(
|
|
||||||
vol_db as f64,
|
|
||||||
self.params.min as f64,
|
|
||||||
self.params.max as f64
|
|
||||||
) * 100.0,
|
|
||||||
vol_db as f64 / 100.0,
|
|
||||||
vol_db
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Linear mapping
|
|
||||||
let alsa_volume =
|
|
||||||
((vol as f64 / 0xFFFF as f64) * self.params.range) as i64 + self.params.min;
|
|
||||||
selem
|
|
||||||
.set_playback_volume_all(alsa_volume)
|
|
||||||
.expect("Couldn't set alsa raw volume");
|
|
||||||
debug!(
|
|
||||||
"Mapping volume [{:.3}%] {:?} [u16] ->> Alsa [{:.3}%] {:?} [i64]",
|
|
||||||
self.pvol(vol, 0x0000, 0xFFFF) * 100.0,
|
|
||||||
vol,
|
|
||||||
self.pvol(
|
|
||||||
alsa_volume as f64,
|
|
||||||
self.params.min as f64,
|
|
||||||
self.params.max as f64
|
|
||||||
) * 100.0,
|
|
||||||
alsa_volume
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
new_vol = (((cur_vol - self.params.min) as f64 / self.params.range) * 0xFFFF as f64)
|
|
||||||
as u16;
|
|
||||||
debug!(
|
|
||||||
"Mapping volume [{:.3}%] {:?} [u16] <<- Alsa [{:.3}%] {:?} [i64]",
|
|
||||||
self.pvol(new_vol, 0x0000, 0xFFFF),
|
|
||||||
new_vol,
|
|
||||||
self.pvol(
|
|
||||||
cur_vol as f64,
|
|
||||||
self.params.min as f64,
|
|
||||||
self.params.max as f64
|
|
||||||
),
|
|
||||||
cur_vol
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(new_vol)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Mixer for AlsaMixer {
|
impl Mixer for AlsaMixer {
|
||||||
fn open(config: Option<MixerConfig>) -> AlsaMixer {
|
fn open(config: MixerConfig) -> Self {
|
||||||
let config = config.unwrap_or_default();
|
|
||||||
info!(
|
info!(
|
||||||
"Setting up new mixer: card:{} mixer:{} index:{}",
|
"Mixing with alsa and volume control: {:?} for card: {} with mixer control: {},{}",
|
||||||
config.card, config.mixer, config.index
|
config.volume_ctrl, config.card, config.control, config.index,
|
||||||
);
|
);
|
||||||
AlsaMixer::init_mixer(config).expect("Error setting up mixer!")
|
|
||||||
|
let mut config = config; // clone
|
||||||
|
|
||||||
|
let mixer =
|
||||||
|
alsa::mixer::Mixer::new(&config.card, false).expect("Could not open Alsa mixer");
|
||||||
|
let simple_element = mixer
|
||||||
|
.find_selem(&SelemId::new(&config.control, config.index))
|
||||||
|
.expect("Could not find Alsa mixer control");
|
||||||
|
|
||||||
|
// Query capabilities
|
||||||
|
let has_switch = simple_element.has_playback_switch();
|
||||||
|
let is_softvol = simple_element
|
||||||
|
.get_playback_vol_db(SelemChannelId::mono())
|
||||||
|
.is_err();
|
||||||
|
|
||||||
|
// Query raw volume range
|
||||||
|
let (min, max) = simple_element.get_playback_volume_range();
|
||||||
|
let range = i64::abs(max - min);
|
||||||
|
|
||||||
|
// Query dB volume range -- note that Alsa exposes a different
|
||||||
|
// API for hardware and software mixers
|
||||||
|
let (min_millibel, max_millibel) = if is_softvol {
|
||||||
|
let control =
|
||||||
|
Ctl::new(&config.card, false).expect("Could not open Alsa softvol with that card");
|
||||||
|
let mut element_id = ElemId::new(ElemIface::Mixer);
|
||||||
|
element_id.set_name(
|
||||||
|
&CString::new(config.control.as_str())
|
||||||
|
.expect("Could not open Alsa softvol with that name"),
|
||||||
|
);
|
||||||
|
element_id.set_index(config.index);
|
||||||
|
let (min_millibel, mut max_millibel) = control
|
||||||
|
.get_db_range(&element_id)
|
||||||
|
.expect("Could not get Alsa softvol dB range");
|
||||||
|
|
||||||
|
// Alsa can report incorrect maximum volumes due to rounding
|
||||||
|
// errors. e.g. Alsa rounds [-60.0..0.0] in range [0..255] to
|
||||||
|
// step size 0.23. Then multiplying 0.23 by 255 incorrectly
|
||||||
|
// returns a dB range of 58.65 instead of 60 dB, from
|
||||||
|
// [-60.00..-1.35]. This workaround checks the default case
|
||||||
|
// where the maximum dB volume is expected to be 0, and cannot
|
||||||
|
// cover all cases.
|
||||||
|
if max_millibel != ZERO_DB {
|
||||||
|
warn!("Alsa mixer reported maximum dB != 0, which is suspect");
|
||||||
|
let reported_step_size = (max_millibel - min_millibel).0 / range;
|
||||||
|
let assumed_step_size = (ZERO_DB - min_millibel).0 / range;
|
||||||
|
if reported_step_size == assumed_step_size {
|
||||||
|
warn!("Alsa rounding error detected, setting maximum dB to {:.2} instead of {:.2}", ZERO_DB.to_db(), max_millibel.to_db());
|
||||||
|
max_millibel = ZERO_DB;
|
||||||
|
} else {
|
||||||
|
warn!("Please manually set with `--volume-ctrl` if this is incorrect");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(min_millibel, max_millibel)
|
||||||
|
} else {
|
||||||
|
let (mut min_millibel, max_millibel) = simple_element.get_playback_db_range();
|
||||||
|
|
||||||
|
// Some controls report that their minimum volume is mute, instead
|
||||||
|
// of their actual lowest dB setting before that.
|
||||||
|
if min_millibel == SND_CTL_TLV_DB_GAIN_MUTE && min < max {
|
||||||
|
debug!("Alsa mixer reported minimum dB as mute, trying workaround");
|
||||||
|
min_millibel = simple_element
|
||||||
|
.ask_playback_vol_db(min + 1)
|
||||||
|
.expect("Could not convert Alsa raw volume to dB volume");
|
||||||
|
}
|
||||||
|
(min_millibel, max_millibel)
|
||||||
|
};
|
||||||
|
|
||||||
|
let min_db = min_millibel.to_db();
|
||||||
|
let max_db = max_millibel.to_db();
|
||||||
|
let db_range = f32::abs(max_db - min_db);
|
||||||
|
|
||||||
|
// Synchronize the volume control dB range with the mixer control,
|
||||||
|
// unless it was already set with a command line option.
|
||||||
|
if !config.volume_ctrl.range_ok() {
|
||||||
|
config.volume_ctrl.set_db_range(db_range);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For hardware controls with a small range (24 dB or less),
|
||||||
|
// force using the dB API with a linear mapping.
|
||||||
|
let mut use_linear_in_db = false;
|
||||||
|
if !is_softvol && db_range <= 24.0 {
|
||||||
|
use_linear_in_db = true;
|
||||||
|
config.volume_ctrl = VolumeCtrl::Linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("Alsa mixer control is softvol: {}", is_softvol);
|
||||||
|
debug!("Alsa support for playback (mute) switch: {}", has_switch);
|
||||||
|
debug!("Alsa raw volume range: [{}..{}] ({})", min, max, range);
|
||||||
|
debug!(
|
||||||
|
"Alsa dB volume range: [{:.2}..{:.2}] ({:.2})",
|
||||||
|
min_db, max_db, db_range
|
||||||
|
);
|
||||||
|
debug!("Alsa forcing linear dB mapping: {}", use_linear_in_db);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
range,
|
||||||
|
min_db,
|
||||||
|
max_db,
|
||||||
|
db_range,
|
||||||
|
has_switch,
|
||||||
|
is_softvol,
|
||||||
|
use_linear_in_db,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn start(&self) {}
|
|
||||||
|
|
||||||
fn stop(&self) {}
|
|
||||||
|
|
||||||
fn volume(&self) -> u16 {
|
fn volume(&self) -> u16 {
|
||||||
match self.map_volume(None) {
|
let mixer =
|
||||||
Ok(vol) => vol,
|
alsa::mixer::Mixer::new(&self.config.card, false).expect("Could not open Alsa mixer");
|
||||||
Err(e) => {
|
let simple_element = mixer
|
||||||
error!("Error getting volume for <{}>, {:?}", self.config.card, e);
|
.find_selem(&SelemId::new(&self.config.control, self.config.index))
|
||||||
0
|
.expect("Could not find Alsa mixer control");
|
||||||
}
|
|
||||||
|
if self.switched_off() {
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut mapped_volume = if self.is_softvol {
|
||||||
|
let raw_volume = simple_element
|
||||||
|
.get_playback_volume(SelemChannelId::mono())
|
||||||
|
.expect("Could not get raw Alsa volume");
|
||||||
|
|
||||||
|
raw_volume as f32 / self.range as f32 - self.min as f32
|
||||||
|
} else {
|
||||||
|
let db_volume = simple_element
|
||||||
|
.get_playback_vol_db(SelemChannelId::mono())
|
||||||
|
.expect("Could not get Alsa dB volume")
|
||||||
|
.to_db();
|
||||||
|
|
||||||
|
if self.use_linear_in_db {
|
||||||
|
(db_volume - self.min_db) / self.db_range
|
||||||
|
} else if f32::abs(db_volume - SND_CTL_TLV_DB_GAIN_MUTE.to_db()) <= f32::EPSILON {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
db_to_ratio(db_volume - self.max_db)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// see comment in `set_volume` why we are handling an antilog volume
|
||||||
|
if mapped_volume > 0.0 && self.is_some_linear() {
|
||||||
|
mapped_volume = LogMapping::linear_to_mapped(mapped_volume, self.db_range);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.config.volume_ctrl.from_mapped(mapped_volume)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_volume(&self, volume: u16) {
|
fn set_volume(&self, volume: u16) {
|
||||||
match self.map_volume(Some(volume)) {
|
let mixer =
|
||||||
Ok(_) => (),
|
alsa::mixer::Mixer::new(&self.config.card, false).expect("Could not open Alsa mixer");
|
||||||
Err(e) => error!("Error setting volume for <{}>, {:?}", self.config.card, e),
|
let simple_element = mixer
|
||||||
}
|
.find_selem(&SelemId::new(&self.config.control, self.config.index))
|
||||||
}
|
.expect("Could not find Alsa mixer control");
|
||||||
|
|
||||||
fn get_audio_filter(&self) -> Option<Box<dyn AudioFilter + Send>> {
|
if self.has_switch {
|
||||||
None
|
if volume == 0 {
|
||||||
|
debug!("Disabling playback (setting mute) on Alsa");
|
||||||
|
simple_element
|
||||||
|
.set_playback_switch_all(0)
|
||||||
|
.expect("Could not disable playback (set mute) on Alsa");
|
||||||
|
} else if self.switched_off() {
|
||||||
|
debug!("Enabling playback (unsetting mute) on Alsa");
|
||||||
|
simple_element
|
||||||
|
.set_playback_switch_all(1)
|
||||||
|
.expect("Could not enable playback (unset mute) on Alsa");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut mapped_volume = self.config.volume_ctrl.to_mapped(volume);
|
||||||
|
|
||||||
|
// Alsa's linear algorithms map everything onto log. Alsa softvol does
|
||||||
|
// this internally. In the case of `use_linear_in_db` this happens
|
||||||
|
// automatically by virtue of the dB scale. This means that linear
|
||||||
|
// controls become log, log becomes log-on-log, and so on. To make
|
||||||
|
// the controls work as expected, perform an antilog calculation to
|
||||||
|
// counteract what Alsa will be doing to the set volume.
|
||||||
|
if mapped_volume > 0.0 && self.is_some_linear() {
|
||||||
|
mapped_volume = LogMapping::mapped_to_linear(mapped_volume, self.db_range);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.is_softvol {
|
||||||
|
let scaled_volume = (self.min as f32 + mapped_volume * self.range as f32) as i64;
|
||||||
|
debug!("Setting Alsa raw volume to {}", scaled_volume);
|
||||||
|
simple_element
|
||||||
|
.set_playback_volume_all(scaled_volume)
|
||||||
|
.expect("Could not set Alsa raw volume");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let db_volume = if self.use_linear_in_db {
|
||||||
|
self.min_db + mapped_volume * self.db_range
|
||||||
|
} else if volume == 0 {
|
||||||
|
// prevent ratio_to_db(0.0) from returning -inf
|
||||||
|
SND_CTL_TLV_DB_GAIN_MUTE.to_db()
|
||||||
|
} else {
|
||||||
|
ratio_to_db(mapped_volume) + self.max_db
|
||||||
|
};
|
||||||
|
|
||||||
|
debug!("Setting Alsa volume to {:.2} dB", db_volume);
|
||||||
|
simple_element
|
||||||
|
.set_playback_db_all(MilliBel::from_db(db_volume), Round::Floor)
|
||||||
|
.expect("Could not set Alsa dB volume");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AlsaMixer {
|
||||||
|
fn switched_off(&self) -> bool {
|
||||||
|
if !self.has_switch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mixer =
|
||||||
|
alsa::mixer::Mixer::new(&self.config.card, false).expect("Could not open Alsa mixer");
|
||||||
|
let simple_element = mixer
|
||||||
|
.find_selem(&SelemId::new(&self.config.control, self.config.index))
|
||||||
|
.expect("Could not find Alsa mixer control");
|
||||||
|
|
||||||
|
simple_element
|
||||||
|
.get_playback_switch(SelemChannelId::mono())
|
||||||
|
.map(|playback| playback == 0)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_some_linear(&self) -> bool {
|
||||||
|
self.is_softvol || self.use_linear_in_db
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
163
playback/src/mixer/mappings.rs
Normal file
163
playback/src/mixer/mappings.rs
Normal file
|
@ -0,0 +1,163 @@
|
||||||
|
use super::VolumeCtrl;
|
||||||
|
use crate::player::db_to_ratio;
|
||||||
|
|
||||||
|
pub trait MappedCtrl {
|
||||||
|
fn to_mapped(&self, volume: u16) -> f32;
|
||||||
|
fn from_mapped(&self, mapped_volume: f32) -> u16;
|
||||||
|
|
||||||
|
fn db_range(&self) -> f32;
|
||||||
|
fn set_db_range(&mut self, new_db_range: f32);
|
||||||
|
fn range_ok(&self) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MappedCtrl for VolumeCtrl {
|
||||||
|
fn to_mapped(&self, volume: u16) -> f32 {
|
||||||
|
// More than just an optimization, this ensures that zero volume is
|
||||||
|
// really mute (both the log and cubic equations would otherwise not
|
||||||
|
// reach zero).
|
||||||
|
if volume == 0 {
|
||||||
|
return 0.0;
|
||||||
|
} else if volume == 1 {
|
||||||
|
// And limit in case of rounding errors (as is the case for log).
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized_volume = volume as f32 / Self::MAX_VOLUME as f32;
|
||||||
|
let mapped_volume = if self.range_ok() {
|
||||||
|
match *self {
|
||||||
|
Self::Cubic(db_range) => {
|
||||||
|
CubicMapping::linear_to_mapped(normalized_volume, db_range)
|
||||||
|
}
|
||||||
|
Self::Log(db_range) => LogMapping::linear_to_mapped(normalized_volume, db_range),
|
||||||
|
_ => normalized_volume,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Ensure not to return -inf or NaN due to division by zero.
|
||||||
|
error!(
|
||||||
|
"{:?} does not work with 0 dB range, using linear mapping instead",
|
||||||
|
self
|
||||||
|
);
|
||||||
|
normalized_volume
|
||||||
|
};
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"Input volume {} mapped to: {:.2}%",
|
||||||
|
volume,
|
||||||
|
mapped_volume * 100.0
|
||||||
|
);
|
||||||
|
|
||||||
|
mapped_volume
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_mapped(&self, mapped_volume: f32) -> u16 {
|
||||||
|
// More than just an optimization, this ensures that zero mapped volume
|
||||||
|
// is unmapped to non-negative real numbers (otherwise the log and cubic
|
||||||
|
// equations would respectively return -inf and -1/9.)
|
||||||
|
if f32::abs(mapped_volume - 0.0) <= f32::EPSILON {
|
||||||
|
return 0;
|
||||||
|
} else if f32::abs(mapped_volume - 1.0) <= f32::EPSILON {
|
||||||
|
return Self::MAX_VOLUME;
|
||||||
|
}
|
||||||
|
|
||||||
|
let unmapped_volume = if self.range_ok() {
|
||||||
|
match *self {
|
||||||
|
Self::Cubic(db_range) => CubicMapping::mapped_to_linear(mapped_volume, db_range),
|
||||||
|
Self::Log(db_range) => LogMapping::mapped_to_linear(mapped_volume, db_range),
|
||||||
|
_ => mapped_volume,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Ensure not to return -inf or NaN due to division by zero.
|
||||||
|
error!(
|
||||||
|
"{:?} does not work with 0 dB range, using linear mapping instead",
|
||||||
|
self
|
||||||
|
);
|
||||||
|
mapped_volume
|
||||||
|
};
|
||||||
|
|
||||||
|
(unmapped_volume * Self::MAX_VOLUME as f32) as u16
|
||||||
|
}
|
||||||
|
|
||||||
|
fn db_range(&self) -> f32 {
|
||||||
|
match *self {
|
||||||
|
Self::Fixed => 0.0,
|
||||||
|
Self::Linear => Self::DEFAULT_DB_RANGE, // arbitrary, could be anything > 0
|
||||||
|
Self::Log(db_range) | Self::Cubic(db_range) => db_range,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_db_range(&mut self, new_db_range: f32) {
|
||||||
|
match self {
|
||||||
|
Self::Cubic(ref mut db_range) | Self::Log(ref mut db_range) => *db_range = new_db_range,
|
||||||
|
_ => error!("Invalid to set dB range for volume control type {:?}", self),
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("Volume control is now {:?}", self)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn range_ok(&self) -> bool {
|
||||||
|
self.db_range() > 0.0 || matches!(self, Self::Fixed | Self::Linear)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait VolumeMapping {
|
||||||
|
fn linear_to_mapped(unmapped_volume: f32, db_range: f32) -> f32;
|
||||||
|
fn mapped_to_linear(mapped_volume: f32, db_range: f32) -> f32;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Volume conversion taken from: https://www.dr-lex.be/info-stuff/volumecontrols.html#ideal2
|
||||||
|
//
|
||||||
|
// As the human auditory system has a logarithmic sensitivity curve, this
|
||||||
|
// mapping results in a near linear loudness experience with the listener.
|
||||||
|
pub struct LogMapping {}
|
||||||
|
impl VolumeMapping for LogMapping {
|
||||||
|
fn linear_to_mapped(normalized_volume: f32, db_range: f32) -> f32 {
|
||||||
|
let (db_ratio, ideal_factor) = Self::coefficients(db_range);
|
||||||
|
f32::exp(ideal_factor * normalized_volume) / db_ratio
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mapped_to_linear(mapped_volume: f32, db_range: f32) -> f32 {
|
||||||
|
let (db_ratio, ideal_factor) = Self::coefficients(db_range);
|
||||||
|
f32::ln(db_ratio * mapped_volume) / ideal_factor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogMapping {
|
||||||
|
fn coefficients(db_range: f32) -> (f32, f32) {
|
||||||
|
let db_ratio = db_to_ratio(db_range);
|
||||||
|
let ideal_factor = f32::ln(db_ratio);
|
||||||
|
(db_ratio, ideal_factor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ported from: https://github.com/alsa-project/alsa-utils/blob/master/alsamixer/volume_mapping.c
|
||||||
|
// which in turn was inspired by: https://www.robotplanet.dk/audio/audio_gui_design/
|
||||||
|
//
|
||||||
|
// Though this mapping is computationally less expensive than the logarithmic
|
||||||
|
// mapping, it really does not matter as librespot memoizes the mapped value.
|
||||||
|
// Use this mapping if you have some reason to mimic Alsa's native mixer or
|
||||||
|
// prefer a more granular control in the upper volume range.
|
||||||
|
//
|
||||||
|
// Note: https://www.dr-lex.be/info-stuff/volumecontrols.html#ideal3 shows
|
||||||
|
// better approximations to the logarithmic curve but because we only intend
|
||||||
|
// to mimic Alsa here, we do not implement them. If your desire is to use a
|
||||||
|
// logarithmic mapping, then use that volume control.
|
||||||
|
pub struct CubicMapping {}
|
||||||
|
impl VolumeMapping for CubicMapping {
|
||||||
|
fn linear_to_mapped(normalized_volume: f32, db_range: f32) -> f32 {
|
||||||
|
let min_norm = Self::min_norm(db_range);
|
||||||
|
f32::powi(normalized_volume * (1.0 - min_norm) + min_norm, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mapped_to_linear(mapped_volume: f32, db_range: f32) -> f32 {
|
||||||
|
let min_norm = Self::min_norm(db_range);
|
||||||
|
(mapped_volume.powf(1.0 / 3.0) - min_norm) / (1.0 - min_norm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CubicMapping {
|
||||||
|
fn min_norm(db_range: f32) -> f32 {
|
||||||
|
// Note that this 60.0 is unrelated to DEFAULT_DB_RANGE.
|
||||||
|
// Instead, it's the cubic voltage to dB ratio.
|
||||||
|
f32::powf(10.0, -1.0 * db_range / 60.0)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,11 +1,16 @@
|
||||||
|
use crate::config::VolumeCtrl;
|
||||||
|
|
||||||
|
pub mod mappings;
|
||||||
|
use self::mappings::MappedCtrl;
|
||||||
|
|
||||||
pub trait Mixer: Send {
|
pub trait Mixer: Send {
|
||||||
fn open(_: Option<MixerConfig>) -> Self
|
fn open(config: MixerConfig) -> Self
|
||||||
where
|
where
|
||||||
Self: Sized;
|
Self: Sized;
|
||||||
fn start(&self);
|
|
||||||
fn stop(&self);
|
|
||||||
fn set_volume(&self, volume: u16);
|
fn set_volume(&self, volume: u16);
|
||||||
fn volume(&self) -> u16;
|
fn volume(&self) -> u16;
|
||||||
|
|
||||||
fn get_audio_filter(&self) -> Option<Box<dyn AudioFilter + Send>> {
|
fn get_audio_filter(&self) -> Option<Box<dyn AudioFilter + Send>> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
@ -15,6 +20,9 @@ pub trait AudioFilter {
|
||||||
fn modify_stream(&self, data: &mut [f32]);
|
fn modify_stream(&self, data: &mut [f32]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub mod softmixer;
|
||||||
|
use self::softmixer::SoftMixer;
|
||||||
|
|
||||||
#[cfg(feature = "alsa-backend")]
|
#[cfg(feature = "alsa-backend")]
|
||||||
pub mod alsamixer;
|
pub mod alsamixer;
|
||||||
#[cfg(feature = "alsa-backend")]
|
#[cfg(feature = "alsa-backend")]
|
||||||
|
@ -23,29 +31,26 @@ use self::alsamixer::AlsaMixer;
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct MixerConfig {
|
pub struct MixerConfig {
|
||||||
pub card: String,
|
pub card: String,
|
||||||
pub mixer: String,
|
pub control: String,
|
||||||
pub index: u32,
|
pub index: u32,
|
||||||
pub mapped_volume: bool,
|
pub volume_ctrl: VolumeCtrl,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for MixerConfig {
|
impl Default for MixerConfig {
|
||||||
fn default() -> MixerConfig {
|
fn default() -> MixerConfig {
|
||||||
MixerConfig {
|
MixerConfig {
|
||||||
card: String::from("default"),
|
card: String::from("default"),
|
||||||
mixer: String::from("PCM"),
|
control: String::from("PCM"),
|
||||||
index: 0,
|
index: 0,
|
||||||
mapped_volume: true,
|
volume_ctrl: VolumeCtrl::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod softmixer;
|
pub type MixerFn = fn(MixerConfig) -> Box<dyn Mixer>;
|
||||||
use self::softmixer::SoftMixer;
|
|
||||||
|
|
||||||
type MixerFn = fn(Option<MixerConfig>) -> Box<dyn Mixer>;
|
fn mk_sink<M: Mixer + 'static>(config: MixerConfig) -> Box<dyn Mixer> {
|
||||||
|
Box::new(M::open(config))
|
||||||
fn mk_sink<M: Mixer + 'static>(device: Option<MixerConfig>) -> Box<dyn Mixer> {
|
|
||||||
Box::new(M::open(device))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn find<T: AsRef<str>>(name: Option<T>) -> Option<MixerFn> {
|
pub fn find<T: AsRef<str>>(name: Option<T>) -> Option<MixerFn> {
|
||||||
|
|
|
@ -1,28 +1,40 @@
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use super::AudioFilter;
|
use super::AudioFilter;
|
||||||
|
use super::{MappedCtrl, VolumeCtrl};
|
||||||
use super::{Mixer, MixerConfig};
|
use super::{Mixer, MixerConfig};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct SoftMixer {
|
pub struct SoftMixer {
|
||||||
volume: Arc<AtomicUsize>,
|
// There is no AtomicF32, so we store the f32 as bits in a u32 field.
|
||||||
|
// It's much faster than a Mutex<f32>.
|
||||||
|
volume: Arc<AtomicU32>,
|
||||||
|
volume_ctrl: VolumeCtrl,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Mixer for SoftMixer {
|
impl Mixer for SoftMixer {
|
||||||
fn open(_: Option<MixerConfig>) -> SoftMixer {
|
fn open(config: MixerConfig) -> Self {
|
||||||
SoftMixer {
|
let volume_ctrl = config.volume_ctrl;
|
||||||
volume: Arc::new(AtomicUsize::new(0xFFFF)),
|
info!("Mixing with softvol and volume control: {:?}", volume_ctrl);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
volume: Arc::new(AtomicU32::new(f32::to_bits(0.5))),
|
||||||
|
volume_ctrl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn start(&self) {}
|
|
||||||
fn stop(&self) {}
|
|
||||||
fn volume(&self) -> u16 {
|
fn volume(&self) -> u16 {
|
||||||
self.volume.load(Ordering::Relaxed) as u16
|
let mapped_volume = f32::from_bits(self.volume.load(Ordering::Relaxed));
|
||||||
|
self.volume_ctrl.from_mapped(mapped_volume)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_volume(&self, volume: u16) {
|
fn set_volume(&self, volume: u16) {
|
||||||
self.volume.store(volume as usize, Ordering::Relaxed);
|
let mapped_volume = self.volume_ctrl.to_mapped(volume);
|
||||||
|
self.volume
|
||||||
|
.store(mapped_volume.to_bits(), Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_audio_filter(&self) -> Option<Box<dyn AudioFilter + Send>> {
|
fn get_audio_filter(&self) -> Option<Box<dyn AudioFilter + Send>> {
|
||||||
Some(Box::new(SoftVolumeApplier {
|
Some(Box::new(SoftVolumeApplier {
|
||||||
volume: self.volume.clone(),
|
volume: self.volume.clone(),
|
||||||
|
@ -31,16 +43,15 @@ impl Mixer for SoftMixer {
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SoftVolumeApplier {
|
struct SoftVolumeApplier {
|
||||||
volume: Arc<AtomicUsize>,
|
volume: Arc<AtomicU32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioFilter for SoftVolumeApplier {
|
impl AudioFilter for SoftVolumeApplier {
|
||||||
fn modify_stream(&self, data: &mut [f32]) {
|
fn modify_stream(&self, data: &mut [f32]) {
|
||||||
let volume = self.volume.load(Ordering::Relaxed) as u16;
|
let volume = f32::from_bits(self.volume.load(Ordering::Relaxed));
|
||||||
if volume != 0xFFFF {
|
if volume < 1.0 {
|
||||||
let volume_factor = volume as f64 / 0xFFFF as f64;
|
|
||||||
for x in data.iter_mut() {
|
for x in data.iter_mut() {
|
||||||
*x = (*x as f64 * volume_factor) as f32;
|
*x = (*x as f64 * volume as f64) as f32;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,7 +30,7 @@ pub const NUM_CHANNELS: u8 = 2;
|
||||||
pub const SAMPLES_PER_SECOND: u32 = SAMPLE_RATE as u32 * NUM_CHANNELS as u32;
|
pub const SAMPLES_PER_SECOND: u32 = SAMPLE_RATE as u32 * NUM_CHANNELS as u32;
|
||||||
|
|
||||||
const PRELOAD_NEXT_TRACK_BEFORE_END_DURATION_MS: u32 = 30000;
|
const PRELOAD_NEXT_TRACK_BEFORE_END_DURATION_MS: u32 = 30000;
|
||||||
const DB_VOLTAGE_RATIO: f32 = 20.0;
|
pub const DB_VOLTAGE_RATIO: f32 = 20.0;
|
||||||
|
|
||||||
pub struct Player {
|
pub struct Player {
|
||||||
commands: Option<mpsc::UnboundedSender<PlayerCommand>>,
|
commands: Option<mpsc::UnboundedSender<PlayerCommand>>,
|
||||||
|
@ -196,6 +196,14 @@ impl PlayerEvent {
|
||||||
|
|
||||||
pub type PlayerEventChannel = mpsc::UnboundedReceiver<PlayerEvent>;
|
pub type PlayerEventChannel = mpsc::UnboundedReceiver<PlayerEvent>;
|
||||||
|
|
||||||
|
pub fn db_to_ratio(db: f32) -> f32 {
|
||||||
|
f32::powf(10.0, db / DB_VOLTAGE_RATIO)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ratio_to_db(ratio: f32) -> f32 {
|
||||||
|
ratio.log10() * DB_VOLTAGE_RATIO
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
pub struct NormalisationData {
|
pub struct NormalisationData {
|
||||||
track_gain_db: f32,
|
track_gain_db: f32,
|
||||||
|
@ -205,14 +213,6 @@ pub struct NormalisationData {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NormalisationData {
|
impl NormalisationData {
|
||||||
pub fn db_to_ratio(db: f32) -> f32 {
|
|
||||||
f32::powf(10.0, db / DB_VOLTAGE_RATIO)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ratio_to_db(ratio: f32) -> f32 {
|
|
||||||
ratio.log10() * DB_VOLTAGE_RATIO
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_from_file<T: Read + Seek>(mut file: T) -> io::Result<NormalisationData> {
|
fn parse_from_file<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;
|
||||||
file.seek(SeekFrom::Start(SPOTIFY_NORMALIZATION_HEADER_START_OFFSET))?;
|
file.seek(SeekFrom::Start(SPOTIFY_NORMALIZATION_HEADER_START_OFFSET))?;
|
||||||
|
@ -243,11 +243,11 @@ impl NormalisationData {
|
||||||
};
|
};
|
||||||
|
|
||||||
let normalisation_power = gain_db + config.normalisation_pregain;
|
let normalisation_power = gain_db + config.normalisation_pregain;
|
||||||
let mut normalisation_factor = Self::db_to_ratio(normalisation_power);
|
let mut normalisation_factor = db_to_ratio(normalisation_power);
|
||||||
|
|
||||||
if normalisation_factor * gain_peak > config.normalisation_threshold {
|
if normalisation_factor * gain_peak > config.normalisation_threshold {
|
||||||
let limited_normalisation_factor = config.normalisation_threshold / gain_peak;
|
let limited_normalisation_factor = config.normalisation_threshold / gain_peak;
|
||||||
let limited_normalisation_power = Self::ratio_to_db(limited_normalisation_factor);
|
let limited_normalisation_power = ratio_to_db(limited_normalisation_factor);
|
||||||
|
|
||||||
if config.normalisation_method == NormalisationMethod::Basic {
|
if config.normalisation_method == NormalisationMethod::Basic {
|
||||||
warn!("Limiting gain to {:.2} dB for the duration of this track to stay under normalisation threshold.", limited_normalisation_power);
|
warn!("Limiting gain to {:.2} dB for the duration of this track to stay under normalisation threshold.", limited_normalisation_power);
|
||||||
|
@ -266,7 +266,7 @@ impl NormalisationData {
|
||||||
debug!("Normalisation Type: {:?}", config.normalisation_type);
|
debug!("Normalisation Type: {:?}", config.normalisation_type);
|
||||||
debug!(
|
debug!(
|
||||||
"Normalisation Threshold: {:.1}",
|
"Normalisation Threshold: {:.1}",
|
||||||
Self::ratio_to_db(config.normalisation_threshold)
|
ratio_to_db(config.normalisation_threshold)
|
||||||
);
|
);
|
||||||
debug!("Normalisation Method: {:?}", config.normalisation_method);
|
debug!("Normalisation Method: {:?}", config.normalisation_method);
|
||||||
debug!("Normalisation Factor: {}", normalisation_factor);
|
debug!("Normalisation Factor: {}", normalisation_factor);
|
||||||
|
|
230
src/main.rs
230
src/main.rs
|
@ -9,15 +9,16 @@ use url::Url;
|
||||||
use librespot::connect::spirc::Spirc;
|
use librespot::connect::spirc::Spirc;
|
||||||
use librespot::core::authentication::Credentials;
|
use librespot::core::authentication::Credentials;
|
||||||
use librespot::core::cache::Cache;
|
use librespot::core::cache::Cache;
|
||||||
use librespot::core::config::{ConnectConfig, DeviceType, SessionConfig, VolumeCtrl};
|
use librespot::core::config::{ConnectConfig, DeviceType, SessionConfig};
|
||||||
use librespot::core::session::Session;
|
use librespot::core::session::Session;
|
||||||
use librespot::core::version;
|
use librespot::core::version;
|
||||||
use librespot::playback::audio_backend::{self, Sink, BACKENDS};
|
use librespot::playback::audio_backend::{self, SinkBuilder, BACKENDS};
|
||||||
use librespot::playback::config::{
|
use librespot::playback::config::{
|
||||||
AudioFormat, Bitrate, NormalisationMethod, NormalisationType, PlayerConfig,
|
AudioFormat, Bitrate, NormalisationMethod, NormalisationType, PlayerConfig, VolumeCtrl,
|
||||||
};
|
};
|
||||||
use librespot::playback::mixer::{self, Mixer, MixerConfig};
|
use librespot::playback::mixer::mappings::MappedCtrl;
|
||||||
use librespot::playback::player::{NormalisationData, Player};
|
use librespot::playback::mixer::{self, MixerConfig, MixerFn};
|
||||||
|
use librespot::playback::player::{db_to_ratio, Player};
|
||||||
|
|
||||||
mod player_event_handler;
|
mod player_event_handler;
|
||||||
use player_event_handler::{emit_sink_event, run_program_on_events};
|
use player_event_handler::{emit_sink_event, run_program_on_events};
|
||||||
|
@ -66,7 +67,7 @@ fn setup_logging(verbose: bool) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn list_backends() {
|
fn list_backends() {
|
||||||
println!("Available Backends : ");
|
println!("Available backends : ");
|
||||||
for (&(name, _), idx) in BACKENDS.iter().zip(0..) {
|
for (&(name, _), idx) in BACKENDS.iter().zip(0..) {
|
||||||
if idx == 0 {
|
if idx == 0 {
|
||||||
println!("- {} (default)", name);
|
println!("- {} (default)", name);
|
||||||
|
@ -172,11 +173,9 @@ fn print_version() {
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct Setup {
|
struct Setup {
|
||||||
format: AudioFormat,
|
format: AudioFormat,
|
||||||
backend: fn(Option<String>, AudioFormat) -> Box<dyn Sink + 'static>,
|
backend: SinkBuilder,
|
||||||
device: Option<String>,
|
device: Option<String>,
|
||||||
|
mixer: MixerFn,
|
||||||
mixer: fn(Option<MixerConfig>) -> Box<dyn Mixer>,
|
|
||||||
|
|
||||||
cache: Option<Cache>,
|
cache: Option<Cache>,
|
||||||
player_config: PlayerConfig,
|
player_config: PlayerConfig,
|
||||||
session_config: SessionConfig,
|
session_config: SessionConfig,
|
||||||
|
@ -266,11 +265,6 @@ fn get_setup(args: &[String]) -> Setup {
|
||||||
"Alsa mixer index, Index of the cards mixer. Defaults to 0",
|
"Alsa mixer index, Index of the cards mixer. Defaults to 0",
|
||||||
"MIXER_INDEX",
|
"MIXER_INDEX",
|
||||||
)
|
)
|
||||||
.optflag(
|
|
||||||
"",
|
|
||||||
"mixer-linear-volume",
|
|
||||||
"Disable alsa's mapped volume scale (cubic). Default false",
|
|
||||||
)
|
|
||||||
.optopt(
|
.optopt(
|
||||||
"",
|
"",
|
||||||
"initial-volume",
|
"initial-volume",
|
||||||
|
@ -333,8 +327,14 @@ fn get_setup(args: &[String]) -> Setup {
|
||||||
.optopt(
|
.optopt(
|
||||||
"",
|
"",
|
||||||
"volume-ctrl",
|
"volume-ctrl",
|
||||||
"Volume control type - [linear, log, fixed]. Default is logarithmic",
|
"Volume control type - [cubic, fixed, linear, log]. Default is log.",
|
||||||
"VOLUME_CTRL"
|
"VOLUME_CTRL"
|
||||||
|
)
|
||||||
|
.optopt(
|
||||||
|
"",
|
||||||
|
"volume-range",
|
||||||
|
"Range of the volume control (dB). Defaults to 60 for softvol and for alsa what the mixer supports.",
|
||||||
|
"RANGE",
|
||||||
)
|
)
|
||||||
.optflag(
|
.optflag(
|
||||||
"",
|
"",
|
||||||
|
@ -399,18 +399,55 @@ fn get_setup(args: &[String]) -> Setup {
|
||||||
let mixer_name = matches.opt_str("mixer");
|
let mixer_name = matches.opt_str("mixer");
|
||||||
let mixer = mixer::find(mixer_name.as_ref()).expect("Invalid mixer");
|
let mixer = mixer::find(mixer_name.as_ref()).expect("Invalid mixer");
|
||||||
|
|
||||||
let mixer_config = MixerConfig {
|
let mixer_config = {
|
||||||
card: matches
|
let card = matches.opt_str("mixer-card").unwrap_or_else(|| {
|
||||||
.opt_str("mixer-card")
|
if let Some(ref device_name) = device {
|
||||||
.unwrap_or_else(|| String::from("default")),
|
device_name.to_string()
|
||||||
mixer: matches
|
} else {
|
||||||
.opt_str("mixer-name")
|
String::from("default")
|
||||||
.unwrap_or_else(|| String::from("PCM")),
|
}
|
||||||
index: matches
|
});
|
||||||
|
let index = matches
|
||||||
.opt_str("mixer-index")
|
.opt_str("mixer-index")
|
||||||
.map(|index| index.parse::<u32>().unwrap())
|
.map(|index| index.parse::<u32>().unwrap())
|
||||||
.unwrap_or(0),
|
.unwrap_or(0);
|
||||||
mapped_volume: !matches.opt_present("mixer-linear-volume"),
|
let control = matches
|
||||||
|
.opt_str("mixer-name")
|
||||||
|
.unwrap_or_else(|| String::from("PCM"));
|
||||||
|
let mut volume_range = matches
|
||||||
|
.opt_str("volume-range")
|
||||||
|
.map(|range| range.parse::<f32>().unwrap())
|
||||||
|
.unwrap_or_else(|| match mixer_name.as_ref().map(AsRef::as_ref) {
|
||||||
|
Some("alsa") => 0.0, // let Alsa query the control
|
||||||
|
_ => VolumeCtrl::DEFAULT_DB_RANGE,
|
||||||
|
});
|
||||||
|
if volume_range < 0.0 {
|
||||||
|
// User might have specified range as minimum dB volume.
|
||||||
|
volume_range *= -1.0;
|
||||||
|
warn!(
|
||||||
|
"Please enter positive volume ranges only, assuming {:.2} dB",
|
||||||
|
volume_range
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let volume_ctrl = matches
|
||||||
|
.opt_str("volume-ctrl")
|
||||||
|
.as_ref()
|
||||||
|
.map(|volume_ctrl| {
|
||||||
|
VolumeCtrl::from_str_with_range(volume_ctrl, volume_range)
|
||||||
|
.expect("Invalid volume control type")
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
let mut volume_ctrl = VolumeCtrl::default();
|
||||||
|
volume_ctrl.set_db_range(volume_range);
|
||||||
|
volume_ctrl
|
||||||
|
});
|
||||||
|
|
||||||
|
MixerConfig {
|
||||||
|
card,
|
||||||
|
control,
|
||||||
|
index,
|
||||||
|
volume_ctrl,
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let cache = {
|
let cache = {
|
||||||
|
@ -459,15 +496,18 @@ fn get_setup(args: &[String]) -> Setup {
|
||||||
|
|
||||||
let initial_volume = matches
|
let initial_volume = matches
|
||||||
.opt_str("initial-volume")
|
.opt_str("initial-volume")
|
||||||
.map(|volume| {
|
.map(|initial_volume| {
|
||||||
let volume = volume.parse::<u16>().unwrap();
|
let volume = initial_volume.parse::<u16>().unwrap();
|
||||||
if volume > 100 {
|
if volume > 100 {
|
||||||
panic!("Initial volume must be in the range 0-100");
|
error!("Initial volume must be in the range 0-100.");
|
||||||
|
// the cast will saturate, not necessary to take further action
|
||||||
}
|
}
|
||||||
(volume as i32 * 0xFFFF / 100) as u16
|
(volume as f32 / 100.0 * VolumeCtrl::MAX_VOLUME as f32) as u16
|
||||||
})
|
})
|
||||||
.or_else(|| cache.as_ref().and_then(Cache::volume))
|
.or_else(|| match mixer_name.as_ref().map(AsRef::as_ref) {
|
||||||
.unwrap_or(0x8000);
|
Some("alsa") => None,
|
||||||
|
_ => cache.as_ref().and_then(Cache::volume),
|
||||||
|
});
|
||||||
|
|
||||||
let zeroconf_port = matches
|
let zeroconf_port = matches
|
||||||
.opt_str("zeroconf-port")
|
.opt_str("zeroconf-port")
|
||||||
|
@ -506,15 +546,15 @@ fn get_setup(args: &[String]) -> Setup {
|
||||||
match Url::parse(&s) {
|
match Url::parse(&s) {
|
||||||
Ok(url) => {
|
Ok(url) => {
|
||||||
if url.host().is_none() || url.port_or_known_default().is_none() {
|
if url.host().is_none() || url.port_or_known_default().is_none() {
|
||||||
panic!("Invalid proxy url, only urls on the format \"http://host:port\" are allowed");
|
panic!("Invalid proxy url, only URLs on the format \"http://host:port\" are allowed");
|
||||||
}
|
}
|
||||||
|
|
||||||
if url.scheme() != "http" {
|
if url.scheme() != "http" {
|
||||||
panic!("Only unsecure http:// proxies are supported");
|
panic!("Only insecure http:// proxies are supported");
|
||||||
}
|
}
|
||||||
url
|
url
|
||||||
},
|
},
|
||||||
Err(err) => panic!("Invalid proxy url: {}, only urls on the format \"http://host:port\" are allowed", err)
|
Err(err) => panic!("Invalid proxy URL: {}, only URLs in the format \"http://host:port\" are allowed", err)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
@ -524,21 +564,14 @@ fn get_setup(args: &[String]) -> Setup {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let passthrough = matches.opt_present("passthrough");
|
|
||||||
|
|
||||||
let player_config = {
|
let player_config = {
|
||||||
let bitrate = matches
|
let bitrate = matches
|
||||||
.opt_str("b")
|
.opt_str("b")
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|bitrate| Bitrate::from_str(bitrate).expect("Invalid bitrate"))
|
.map(|bitrate| Bitrate::from_str(bitrate).expect("Invalid bitrate"))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let gain_type = matches
|
let gapless = !matches.opt_present("disable-gapless");
|
||||||
.opt_str("normalisation-gain-type")
|
let normalisation = matches.opt_present("enable-volume-normalisation");
|
||||||
.as_ref()
|
|
||||||
.map(|gain_type| {
|
|
||||||
NormalisationType::from_str(gain_type).expect("Invalid normalisation type")
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
let normalisation_method = matches
|
let normalisation_method = matches
|
||||||
.opt_str("normalisation-method")
|
.opt_str("normalisation-method")
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
@ -546,41 +579,52 @@ fn get_setup(args: &[String]) -> Setup {
|
||||||
NormalisationMethod::from_str(gain_type).expect("Invalid normalisation method")
|
NormalisationMethod::from_str(gain_type).expect("Invalid normalisation method")
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
let normalisation_type = matches
|
||||||
|
.opt_str("normalisation-gain-type")
|
||||||
|
.as_ref()
|
||||||
|
.map(|gain_type| {
|
||||||
|
NormalisationType::from_str(gain_type).expect("Invalid normalisation type")
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let normalisation_pregain = matches
|
||||||
|
.opt_str("normalisation-pregain")
|
||||||
|
.map(|pregain| pregain.parse::<f32>().expect("Invalid pregain float value"))
|
||||||
|
.unwrap_or(PlayerConfig::default().normalisation_pregain);
|
||||||
|
let normalisation_threshold = matches
|
||||||
|
.opt_str("normalisation-threshold")
|
||||||
|
.map(|threshold| {
|
||||||
|
db_to_ratio(
|
||||||
|
threshold
|
||||||
|
.parse::<f32>()
|
||||||
|
.expect("Invalid threshold float value"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or(PlayerConfig::default().normalisation_threshold);
|
||||||
|
let normalisation_attack = matches
|
||||||
|
.opt_str("normalisation-attack")
|
||||||
|
.map(|attack| attack.parse::<f32>().expect("Invalid attack float value") / MILLIS)
|
||||||
|
.unwrap_or(PlayerConfig::default().normalisation_attack);
|
||||||
|
let normalisation_release = matches
|
||||||
|
.opt_str("normalisation-release")
|
||||||
|
.map(|release| release.parse::<f32>().expect("Invalid release float value") / MILLIS)
|
||||||
|
.unwrap_or(PlayerConfig::default().normalisation_release);
|
||||||
|
let normalisation_knee = matches
|
||||||
|
.opt_str("normalisation-knee")
|
||||||
|
.map(|knee| knee.parse::<f32>().expect("Invalid knee float value"))
|
||||||
|
.unwrap_or(PlayerConfig::default().normalisation_knee);
|
||||||
|
let passthrough = matches.opt_present("passthrough");
|
||||||
|
|
||||||
PlayerConfig {
|
PlayerConfig {
|
||||||
bitrate,
|
bitrate,
|
||||||
gapless: !matches.opt_present("disable-gapless"),
|
gapless,
|
||||||
normalisation: matches.opt_present("enable-volume-normalisation"),
|
normalisation,
|
||||||
|
normalisation_type,
|
||||||
normalisation_method,
|
normalisation_method,
|
||||||
normalisation_type: gain_type,
|
normalisation_pregain,
|
||||||
normalisation_pregain: matches
|
normalisation_threshold,
|
||||||
.opt_str("normalisation-pregain")
|
normalisation_attack,
|
||||||
.map(|pregain| pregain.parse::<f32>().expect("Invalid pregain float value"))
|
normalisation_release,
|
||||||
.unwrap_or(PlayerConfig::default().normalisation_pregain),
|
normalisation_knee,
|
||||||
normalisation_threshold: matches
|
|
||||||
.opt_str("normalisation-threshold")
|
|
||||||
.map(|threshold| {
|
|
||||||
NormalisationData::db_to_ratio(
|
|
||||||
threshold
|
|
||||||
.parse::<f32>()
|
|
||||||
.expect("Invalid threshold float value"),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.unwrap_or(PlayerConfig::default().normalisation_threshold),
|
|
||||||
normalisation_attack: matches
|
|
||||||
.opt_str("normalisation-attack")
|
|
||||||
.map(|attack| attack.parse::<f32>().expect("Invalid attack float value") / MILLIS)
|
|
||||||
.unwrap_or(PlayerConfig::default().normalisation_attack),
|
|
||||||
normalisation_release: matches
|
|
||||||
.opt_str("normalisation-release")
|
|
||||||
.map(|release| {
|
|
||||||
release.parse::<f32>().expect("Invalid release float value") / MILLIS
|
|
||||||
})
|
|
||||||
.unwrap_or(PlayerConfig::default().normalisation_release),
|
|
||||||
normalisation_knee: matches
|
|
||||||
.opt_str("normalisation-knee")
|
|
||||||
.map(|knee| knee.parse::<f32>().expect("Invalid knee float value"))
|
|
||||||
.unwrap_or(PlayerConfig::default().normalisation_knee),
|
|
||||||
passthrough,
|
passthrough,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -591,39 +635,37 @@ fn get_setup(args: &[String]) -> Setup {
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|device_type| DeviceType::from_str(device_type).expect("Invalid device type"))
|
.map(|device_type| DeviceType::from_str(device_type).expect("Invalid device type"))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
let has_volume_ctrl = !matches!(mixer_config.volume_ctrl, VolumeCtrl::Fixed);
|
||||||
let volume_ctrl = matches
|
let autoplay = matches.opt_present("autoplay");
|
||||||
.opt_str("volume-ctrl")
|
|
||||||
.as_ref()
|
|
||||||
.map(|volume_ctrl| VolumeCtrl::from_str(volume_ctrl).expect("Invalid volume ctrl type"))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
ConnectConfig {
|
ConnectConfig {
|
||||||
name,
|
name,
|
||||||
device_type,
|
device_type,
|
||||||
volume: initial_volume,
|
initial_volume,
|
||||||
volume_ctrl,
|
has_volume_ctrl,
|
||||||
autoplay: matches.opt_present("autoplay"),
|
autoplay,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let enable_discovery = !matches.opt_present("disable-discovery");
|
let enable_discovery = !matches.opt_present("disable-discovery");
|
||||||
|
let player_event_program = matches.opt_str("onevent");
|
||||||
|
let emit_sink_events = matches.opt_present("emit-sink-events");
|
||||||
|
|
||||||
Setup {
|
Setup {
|
||||||
format,
|
format,
|
||||||
backend,
|
backend,
|
||||||
cache,
|
|
||||||
session_config,
|
|
||||||
player_config,
|
|
||||||
connect_config,
|
|
||||||
credentials,
|
|
||||||
device,
|
device,
|
||||||
|
mixer,
|
||||||
|
cache,
|
||||||
|
player_config,
|
||||||
|
session_config,
|
||||||
|
connect_config,
|
||||||
|
mixer_config,
|
||||||
|
credentials,
|
||||||
enable_discovery,
|
enable_discovery,
|
||||||
zeroconf_port,
|
zeroconf_port,
|
||||||
mixer,
|
player_event_program,
|
||||||
mixer_config,
|
emit_sink_events,
|
||||||
player_event_program: matches.opt_str("onevent"),
|
|
||||||
emit_sink_events: matches.opt_present("emit-sink-events"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -697,7 +739,7 @@ async fn main() {
|
||||||
session = &mut connecting, if !connecting.is_terminated() => match session {
|
session = &mut connecting, if !connecting.is_terminated() => match session {
|
||||||
Ok(session) => {
|
Ok(session) => {
|
||||||
let mixer_config = setup.mixer_config.clone();
|
let mixer_config = setup.mixer_config.clone();
|
||||||
let mixer = (setup.mixer)(Some(mixer_config));
|
let mixer = (setup.mixer)(mixer_config);
|
||||||
let player_config = setup.player_config.clone();
|
let player_config = setup.player_config.clone();
|
||||||
let connect_config = setup.connect_config.clone();
|
let connect_config = setup.connect_config.clone();
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue