timestretch 0.5.0

Pure Rust audio time stretching library optimized for EDM
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
//! Streaming pre-analysis integration: source-position tracking and
//! artifact-driven transient handling.

use std::f32::consts::PI;
use timestretch::{analyze_for_dj, EdmPreset, StreamProcessor, StretchParams};

const SR: u32 = 44_100;
const CHUNK: usize = 1024;

fn stream_params(ratio: f64) -> StretchParams {
    StretchParams::new(ratio)
        .with_sample_rate(SR)
        .with_channels(1)
        .with_preset(EdmPreset::DjBeatmatch)
}

fn click_train(sample_rate: u32, bpm: f64, seconds: f64) -> Vec<f32> {
    let len = (sample_rate as f64 * seconds) as usize;
    let beat_interval = (60.0 * sample_rate as f64 / bpm) as usize;
    let mut out = vec![0.0f32; len];
    for i in (0..len).step_by(beat_interval.max(1)) {
        for j in 0..10.min(len - i) {
            out[i + j] = if j < 5 { 1.0 } else { -0.4 };
        }
    }
    for (i, s) in out.iter_mut().enumerate() {
        let t = i as f32 / sample_rate as f32;
        *s += 0.15 * (2.0 * PI * 110.0 * t).sin();
    }
    out
}

#[test]
fn test_source_position_tracks_passthrough_then_dsp() {
    let mut processor = StreamProcessor::new(stream_params(1.0));
    assert_eq!(processor.source_position(), 0);

    // Unity ratio: the passthrough fast path bypasses the input ring but
    // must still advance the source position.
    let input = click_train(SR, 128.0, 2.0);
    let mut output = Vec::with_capacity(input.len() * 4);
    let mut pushed = 0usize;
    for chunk in input.chunks(CHUNK).take(8) {
        processor
            .process_into(chunk, &mut output)
            .expect("unity process");
        pushed += chunk.len();
        assert_eq!(
            processor.source_position(),
            pushed,
            "passthrough position must equal frames pushed"
        );
    }

    // Engage the DSP path; the position now advances as frames are consumed,
    // always monotonic and never ahead of what was pushed.
    processor
        .set_stretch_ratio(1.1)
        .expect("ratio change should be accepted");
    let mut last_position = processor.source_position();
    for chunk in input.chunks(CHUNK).skip(8) {
        processor.process_into(chunk, &mut output).expect("process");
        pushed += chunk.len();
        let position = processor.source_position();
        assert!(position >= last_position, "position must be monotonic");
        assert!(
            position <= pushed,
            "position {} must not exceed pushed frames {}",
            position,
            pushed
        );
        last_position = position;
    }
    assert!(
        last_position > 8 * CHUNK,
        "DSP path must advance the position past the passthrough prefix"
    );
}

#[test]
fn test_set_source_position_rejected_mid_stream() {
    // After DSP-path input.
    let mut processor = StreamProcessor::new(stream_params(1.1));
    let input = click_train(SR, 128.0, 1.0);
    let mut output = Vec::with_capacity(input.len() * 4);
    processor
        .process_into(&input[..CHUNK], &mut output)
        .expect("process");
    assert!(processor.set_source_position(0).is_err());

    // After unity passthrough input.
    let mut processor = StreamProcessor::new(stream_params(1.0));
    output.clear();
    processor
        .process_into(&input[..CHUNK], &mut output)
        .expect("unity process");
    assert!(processor.set_source_position(0).is_err());
}

#[test]
fn test_reset_returns_position_to_zero() {
    let mut processor = StreamProcessor::new(stream_params(1.1));
    processor
        .set_source_position(123_456)
        .expect("fresh processor accepts a source position");
    assert_eq!(processor.source_position(), 123_456);

    let input = click_train(SR, 128.0, 1.0);
    let mut output = Vec::with_capacity(input.len() * 4);
    for chunk in input.chunks(CHUNK) {
        processor.process_into(chunk, &mut output).expect("process");
    }
    assert!(processor.source_position() > 123_456);

    processor.reset();
    assert_eq!(processor.source_position(), 0);
    processor
        .set_source_position(777)
        .expect("freshly-reset processor accepts a source position");
    assert_eq!(processor.source_position(), 777);
}

