Skip to main content

alpha2_audio/
time_stretch.rs

1#![allow(
2    clippy::cast_lossless,
3    clippy::cast_possible_truncation,
4    clippy::cast_precision_loss,
5    clippy::cast_sign_loss,
6    clippy::needless_range_loop
7)]
8
9use crate::{ProcessCtx, Processor, StateBlob, StateError};
10
11/// Deterministic two-tap delay-line pitch shifter with a fixed prepared
12/// window (20 ms).
13///
14/// It does not change duration: a host that plays a source `rate` times as
15/// fast (which also transposes it by `rate`) runs it through this processor
16/// to transpose it by `pitch / rate`, so the result plays at `rate` with its
17/// pitch scaled by `pitch`. Two taps read the input's recent history at
18/// delays that sweep one window at the transposition's speed, half a window
19/// apart; each fades to nothing where its sweep wraps, so neither jumps
20/// while it is heard, and their weights sum to one. Untransposed
21/// (`pitch == rate`), the output is the input delayed by exactly
22/// [`Processor::latency_samples`], one window, which is also the mean delay
23/// of a transposed output and does not depend on the ratios. Hosts provide
24/// continuous project-rate blocks and may save/restore the complete history.
25#[derive(Clone, Debug)]
26pub struct TimeStretchProcessor {
27    rate: f32,
28    pitch: f32,
29    history: [Vec<f32>; 2],
30    write: usize,
31    /// Where the first tap is in its sweep, in `[0, window)`: its delay is
32    /// half a window more than this.
33    sweep: f64,
34    window: usize,
35    bypass: bool,
36}
37
38impl TimeStretchProcessor {
39    /// Creates a processor. Both ratios are clamped to 0.25 to 4.0; a non-finite
40    /// ratio becomes 1.0.
41    #[must_use]
42    pub fn new(rate: f32, pitch: f32) -> Self {
43        Self {
44            rate: ratio(rate),
45            pitch: ratio(pitch),
46            history: [Vec::new(), Vec::new()],
47            write: 0,
48            sweep: 0.0,
49            window: 0,
50            bypass: false,
51        }
52    }
53    /// Updates time and pitch ratios without resetting history. The latency
54    /// does not change.
55    pub fn set_ratios(&mut self, rate: f32, pitch: f32) {
56        self.rate = ratio(rate);
57        self.pitch = ratio(pitch);
58    }
59    /// Enables latency-preserving bypass: the input delayed by
60    /// [`Processor::latency_samples`], while the history keeps following it.
61    pub fn set_bypass(&mut self, bypass: bool) {
62        self.bypass = bypass;
63    }
64    /// The sweep position an untransposed output holds: the first tap at
65    /// one window's delay, at full weight.
66    fn centre(&self) -> f64 {
67        self.window as f64 / 2.0
68    }
69    /// Reads `channel` `delay` samples before the sample just written,
70    /// interpolating linearly.
71    fn read(&self, channel: usize, delay: f64) -> f32 {
72        let history = &self.history[channel];
73        let capacity = history.len();
74        let position = (self.write as f64 + capacity as f64 - delay).rem_euclid(capacity as f64);
75        let first = position.floor() as usize % capacity;
76        let second = (first + 1) % capacity;
77        let fraction = (position - position.floor()) as f32;
78        history[first] + (history[second] - history[first]) * fraction
79    }
80}
81
82/// A tap's weight at sweep position `sweep` of `window`: nothing where the
83/// sweep wraps, all at its middle. Two taps half a window apart sum to one.
84fn weight(sweep: f64, window: f64) -> f32 {
85    (1.0 - (2.0 * sweep / window - 1.0).abs()) as f32
86}
87
88impl Processor for TimeStretchProcessor {
89    fn prepare(&mut self, sample_rate: u32, _max_block: usize) {
90        self.window = (usize::try_from(sample_rate).unwrap_or(48_000) / 50).max(32);
91        // The longest read is one and a half windows back, plus one sample to interpolate.
92        let capacity = self.window * 2;
93        self.history = [vec![0.0; capacity], vec![0.0; capacity]];
94        self.write = 0;
95        self.sweep = self.centre();
96    }
97    fn process(&mut self, _ctx: &ProcessCtx, inputs: &[&[f32]], outputs: &mut [&mut [f32]]) {
98        let channels = inputs.len().min(outputs.len()).min(2);
99        let frames = inputs
100            .iter()
101            .take(channels)
102            .map(|v| v.len())
103            .chain(outputs.iter().take(channels).map(|v| v.len()))
104            .min()
105            .unwrap_or(0);
106        let capacity = self.history[0].len();
107        if capacity == 0 {
108            return;
109        }
110        let window = self.window as f64;
111        let half = window / 2.0;
112        // Each output sample, the taps' delays grow by one sample minus the transposition's read
113        // speed.
114        let drift = 1.0 - f64::from(self.pitch) / f64::from(self.rate);
115        for frame in 0..frames {
116            for channel in 0..channels {
117                let input = inputs[channel][frame];
118                // A sample the history cannot hold would leak through a tap at zero weight
119                // (as 0 * inf).
120                self.history[channel][self.write] = if input.is_finite() { input } else { 0.0 };
121            }
122            let first = self.sweep;
123            let second = (first + half).rem_euclid(window);
124            let (first_weight, second_weight) = (weight(first, window), weight(second, window));
125            for channel in 0..channels {
126                outputs[channel][frame] = if self.bypass {
127                    self.history[channel][(self.write + capacity - self.window) % capacity]
128                } else {
129                    self.read(channel, half + first) * first_weight
130                        + self.read(channel, half + second) * second_weight
131                };
132            }
133            self.sweep = (self.sweep + drift).rem_euclid(window);
134            // A tiny negative sum wraps to `window` itself in rounding.
135            if self.sweep >= window {
136                self.sweep = 0.0;
137            }
138            self.write = (self.write + 1) % capacity;
139        }
140    }
141    fn reset(&mut self) {
142        for channel in &mut self.history {
143            channel.fill(0.0);
144        }
145        self.write = 0;
146        self.sweep = self.centre();
147    }
148    /// One window: an untransposed output's delay, and a transposed one's
149    /// mean, whatever the ratios.
150    fn latency_samples(&self) -> u32 {
151        u32::try_from(self.window).unwrap_or(u32::MAX)
152    }
153    fn save_state(&self) -> StateBlob {
154        let mut bytes =
155            Vec::with_capacity(16 + self.history.iter().map(Vec::len).sum::<usize>() * 4);
156        bytes.extend_from_slice(&(u64::try_from(self.write).unwrap_or(0)).to_le_bytes());
157        bytes.extend_from_slice(&self.sweep.to_le_bytes());
158        for channel in &self.history {
159            for sample in channel {
160                bytes.extend_from_slice(&sample.to_le_bytes());
161            }
162        }
163        StateBlob::new(bytes)
164    }
165    fn load_state(&mut self, state: &StateBlob) -> Result<(), StateError> {
166        let bytes = state.as_bytes();
167        let expected = 16 + self.history.iter().map(Vec::len).sum::<usize>() * 4;
168        if bytes.len() != expected {
169            return Err(StateError::Mismatch);
170        }
171        let write = usize::try_from(u64::from_le_bytes(
172            bytes[0..8].try_into().expect("fixed range"),
173        ))
174        .ok();
175        let sweep = f64::from_le_bytes(bytes[8..16].try_into().expect("fixed range"));
176        let samples = bytes[16..]
177            .as_chunks::<4>()
178            .0
179            .iter()
180            .map(|word| f32::from_le_bytes(*word));
181        if write.is_none_or(|value| value >= self.history[0].len())
182            || !(0.0..self.window as f64).contains(&sweep)
183            || samples.clone().any(|sample| !sample.is_finite())
184        {
185            return Err(StateError::Invalid);
186        }
187        self.write = write.unwrap_or(0);
188        self.sweep = sweep;
189        for (target, sample) in self.history.iter_mut().flatten().zip(samples) {
190            *target = sample;
191        }
192        Ok(())
193    }
194}
195
196fn ratio(value: f32) -> f32 {
197    if value.is_finite() {
198        value.clamp(0.25, 4.0)
199    } else {
200        1.0
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::TimeStretchProcessor;
207    use crate::{ProcessCtx, Processor, TransportState};
208    use alpha2_time::MediaTime;
209
210    fn context(sample_rate: u32) -> ProcessCtx {
211        ProcessCtx {
212            position: MediaTime::ZERO,
213            sample_position: 0,
214            sample_rate,
215            transport: TransportState::Playing,
216        }
217    }
218
219    /// Runs `input` through `processor` in 512-sample blocks.
220    fn run(processor: &mut TimeStretchProcessor, sample_rate: u32, input: &[f32]) -> Vec<f32> {
221        processor.prepare(sample_rate, 512);
222        let mut output = vec![0.0; input.len()];
223        for (input, output) in input.chunks(512).zip(output.chunks_mut(512)) {
224            processor.process(&context(sample_rate), &[input], &mut [output]);
225        }
226        output
227    }
228
229    fn largest_step(samples: &[f32]) -> f32 {
230        samples
231            .windows(2)
232            .map(|pair| (pair[1] - pair[0]).abs())
233            .fold(0.0, f32::max)
234    }
235
236    #[test]
237    #[allow(clippy::cast_precision_loss)]
238    fn a_transposed_tone_moves_as_smoothly_as_the_tone_it_becomes() {
239        // Tones whose period does not divide the 20 ms window, so a splice between grains would
240        // show.
241        for (rate, pitch, frequency) in [
242            (1.0, 1.2, 37.0),
243            (1.0, 0.8, 37.0),
244            (1.0, 1.2, 440.0),
245            (0.9, 1.2, 37.0),
246            (1.0, 1.05, 220.0),
247            (2.0, 0.5, 97.0),
248        ] {
249            let input = (0..96_000)
250                .map(|index| {
251                    0.5 * libm::sinf(core::f32::consts::TAU * frequency * index as f32 / 48_000.0)
252                })
253                .collect::<Vec<_>>();
254            let mut processor = TimeStretchProcessor::new(rate, pitch);
255            let output = run(&mut processor, 48_000, &input);
256            // The tone it becomes steps at most 0.5 * 2 pi f r / fs a sample; the crossfade between
257            // two grains adds a little.
258            let transposed =
259                0.5 * core::f32::consts::TAU * frequency * (pitch / rate).max(1.0) / 48_000.0;
260            let step = largest_step(&output[4 * 960..]);
261            assert!(
262                step < 2.0 * transposed + 2.0e-3,
263                "rate {rate} pitch {pitch} {frequency} Hz: a step of {step}, the tone's {transposed}"
264            );
265        }
266    }
267
268    #[test]
269    fn an_impulse_comes_out_as_late_as_the_reported_latency_bypassed_or_not() {
270        for sample_rate in [44_100, 48_000, 96_000] {
271            for ratio in [0.25, 0.5, 1.0, 1.5, 2.0, 4.0] {
272                for bypass in [false, true] {
273                    let mut input = vec![0.0; 16_384];
274                    input[1_000] = 1.0;
275                    let mut processor = TimeStretchProcessor::new(ratio, ratio);
276                    processor.set_bypass(bypass);
277                    let output = run(&mut processor, sample_rate, &input);
278                    let peak = output
279                        .iter()
280                        .enumerate()
281                        .max_by(|a, b| a.1.abs().total_cmp(&b.1.abs()))
282                        .map(|(index, _)| index)
283                        .expect("samples");
284                    assert_eq!(
285                        peak - 1_000,
286                        processor.latency_samples() as usize,
287                        "{sample_rate} Hz, ratio {ratio}, bypass {bypass}"
288                    );
289                    assert_eq!(output[peak].to_bits(), 1.0_f32.to_bits());
290                }
291            }
292        }
293    }
294
295    #[test]
296    fn changing_the_ratios_keeps_the_latency() {
297        let mut processor = TimeStretchProcessor::new(1.0, 1.0);
298        processor.prepare(48_000, 512);
299        let latency = processor.latency_samples();
300        processor.set_ratios(0.5, 2.0);
301        assert_eq!(processor.latency_samples(), latency);
302        processor.set_ratios(4.0, 0.25);
303        assert_eq!(processor.latency_samples(), latency);
304    }
305}