Skip to main content

audio_lab/signal/types/
mod.rs

1use ndarray::prelude::*;
2
3use crate::signal::data::XAxis;
4
5pub use freq_signal::FreqSignal;
6pub use spectrogram::{Spectrogram, SpectrogramNormalization};
7pub use time_signal::TimeSignal;
8
9mod freq_signal;
10mod spectrogram;
11mod time_signal;
12
13#[derive(Debug, thiserror::Error)]
14pub enum SignalError {
15    #[error("Sample rate must be finite and > 0")]
16    SampleRateZeroOrNeg,
17    #[error("At least one signal is required")]
18    EmptySignalList,
19    #[error("All signals must have the same sample rate")]
20    SampleRateMismatch,
21    #[error("All signals must have the same number of time steps")]
22    TimeStepMismatch,
23    #[error("All frequency signals must have the same number of bins")]
24    FrequencyBinMismatch,
25    #[error("All signals must have the same number of channels")]
26    ChannelMismatch,
27    #[error("num_time_steps must be > 0 when there are {num_freq_bins} frequency bins")]
28    NumTimeStepsZero { num_freq_bins: usize },
29    #[error("Window size must be > 0")]
30    WindowSizeZero,
31    #[error("Hop size must be > 0")]
32    HopSizeZero,
33    #[error(
34        "A window of {window_size} samples gives {expected} frequency bins, but the data has {actual}"
35    )]
36    SpectrogramBinMismatch {
37        window_size: usize,
38        expected: usize,
39        actual: usize,
40    },
41}
42
43fn validate_sample_rate(sample_rate: f64) -> Result<(), SignalError> {
44    if sample_rate.is_finite() && sample_rate > 0.0 {
45        Ok(())
46    } else {
47        Err(SignalError::SampleRateZeroOrNeg)
48    }
49}
50
51/// Uniform time axis `i / sample_rate` in seconds. `sample_rate` must be validated.
52fn time_axis(num_time_steps: usize, sample_rate: f64) -> XAxis {
53    XAxis::uniform(0.0, 1.0 / sample_rate, num_time_steps)
54        .expect("validated sample rate gives a finite, positive step")
55}
56
57/// Uniform frequency axis `k · sample_rate / num_time_steps` in Hz.
58/// `sample_rate` must be validated.
59fn freq_axis(
60    num_freq_bins: usize,
61    sample_rate: f64,
62    num_time_steps: usize,
63) -> Result<XAxis, SignalError> {
64    if num_time_steps == 0 {
65        // No spacing is defined. A single bin sits at 0 Hz either way.
66        if num_freq_bins > 1 {
67            return Err(SignalError::NumTimeStepsZero { num_freq_bins });
68        }
69        return Ok(XAxis::indices(num_freq_bins));
70    }
71    Ok(
72        XAxis::uniform(0.0, sample_rate / num_time_steps as f64, num_freq_bins)
73            .expect("validated sample rate gives a finite, positive step"),
74    )
75}
76
77pub fn join_time_signals(signals: &[TimeSignal]) -> Result<TimeSignal, SignalError> {
78    let Some(first_signal) = signals.first() else {
79        return Err(SignalError::EmptySignalList);
80    };
81
82    let sample_rate = first_signal.sample_rate();
83    let num_time_steps = first_signal.num_time_steps();
84
85    if signals
86        .iter()
87        .any(|signal| signal.sample_rate() != sample_rate)
88    {
89        return Err(SignalError::SampleRateMismatch);
90    }
91    if signals
92        .iter()
93        .any(|signal| signal.num_time_steps() != num_time_steps)
94    {
95        return Err(SignalError::TimeStepMismatch);
96    }
97
98    let total_channels = signals.iter().map(|signal| signal.num_channels()).sum();
99    let mut data = Array2::zeros((total_channels, num_time_steps));
100    let mut next_channel = 0;
101
102    for signal in signals {
103        let end_channel = next_channel + signal.num_channels();
104        data.slice_mut(s![next_channel..end_channel, ..])
105            .assign(&signal.time_data());
106        next_channel = end_channel;
107    }
108
109    let mut stacked = TimeSignal::new(data, sample_rate)?;
110    propagate_channel_labels(&mut stacked, signals);
111    Ok(stacked)
112}
113
114fn propagate_channel_labels(stacked: &mut TimeSignal, signals: &[TimeSignal]) {
115    let mut next_channel = 0;
116    for signal in signals {
117        let multi_channel = signal.num_channels() > 1;
118        for source_channel in 0..signal.num_channels() {
119            let label = source_label(
120                signal.channel_label(source_channel),
121                signal.comment(),
122                source_channel,
123                multi_channel,
124            );
125            stacked.set_channel_label(next_channel, label.as_deref());
126            next_channel += 1;
127        }
128    }
129}
130
131fn source_label(
132    explicit_label: Option<&str>,
133    comment: Option<&str>,
134    source_channel: usize,
135    multi_channel: bool,
136) -> Option<String> {
137    if let Some(label) = explicit_label {
138        return Some(label.to_string());
139    }
140    let base = comment?;
141    Some(if multi_channel {
142        format!("{base} · Ch {source_channel}")
143    } else {
144        base.to_string()
145    })
146}
147
148pub fn mix_time_signals(signals: &[TimeSignal]) -> Result<TimeSignal, SignalError> {
149    let Some(first) = signals.first() else {
150        return Err(SignalError::EmptySignalList);
151    };
152
153    let sample_rate = first.sample_rate();
154    let num_time_steps = first.num_time_steps();
155    let num_channels = first.num_channels();
156
157    if signals.iter().any(|s| s.sample_rate() != sample_rate) {
158        return Err(SignalError::SampleRateMismatch);
159    }
160    if signals.iter().any(|s| s.num_time_steps() != num_time_steps) {
161        return Err(SignalError::TimeStepMismatch);
162    }
163    if signals.iter().any(|s| s.num_channels() != num_channels) {
164        return Err(SignalError::ChannelMismatch);
165    }
166
167    let mut data = Array2::zeros((num_channels, num_time_steps));
168    for signal in signals {
169        data += &signal.time_data();
170    }
171
172    TimeSignal::new(data, sample_rate)
173}
174
175pub fn join_freq_signals(signals: &[FreqSignal]) -> Result<FreqSignal, SignalError> {
176    let Some(first_signal) = signals.first() else {
177        return Err(SignalError::EmptySignalList);
178    };
179
180    let sample_rate = first_signal.sample_rate();
181    let num_time_steps = first_signal.num_time_steps();
182    let num_freq_bins = first_signal.num_freq_bins();
183
184    if signals
185        .iter()
186        .any(|signal| signal.sample_rate() != sample_rate)
187    {
188        return Err(SignalError::SampleRateMismatch);
189    }
190    if signals
191        .iter()
192        .any(|signal| signal.num_time_steps() != num_time_steps)
193    {
194        return Err(SignalError::TimeStepMismatch);
195    }
196    if signals
197        .iter()
198        .any(|signal| signal.num_freq_bins() != num_freq_bins)
199    {
200        return Err(SignalError::FrequencyBinMismatch);
201    }
202
203    let total_channels = signals.iter().map(|signal| signal.num_channels()).sum();
204    let mut data = Array2::zeros((total_channels, num_freq_bins));
205    let mut next_channel = 0;
206
207    for signal in signals {
208        let end_channel = next_channel + signal.num_channels();
209        data.slice_mut(s![next_channel..end_channel, ..])
210            .assign(&signal.freq_data());
211        next_channel = end_channel;
212    }
213
214    let mut stacked = FreqSignal::new(data, sample_rate, Some(num_time_steps))?;
215    propagate_channel_labels_freq(&mut stacked, signals);
216    Ok(stacked)
217}
218
219fn propagate_channel_labels_freq(stacked: &mut FreqSignal, signals: &[FreqSignal]) {
220    let mut next_channel = 0;
221    for signal in signals {
222        let multi_channel = signal.num_channels() > 1;
223        for source_channel in 0..signal.num_channels() {
224            let label = source_label(
225                signal.channel_label(source_channel),
226                signal.comment(),
227                source_channel,
228                multi_channel,
229            );
230            stacked.set_channel_label(next_channel, label.as_deref());
231            next_channel += 1;
232        }
233    }
234}
235
236pub fn mix_freq_signals(signals: &[FreqSignal]) -> Result<FreqSignal, SignalError> {
237    let Some(first) = signals.first() else {
238        return Err(SignalError::EmptySignalList);
239    };
240
241    let sample_rate = first.sample_rate();
242    let num_time_steps = first.num_time_steps();
243    let num_freq_bins = first.num_freq_bins();
244    let num_channels = first.num_channels();
245
246    if signals
247        .iter()
248        .any(|signal| signal.sample_rate() != sample_rate)
249    {
250        return Err(SignalError::SampleRateMismatch);
251    }
252    if signals
253        .iter()
254        .any(|signal| signal.num_time_steps() != num_time_steps)
255    {
256        return Err(SignalError::TimeStepMismatch);
257    }
258    if signals
259        .iter()
260        .any(|signal| signal.num_freq_bins() != num_freq_bins)
261    {
262        return Err(SignalError::FrequencyBinMismatch);
263    }
264    if signals
265        .iter()
266        .any(|signal| signal.num_channels() != num_channels)
267    {
268        return Err(SignalError::ChannelMismatch);
269    }
270
271    let mut data = Array2::zeros((num_channels, num_freq_bins));
272    for signal in signals {
273        data += &signal.freq_data();
274    }
275
276    FreqSignal::new(data, sample_rate, Some(num_time_steps))
277}
278
279pub mod utils {
280    use super::*;
281
282    pub fn f_from_t(t: usize) -> usize {
283        t / 2 + 1
284    }
285
286    pub fn t_from_f(f: usize) -> usize {
287        f.saturating_sub(1) * 2
288    }
289
290    pub fn generate_time_steps(num: usize, sample_rate: f64) -> Array1<f64> {
291        if num == 0 {
292            return Array1::zeros(0);
293        }
294        let end_time = (num - 1) as f64 / sample_rate;
295        Array1::linspace(0.0, end_time, num)
296    }
297
298    pub fn generate_freq_steps(num: usize, sample_rate: f64, num_time_steps: usize) -> Array1<f64> {
299        if num == 0 || num_time_steps == 0 {
300            return Array1::zeros(num);
301        }
302        let end_freq = (num - 1) as f64 * sample_rate / num_time_steps as f64;
303        Array1::linspace(0.0, end_freq, num)
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use approx::assert_abs_diff_eq;
310    use ndarray::arr2;
311    use num::complex::Complex64;
312
313    use super::*;
314
315    #[test]
316    fn join_channels_from_multiple_signals() -> Result<(), SignalError> {
317        let mut first = TimeSignal::new(arr2(&[[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]), 48_000.0)?;
318        first.set_comment(Some("first"));
319        let second = TimeSignal::new(arr2(&[[6.0, 7.0, 8.0]]), 48_000.0)?;
320
321        let joined = join_time!(first, second)?;
322
323        assert_eq!(joined.num_channels(), 3);
324        assert_eq!(
325            joined.time_data(),
326            arr2(&[[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0]])
327        );
328        assert_eq!(joined.comment(), None);
329        assert_eq!(joined.channel_label(0), Some("first · Ch 0"));
330        assert_eq!(joined.channel_label(1), Some("first · Ch 1"));
331        assert_eq!(joined.channel_label(2), None);
332        Ok(())
333    }
334
335    #[test]
336    fn join_channels_rejects_empty_input() {
337        let result = join_time_signals(&[]);
338        assert!(matches!(result, Err(SignalError::EmptySignalList)));
339    }
340
341    #[test]
342    fn join_channels_rejects_mismatched_inputs() -> Result<(), SignalError> {
343        let base = TimeSignal::new(arr2(&[[0.0, 1.0]]), 48_000.0)?;
344        let different_rate = TimeSignal::new(arr2(&[[2.0, 3.0]]), 44_100.0)?;
345        let different_length = TimeSignal::new(arr2(&[[2.0, 3.0, 4.0]]), 48_000.0)?;
346
347        let result = join_time!(base.clone(), different_rate);
348        assert!(matches!(result, Err(SignalError::SampleRateMismatch)));
349
350        let result = join_time!(base, different_length);
351        assert!(matches!(result, Err(SignalError::TimeStepMismatch)));
352        Ok(())
353    }
354
355    #[test]
356    fn mix_combines_matching_signals() -> Result<(), SignalError> {
357        let first = TimeSignal::new(arr2(&[[0.0, 1.0, 2.0]]), 48_000.0)?;
358        let second = TimeSignal::new(arr2(&[[0.5, 1.5, -1.0]]), 48_000.0)?;
359
360        let mixed = mix_time!(first, second)?;
361
362        assert_eq!(mixed.num_channels(), 1);
363        assert_eq!(mixed.time_data(), arr2(&[[0.5, 2.5, 1.0]]));
364        Ok(())
365    }
366
367    #[test]
368    fn mix_rejects_channel_mismatch() -> Result<(), SignalError> {
369        let mono = TimeSignal::new(arr2(&[[0.0, 1.0]]), 48_000.0)?;
370        let stereo = TimeSignal::new(arr2(&[[0.0, 1.0], [2.0, 3.0]]), 48_000.0)?;
371
372        let result = mix_time!(mono, stereo);
373
374        assert!(matches!(result, Err(SignalError::ChannelMismatch)));
375        Ok(())
376    }
377
378    #[test]
379    fn mixing_time_and_frequency_signals_produces_same_time_result() -> Result<(), SignalError> {
380        let first = TimeSignal::new(
381            arr2(&[[0.25, -0.5, 1.0, 0.75, -0.125, 0.0, 0.5, -0.25]]),
382            48_000.0,
383        )?;
384        let second = TimeSignal::new(
385            arr2(&[[0.5, 0.25, -0.75, 0.125, 0.875, -0.5, 0.0, 0.25]]),
386            48_000.0,
387        )?;
388
389        let mixed_time = mix_time_signals(&[first.clone(), second.clone()])?;
390        let mixed_freq = mix_freq_signals(&[first.into_freq(), second.into_freq()])?.into_time();
391
392        assert_abs_diff_eq!(mixed_time, mixed_freq, epsilon = 1e-12);
393        Ok(())
394    }
395
396    #[test]
397    fn join_frequency_channels_from_multiple_signals() -> Result<(), SignalError> {
398        let mut first = FreqSignal::new(
399            arr2(&[[
400                Complex64::new(1.0, 0.0),
401                Complex64::new(2.0, 0.0),
402                Complex64::new(3.0, 0.0),
403            ]]),
404            48_000.0,
405            Some(4),
406        )?;
407        first.set_comment(Some("first"));
408        let second = FreqSignal::new(
409            arr2(&[[
410                Complex64::new(4.0, 0.0),
411                Complex64::new(5.0, 0.0),
412                Complex64::new(6.0, 0.0),
413            ]]),
414            48_000.0,
415            Some(4),
416        )?;
417
418        let joined = join_freq_signals(&[first, second])?;
419
420        assert_eq!(joined.num_channels(), 2);
421        assert_eq!(
422            joined.freq_data(),
423            arr2(&[
424                [
425                    Complex64::new(1.0, 0.0),
426                    Complex64::new(2.0, 0.0),
427                    Complex64::new(3.0, 0.0),
428                ],
429                [
430                    Complex64::new(4.0, 0.0),
431                    Complex64::new(5.0, 0.0),
432                    Complex64::new(6.0, 0.0),
433                ]
434            ])
435        );
436        assert_eq!(joined.comment(), None);
437        assert_eq!(joined.channel_label(0), Some("first"));
438        assert_eq!(joined.channel_label(1), None);
439        Ok(())
440    }
441
442    #[test]
443    fn join_frequency_channels_rejects_empty_input() {
444        let result = join_freq_signals(&[]);
445        assert!(matches!(result, Err(SignalError::EmptySignalList)));
446    }
447
448    #[test]
449    fn join_frequency_channels_rejects_mismatched_inputs() -> Result<(), SignalError> {
450        let base = FreqSignal::new(
451            arr2(&[[Complex64::new(1.0, 0.0), Complex64::new(2.0, 0.0)]]),
452            48_000.0,
453            Some(2),
454        )?;
455        let different_rate = FreqSignal::new(
456            arr2(&[[Complex64::new(3.0, 0.0), Complex64::new(4.0, 0.0)]]),
457            44_100.0,
458            Some(2),
459        )?;
460        let different_length = FreqSignal::new(
461            arr2(&[[Complex64::new(3.0, 0.0), Complex64::new(4.0, 0.0)]]),
462            48_000.0,
463            Some(4),
464        )?;
465        let different_bins = FreqSignal::new(
466            arr2(&[[
467                Complex64::new(3.0, 0.0),
468                Complex64::new(4.0, 0.0),
469                Complex64::new(5.0, 0.0),
470            ]]),
471            48_000.0,
472            Some(2),
473        )?;
474
475        let result = join_freq_signals(&[base.clone(), different_rate]);
476        assert!(matches!(result, Err(SignalError::SampleRateMismatch)));
477
478        let result = join_freq_signals(&[base.clone(), different_length]);
479        assert!(matches!(result, Err(SignalError::TimeStepMismatch)));
480
481        let result = join_freq_signals(&[base, different_bins]);
482        assert!(matches!(result, Err(SignalError::FrequencyBinMismatch)));
483        Ok(())
484    }
485
486    #[test]
487    fn mix_frequency_signals_combines_matching_spectra() -> Result<(), SignalError> {
488        let first = FreqSignal::new(
489            arr2(&[[
490                Complex64::new(1.0, 2.0),
491                Complex64::new(3.0, 4.0),
492                Complex64::new(5.0, 6.0),
493            ]]),
494            48_000.0,
495            Some(4),
496        )?;
497        let second = FreqSignal::new(
498            arr2(&[[
499                Complex64::new(-1.0, 1.0),
500                Complex64::new(2.0, -2.0),
501                Complex64::new(0.5, 0.5),
502            ]]),
503            48_000.0,
504            Some(4),
505        )?;
506
507        let mixed = mix_freq_signals(&[first, second])?;
508
509        assert_eq!(
510            mixed.freq_data(),
511            arr2(&[[
512                Complex64::new(0.0, 3.0),
513                Complex64::new(5.0, 2.0),
514                Complex64::new(5.5, 6.5),
515            ]])
516        );
517        Ok(())
518    }
519
520    #[test]
521    fn mix_frequency_signals_rejects_mismatched_inputs() -> Result<(), SignalError> {
522        let base = FreqSignal::new(
523            arr2(&[[Complex64::new(1.0, 0.0), Complex64::new(2.0, 0.0)]]),
524            48_000.0,
525            Some(2),
526        )?;
527        let different_rate = FreqSignal::new(
528            arr2(&[[Complex64::new(3.0, 0.0), Complex64::new(4.0, 0.0)]]),
529            44_100.0,
530            Some(2),
531        )?;
532        let different_length = FreqSignal::new(
533            arr2(&[[Complex64::new(3.0, 0.0), Complex64::new(4.0, 0.0)]]),
534            48_000.0,
535            Some(4),
536        )?;
537        let different_bins = FreqSignal::new(
538            arr2(&[[
539                Complex64::new(3.0, 0.0),
540                Complex64::new(4.0, 0.0),
541                Complex64::new(5.0, 0.0),
542            ]]),
543            48_000.0,
544            Some(2),
545        )?;
546        let stereo = FreqSignal::new(
547            arr2(&[
548                [Complex64::new(0.0, 0.0), Complex64::new(1.0, 0.0)],
549                [Complex64::new(2.0, 0.0), Complex64::new(3.0, 0.0)],
550            ]),
551            48_000.0,
552            Some(2),
553        )?;
554
555        let result = mix_freq_signals(&[base.clone(), different_rate]);
556        assert!(matches!(result, Err(SignalError::SampleRateMismatch)));
557
558        let result = mix_freq_signals(&[base.clone(), different_length]);
559        assert!(matches!(result, Err(SignalError::TimeStepMismatch)));
560
561        let result = mix_freq_signals(&[base.clone(), different_bins]);
562        assert!(matches!(result, Err(SignalError::FrequencyBinMismatch)));
563
564        let result = mix_freq_signals(&[base, stereo]);
565        assert!(matches!(result, Err(SignalError::ChannelMismatch)));
566        Ok(())
567    }
568}
569
570/// Reading data back runs the same checks as the constructors.
571#[cfg(all(test, feature = "serde"))]
572mod serde_validation_tests {
573    use ndarray::arr2;
574    use num::complex::Complex64;
575    use serde_json::{Value, json};
576
577    use super::*;
578    use crate::signal::data::{ComplexData, RealData};
579
580    fn round_trip<T: serde::Serialize + serde::de::DeserializeOwned>(value: &T) -> T {
581        serde_json::from_str(&serde_json::to_string(value).unwrap()).unwrap()
582    }
583
584    /// Serialize `value`, apply `edit` to the JSON, and try to read it back.
585    fn read_edited<T: serde::Serialize + serde::de::DeserializeOwned>(
586        value: &T,
587        edit: impl FnOnce(&mut Value),
588    ) -> Result<T, serde_json::Error> {
589        let mut json = serde_json::to_value(value).unwrap();
590        edit(&mut json);
591        serde_json::from_value(json)
592    }
593
594    fn time_signal() -> TimeSignal {
595        let mut signal = TimeSignal::new(arr2(&[[1.0, 2.0, 3.0, 4.0]]), 8.0).unwrap();
596        signal.set_comment(Some("comment"));
597        signal.set_channel_label(0, Some("left"));
598        signal
599    }
600
601    #[test]
602    fn valid_values_round_trip_with_metadata() {
603        let time = time_signal();
604        let back = round_trip(&time);
605        assert_eq!(back, time);
606        assert_eq!(back.comment(), Some("comment"));
607        assert_eq!(back.channel_label(0), Some("left"));
608
609        let freq = time.to_freq();
610        assert_eq!(round_trip(&freq), freq);
611
612        let real =
613            RealData::new(ndarray::arr1(&[0.0, 1.0, 5.0]), arr2(&[[1.0, 2.0, 3.0]])).unwrap();
614        assert_eq!(round_trip(&real), real);
615    }
616
617    #[test]
618    fn data_rejects_y_length_that_does_not_match_x_axis() {
619        let real = RealData::from_y_data(arr2(&[[1.0, 2.0, 3.0]]));
620        let error = read_edited(&real, |json| {
621            json["y_data"] = json!({ "v": 1, "dim": [1, 2], "data": [1.0, 2.0] });
622        })
623        .unwrap_err();
624        assert!(error.to_string().contains("same length"), "{error}");
625
626        let complex = ComplexData::from_y_data(arr2(&[[Complex64::new(1.0, 0.0); 3]]));
627        let error = read_edited(&complex, |json| {
628            json["x_axis"] = json!({ "Uniform": { "start": 0.0, "step": 1.0, "len": 2 } });
629        })
630        .unwrap_err();
631        assert!(error.to_string().contains("same length"), "{error}");
632    }
633
634    #[test]
635    fn data_rejects_wrong_number_of_channel_labels() {
636        let real = RealData::from_y_data(arr2(&[[1.0, 2.0], [3.0, 4.0]]));
637        let error = read_edited(&real, |json| json["channel_labels"] = json!([null])).unwrap_err();
638        assert!(
639            error
640                .to_string()
641                .contains("1 channel labels for 2 channels"),
642            "{error}"
643        );
644    }
645
646    #[test]
647    fn signals_store_only_what_cannot_be_derived() {
648        let time = serde_json::to_value(time_signal()).unwrap();
649        let mut keys: Vec<_> = time.as_object().unwrap().keys().cloned().collect();
650        keys.sort();
651        assert_eq!(
652            keys,
653            ["channel_labels", "comment", "sample_rate", "samples"]
654        );
655
656        let freq = serde_json::to_value(time_signal().to_freq()).unwrap();
657        let mut keys: Vec<_> = freq.as_object().unwrap().keys().cloned().collect();
658        keys.sort();
659        assert_eq!(
660            keys,
661            [
662                "channel_labels",
663                "comment",
664                "num_time_steps",
665                "sample_rate",
666                "spectrum"
667            ]
668        );
669    }
670
671    #[test]
672    fn time_signal_rejects_invalid_sample_rate_and_label_count() {
673        let signal = time_signal();
674
675        let error = read_edited(&signal, |json| json["sample_rate"] = json!(-8.0)).unwrap_err();
676        assert!(error.to_string().contains("Sample rate"), "{error}");
677
678        let error =
679            read_edited(&signal, |json| json["channel_labels"] = json!([null, null])).unwrap_err();
680        assert!(
681            error
682                .to_string()
683                .contains("2 channel labels for 1 channels"),
684            "{error}"
685        );
686    }
687
688    #[test]
689    fn freq_signal_rejects_invalid_sample_rate_time_steps_and_label_count() {
690        let signal = time_signal().to_freq();
691
692        let error = read_edited(&signal, |json| json["sample_rate"] = json!(0.0)).unwrap_err();
693        assert!(error.to_string().contains("Sample rate"), "{error}");
694
695        // No frequency spacing is defined; must be an error, not a panic.
696        let error = read_edited(&signal, |json| json["num_time_steps"] = json!(0)).unwrap_err();
697        assert!(
698            error.to_string().contains("num_time_steps must be > 0"),
699            "{error}"
700        );
701
702        let error = read_edited(&signal, |json| json["channel_labels"] = json!([])).unwrap_err();
703        assert!(
704            error
705                .to_string()
706                .contains("0 channel labels for 1 channels"),
707            "{error}"
708        );
709    }
710
711    #[test]
712    fn read_signal_rebuilds_axis_from_sample_rate() {
713        let signal = time_signal();
714        let back = round_trip(&signal);
715        assert_eq!(back.time_axis().as_uniform(), Some((0.0, 1.0 / 8.0)));
716
717        // Changing the stored sample rate changes the rebuilt axis with it.
718        let resampled: TimeSignal =
719            read_edited(&signal, |json| json["sample_rate"] = json!(16.0)).unwrap();
720        assert_eq!(resampled.time_axis().as_uniform(), Some((0.0, 1.0 / 16.0)));
721    }
722}