#[test]
fn test_artifact_attaches_via_params_and_setter() {
    let input = click_train(SR, 128.0, 4.0);
    let artifact = analyze_for_dj(&input, SR);

    // Via params at construction.
    let params = stream_params(1.1)
        .with_pre_analysis(artifact.clone())
        .with_beat_snap_confidence_threshold(0.1);
    let mut processor = StreamProcessor::new(params);
    let mut output = Vec::with_capacity(input.len() * 4);
    for chunk in input.chunks(CHUNK) {
        processor.process_into(chunk, &mut output).expect("process");
    }
    assert!(!output.is_empty());

    // Via the runtime setter (build/rebuild-time API).
    let mut processor =
        StreamProcessor::new(stream_params(1.1).with_beat_snap_confidence_threshold(0.1));
    processor.set_pre_analysis(Some(artifact));
    output.clear();
    for chunk in input.chunks(CHUNK) {
        processor.process_into(chunk, &mut output).expect("process");
    }
    assert!(!output.is_empty());
}

/// Simple local peak detector: samples above `threshold` that are local
/// maxima, deduplicated by `min_distance`.
fn detect_peaks(signal: &[f32], threshold: f32, min_distance: usize) -> Vec<usize> {
    let mut peaks: Vec<usize> = Vec::new();
    for i in 1..signal.len().saturating_sub(1) {
        let s = signal[i].abs();
        if s >= threshold && s >= signal[i - 1].abs() && s >= signal[i + 1].abs() {
            if let Some(&last) = peaks.last() {
                if i - last < min_distance {
                    if s > signal[last].abs() {
                        *peaks.last_mut().unwrap() = i;
                    }
                    continue;
                }
            }
            peaks.push(i);
        }
    }
    peaks
}

#[test]
fn test_artifact_scheduled_resets_replace_online_scheduler() {
    let input = click_train(SR, 128.0, 4.0);
    let artifact = analyze_for_dj(&input, SR);
    assert!(artifact.transient_onsets.len() >= 6);

    let params = stream_params(1.1)
        .with_pre_analysis(artifact.clone())
        .with_beat_snap_confidence_threshold(0.1);
    let mut processor = StreamProcessor::new(params);
    let mut output = Vec::with_capacity(input.len() * 4);
    for chunk in input.chunks(CHUNK) {
        processor.process_into(chunk, &mut output).expect("process");
    }

    let stats = processor.transient_reset_stats();
    assert_eq!(
        stats.events_detected_total, 0,
        "online scheduler must stay idle while an artifact drives resets"
    );
    // Render passes tile the consumed timeline contiguously from frame 0,
    // so exactly the onsets inside the consumed span are scheduled.
    let consumed = stats.input_frames_consumed_total;
    let expected = artifact
        .transient_onsets
        .iter()
        .filter(|&&onset| onset < consumed)
        .count() as u64;
    assert!(expected > 0, "test input too short to consume any onsets");
    assert_eq!(stats.artifact_events_scheduled_total, expected);
    assert!(
        stats.reset_band_counts_total[2] > 0 && stats.reset_band_counts_total[3] > 0,
        "artifact events must reset the upper bands"
    );
}

