Skip to main content

audio_lab/signal/types/
spectrogram.rs

1use ndarray::{prelude::*, s};
2use num::complex::Complex64;
3
4use super::{SignalError, validate_sample_rate};
5use crate::dsp::window::{WindowFn, generate_periodic_window};
6use crate::signal::data::XAxis;
7
8/// Window gains used to calibrate spectra. Always computed from the window, see
9/// [`Spectrogram::normalization`].
10#[derive(Clone, Copy, Debug, PartialEq)]
11pub struct SpectrogramNormalization {
12    /// Mean window value, used to correct coherent tone amplitude.
13    coherent_gain: f64,
14    /// Sum of squared window values, used for power / PSD estimates.
15    window_energy: f64,
16}
17
18impl SpectrogramNormalization {
19    /// Gains of the periodic `window_fn` window of `window_size` samples, the
20    /// window [`stft`](crate::dsp::stft()) tapers frames with.
21    pub(crate) fn of_window(window_fn: WindowFn, window_size: usize) -> Self {
22        let window = generate_periodic_window(window_fn, window_size);
23        let coherent_gain = window.sum() / window_size as f64;
24        let window_energy = window.iter().map(|w| w * w).sum();
25        // Periodic windows are never all zeros (Blackman, the smallest, has a
26        // mean of 0.42), so calibrated spectra never divide by zero.
27        debug_assert!(window_size == 0 || coherent_gain > 0.4);
28        Self {
29            coherent_gain,
30            window_energy,
31        }
32    }
33
34    pub fn coherent_gain(&self) -> f64 {
35        self.coherent_gain
36    }
37
38    pub fn window_energy(&self) -> f64 {
39        self.window_energy
40    }
41}
42
43/// A short-time Fourier transform (STFT) result.
44///
45/// Data layout: `(num_channels, num_frames, num_freq_bins)`.
46/// Use [`channel`](Spectrogram::channel) for per-channel access and
47/// [`frame`](Spectrogram::frame) for per-frame access.
48///
49/// Frame times and frequency bins are derived from the sample rate, window
50/// size and hop size, so they always match the data.
51#[derive(Clone, Debug, PartialEq)]
52pub struct Spectrogram {
53    /// Shape: (num_channels, num_frames, num_freq_bins)
54    data: Array3<Complex64>,
55    /// Center time of each frame in seconds
56    frame_axis: XAxis,
57    /// Frequency bins in Hz
58    freq_axis: XAxis,
59    sample_rate: f64,
60    window_size: usize,
61    hop_size: usize,
62    window_fn: WindowFn,
63    /// Derived from `window_fn` and `window_size`, cached.
64    normalization: SpectrogramNormalization,
65    channel_labels: Vec<Option<String>>,
66}
67
68impl Spectrogram {
69    /// Wrap STFT data, shape `(num_channels, num_frames, num_freq_bins)`.
70    ///
71    /// Frame `f` is centred at `(f * hop_size + window_size / 2) / sample_rate`
72    /// seconds and bin `k` sits at `k * sample_rate / window_size` Hz. The data
73    /// must have `window_size / 2 + 1` bins, as a real FFT of that size gives.
74    /// The calibration gains are computed from `window_fn`, the periodic window
75    /// the frames were tapered with.
76    pub fn new(
77        data: Array3<Complex64>,
78        sample_rate: f64,
79        window_size: usize,
80        hop_size: usize,
81        window_fn: WindowFn,
82    ) -> Result<Self, SignalError> {
83        validate_sample_rate(sample_rate)?;
84        if window_size == 0 {
85            return Err(SignalError::WindowSizeZero);
86        }
87        if hop_size == 0 {
88            return Err(SignalError::HopSizeZero);
89        }
90        let (num_channels, num_frames, num_freq_bins) = data.dim();
91        let expected_bins = window_size / 2 + 1;
92        if num_freq_bins != expected_bins {
93            return Err(SignalError::SpectrogramBinMismatch {
94                window_size,
95                expected: expected_bins,
96                actual: num_freq_bins,
97            });
98        }
99        let normalization = SpectrogramNormalization::of_window(window_fn, window_size);
100
101        let frame_axis = XAxis::uniform(
102            (window_size / 2) as f64 / sample_rate,
103            hop_size as f64 / sample_rate,
104            num_frames,
105        )
106        .expect("validated sample rate and hop size give a finite, positive step");
107        let freq_axis = XAxis::uniform(0.0, sample_rate / window_size as f64, num_freq_bins)
108            .expect("validated sample rate and window size give a finite, positive step");
109
110        Ok(Self {
111            data,
112            frame_axis,
113            freq_axis,
114            sample_rate,
115            window_size,
116            hop_size,
117            window_fn,
118            normalization,
119            channel_labels: vec![None; num_channels],
120        })
121    }
122
123    pub fn channel_label(&self, channel: usize) -> Option<&str> {
124        self.channel_labels.get(channel).and_then(|s| s.as_deref())
125    }
126
127    pub fn set_channel_label(&mut self, channel: usize, label: Option<&str>) {
128        if let Some(slot) = self.channel_labels.get_mut(channel) {
129            *slot = label.map(Into::into);
130        }
131    }
132
133    pub fn num_channels(&self) -> usize {
134        self.data.shape()[0]
135    }
136
137    pub fn num_frames(&self) -> usize {
138        self.data.shape()[1]
139    }
140
141    pub fn num_freq_bins(&self) -> usize {
142        self.data.shape()[2]
143    }
144
145    pub fn sample_rate(&self) -> f64 {
146        self.sample_rate
147    }
148
149    pub fn window_size(&self) -> usize {
150        self.window_size
151    }
152
153    pub fn hop_size(&self) -> usize {
154        self.hop_size
155    }
156
157    /// The window the frames were tapered with (its periodic form).
158    pub fn window_fn(&self) -> WindowFn {
159        self.window_fn
160    }
161
162    /// Calibration gains of [`window_fn`](Self::window_fn) at
163    /// [`window_size`](Self::window_size) samples.
164    pub fn normalization(&self) -> SpectrogramNormalization {
165        self.normalization
166    }
167
168    /// Center time of each STFT frame in seconds. Computed on each call; use
169    /// [`frame_axis`](Self::frame_axis) for single values.
170    pub fn frame_times(&self) -> CowArray<'_, f64, Ix1> {
171        self.frame_axis.to_cow_array()
172    }
173
174    /// Frame time axis in seconds, without allocating.
175    pub fn frame_axis(&self) -> &XAxis {
176        &self.frame_axis
177    }
178
179    /// Frequency bin centers in Hz. Computed on each call; use
180    /// [`freq_axis`](Self::freq_axis) for single values.
181    pub fn freq_bins(&self) -> CowArray<'_, f64, Ix1> {
182        self.freq_axis.to_cow_array()
183    }
184
185    /// Frequency axis in Hz, without allocating.
186    pub fn freq_axis(&self) -> &XAxis {
187        &self.freq_axis
188    }
189
190    /// Raw complex STFT data, shape `(num_channels, num_frames, num_freq_bins)`.
191    pub fn data(&self) -> ArrayView3<'_, Complex64> {
192        self.data.view()
193    }
194
195    /// Complex STFT data for a single channel, shape `(num_frames, num_freq_bins)`.
196    pub fn channel(&self, ch: usize) -> ArrayView2<'_, Complex64> {
197        self.data.slice(s![ch, .., ..])
198    }
199
200    /// Complex STFT data for a single frame, shape `(num_channels, num_freq_bins)`.
201    pub fn frame(&self, t: usize) -> ArrayView2<'_, Complex64> {
202        self.data.slice(s![.., t, ..])
203    }
204}
205
206/// Stored form: the data plus what cannot be derived from it. Frame times,
207/// frequency bins and calibration gains are rebuilt when reading, through
208/// [`Spectrogram::new`].
209#[cfg(feature = "serde")]
210impl serde::Serialize for Spectrogram {
211    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
212        #[derive(serde::Serialize)]
213        struct Stored<'a> {
214            data: ArrayView3<'a, Complex64>,
215            sample_rate: f64,
216            window_size: usize,
217            hop_size: usize,
218            window_fn: WindowFn,
219            channel_labels: &'a [Option<String>],
220        }
221
222        Stored {
223            data: self.data(),
224            sample_rate: self.sample_rate,
225            window_size: self.window_size,
226            hop_size: self.hop_size,
227            window_fn: self.window_fn,
228            channel_labels: &self.channel_labels,
229        }
230        .serialize(serializer)
231    }
232}
233
234#[cfg(feature = "serde")]
235impl<'de> serde::Deserialize<'de> for Spectrogram {
236    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
237        use serde::de::Error;
238
239        #[derive(serde::Deserialize)]
240        struct Stored {
241            data: Array3<Complex64>,
242            sample_rate: f64,
243            window_size: usize,
244            hop_size: usize,
245            window_fn: WindowFn,
246            channel_labels: Vec<Option<String>>,
247        }
248
249        let stored = Stored::deserialize(deserializer)?;
250        let mut spectrogram = Spectrogram::new(
251            stored.data,
252            stored.sample_rate,
253            stored.window_size,
254            stored.hop_size,
255            stored.window_fn,
256        )
257        .map_err(Error::custom)?;
258        if stored.channel_labels.len() != spectrogram.num_channels() {
259            return Err(Error::custom(
260                crate::signal::data::DataError::ChannelLabelMismatch {
261                    labels: stored.channel_labels.len(),
262                    channels: spectrogram.num_channels(),
263                },
264            ));
265        }
266        spectrogram.channel_labels = stored.channel_labels;
267        Ok(spectrogram)
268    }
269}
270
271impl std::fmt::Display for Spectrogram {
272    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
273        write!(
274            f,
275            "Spectrogram with {} channels, {} frames, {} freq bins at {} Hz (window={}, hop={}).",
276            self.num_channels(),
277            self.num_frames(),
278            self.num_freq_bins(),
279            self.sample_rate(),
280            self.window_size(),
281            self.hop_size(),
282        )
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    fn data(window_size: usize, num_frames: usize) -> Array3<Complex64> {
291        Array3::from_shape_fn((2, num_frames, window_size / 2 + 1), |(ch, f, b)| {
292            Complex64::new((ch + 1) as f64 * b as f64, f as f64)
293        })
294    }
295
296    #[test]
297    fn new_rejects_inconsistent_parameters() {
298        let result = Spectrogram::new(data(8, 3), 0.0, 8, 4, WindowFn::Hann);
299        assert!(matches!(result, Err(SignalError::SampleRateZeroOrNeg)));
300
301        let result = Spectrogram::new(data(8, 3), 8.0, 0, 4, WindowFn::Hann);
302        assert!(matches!(result, Err(SignalError::WindowSizeZero)));
303
304        let result = Spectrogram::new(data(8, 3), 8.0, 8, 0, WindowFn::Hann);
305        assert!(matches!(result, Err(SignalError::HopSizeZero)));
306
307        // 5 bins fit windows of 8 or 9 samples, not 16.
308        assert!(Spectrogram::new(data(8, 3), 8.0, 9, 4, WindowFn::Hann).is_ok());
309        let result = Spectrogram::new(data(8, 3), 8.0, 16, 4, WindowFn::Hann);
310        assert!(matches!(
311            result,
312            Err(SignalError::SpectrogramBinMismatch {
313                window_size: 16,
314                expected: 9,
315                actual: 5
316            })
317        ));
318    }
319
320    #[test]
321    fn normalization_is_computed_from_the_window() {
322        for window_fn in [
323            WindowFn::Hann,
324            WindowFn::Hamming,
325            WindowFn::Blackman,
326            WindowFn::Rectangular,
327        ] {
328            let spec = Spectrogram::new(data(8, 3), 8.0, 8, 4, window_fn).unwrap();
329            let window = generate_periodic_window(window_fn, 8);
330            assert_eq!(spec.window_fn(), window_fn);
331            approx::assert_relative_eq!(
332                spec.normalization().coherent_gain(),
333                window.sum() / 8.0,
334                max_relative = 1e-15
335            );
336            approx::assert_relative_eq!(
337                spec.normalization().window_energy(),
338                window.iter().map(|w| w * w).sum::<f64>(),
339                max_relative = 1e-15
340            );
341        }
342    }
343
344    #[test]
345    fn every_window_gives_positive_gains_down_to_one_sample() {
346        // The symmetric 2-sample Hann and Blackman windows are all zeros; the
347        // periodic ones used here are not.
348        for window_fn in [
349            WindowFn::Hann,
350            WindowFn::Hamming,
351            WindowFn::Blackman,
352            WindowFn::Rectangular,
353        ] {
354            for window_size in [1, 2, 3] {
355                let spec =
356                    Spectrogram::new(data(window_size, 3), 8.0, window_size, 1, window_fn).unwrap();
357                assert!(spec.normalization().coherent_gain() > 0.4);
358                assert!(spec.normalization().window_energy() > 0.0);
359            }
360        }
361    }
362
363    #[test]
364    fn axes_follow_from_sample_rate_window_and_hop() {
365        let (sample_rate, window_size, hop_size, num_frames) = (48_000.0, 1024, 256, 40);
366        let spec = Spectrogram::new(
367            data(window_size, num_frames),
368            sample_rate,
369            window_size,
370            hop_size,
371            WindowFn::Hann,
372        )
373        .unwrap();
374
375        assert_eq!(spec.frame_axis().len(), num_frames);
376        assert_eq!(spec.freq_axis().len(), window_size / 2 + 1);
377        // Same values the STFT used to compute and pass in.
378        for (f, time) in spec.frame_axis().iter().enumerate() {
379            let expected = (f * hop_size + window_size / 2) as f64 / sample_rate;
380            approx::assert_relative_eq!(time, expected, max_relative = 1e-14);
381        }
382        for (k, freq) in spec.freq_axis().iter().enumerate() {
383            approx::assert_relative_eq!(
384                freq,
385                k as f64 * sample_rate / window_size as f64,
386                max_relative = 1e-14
387            );
388        }
389        assert_eq!(spec.frame_times()[0], spec.frame_axis().get(0));
390    }
391
392    #[cfg(feature = "serde")]
393    #[test]
394    fn serde_stores_parameters_and_validates_on_read() {
395        use serde_json::json;
396
397        let mut spec = Spectrogram::new(data(8, 3), 8.0, 8, 4, WindowFn::Hann).unwrap();
398        spec.set_channel_label(1, Some("right"));
399
400        let json = serde_json::to_value(&spec).unwrap();
401        let mut keys: Vec<_> = json.as_object().unwrap().keys().cloned().collect();
402        keys.sort();
403        assert_eq!(
404            keys,
405            [
406                "channel_labels",
407                "data",
408                "hop_size",
409                "sample_rate",
410                "window_fn",
411                "window_size"
412            ]
413        );
414
415        let back: Spectrogram = serde_json::from_value(json.clone()).unwrap();
416        assert_eq!(back, spec);
417        assert_eq!(back.channel_label(1), Some("right"));
418
419        let read_edited = |key: &str, value: serde_json::Value| {
420            let mut edited = json.clone();
421            edited[key] = value;
422            serde_json::from_value::<Spectrogram>(edited)
423                .unwrap_err()
424                .to_string()
425        };
426        assert!(read_edited("sample_rate", json!(-1.0)).contains("Sample rate"));
427        assert!(read_edited("hop_size", json!(0)).contains("Hop size"));
428        assert!(read_edited("window_size", json!(32)).contains("but the data has 5"));
429        assert!(read_edited("channel_labels", json!([null])).contains("1 channel labels for 2"));
430        assert!(read_edited("window_fn", json!("Kaiser")).contains("unknown variant"));
431    }
432}