spectrum_analyzer/scaling.rs
1/*
2MIT License
3
4Copyright (c) 2023 Philipp Schuster
5
6Permission is hereby granted, free of charge, to any person obtaining a copy
7of this software and associated documentation files (the "Software"), to deal
8in the Software without restriction, including without limitation the rights
9to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10copies of the Software, and to permit persons to whom the Software is
11furnished to do so, subject to the following conditions:
12
13The above copyright notice and this permission notice shall be included in all
14copies or substantial portions of the Software.
15
16THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22SOFTWARE.
23*/
24//! This module contains convenient public transform functions that you can use
25//! as parameters in [`samples_fft_to_spectrum`] for scaling the frequency value
26//! (the FFT result).
27//!
28//! ## Which scaling should I use?
29//! * [`divide_by_N`]: the default. It makes the values independent of the
30//! number of samples, so spectra of different lengths are comparable.
31//! * [`scale_20_times_log10`]: decibels, for a display that should match how
32//! loudness is perceived.
33//! * [`scale_to_zero_to_one`]: for a single block where only the relative
34//! height of the peaks matters, e.g. a plot or a test. Avoid it for a live
35//! view: it normalises every block to its own loudest value, so silence
36//! gets amplified to full scale.
37//! * [`divide_by_N_sqrt`]: preserves the energy of the signal, for a forward
38//! and inverse transform pair.
39//! * None at all is fine if you only compare values within one spectrum, for
40//! example to find the loudest frequency.
41//!
42//! To read the amplitude of a tone, use [`divide_by_N`], multiply by `2` and
43//! divide by the coherent gain of your window, see
44//! [`crate::samples_fft_to_spectrum`].
45//!
46//! They act as "idea/inspiration". Feel free to create your own derivation
47//! from them. To chain two of them, write a closure:
48//!
49//! ```
50//! use spectrum_analyzer::scaling::{divide_by_N, scale_20_times_log10};
51//! let scaling_fn = |val, stats: &_| scale_20_times_log10(divide_by_N(val, stats), stats);
52//! ```
53//!
54//! [`samples_fft_to_spectrum`]: crate::samples_fft_to_spectrum
55
56use crate::FrequencyValue;
57
58/// Helper struct for [`SpectrumScalingFunction`] that is passed into the
59/// scaling function together with the current frequency value.
60///
61/// This structure can be used to scale each value. All properties reference the
62/// current data of a [`FrequencySpectrum`].
63///
64/// [`FrequencySpectrum`]: crate::FrequencySpectrum
65/// [`FrequencyValue`]: crate::FrequencyValue
66#[derive(Debug)]
67pub struct SpectrumDataStats {
68 /// Minimal frequency value in spectrum.
69 pub min: FrequencyValue,
70 /// Maximum frequency value in spectrum.
71 pub max: FrequencyValue,
72 /// Average frequency value in spectrum.
73 pub average: FrequencyValue,
74 /// Number of samples (`samples.len()`), not the number of values in the
75 /// spectrum (which can be smaller due to a frequency limit).
76 pub n: f32,
77}
78
79/// Describes the type for a function that scales/normalizes the data inside
80/// [`FrequencySpectrum`].
81///
82/// The scaling only affects the value of the frequency, but not the
83/// frequency itself. It is applied to every single element.
84///
85/// A scaling function can be used for example to subtract the minimum (`min`)
86/// from each value. It is optional to use the second parameter
87/// [`SpectrumDataStats`], which describes the spectrum before the function is
88/// applied to it.
89///
90/// The type works with static functions as well as dynamically created
91/// closures.
92///
93/// You must take care of, that you don't have division by zero in your function
94/// or that the result is NaN or Infinity (regarding IEEE-754). If the result
95/// is NaN or Infinity, the library will return `Err`.
96///
97/// [`FrequencySpectrum`]: crate::FrequencySpectrum
98/// [`FrequencyValue`]: crate::FrequencyValue
99pub type SpectrumScalingFunction = dyn Fn(f32, &SpectrumDataStats) -> f32;
100
101/// Lower bound for the input of [`scale_20_times_log10`], i.e., `-100 dB`.
102const DB_FLOOR: f32 = 1e-5;
103
104/// Converts each value to decibels: `20 * log10(value)`.
105///
106/// See the [module docs](crate::scaling) for picking a scaling function.
107///
108/// A value of `1.0` becomes `0 dB`. Unscaled values grow with the number of
109/// samples (see [`crate::samples_fft_to_spectrum`]), so the absolute levels
110/// depend on `N` and on the input range. For levels relative to a full-scale
111/// sine wave (dBFS), scale to amplitudes first, in a separate call to
112/// `apply_scaling_fn`.
113///
114/// Values below `1e-5` are clamped, so the result is never below `-100 dB`
115/// and silence stays at the bottom of the scale.
116///
117/// This scaling is quite common, you can find more information for example
118/// here:
119/// <https://www.sjsu.edu/people/burford.furman/docs/me120/FFT_tutorial_NI.pdf>
120///
121/// ## Usage
122/// ```rust
123///use spectrum_analyzer::{samples_fft_to_spectrum, scaling, FrequencyLimit};
124///let window = [0.0, 0.1, 0.2, 0.3]; // add real data here
125///let spectrum = samples_fft_to_spectrum(
126/// &window,
127/// 44100,
128/// FrequencyLimit::All,
129/// Some(&scaling::scale_20_times_log10),
130/// );
131/// ```
132/// Function is of type [`SpectrumScalingFunction`].
133#[must_use]
134pub fn scale_20_times_log10(fr_val: f32, _stats: &SpectrumDataStats) -> f32 {
135 debug_assert!(!fr_val.is_infinite());
136 debug_assert!(!fr_val.is_nan());
137 debug_assert!(fr_val >= 0.0);
138 // Clamping keeps silence below every other value (0 dB would not).
139 20.0 * libm::log10f(fr_val.max(DB_FLOOR))
140}
141
142/// Divides each value by the maximum, so that the loudest frequency becomes
143/// `1.0` and every other keeps its ratio to it.
144///
145/// See the [module docs](crate::scaling) for picking a scaling function.
146///
147/// The smallest value only becomes `0.0` if it already was `0.0`; the values
148/// are not stretched over the whole interval. All of them must be positive or
149/// zero, which holds for magnitudes but not for the output of
150/// [`scale_20_times_log10`]. If the maximum is `0.0`, all values become `0.0`.
151///
152/// Function is of type [`SpectrumScalingFunction`].
153#[must_use]
154pub fn scale_to_zero_to_one(fr_val: f32, stats: &SpectrumDataStats) -> f32 {
155 debug_assert!(!fr_val.is_infinite());
156 debug_assert!(!fr_val.is_nan());
157 debug_assert!(fr_val >= 0.0);
158 if stats.max != 0.0 {
159 fr_val / stats.max
160 } else {
161 0.0
162 }
163}
164
165/// Divides each value by `N`, the number of samples.
166///
167/// See the [module docs](crate::scaling) for picking a scaling function.
168///
169/// This makes spectra of different lengths comparable. A sine wave with
170/// amplitude `A` on a bin frequency then shows up as `A / 2` (times the
171/// window's coherent gain), see [`crate::samples_fft_to_spectrum`].
172#[allow(non_snake_case)]
173#[must_use]
174pub fn divide_by_N(fr_val: f32, stats: &SpectrumDataStats) -> f32 {
175 debug_assert!(!fr_val.is_infinite());
176 debug_assert!(!fr_val.is_nan());
177 debug_assert!(fr_val >= 0.0);
178 if stats.n == 0.0 {
179 fr_val
180 } else {
181 fr_val / stats.n
182 }
183}
184
185/// Like [`divide_by_N`] but divides each value by `sqrt(N)`.
186///
187/// See the [module docs](crate::scaling) for picking a scaling function.
188///
189/// This is the normalization that preserves the energy of the signal, which
190/// `rustfft` recommends for a forward and inverse transform pair. The values
191/// still grow with `sqrt(N)`, so for comparing spectra of different lengths
192/// use [`divide_by_N`] instead.
193/// See <https://docs.rs/rustfft/latest/rustfft/#normalization>
194#[allow(non_snake_case)]
195#[must_use]
196pub fn divide_by_N_sqrt(fr_val: f32, stats: &SpectrumDataStats) -> f32 {
197 debug_assert!(!fr_val.is_infinite());
198 debug_assert!(!fr_val.is_nan());
199 debug_assert!(fr_val >= 0.0);
200 if stats.n == 0.0 {
201 fr_val
202 } else {
203 // https://docs.rs/rustfft/latest/rustfft/#normalization
204 fr_val / libm::sqrtf(stats.n)
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use alloc::vec::Vec;
212
213 #[test]
214 fn test_scale_to_zero_to_one() {
215 let data = vec![0.0_f32, 1.1, 2.2, 3.3, 4.4, 5.5];
216 let stats = SpectrumDataStats {
217 min: data[0].into(),
218 max: data[data.len() - 1].into(),
219 average: (data.iter().sum::<f32>() / data.len() as f32).into(),
220 n: data.len() as f32,
221 };
222 // check that type matches
223 let scaling_fn: &SpectrumScalingFunction = &scale_to_zero_to_one;
224 let scaled_data = data
225 .into_iter()
226 .map(|x| scaling_fn(x, &stats))
227 .collect::<Vec<_>>();
228 let expected = [0.0_f32, 0.2, 0.4, 0.6, 0.8, 1.0];
229 for (expected_val, actual_val) in expected.iter().zip(scaled_data.iter()) {
230 float_cmp::approx_eq!(f32, *expected_val, *actual_val, ulps = 3);
231 }
232 }
233
234 #[test]
235 fn test_scale_20_times_log10() {
236 let stats = SpectrumDataStats {
237 min: 0.0.into(),
238 max: 10.0.into(),
239 average: 0.0.into(),
240 n: 4.0,
241 };
242 let db = |val: f32| scale_20_times_log10(val, &stats);
243 assert!(float_cmp::approx_eq!(f32, db(1.0), 0.0, epsilon = 1e-4));
244 assert!(float_cmp::approx_eq!(f32, db(10.0), 20.0, epsilon = 1e-4));
245 assert!(float_cmp::approx_eq!(f32, db(0.0), -100.0, epsilon = 1e-3));
246 // silence must stay below every other value
247 assert!(db(0.0) < db(0.5) && db(0.5) < db(1.0));
248 }
249
250 /// A closure replaces the removed `combined()`: it can chain the
251 /// functions and, unlike `combined()`, capture its environment.
252 #[test]
253 fn test_chaining_with_a_closure() {
254 let stats = SpectrumDataStats {
255 min: 0.0.into(),
256 max: 10.0.into(),
257 average: 5.0.into(),
258 n: 4.0,
259 };
260 let scaling_fn = |val, stats: &_| scale_20_times_log10(divide_by_N(val, stats), stats);
261 let _: &SpectrumScalingFunction = &scaling_fn;
262 // 10.0 / 4 = 2.5 -> 20 * log10(2.5)
263 assert!(float_cmp::approx_eq!(
264 f32,
265 scaling_fn(stats.max.val(), &stats),
266 7.9588,
267 epsilon = 1e-3
268 ));
269 }
270}