#[test]
fn test_seek_offsets_artifact_scheduling() {
    let input = click_train(SR, 128.0, 4.0);
    let artifact = analyze_for_dj(&input, SR);
    assert!(artifact.transient_onsets.len() >= 4);
    // Start between onsets 1 and 2, as a seek-rebuild flow would.
    let seek = (artifact.transient_onsets[1] + artifact.transient_onsets[2]) / 2;

    let params = stream_params(1.1)
        .with_pre_analysis(artifact.clone())
        .with_beat_snap_confidence_threshold(0.1);
    let mut processor = StreamProcessor::new(params);
    processor.set_source_position(seek).expect("seek position");

    let mut output = Vec::with_capacity(input.len() * 4);
    for chunk in input[seek..].chunks(CHUNK) {
        processor.process_into(chunk, &mut output).expect("process");
    }

    let stats = processor.transient_reset_stats();
    assert_eq!(stats.events_detected_total, 0);
    let consumed_end = seek + stats.input_frames_consumed_total;
    let expected = artifact
        .transient_onsets
        .iter()
        .filter(|&&onset| onset >= seek && onset < consumed_end)
        .count() as u64;
    assert!(expected >= 2, "seek test needs onsets past the seek point");
    assert_eq!(
        stats.artifact_events_scheduled_total, expected,
        "onsets before the seek point must not fire; those after must"
    );
}

#[test]
fn test_artifact_low_band_resets_suppressed_during_ratio_glide() {
    let input = click_train(SR, 128.0, 4.0);
    let artifact = analyze_for_dj(&input, SR);

    let params = stream_params(1.08)
        .with_pre_analysis(artifact)
        .with_beat_snap_confidence_threshold(0.1);
    let mut processor = StreamProcessor::new(params);
    let mut output = Vec::with_capacity(input.len() * 4);
    let mut toggle = false;
    for chunk in input.chunks(CHUNK) {
        // Keep a ratio slew in flight for the entire stream.
        toggle = !toggle;
        processor
            .set_stretch_ratio(if toggle { 1.12 } else { 1.08 })
            .expect("ratio change");
        processor.process_into(chunk, &mut output).expect("process");
    }

    let stats = processor.transient_reset_stats();
    assert!(stats.artifact_events_scheduled_total > 0);
    assert_eq!(
        stats.reset_band_counts_total[0], 0,
        "sub-bass resets must stay suppressed during modulation"
    );
    assert_eq!(
        stats.reset_band_counts_total[1], 0,
        "low-band resets must stay suppressed during modulation"
    );
    assert!(
        stats.reset_band_counts_total[2] > 0,
        "upper bands still re-lock during modulation"
    );
}

#[test]
fn test_artifact_stream_transient_preservation_parity() {
    let bpm = 128.0;
    let input = click_train(SR, bpm, 4.0);
    let artifact = analyze_for_dj(&input, SR);
    let ratio = 1.1;

    let run = |params: StretchParams| {
        let mut processor = StreamProcessor::new(params);
        let mut output = Vec::with_capacity(input.len() * 4);
        for chunk in input.chunks(CHUNK) {
            processor.process_into(chunk, &mut output).expect("process");
        }
        let mut tail = Vec::with_capacity(input.len() * 2);
        processor.flush_into(&mut tail).expect("flush");
        output.extend_from_slice(&tail);
        output
    };

    let out_online = run(stream_params(ratio));
    let out_artifact = run(stream_params(ratio)
        .with_pre_analysis(artifact)
        .with_beat_snap_confidence_threshold(0.1));

    let beat_interval = 60.0 * SR as f64 / bpm;
    let min_distance = (beat_interval * ratio * 0.5) as usize;
    let peaks_online = detect_peaks(&out_online, 0.3, min_distance);
    let peaks_artifact = detect_peaks(&out_artifact, 0.3, min_distance);
    assert!(
        peaks_artifact.len() + 1 >= peaks_online.len(),
        "artifact-driven stream preserved fewer transients than online: {} vs {}",
        peaks_artifact.len(),
        peaks_online.len()
    );
    assert!(
        peaks_artifact.len() >= 4,
        "artifact-driven stream lost transients: {} peaks",
        peaks_artifact.len()
    );
}

