Skip to main content

audio_lab/dsp/
time.rs

1use std::time::Duration;
2
3use ndarray::s;
4use rubato::audioadapter_buffers::direct::SequentialSlice;
5use rubato::{
6    Fft, FixedSync, ResampleError, Resampler, ResamplerConstructionError, WindowFunction,
7};
8
9use crate::signal::math::db_to_gain;
10use crate::signal::{SignalError, TimeSignal};
11
12use crate::dsp::apply_hann_right;
13
14#[derive(Debug, thiserror::Error)]
15pub enum TimeError {
16    #[error("invalid trim range: start={start}, end={end}, signal_len={signal_len}")]
17    InvalidTrimRange {
18        start: usize,
19        end: usize,
20        signal_len: usize,
21    },
22    #[error("invalid analysis threshold: {threshold_db} dB")]
23    InvalidThreshold { threshold_db: f64 },
24    #[error(
25        "impulse response start could not be detected in channel {channel}: peak power {peak_power} below threshold {required_peak_power} from noise floor {noise_power}"
26    )]
27    LowSnr {
28        channel: usize,
29        peak_power: f64,
30        noise_power: f64,
31        required_peak_power: f64,
32    },
33    #[error("trimmed window duration must map to at least one sample, got {duration_seconds} s")]
34    InvalidWindowDuration { duration_seconds: f64 },
35    #[error("invalid sample rate for resampling: {sample_rate} Hz")]
36    InvalidResampleRate { sample_rate: f64 },
37    #[error("shift vector length {got} does not match channel count {expected}")]
38    InvalidShiftCount { expected: usize, got: usize },
39    #[error("linear shift magnitude {shift} exceeds signal length {signal_len}")]
40    ShiftExceedsSignal { shift: isize, signal_len: usize },
41    #[error(transparent)]
42    ResamplerConstruction(#[from] ResamplerConstructionError),
43    #[error(transparent)]
44    Resampler(#[from] ResampleError),
45    #[error(transparent)]
46    Signal(#[from] SignalError),
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum TimeShiftMode {
51    Linear,
52    Cyclic,
53}
54
55pub fn trim_samples(
56    signal: &TimeSignal,
57    start_sample: usize,
58    end_sample: i64,
59) -> Result<TimeSignal, TimeError> {
60    let signal_len = signal.num_time_steps();
61    let end_sample = if end_sample < 0 {
62        signal_len.saturating_sub((-end_sample) as usize)
63    } else {
64        end_sample as usize
65    };
66
67    if start_sample >= end_sample || end_sample > signal_len {
68        return Err(TimeError::InvalidTrimRange {
69            start: start_sample,
70            end: end_sample,
71            signal_len,
72        });
73    }
74
75    Ok(TimeSignal::new(
76        signal
77            .time_data()
78            .slice(s![.., start_sample..end_sample])
79            .to_owned(),
80        signal.sample_rate(),
81    )?)
82}
83
84pub fn trim_duration(
85    signal: &TimeSignal,
86    start_duration: Duration,
87    end_duration: Duration,
88) -> Result<TimeSignal, TimeError> {
89    let start_sample = (signal.sample_rate() * start_duration.as_secs_f64()).round() as usize;
90    let end_sample = (signal.sample_rate() * end_duration.as_secs_f64()).round() as i64;
91    trim_samples(signal, start_sample, end_sample)
92}
93
94pub fn apply_gain(signal: &TimeSignal, gain: f64) -> Result<TimeSignal, TimeError> {
95    Ok(TimeSignal::new(
96        signal.time_data().mapv(|sample| sample * gain),
97        signal.sample_rate(),
98    )?)
99}
100
101pub fn apply_gain_db(signal: &TimeSignal, gain_db: f64) -> Result<TimeSignal, TimeError> {
102    apply_gain(signal, db_to_gain(gain_db))
103}
104
105pub fn time_shift(
106    signal: &TimeSignal,
107    shift_samples: isize,
108    mode: TimeShiftMode,
109    pad_value: f64,
110) -> Result<TimeSignal, TimeError> {
111    let shifts = vec![shift_samples; signal.num_channels()];
112    time_shift_per_channel(signal, &shifts, mode, pad_value)
113}
114
115pub fn time_shift_per_channel(
116    signal: &TimeSignal,
117    shift_samples: &[isize],
118    mode: TimeShiftMode,
119    pad_value: f64,
120) -> Result<TimeSignal, TimeError> {
121    if shift_samples.len() != signal.num_channels() {
122        return Err(TimeError::InvalidShiftCount {
123            expected: signal.num_channels(),
124            got: shift_samples.len(),
125        });
126    }
127
128    let signal_len = signal.num_time_steps();
129    if let Some(shift) = shift_samples
130        .iter()
131        .copied()
132        .find(|&shift| matches!(mode, TimeShiftMode::Linear) && shift.unsigned_abs() > signal_len)
133    {
134        return Err(TimeError::ShiftExceedsSignal { shift, signal_len });
135    }
136
137    let mut shifted = signal.clone();
138    for (channel_index, mut channel) in shifted.channel_iter_mut().enumerate() {
139        let shift = shift_samples[channel_index];
140        rotate_channel(
141            channel.as_slice_mut().expect("channel view is contiguous"),
142            shift,
143        );
144
145        if matches!(mode, TimeShiftMode::Linear) {
146            if shift > 0 {
147                channel.slice_mut(s![..shift as usize]).fill(pad_value);
148            } else if shift < 0 {
149                let start = signal_len - shift.unsigned_abs();
150                channel.slice_mut(s![start..]).fill(pad_value);
151            }
152        }
153    }
154
155    Ok(shifted)
156}
157
158pub fn find_impulse_response_start(
159    impulse_response: &TimeSignal,
160    threshold_db: Option<f64>,
161) -> Result<Vec<usize>, TimeError> {
162    let threshold_db = threshold_db.unwrap_or(20.0);
163    if !threshold_db.is_finite() || threshold_db < 0.0 {
164        return Err(TimeError::InvalidThreshold { threshold_db });
165    }
166
167    let num_time_steps = impulse_response.num_time_steps();
168    let mask_start = ((0.9 * num_time_steps as f64) as usize).min(num_time_steps);
169    let analysis_len = num_time_steps.saturating_sub(mask_start);
170    let snr_ratio = 10.0_f64.powf(threshold_db / 10.0);
171    let relative_threshold = 10.0_f64.powf(-threshold_db / 10.0);
172
173    let mut start_samples = Vec::with_capacity(impulse_response.num_channels());
174    for (channel_index, channel) in impulse_response.channel_iter().enumerate() {
175        let power: Vec<f64> = channel.iter().map(|sample| sample.abs().powi(2)).collect();
176        let noise_power = if analysis_len == 0 {
177            0.0
178        } else {
179            power.iter().skip(mask_start).sum::<f64>() / analysis_len as f64
180        };
181
182        let (peak_index, peak_power) = power
183            .iter()
184            .copied()
185            .enumerate()
186            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
187            .unwrap_or((0, 0.0));
188
189        let required_peak_power = noise_power * snr_ratio;
190        if peak_power < required_peak_power || peak_index > mask_start {
191            return Err(TimeError::LowSnr {
192                channel: channel_index,
193                peak_power,
194                noise_power,
195                required_peak_power,
196            });
197        }
198
199        let first_above_threshold =
200            power
201                .iter()
202                .take(peak_index + 1)
203                .enumerate()
204                .find_map(|(index, value)| {
205                    if peak_power > 0.0 && (*value / peak_power) >= relative_threshold {
206                        Some(index)
207                    } else {
208                        None
209                    }
210                });
211
212        let start_sample = first_above_threshold
213            .map(|index| index.saturating_sub(1))
214            .unwrap_or(0);
215        start_samples.push(start_sample);
216    }
217
218    Ok(start_samples)
219}
220
221pub fn window_and_trim(
222    impulse_response: &TimeSignal,
223    duration_seconds: f64,
224) -> Result<(TimeSignal, Vec<usize>), TimeError> {
225    let len = (duration_seconds * impulse_response.sample_rate()).ceil() as usize;
226    if len == 0 {
227        return Err(TimeError::InvalidWindowDuration { duration_seconds });
228    }
229
230    let starts = find_impulse_response_start(impulse_response, None)?;
231    let mut windowed = TimeSignal::zeros(
232        impulse_response.num_channels(),
233        len,
234        impulse_response.sample_rate(),
235    )?;
236
237    for ((mut output_channel, input_channel), &start) in windowed
238        .channel_iter_mut()
239        .zip(impulse_response.channel_iter())
240        .zip(starts.iter())
241    {
242        output_channel
243            .iter_mut()
244            .zip(input_channel.iter().skip(start).take(len))
245            .for_each(|(dst, src)| *dst = *src);
246
247        if let Some(slice) = output_channel.as_slice_mut() {
248            apply_hann_right(slice, 0, len);
249        }
250    }
251
252    Ok((windowed, starts))
253}
254
255pub fn resample(signal: &TimeSignal, new_sample_rate: f64) -> Result<TimeSignal, TimeError> {
256    if (new_sample_rate - signal.sample_rate()).abs() < 1e-9 {
257        return Ok(signal.clone());
258    }
259
260    let sample_rate_in = validated_resample_rate(signal.sample_rate())?;
261    let sample_rate_out = validated_resample_rate(new_sample_rate)?;
262    let num_channels = signal.num_channels();
263    let num_input_frames = signal.num_time_steps();
264
265    let mut resampler = Fft::<f64>::new_custom(
266        sample_rate_in,
267        sample_rate_out,
268        1024,
269        2,
270        num_channels,
271        WindowFunction::BlackmanHarris2,
272        FixedSync::Both,
273    )?;
274
275    let input_sequential: Vec<f64> = signal.time_data().iter().copied().collect();
276    let buffer_in = SequentialSlice::new(&input_sequential, num_channels, num_input_frames)
277        .expect("sequential input length matches channel/frame dimensions");
278
279    let num_output_frames = resampler.process_all_needed_output_len(num_input_frames);
280    let mut output_sequential = vec![0.0; num_output_frames * num_channels];
281    let mut buffer_out =
282        SequentialSlice::new_mut(&mut output_sequential, num_channels, num_output_frames)
283            .expect("allocated output length matches channel/frame dimensions");
284
285    let (_, actual_output_frames) =
286        resampler.process_all_into_buffer(&buffer_in, &mut buffer_out, num_input_frames, None)?;
287
288    output_sequential.truncate(actual_output_frames * num_channels);
289    let data =
290        ndarray::Array2::from_shape_vec((num_channels, actual_output_frames), output_sequential)
291            .expect("resampler output length matches channel/frame dimensions");
292
293    TimeSignal::new(data, new_sample_rate).map_err(TimeError::from)
294}
295
296fn validated_resample_rate(sample_rate: f64) -> Result<usize, TimeError> {
297    if !sample_rate.is_finite() || sample_rate <= 0.0 {
298        return Err(TimeError::InvalidResampleRate { sample_rate });
299    }
300
301    let rounded = sample_rate.round();
302    if (sample_rate - rounded).abs() > 1e-9 {
303        return Err(TimeError::InvalidResampleRate { sample_rate });
304    }
305
306    Ok(rounded as usize)
307}
308
309fn rotate_channel(channel: &mut [f64], shift: isize) {
310    if channel.is_empty() {
311        return;
312    }
313
314    let normalized_shift = shift.rem_euclid(channel.len() as isize) as usize;
315    channel.rotate_right(normalized_shift);
316}
317
318#[cfg(test)]
319mod tests {
320    use std::time::Duration;
321
322    use crate::signal::{SineConfig, generate_sine};
323    use approx::assert_abs_diff_eq;
324    use ndarray::arr2;
325
326    use super::*;
327
328    #[test]
329    fn trim_samples_accepts_positive_and_negative_end() {
330        let signal = TimeSignal::new(arr2(&[[0.0, 1.0, 2.0, 3.0, 4.0]]), 10.0).unwrap();
331
332        let trimmed = trim_samples(&signal, 1, 4).unwrap();
333        assert_eq!(trimmed.num_time_steps(), 3);
334        assert_abs_diff_eq!(trimmed.channel(0)[0], 1.0, epsilon = 1e-12);
335
336        let trimmed = trim_samples(&signal, 1, -1).unwrap();
337        assert_eq!(trimmed.num_time_steps(), 3);
338        assert_abs_diff_eq!(trimmed.channel(0)[2], 3.0, epsilon = 1e-12);
339    }
340
341    #[test]
342    fn trim_duration_maps_to_sample_positions() {
343        let signal = TimeSignal::new(arr2(&[[0.0; 20]]), 10.0).unwrap();
344        let trimmed = trim_duration(
345            &signal,
346            Duration::from_secs_f64(0.2),
347            Duration::from_secs_f64(1.1),
348        )
349        .unwrap();
350
351        assert_eq!(trimmed.num_time_steps(), 9);
352    }
353
354    #[test]
355    fn trim_rejects_invalid_ranges() {
356        let signal = TimeSignal::new(arr2(&[[0.0, 1.0, 2.0]]), 1.0).unwrap();
357        let result = trim_samples(&signal, 2, 2);
358
359        assert!(matches!(result, Err(TimeError::InvalidTrimRange { .. })));
360    }
361
362    #[test]
363    fn apply_gain_scales_samples_linearly() {
364        let signal = TimeSignal::new(arr2(&[[0.25, -0.5, 1.0]]), 48_000.0).unwrap();
365
366        let gained = apply_gain(&signal, 2.0).unwrap();
367
368        assert_abs_diff_eq!(gained.channel(0)[0], 0.5, epsilon = 1e-12);
369        assert_abs_diff_eq!(gained.channel(0)[1], -1.0, epsilon = 1e-12);
370        assert_abs_diff_eq!(gained.channel(0)[2], 2.0, epsilon = 1e-12);
371    }
372
373    #[test]
374    fn apply_gain_db_converts_db_to_linear_gain() {
375        let signal = TimeSignal::new(arr2(&[[0.25, -0.5, 1.0]]), 48_000.0).unwrap();
376
377        let gained = apply_gain_db(&signal, -6.0).unwrap();
378        let expected_gain = db_to_gain(-6.0);
379
380        assert_abs_diff_eq!(gained.channel(0)[0], 0.25 * expected_gain, epsilon = 1e-12);
381        assert_abs_diff_eq!(gained.channel(0)[1], -0.5 * expected_gain, epsilon = 1e-12);
382        assert_abs_diff_eq!(gained.channel(0)[2], expected_gain, epsilon = 1e-12);
383    }
384
385    #[test]
386    fn time_shift_cyclic_wraps_signal() {
387        let signal = TimeSignal::new(arr2(&[[1.0, 2.0, 3.0, 4.0]]), 48_000.0).unwrap();
388
389        let shifted = time_shift(&signal, 1, TimeShiftMode::Cyclic, 0.0).unwrap();
390
391        assert_abs_diff_eq!(
392            shifted.channel(0),
393            ndarray::arr1(&[4.0, 1.0, 2.0, 3.0]),
394            epsilon = 1e-12
395        );
396    }
397
398    #[test]
399    fn time_shift_linear_pads_exposed_samples() {
400        let signal = TimeSignal::new(arr2(&[[1.0, 2.0, 3.0, 4.0]]), 48_000.0).unwrap();
401
402        let shifted = time_shift(&signal, -2, TimeShiftMode::Linear, 0.0).unwrap();
403
404        assert_abs_diff_eq!(
405            shifted.channel(0),
406            ndarray::arr1(&[3.0, 4.0, 0.0, 0.0]),
407            epsilon = 1e-12
408        );
409    }
410
411    #[test]
412    fn time_shift_accepts_per_channel_shifts() {
413        let signal =
414            TimeSignal::new(arr2(&[[1.0, 2.0, 3.0], [10.0, 20.0, 30.0]]), 48_000.0).unwrap();
415
416        let shifted =
417            time_shift_per_channel(&signal, &[1, -1], TimeShiftMode::Linear, -1.0).unwrap();
418
419        assert_abs_diff_eq!(
420            shifted.channel(0),
421            ndarray::arr1(&[-1.0, 1.0, 2.0]),
422            epsilon = 1e-12
423        );
424        assert_abs_diff_eq!(
425            shifted.channel(1),
426            ndarray::arr1(&[20.0, 30.0, -1.0]),
427            epsilon = 1e-12
428        );
429    }
430
431    #[test]
432    fn find_impulse_response_start_detects_onset_per_channel() {
433        let signal = TimeSignal::new(
434            arr2(&[
435                [0.0, 0.0, 0.1, 0.4, 1.0, 0.5, 0.0, 0.0, 0.0, 0.0],
436                [0.0, 0.0, 0.0, 0.2, 0.6, 1.0, 0.2, 0.0, 0.0, 0.0],
437            ]),
438            10.0,
439        )
440        .unwrap();
441
442        let starts = find_impulse_response_start(&signal, Some(20.0)).unwrap();
443
444        assert_eq!(starts, vec![1, 2]);
445    }
446
447    #[test]
448    fn find_impulse_response_start_rejects_low_snr() {
449        let signal = TimeSignal::new(
450            arr2(&[[0.0, 0.0, 0.01, 0.0, 0.0, 0.01, 0.01, 0.01, 0.01, 0.01]]),
451            10.0,
452        )
453        .unwrap();
454
455        let result = find_impulse_response_start(&signal, Some(20.0));
456
457        assert!(matches!(result, Err(TimeError::LowSnr { channel: 0, .. })));
458    }
459
460    #[test]
461    fn window_and_trim_uses_detected_onset_and_applies_fade_out() {
462        let signal = TimeSignal::new(
463            arr2(&[[0.0, 0.0, 0.1, 0.4, 1.0, 0.5, 0.25, 0.0, 0.0, 0.0]]),
464            10.0,
465        )
466        .unwrap();
467
468        let (trimmed, starts) = window_and_trim(&signal, 0.5).unwrap();
469
470        assert_eq!(starts, vec![1]);
471        assert_eq!(trimmed.num_time_steps(), 5);
472        assert_abs_diff_eq!(trimmed.channel(0)[0], 0.0, epsilon = 1e-12);
473        assert!(trimmed.channel(0)[1] > 0.0);
474        assert!(trimmed.channel(0)[4].abs() < trimmed.channel(0)[3].abs());
475    }
476
477    #[test]
478    fn window_and_trim_rejects_zero_duration() {
479        let signal = TimeSignal::new(arr2(&[[1.0, 0.0, 0.0]]), 10.0).unwrap();
480
481        let result = window_and_trim(&signal, 0.0);
482
483        assert!(matches!(
484            result,
485            Err(TimeError::InvalidWindowDuration { .. })
486        ));
487    }
488
489    #[test]
490    fn resample_preserves_duration_and_frequency() {
491        let signal = generate_sine(
492            4_410,
493            440.0,
494            SineConfig {
495                amplitude: 0.5,
496                sample_rate: 44_100.0,
497                num_channels: 1,
498            },
499        )
500        .unwrap();
501
502        let resampled = resample(&signal, 48_000.0).unwrap();
503
504        assert_eq!(resampled.sample_rate(), 48_000.0);
505        assert_abs_diff_eq!(
506            resampled.length_in_seconds(),
507            signal.length_in_seconds(),
508            epsilon = 1e-3
509        );
510
511        let (peak_freq, _) = resampled.into_freq().to_magnitude().max_per_channel()[0];
512        assert_abs_diff_eq!(peak_freq, 440.0, epsilon = 1.0);
513    }
514
515    #[test]
516    fn resample_rejects_non_integer_target_rate() {
517        let signal = TimeSignal::new(arr2(&[[0.0, 1.0, 0.0]]), 48_000.0).unwrap();
518
519        let result = resample(&signal, 48_000.5);
520
521        assert!(matches!(
522            result,
523            Err(TimeError::InvalidResampleRate {
524                sample_rate: 48_000.5
525            })
526        ));
527    }
528
529    #[test]
530    fn resample_short_circuits_when_sample_rate_is_unchanged() {
531        let signal = TimeSignal::new(arr2(&[[0.0, 1.0, 0.0, -1.0]]), 48_000.0).unwrap();
532
533        let resampled = resample(&signal, 48_000.0).unwrap();
534
535        assert_eq!(resampled, signal);
536    }
537}