Skip to main content

harmos_signal/spectrum/
decibel.rs

1//! Decibels: the reference and factor one scale is, the presets worth naming,
2//! and what arithmetic between two scales lands in.
3
4/// A decibel scale: `dB = factor · log10(linear / reference)`.
5///
6/// The factor is the whole difference between an amplitude quantity and a power
7/// one — twenty for volts, amperes and pressures, ten for watts — and mixing
8/// the two is the mistake this type exists to make unrepresentable.
9#[derive(Clone, Copy, Debug, PartialEq)]
10pub struct Decibel {
11    /// The linear value that reads as zero decibels, in base units.
12    pub reference: f64,
13    /// The log factor: twenty for amplitudes, ten for powers.
14    pub factor: f64,
15}
16
17impl Decibel {
18    /// An amplitude scale — factor twenty — against a stated reference.
19    #[must_use]
20    pub fn amplitude(reference: f64) -> Self {
21        Self {
22            reference,
23            factor: 20.0,
24        }
25    }
26
27    /// A power scale — factor ten — against a stated reference.
28    #[must_use]
29    pub fn power(reference: f64) -> Self {
30        Self {
31            reference,
32            factor: 10.0,
33        }
34    }
35
36    /// dBV: amplitude against one volt.
37    #[must_use]
38    pub fn volt() -> Self {
39        Self::amplitude(1.0)
40    }
41
42    /// dBmV: amplitude against one millivolt.
43    #[must_use]
44    pub fn millivolt() -> Self {
45        Self::amplitude(1e-3)
46    }
47
48    /// dBµV: amplitude against one microvolt, the electromagnetic-compatibility
49    /// convention.
50    #[must_use]
51    pub fn microvolt() -> Self {
52        Self::amplitude(1e-6)
53    }
54
55    /// dBA: amplitude against one ampere.
56    #[must_use]
57    pub fn ampere() -> Self {
58        Self::amplitude(1.0)
59    }
60
61    /// dBmA: amplitude against one milliampere.
62    #[must_use]
63    pub fn milliampere() -> Self {
64        Self::amplitude(1e-3)
65    }
66
67    /// dBµA: amplitude against one microampere.
68    #[must_use]
69    pub fn microampere() -> Self {
70        Self::amplitude(1e-6)
71    }
72
73    /// dB SPL: amplitude against twenty micropascals.
74    #[must_use]
75    pub fn sound_pressure() -> Self {
76        Self::amplitude(20e-6)
77    }
78
79    /// dBW: power against one watt.
80    #[must_use]
81    pub fn watt() -> Self {
82        Self::power(1.0)
83    }
84
85    /// dBm: power against one milliwatt, the radio convention.
86    #[must_use]
87    pub fn milliwatt() -> Self {
88        Self::power(1e-3)
89    }
90
91    /// This linear value in decibels. Zero and below read as negative infinity.
92    #[must_use]
93    pub fn to_db(&self, linear: f64) -> f64 {
94        if linear > 0.0 {
95            self.factor * (linear / self.reference).log10()
96        } else {
97            f64::NEG_INFINITY
98        }
99    }
100
101    /// This decibel value back in base units.
102    #[must_use]
103    pub fn to_linear(&self, db: f64) -> f64 {
104        self.reference * 10.0_f64.powf(db / self.factor)
105    }
106
107    /// Whether two scales measure the same kind of quantity.
108    ///
109    /// Same factor, any reference: dBµV and dBmV are one subtraction apart,
110    /// dBV and dBm are not comparable at all.
111    #[must_use]
112    pub fn is_compatible(&self, other: &Self) -> bool {
113        (self.factor - other.factor).abs() < f64::EPSILON
114    }
115
116    /// What to add to a value in `other` to read it in this scale.
117    ///
118    /// Nothing, when the two measure different kinds of quantity.
119    #[must_use]
120    pub fn offset_from(&self, other: &Self) -> Option<f64> {
121        self.is_compatible(other)
122            .then(|| self.factor * (other.reference / self.reference).log10())
123    }
124}
125
126/// What a column of numbers means: base units, decibels against a reference, or
127/// a bare decibel difference.
128///
129/// A ratio is what a subtraction of two decibel columns leaves — a margin
130/// against a limit, say — and it has no reference, so it can never be converted
131/// back to base units. Keeping that in the type is what stops a margin from
132/// being read as a measurement.
133#[derive(Clone, Copy, Debug, Default, PartialEq)]
134pub enum Scale {
135    /// Base units, as measured.
136    #[default]
137    Linear,
138    /// Decibels against the stated reference.
139    Decibel(Decibel),
140    /// A decibel difference, carrying only the factor it was taken at.
141    Ratio {
142        /// The log factor the difference was taken at.
143        factor: f64,
144    },
145}
146
147impl Scale {
148    /// The log factor, if this scale is logarithmic at all.
149    #[must_use]
150    pub fn factor(&self) -> Option<f64> {
151        match self {
152            Self::Linear => None,
153            Self::Decibel(scale) => Some(scale.factor),
154            Self::Ratio { factor } => Some(*factor),
155        }
156    }
157
158    /// The reference, if this scale has one to convert against.
159    #[must_use]
160    pub fn reference(&self) -> Option<f64> {
161        match self {
162            Self::Decibel(scale) => Some(scale.reference),
163            Self::Linear | Self::Ratio { .. } => None,
164        }
165    }
166
167    /// Whether values in these two scales may be combined at all.
168    #[must_use]
169    pub fn is_compatible(&self, other: &Self) -> bool {
170        match (self, other) {
171            (Self::Linear, Self::Linear) => true,
172            (Self::Decibel(left), Self::Decibel(right)) => left.is_compatible(right),
173            (Self::Decibel(scale), Self::Ratio { factor })
174            | (Self::Ratio { factor }, Self::Decibel(scale)) => {
175                (scale.factor - factor).abs() < f64::EPSILON
176            }
177            (Self::Ratio { factor: left }, Self::Ratio { factor: right }) => {
178                (left - right).abs() < f64::EPSILON
179            }
180            (Self::Linear, _) | (_, Self::Linear) => false,
181        }
182    }
183
184    /// The scale a sum of these two lands in, when the sum means anything.
185    ///
186    /// Adding a ratio back to a measurement restores the measurement's scale —
187    /// a margin added to a limit is a level again.
188    #[must_use]
189    pub fn sum(&self, other: &Self) -> Option<Self> {
190        match (self, other) {
191            (Self::Linear, Self::Linear) => Some(Self::Linear),
192            (Self::Decibel(scale), Self::Ratio { .. })
193            | (Self::Ratio { .. }, Self::Decibel(scale))
194                if self.is_compatible(other) =>
195            {
196                Some(Self::Decibel(*scale))
197            }
198            (Self::Ratio { factor }, Self::Ratio { .. }) if self.is_compatible(other) => {
199                Some(Self::Ratio { factor: *factor })
200            }
201            _ => None,
202        }
203    }
204
205    /// The scale a difference of these two lands in, when it means anything.
206    ///
207    /// Two levels differ by a ratio, and the references cancel: that is the
208    /// margin calculation, and why [`Scale::Ratio`] exists.
209    #[must_use]
210    pub fn difference(&self, other: &Self) -> Option<Self> {
211        match (self, other) {
212            (Self::Linear, Self::Linear) => Some(Self::Linear),
213            (Self::Decibel(left), Self::Decibel(_)) if self.is_compatible(other) => {
214                Some(Self::Ratio {
215                    factor: left.factor,
216                })
217            }
218            (Self::Decibel(scale), Self::Ratio { .. }) if self.is_compatible(other) => {
219                Some(Self::Decibel(*scale))
220            }
221            (Self::Ratio { factor }, Self::Ratio { .. }) if self.is_compatible(other) => {
222                Some(Self::Ratio { factor: *factor })
223            }
224            _ => None,
225        }
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn a_microvolt_scale_counts_twenty_db_per_decade_of_amplitude() {
235        let scale = Decibel::microvolt();
236
237        assert!(scale.to_db(1e-6).abs() < 1e-9);
238        assert!((scale.to_db(1e-3) - 60.0).abs() < 1e-9);
239        assert!((scale.to_db(1.0) - 120.0).abs() < 1e-9);
240    }
241
242    #[test]
243    fn a_conversion_and_its_inverse_return_the_value() {
244        let scale = Decibel::microvolt();
245
246        assert!((scale.to_linear(scale.to_db(0.005)) - 0.005).abs() < 1e-15);
247    }
248
249    #[test]
250    fn nothing_positive_is_negative_infinity_decibels() {
251        assert_eq!(Decibel::volt().to_db(0.0), f64::NEG_INFINITY);
252        assert_eq!(Decibel::volt().to_db(-1.0), f64::NEG_INFINITY);
253    }
254
255    #[test]
256    fn a_power_scale_counts_ten_db_per_decade() {
257        assert!((Decibel::milliwatt().to_db(1.0) - 30.0).abs() < 1e-9);
258        assert!((Decibel::watt().to_db(1e-3) + 30.0).abs() < 1e-9);
259    }
260
261    #[test]
262    fn amplitude_and_power_scales_do_not_mix() {
263        assert!(!Decibel::volt().is_compatible(&Decibel::milliwatt()));
264        assert!(Decibel::volt().is_compatible(&Decibel::microvolt()));
265        assert!(Decibel::milliwatt().offset_from(&Decibel::volt()).is_none());
266    }
267
268    #[test]
269    fn two_references_are_one_offset_apart() {
270        let offset = Decibel::microvolt().offset_from(&Decibel::millivolt());
271
272        assert!(offset.is_some_and(|offset| (offset - 60.0).abs() < 1e-9));
273    }
274
275    #[test]
276    fn a_scale_reports_the_factor_and_reference_it_carries() {
277        assert_eq!(Scale::Decibel(Decibel::microvolt()).factor(), Some(20.0));
278        assert_eq!(Scale::Decibel(Decibel::microvolt()).reference(), Some(1e-6));
279        assert_eq!(Scale::Ratio { factor: 20.0 }.factor(), Some(20.0));
280        assert_eq!(Scale::Ratio { factor: 20.0 }.reference(), None);
281        assert_eq!(Scale::default().factor(), None);
282    }
283
284    #[test]
285    fn linear_and_logarithmic_columns_are_never_compatible() {
286        assert!(!Scale::Linear.is_compatible(&Scale::Decibel(Decibel::microvolt())));
287        assert!(Scale::Linear.is_compatible(&Scale::Linear));
288        assert!(
289            Scale::Decibel(Decibel::microvolt())
290                .is_compatible(&Scale::Decibel(Decibel::millivolt()))
291        );
292        assert!(
293            !Scale::Decibel(Decibel::microvolt())
294                .is_compatible(&Scale::Decibel(Decibel::milliwatt()))
295        );
296    }
297
298    #[test]
299    fn two_levels_differ_by_a_ratio() {
300        let level = Scale::Decibel(Decibel::microvolt());
301        let limit = Scale::Decibel(Decibel::millivolt());
302
303        assert_eq!(
304            level.difference(&limit),
305            Some(Scale::Ratio { factor: 20.0 })
306        );
307    }
308
309    #[test]
310    fn a_margin_added_back_to_a_level_is_a_level() {
311        let level = Scale::Decibel(Decibel::microvolt());
312        let margin = Scale::Ratio { factor: 20.0 };
313
314        assert_eq!(level.sum(&margin), Some(level));
315        assert_eq!(margin.sum(&level), Some(level));
316        assert_eq!(level.difference(&margin), Some(level));
317    }
318
319    #[test]
320    fn a_ratio_never_becomes_a_measurement() {
321        let ratio = Scale::Ratio { factor: 20.0 };
322
323        assert_eq!(ratio.sum(&Scale::Linear), None);
324        assert_eq!(ratio.difference(&Scale::Linear), None);
325        assert_eq!(ratio.sum(&Scale::Ratio { factor: 10.0 }), None);
326        assert_eq!(ratio.difference(&ratio), Some(ratio));
327    }
328
329    #[test]
330    fn linear_columns_add_and_subtract_as_themselves() {
331        assert_eq!(Scale::Linear.sum(&Scale::Linear), Some(Scale::Linear));
332        assert_eq!(
333            Scale::Linear.difference(&Scale::Linear),
334            Some(Scale::Linear)
335        );
336        assert_eq!(Scale::Linear.sum(&Scale::Decibel(Decibel::volt())), None);
337    }
338
339    #[test]
340    fn two_levels_do_not_add() {
341        let level = Scale::Decibel(Decibel::microvolt());
342
343        assert_eq!(level.sum(&level), None);
344    }
345}