#[test]
fn test_hybrid_engine_artifact_transient_parity() {
    let bpm = 128.0;
    let input = click_train(SR, bpm, 4.0);
    let artifact = analyze_for_dj(&input, SR);
    let ratio = 1.1;

    let run = |params: StretchParams| {
        let mut processor = StreamProcessor::new(params);
        processor.set_hybrid_mode(true);
        let mut output = Vec::with_capacity(input.len() * 4);
        for chunk in input.chunks(CHUNK) {
            processor.process_into(chunk, &mut output).expect("process");
        }
        let mut tail = Vec::with_capacity(input.len() * 2);
        processor.flush_into(&mut tail).expect("flush");
        output.extend_from_slice(&tail);
        output
    };

    let out_online = run(stream_params(ratio));
    let out_artifact = run(stream_params(ratio)
        .with_pre_analysis(artifact)
        .with_beat_snap_confidence_threshold(0.1));

    let beat_interval = 60.0 * SR as f64 / bpm;
    let min_distance = (beat_interval * ratio * 0.5) as usize;
    assert_eq!(
        out_online.len(),
        out_artifact.len(),
        "hybrid engine output length must not depend on the anchor source"
    );
    // The legacy re-render engine's chunk-boundary crossfades smear strong
    // peaks regardless of anchor source (its quality gap is tracked by the
    // roadmap's deterministic-engine work), so this is a parity gate, not
    // an absolute one: the artifact path must not lose transients relative
    // to online detection.
    let peaks_online = detect_peaks(&out_online, 0.3, min_distance);
    let peaks_artifact = detect_peaks(&out_artifact, 0.3, min_distance);
    assert!(
        peaks_artifact.len() + 1 >= peaks_online.len(),
        "hybrid artifact stream preserved fewer transients than online: {} vs {}",
        peaks_artifact.len(),
        peaks_online.len()
    );
    assert!(
        !peaks_artifact.is_empty(),
        "hybrid artifact stream lost all transients"
    );
}

#[test]
fn test_hybrid_engine_artifact_seek_alignment() {
    let bpm = 128.0;
    let input = click_train(SR, bpm, 6.0);
    let artifact = analyze_for_dj(&input, SR);
    let ratio = 1.1;
    let beat_interval = (60.0 * SR as f64 / bpm) as usize;
    // Seek to just before a click so the fed slice starts mid-track; an
    // off-by-one-discard window base would misplace every anchor.
    let seek = beat_interval * 4 - 1000;

    let run = |pre_analysis: Option<timestretch::PreAnalysisArtifact>| {
        let mut params = stream_params(ratio).with_beat_snap_confidence_threshold(0.1);
        if let Some(artifact) = pre_analysis {
            params = params.with_pre_analysis(artifact);
        }
        let mut processor = StreamProcessor::new(params);
        processor.set_hybrid_mode(true);
        processor.set_source_position(seek).expect("seek position");
        let mut output = Vec::with_capacity(input.len() * 4);
        for chunk in input[seek..].chunks(CHUNK) {
            processor.process_into(chunk, &mut output).expect("process");
        }
        let mut tail = Vec::with_capacity(input.len() * 2);
        processor.flush_into(&mut tail).expect("flush");
        output.extend_from_slice(&tail);
        output
    };

    let out_artifact = run(Some(artifact));

    // Absolute click preservation over the seeked slice: with a broken window
    // base the artifact anchors land off-position and clicks smear away, so
    // requiring most of the fed clicks to survive catches the alignment bug
    // without depending on the (rolling-window-boundary-noisy) online path.
    let clicks_in_slice = (input.len() - seek) / beat_interval;
    let min_distance = (beat_interval as f64 * ratio * 0.5) as usize;
    let peaks_artifact = detect_peaks(&out_artifact, 0.3, min_distance);
    assert!(
        peaks_artifact.len() * 2 >= clicks_in_slice,
        "seek-offset hybrid artifact stream lost transients: {} peaks for ~{} fed clicks",
        peaks_artifact.len(),
        clicks_in_slice
    );
}