Skip to main content

harmos_signal/spectrum/
taper.rs

1//! The two shaping vocabularies: what a taper does to samples before the
2//! transform, and what a band does to bins before the reconstruction.
3
4use std::f64::consts::PI;
5
6/// A window function applied to samples before the forward transform.
7///
8/// Every taper trades main-lobe width against sidelobe height. A rectangular
9/// taper is no taper at all: it keeps amplitude exact for a tone that lands on
10/// a bin and leaks badly for one that does not.
11///
12/// The forward transform divides by this taper's coherent gain, so a tone's
13/// recovered amplitude is the tone's amplitude whichever taper measured it.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum Taper {
16    /// No shaping: exact on-bin amplitude, worst leakage off it.
17    Rectangular,
18    /// Hamming — a lower first sidelobe than Hann at a higher noise floor.
19    Hamming,
20    /// Hann, once spelled Hanning — the general-purpose choice, zero at both ends.
21    Hann,
22    /// Blackman — the lowest sidelobes here, and the widest main lobe.
23    Blackman,
24}
25
26impl Taper {
27    /// This taper's coefficient for sample `index` of a `length`-sample segment.
28    #[must_use]
29    pub fn coefficient(&self, index: usize, length: usize) -> f64 {
30        let length = length as f64;
31        let index = index as f64;
32
33        match self {
34            Self::Rectangular => 1.0,
35            Self::Hamming => 0.54 - 0.46 * (2.0 * PI * index / length).cos(),
36            Self::Hann => 0.5 * (1.0 - (2.0 * PI * index / length).cos()),
37            Self::Blackman => {
38                0.42 - 0.5 * (2.0 * PI * index / length).cos()
39                    + 0.08 * (4.0 * PI * index / length).cos()
40            }
41        }
42    }
43
44    /// The coefficients of a whole `length`-sample segment.
45    #[must_use]
46    pub fn coefficients(&self, length: usize) -> Vec<f64> {
47        (0..length)
48            .map(|index| self.coefficient(index, length))
49            .collect()
50    }
51}
52
53/// A frequency-domain window: which bins survive a reconstruction, and how
54/// sharply the surviving band ends.
55///
56/// Every shape is zero outside `start..=end`. They differ in what happens
57/// inside it, which is the difference between a brick-wall filter and one whose
58/// edges do not ring.
59#[derive(Clone, Copy, Debug, PartialEq)]
60pub enum Band {
61    /// A brick wall: full pass inside the band, nothing outside it.
62    Rectangular {
63        /// The lowest frequency the band keeps, in Hz.
64        start: f64,
65        /// The highest frequency the band keeps, in Hz.
66        end: f64,
67    },
68    /// Raised-cosine edges of a stated width, flat in between.
69    RaisedCosine {
70        /// The lowest frequency the band keeps, in Hz.
71        start: f64,
72        /// The highest frequency the band keeps, in Hz.
73        end: f64,
74        /// The width of each cosine edge, in Hz.
75        transition: f64,
76    },
77    /// A Kaiser shape, whose `beta` trades edge width against sidelobe height.
78    Kaiser {
79        /// The lowest frequency the band keeps, in Hz.
80        start: f64,
81        /// The highest frequency the band keeps, in Hz.
82        end: f64,
83        /// The shape parameter: larger is smoother and wider-edged.
84        beta: f64,
85    },
86    /// A Gaussian shape of a stated standard deviation, centred in the band.
87    Gaussian {
88        /// The lowest frequency the band keeps, in Hz.
89        start: f64,
90        /// The highest frequency the band keeps, in Hz.
91        end: f64,
92        /// The standard deviation, in Hz.
93        sigma: f64,
94    },
95}
96
97impl Band {
98    /// What fraction of one bin at `frequency` this band lets through, `0.0..=1.0`.
99    #[must_use]
100    pub fn coefficient(&self, frequency: f64) -> f64 {
101        let (start, end) = self.bounds();
102        if frequency < start || frequency > end {
103            return 0.0;
104        }
105
106        match self {
107            Self::Rectangular { .. } => 1.0,
108            Self::RaisedCosine { transition, .. } => {
109                if frequency < start + transition {
110                    0.5 * (1.0 - (PI * (frequency - start) / transition).cos())
111                } else if frequency > end - transition {
112                    0.5 * (1.0 - (PI * (end - frequency) / transition).cos())
113                } else {
114                    1.0
115                }
116            }
117            Self::Kaiser { beta, .. } => {
118                let half = (end - start) / 2.0;
119                let offset = (frequency - (start + end) / 2.0) / half;
120                bessel_i0(beta * (1.0 - offset * offset).sqrt()) / bessel_i0(*beta)
121            }
122            Self::Gaussian { sigma, .. } => {
123                let offset = (frequency - (start + end) / 2.0) / sigma;
124                (-0.5 * offset * offset).exp()
125            }
126        }
127    }
128
129    /// The band's frequency bounds, in Hz.
130    #[must_use]
131    pub fn bounds(&self) -> (f64, f64) {
132        match self {
133            Self::Rectangular { start, end }
134            | Self::RaisedCosine { start, end, .. }
135            | Self::Kaiser { start, end, .. }
136            | Self::Gaussian { start, end, .. } => (*start, *end),
137        }
138    }
139}
140
141/// The modified Bessel function of the first kind, order zero.
142///
143/// The Abramowitz and Stegun polynomial approximation, which is what a Kaiser
144/// shape is normally computed from and is accurate to about seven digits.
145fn bessel_i0(argument: f64) -> f64 {
146    let magnitude = argument.abs();
147
148    if magnitude < 3.75 {
149        let square = (argument / 3.75).powi(2);
150        1.0 + square
151            * (3.515_622_9
152                + square
153                    * (3.089_942_4
154                        + square
155                            * (1.206_749_2
156                                + square
157                                    * (0.265_973_2
158                                        + square * (0.036_076_8 + square * 0.004_581_3)))))
159    } else {
160        let ratio = 3.75 / magnitude;
161        (magnitude.exp() / magnitude.sqrt())
162            * (0.398_942_28
163                + ratio
164                    * (0.013_285_92
165                        + ratio
166                            * (0.002_253_19
167                                + ratio
168                                    * (-0.001_575_65
169                                        + ratio
170                                            * (0.009_162_81
171                                                + ratio
172                                                    * (-0.020_577_06
173                                                        + ratio
174                                                            * (0.026_355_37
175                                                                + ratio
176                                                                    * (-0.016_476_33
177                                                                        + ratio
178                                                                            * 0.003_923_77))))))))
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn a_rectangular_taper_is_no_taper() {
188        for index in 0..64 {
189            assert_eq!(Taper::Rectangular.coefficient(index, 64), 1.0);
190        }
191    }
192
193    #[test]
194    fn hann_starts_at_zero_and_hamming_at_its_pedestal() {
195        assert!(Taper::Hann.coefficient(0, 128) < 1e-12);
196        assert!((Taper::Hamming.coefficient(0, 128) - 0.08).abs() < 1e-12);
197    }
198
199    #[test]
200    fn every_taper_is_symmetric_about_its_centre() {
201        let length = 100;
202        for taper in [Taper::Hamming, Taper::Hann, Taper::Blackman] {
203            let left = taper.coefficient(length / 2 - 10, length);
204            let right = taper.coefficient(length / 2 + 10, length);
205            assert!((left - right).abs() < 1e-12, "{taper:?} is not symmetric");
206        }
207    }
208
209    #[test]
210    fn coefficients_are_the_whole_segment() {
211        let coefficients = Taper::Blackman.coefficients(32);
212
213        assert_eq!(coefficients.len(), 32);
214        assert_eq!(coefficients[7], Taper::Blackman.coefficient(7, 32));
215    }
216
217    #[test]
218    fn a_rectangular_band_is_a_brick_wall() {
219        let band = Band::Rectangular {
220            start: 100.0,
221            end: 200.0,
222        };
223
224        assert_eq!(band.coefficient(99.0), 0.0);
225        assert_eq!(band.coefficient(100.0), 1.0);
226        assert_eq!(band.coefficient(150.0), 1.0);
227        assert_eq!(band.coefficient(200.0), 1.0);
228        assert_eq!(band.coefficient(201.0), 0.0);
229    }
230
231    #[test]
232    fn a_raised_cosine_band_rises_and_falls_inside_its_edges() {
233        let band = Band::RaisedCosine {
234            start: 100.0,
235            end: 200.0,
236            transition: 20.0,
237        };
238
239        assert_eq!(band.coefficient(50.0), 0.0);
240        assert_eq!(band.coefficient(150.0), 1.0);
241        assert!((0.0..1.0).contains(&band.coefficient(110.0)));
242        assert!((0.0..1.0).contains(&band.coefficient(190.0)));
243    }
244
245    #[test]
246    fn a_kaiser_band_peaks_at_its_centre() {
247        let band = Band::Kaiser {
248            start: 0.0,
249            end: 100.0,
250            beta: 6.0,
251        };
252
253        assert!((band.coefficient(50.0) - 1.0).abs() < 1e-9);
254        assert!(band.coefficient(10.0) < band.coefficient(40.0));
255        assert_eq!(band.coefficient(101.0), 0.0);
256    }
257
258    #[test]
259    fn a_gaussian_band_falls_off_by_its_sigma() {
260        let band = Band::Gaussian {
261            start: 0.0,
262            end: 100.0,
263            sigma: 25.0,
264        };
265
266        assert!((band.coefficient(50.0) - 1.0).abs() < 1e-12);
267        // One sigma from the centre is exp(-1/2) of the peak.
268        assert!((band.coefficient(75.0) - (-0.5f64).exp()).abs() < 1e-12);
269    }
270
271    #[test]
272    fn every_band_reports_the_bounds_it_was_built_with() {
273        assert_eq!(
274            Band::Rectangular {
275                start: 1.0,
276                end: 2.0
277            }
278            .bounds(),
279            (1.0, 2.0)
280        );
281        assert_eq!(
282            Band::RaisedCosine {
283                start: 1.0,
284                end: 2.0,
285                transition: 0.1
286            }
287            .bounds(),
288            (1.0, 2.0)
289        );
290        assert_eq!(
291            Band::Kaiser {
292                start: 1.0,
293                end: 2.0,
294                beta: 6.0
295            }
296            .bounds(),
297            (1.0, 2.0)
298        );
299        assert_eq!(
300            Band::Gaussian {
301                start: 1.0,
302                end: 2.0,
303                sigma: 0.5
304            }
305            .bounds(),
306            (1.0, 2.0)
307        );
308    }
309}