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}