librespot_playback/mixer/
mappings.rs

1use super::VolumeCtrl;
2use crate::player::db_to_ratio;
3
4pub trait MappedCtrl {
5    fn to_mapped(&self, volume: u16) -> f64;
6    fn as_unmapped(&self, mapped_volume: f64) -> u16;
7
8    fn db_range(&self) -> f64;
9    fn set_db_range(&mut self, new_db_range: f64);
10    fn range_ok(&self) -> bool;
11}
12
13impl MappedCtrl for VolumeCtrl {
14    fn to_mapped(&self, volume: u16) -> f64 {
15        // More than just an optimization, this ensures that zero volume is
16        // really mute (both the log and cubic equations would otherwise not
17        // reach zero).
18        if volume == 0 {
19            return 0.0;
20        } else if volume == Self::MAX_VOLUME {
21            // And limit in case of rounding errors (as is the case for log).
22            return 1.0;
23        }
24
25        let normalized_volume = volume as f64 / Self::MAX_VOLUME as f64;
26        let mapped_volume = if self.range_ok() {
27            match *self {
28                Self::Cubic(db_range) => {
29                    CubicMapping::linear_to_mapped(normalized_volume, db_range)
30                }
31                Self::Log(db_range) => LogMapping::linear_to_mapped(normalized_volume, db_range),
32                _ => normalized_volume,
33            }
34        } else {
35            // Ensure not to return -inf or NaN due to division by zero.
36            error!("{self:?} does not work with 0 dB range, using linear mapping instead");
37            normalized_volume
38        };
39
40        debug!(
41            "Input volume {} mapped to: {:.2}%",
42            volume,
43            mapped_volume * 100.0
44        );
45
46        mapped_volume
47    }
48
49    fn as_unmapped(&self, mapped_volume: f64) -> u16 {
50        // More than just an optimization, this ensures that zero mapped volume
51        // is unmapped to non-negative real numbers (otherwise the log and cubic
52        // equations would respectively return -inf and -1/9.)
53        if f64::abs(mapped_volume - 0.0) <= f64::EPSILON {
54            return 0;
55        } else if f64::abs(mapped_volume - 1.0) <= f64::EPSILON {
56            return Self::MAX_VOLUME;
57        }
58
59        let unmapped_volume = if self.range_ok() {
60            match *self {
61                Self::Cubic(db_range) => CubicMapping::mapped_to_linear(mapped_volume, db_range),
62                Self::Log(db_range) => LogMapping::mapped_to_linear(mapped_volume, db_range),
63                _ => mapped_volume,
64            }
65        } else {
66            // Ensure not to return -inf or NaN due to division by zero.
67            error!("{self:?} does not work with 0 dB range, using linear mapping instead");
68            mapped_volume
69        };
70
71        (unmapped_volume * Self::MAX_VOLUME as f64) as u16
72    }
73
74    fn db_range(&self) -> f64 {
75        match *self {
76            Self::Fixed => 0.0,
77            Self::Linear => Self::DEFAULT_DB_RANGE, // arbitrary, could be anything > 0
78            Self::Log(db_range) | Self::Cubic(db_range) => db_range,
79        }
80    }
81
82    fn set_db_range(&mut self, new_db_range: f64) {
83        match self {
84            Self::Cubic(db_range) | Self::Log(db_range) => *db_range = new_db_range,
85            _ => error!("Invalid to set dB range for volume control type {self:?}"),
86        }
87
88        debug!("Volume control is now {self:?}")
89    }
90
91    fn range_ok(&self) -> bool {
92        self.db_range() > 0.0 || matches!(self, Self::Fixed | Self::Linear)
93    }
94}
95
96pub trait VolumeMapping {
97    fn linear_to_mapped(unmapped_volume: f64, db_range: f64) -> f64;
98    fn mapped_to_linear(mapped_volume: f64, db_range: f64) -> f64;
99}
100
101// Volume conversion taken from: https://www.dr-lex.be/info-stuff/volumecontrols.html#ideal2
102//
103// As the human auditory system has a logarithmic sensitivity curve, this
104// mapping results in a near linear loudness experience with the listener.
105pub struct LogMapping {}
106impl VolumeMapping for LogMapping {
107    fn linear_to_mapped(normalized_volume: f64, db_range: f64) -> f64 {
108        let (db_ratio, ideal_factor) = Self::coefficients(db_range);
109        f64::exp(ideal_factor * normalized_volume) / db_ratio
110    }
111
112    fn mapped_to_linear(mapped_volume: f64, db_range: f64) -> f64 {
113        let (db_ratio, ideal_factor) = Self::coefficients(db_range);
114        f64::ln(db_ratio * mapped_volume) / ideal_factor
115    }
116}
117
118impl LogMapping {
119    fn coefficients(db_range: f64) -> (f64, f64) {
120        let db_ratio = db_to_ratio(db_range);
121        let ideal_factor = f64::ln(db_ratio);
122        (db_ratio, ideal_factor)
123    }
124}
125
126// Ported from: https://github.com/alsa-project/alsa-utils/blob/master/alsamixer/volume_mapping.c
127// which in turn was inspired by: https://www.robotplanet.dk/audio/audio_gui_design/
128//
129// Though this mapping is computationally less expensive than the logarithmic
130// mapping, it really does not matter as librespot memoizes the mapped value.
131// Use this mapping if you have some reason to mimic Alsa's native mixer or
132// prefer a more granular control in the upper volume range.
133//
134// Note: https://www.dr-lex.be/info-stuff/volumecontrols.html#ideal3 shows
135// better approximations to the logarithmic curve but because we only intend
136// to mimic Alsa here, we do not implement them. If your desire is to use a
137// logarithmic mapping, then use that volume control.
138pub struct CubicMapping {}
139impl VolumeMapping for CubicMapping {
140    fn linear_to_mapped(normalized_volume: f64, db_range: f64) -> f64 {
141        let min_norm = Self::min_norm(db_range);
142        f64::powi(normalized_volume * (1.0 - min_norm) + min_norm, 3)
143    }
144
145    fn mapped_to_linear(mapped_volume: f64, db_range: f64) -> f64 {
146        let min_norm = Self::min_norm(db_range);
147        (mapped_volume.powf(1.0 / 3.0) - min_norm) / (1.0 - min_norm)
148    }
149}
150
151impl CubicMapping {
152    fn min_norm(db_range: f64) -> f64 {
153        // Note that this 60.0 is unrelated to DEFAULT_DB_RANGE.
154        // Instead, it's the cubic voltage to dB ratio.
155        f64::powf(10.0, -db_range / 60.0)
156    }
157}