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#[derive(Clone, Copy, Debug, PartialEq)]
11pub struct SpectrogramNormalization {
12 coherent_gain: f64,
14 window_energy: f64,
16}
17
18impl SpectrogramNormalization {
19 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 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#[derive(Clone, Debug, PartialEq)]
52pub struct Spectrogram {
53 data: Array3<Complex64>,
55 frame_axis: XAxis,
57 freq_axis: XAxis,
59 sample_rate: f64,
60 window_size: usize,
61 hop_size: usize,
62 window_fn: WindowFn,
63 normalization: SpectrogramNormalization,
65 channel_labels: Vec<Option<String>>,
66}
67
68impl Spectrogram {
69 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 pub fn window_fn(&self) -> WindowFn {
159 self.window_fn
160 }
161
162 pub fn normalization(&self) -> SpectrogramNormalization {
165 self.normalization
166 }
167
168 pub fn frame_times(&self) -> CowArray<'_, f64, Ix1> {
171 self.frame_axis.to_cow_array()
172 }
173
174 pub fn frame_axis(&self) -> &XAxis {
176 &self.frame_axis
177 }
178
179 pub fn freq_bins(&self) -> CowArray<'_, f64, Ix1> {
182 self.freq_axis.to_cow_array()
183 }
184
185 pub fn freq_axis(&self) -> &XAxis {
187 &self.freq_axis
188 }
189
190 pub fn data(&self) -> ArrayView3<'_, Complex64> {
192 self.data.view()
193 }
194
195 pub fn channel(&self, ch: usize) -> ArrayView2<'_, Complex64> {
197 self.data.slice(s![ch, .., ..])
198 }
199
200 pub fn frame(&self, t: usize) -> ArrayView2<'_, Complex64> {
202 self.data.slice(s![.., t, ..])
203 }
204}
205
206#[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 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 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 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}