Skip to main content

oxframe_export/
mix.rs

1//! Mix a soundtrack and sound effects into one PCM buffer.
2//!
3//! Decoding uses symphonia, so the samples do not depend on which ffmpeg
4//! built the file. ffmpeg only encodes this buffer into the video.
5
6use std::fmt;
7use std::fs::File;
8use std::io::Write;
9use std::path::Path;
10
11use symphonia::core::audio::SampleBuffer;
12use symphonia::core::codecs::DecoderOptions;
13use symphonia::core::errors::Error as SymError;
14use symphonia::core::formats::FormatOptions;
15use symphonia::core::io::MediaSourceStream;
16use symphonia::core::meta::MetadataOptions;
17use symphonia::core::probe::Hint;
18
19/// Samples per second of every mix. One rate keeps renders comparable.
20pub const SAMPLE_RATE: u32 = 48_000;
21
22/// Preview playback is coarser than the render mix.
23pub const PREVIEW_RATE: u32 = 16_000;
24
25const DUCK_FLOOR: f32 = 0.25;
26const DUCK_ATTACK: f64 = 0.02;
27const DUCK_RELEASE: f64 = 0.30;
28const CEILING: f32 = 0.89;
29const LOOKAHEAD: f64 = 0.005;
30const LIMIT_RELEASE: f64 = 0.05;
31/// Short fade on the ends of the whole mix, so a cut does not click.
32const EDGE: f64 = 0.002;
33
34/// One clip on the output timeline.
35#[derive(Debug, Clone)]
36pub struct MixClip {
37    /// Audio file.
38    pub file: std::path::PathBuf,
39    /// Start on the output timeline, in seconds. Negative skips into the clip.
40    pub start: f64,
41    /// Linear gain.
42    pub volume: f32,
43    /// Fade in, seconds.
44    pub fade_in: f64,
45    /// Fade out, seconds.
46    pub fade_out: f64,
47    /// Seconds skipped from the start of the file.
48    pub trim_start: f64,
49    /// End point in the file. `None` plays to the end.
50    pub trim_end: Option<f64>,
51    /// While this clip plays, music ducks.
52    pub priority: bool,
53    /// This clip is the soundtrack, so ducking applies to it.
54    pub music: bool,
55}
56
57/// What to mix.
58#[derive(Debug, Clone)]
59pub struct MixRequest {
60    /// Length of the output, in seconds.
61    pub seconds: f64,
62    /// Duck music under priority clips.
63    pub duck: bool,
64    /// Fade at the start of the whole mix.
65    pub master_fade_in: f64,
66    /// Fade at the end of the whole mix.
67    pub master_fade_out: f64,
68    /// Bring the whole mix to this integrated loudness (LUFS, EBU R128)
69    /// before the limiter. `None` leaves the levels as they are.
70    pub loudness: Option<f64>,
71    /// Clips, in any order.
72    pub clips: Vec<MixClip>,
73}
74
75/// Mono PCM at [`SAMPLE_RATE`].
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct Mixed {
78    /// Samples per second.
79    pub rate: u32,
80    /// One sample per frame, already limited.
81    pub samples: Vec<i16>,
82}
83
84/// Why a mix failed.
85#[derive(Debug)]
86pub enum MixError {
87    /// The file could not be read or decoded.
88    Read {
89        /// Path that failed.
90        file: String,
91        /// What went wrong.
92        message: String,
93    },
94}
95
96impl fmt::Display for MixError {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        match self {
99            Self::Read { file, message } => {
100                write!(f, "cannot read {file}: {message}")
101            }
102        }
103    }
104}
105
106impl std::error::Error for MixError {}
107
108/// Length of an audio file in seconds.
109///
110/// # Errors
111///
112/// If the file cannot be decoded.
113pub fn probe_duration(path: &Path) -> Result<f64, MixError> {
114    let (rate, samples) = decode(path)?;
115    Ok(samples.len() as f64 / f64::from(rate.max(1)))
116}
117
118/// Mix `req` into mono PCM.
119///
120/// # Errors
121///
122/// If a clip's file cannot be decoded.
123pub fn mix(req: &MixRequest) -> Result<Mixed, MixError> {
124    let rate = SAMPLE_RATE;
125    let n = (req.seconds.max(0.0) * f64::from(rate)).round() as usize;
126    let mut loaded: Vec<Loaded> = Vec::with_capacity(req.clips.len());
127    for clip in &req.clips {
128        let pcm = load_clip(clip)?;
129        loaded.push(Loaded {
130            samples: pcm,
131            start: clip.start,
132            volume: clip.volume.max(0.0),
133            fade_in: clip.fade_in.max(0.0),
134            fade_out: clip.fade_out.max(0.0),
135            priority: clip.priority,
136            music: clip.music,
137        });
138    }
139    let voices: Vec<(f64, f64)> = loaded
140        .iter()
141        .filter(|c| c.priority)
142        .map(|c| {
143            let dur = c.samples.len() as f64 / f64::from(rate);
144            (c.start, c.start + dur)
145        })
146        .collect();
147    let duck = duck_envelope(n, rate, req.duck, &voices);
148    let mut acc = vec![0.0f32; n];
149    for clip in &loaded {
150        add_clip(&mut acc, clip, rate, if clip.music { &duck } else { &[] });
151    }
152    apply_master_fade(
153        &mut acc,
154        rate,
155        req.master_fade_in.max(EDGE),
156        req.master_fade_out.max(EDGE),
157    );
158    if let Some(target) = req.loudness
159        && let Some(measured) = integrated_loudness(&acc, rate)
160    {
161        let gain = 10f64.powf((target - measured) / 20.0) as f32;
162        for s in &mut acc {
163            *s *= gain;
164        }
165    }
166    let limited = limit(&acc, rate);
167    let samples = limited
168        .into_iter()
169        .map(|s| (s.clamp(-1.0, 1.0) * 32767.0).round() as i16)
170        .collect();
171    Ok(Mixed { rate, samples })
172}
173
174/// Integrated loudness of `mixed` in LUFS (EBU R128 / ITU-R BS.1770), as
175/// players and platforms measure the video: the stereo file, both channels
176/// the same. `None` for silence.
177pub fn loudness(mixed: &Mixed) -> Option<f64> {
178    let samples: Vec<f32> = mixed
179        .samples
180        .iter()
181        .map(|s| f32::from(*s) / 32768.0)
182        .collect();
183    integrated_loudness(&samples, mixed.rate)
184}
185
186/// K-weighting (BS.1770 at 48 kHz), 400 ms blocks every 100 ms, an absolute
187/// gate at -70 LUFS and a relative one 10 LU below the mean. The mono mix is
188/// written as two equal channels, so their powers add (+3 dB).
189fn integrated_loudness(samples: &[f32], rate: u32) -> Option<f64> {
190    debug_assert_eq!(rate, SAMPLE_RATE, "K-weighting coefficients are for 48 kHz");
191    // (b0, b1, b2, a1, a2): the high shelf, then the high pass.
192    const STAGES: [[f64; 5]; 2] = [
193        [
194            1.535_124_859_586_97,
195            -2.691_696_189_406_38,
196            1.198_392_810_852_85,
197            -1.690_659_293_182_41,
198            0.732_480_774_215_85,
199        ],
200        [1.0, -2.0, 1.0, -1.990_047_454_833_98, 0.990_072_250_366_21],
201    ];
202    let mut weighted: Vec<f64> = samples.iter().map(|s| f64::from(*s)).collect();
203    for [b0, b1, b2, a1, a2] in STAGES {
204        let (mut x1, mut x2, mut y1, mut y2) = (0.0, 0.0, 0.0, 0.0);
205        for v in &mut weighted {
206            let x = *v;
207            let y = b0 * x + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2;
208            (x2, x1, y2, y1) = (x1, x, y1, y);
209            *v = y;
210        }
211    }
212    let block = (0.4 * f64::from(rate)) as usize;
213    let step = (0.1 * f64::from(rate)) as usize;
214    if weighted.is_empty() {
215        return None;
216    }
217    let power = |w: &[f64]| 2.0 * w.iter().map(|v| v * v).sum::<f64>() / w.len() as f64;
218    let blocks: Vec<f64> = if weighted.len() < block {
219        vec![power(&weighted)]
220    } else {
221        (0..=(weighted.len() - block) / step)
222            .map(|i| power(&weighted[i * step..i * step + block]))
223            .collect()
224    };
225    let lufs = |p: f64| -0.691 + 10.0 * p.log10();
226    let loud: Vec<f64> = blocks.into_iter().filter(|p| lufs(*p) > -70.0).collect();
227    if loud.is_empty() {
228        return None;
229    }
230    let relative = lufs(loud.iter().sum::<f64>() / loud.len() as f64) - 10.0;
231    let kept: Vec<f64> = loud.into_iter().filter(|p| lufs(*p) > relative).collect();
232    if kept.is_empty() {
233        return None;
234    }
235    Some(lufs(kept.iter().sum::<f64>() / kept.len() as f64))
236}
237
238/// Write mono PCM as a stereo 16-bit WAV (both channels identical).
239///
240/// # Errors
241///
242/// If the file cannot be created.
243pub fn write_wav(path: &Path, mixed: &Mixed) -> Result<(), MixError> {
244    let channels: u16 = 2;
245    let bits: u16 = 16;
246    let block = channels * bits / 8;
247    let data_len = (mixed.samples.len() * usize::from(block)) as u32;
248    let mut out = File::create(path).map_err(|e| MixError::Read {
249        file: path.display().to_string(),
250        message: e.to_string(),
251    })?;
252    let write = |f: &mut File, bytes: &[u8]| {
253        f.write_all(bytes).map_err(|e| MixError::Read {
254            file: path.display().to_string(),
255            message: e.to_string(),
256        })
257    };
258    write(&mut out, b"RIFF")?;
259    write(&mut out, &(36 + data_len).to_le_bytes())?;
260    write(&mut out, b"WAVEfmt ")?;
261    write(&mut out, &16u32.to_le_bytes())?;
262    write(&mut out, &1u16.to_le_bytes())?;
263    write(&mut out, &channels.to_le_bytes())?;
264    write(&mut out, &mixed.rate.to_le_bytes())?;
265    write(&mut out, &(mixed.rate * u32::from(block)).to_le_bytes())?;
266    write(&mut out, &block.to_le_bytes())?;
267    write(&mut out, &bits.to_le_bytes())?;
268    write(&mut out, b"data")?;
269    write(&mut out, &data_len.to_le_bytes())?;
270    for s in &mixed.samples {
271        let b = s.to_le_bytes();
272        write(&mut out, &b)?;
273        write(&mut out, &b)?;
274    }
275    Ok(())
276}
277
278/// Peak envelope in `0..=1`, one value per bucket.
279pub fn envelope(samples: &[i16], buckets: usize) -> Vec<f32> {
280    if buckets == 0 {
281        return Vec::new();
282    }
283    if samples.is_empty() {
284        return vec![0.0; buckets];
285    }
286    (0..buckets)
287        .map(|i| {
288            let a = i * samples.len() / buckets;
289            let b = ((i + 1) * samples.len() / buckets)
290                .max(a + 1)
291                .min(samples.len());
292            let peak = samples[a..b]
293                .iter()
294                .map(|s| s.unsigned_abs())
295                .max()
296                .unwrap_or(0);
297            f32::from(peak) / 32768.0
298        })
299        .collect()
300}
301
302/// Average `from` Hz samples down to `to` Hz.
303pub fn downsample(samples: &[i16], from: u32, to: u32) -> Vec<i16> {
304    if samples.is_empty() || to == 0 || from == 0 || from == to {
305        return samples.to_vec();
306    }
307    let ratio = f64::from(from) / f64::from(to);
308    let n = (samples.len() as f64 / ratio).floor() as usize;
309    (0..n)
310        .map(|i| {
311            let a = (i as f64 * ratio).floor() as usize;
312            let b = (((i + 1) as f64) * ratio).floor() as usize;
313            let b = b.max(a + 1).min(samples.len());
314            let sum: i32 = samples[a..b].iter().map(|s| i32::from(*s)).sum();
315            (sum / (b - a) as i32) as i16
316        })
317        .collect()
318}
319
320struct Loaded {
321    samples: Vec<f32>,
322    start: f64,
323    volume: f32,
324    fade_in: f64,
325    fade_out: f64,
326    priority: bool,
327    music: bool,
328}
329
330fn load_clip(clip: &MixClip) -> Result<Vec<f32>, MixError> {
331    let (rate, pcm) = decode(&clip.file)?;
332    let at_rate = resample(&pcm, rate, SAMPLE_RATE);
333    let start = (clip.trim_start.max(0.0) * f64::from(SAMPLE_RATE)).round() as usize;
334    let end = clip
335        .trim_end
336        .map(|t| (t.max(0.0) * f64::from(SAMPLE_RATE)).round() as usize)
337        .unwrap_or(at_rate.len())
338        .min(at_rate.len());
339    if end <= start || start >= at_rate.len() {
340        return Ok(Vec::new());
341    }
342    Ok(at_rate[start..end].to_vec())
343}
344
345fn decode(path: &Path) -> Result<(u32, Vec<f32>), MixError> {
346    let file = File::open(path).map_err(|e| MixError::Read {
347        file: path.display().to_string(),
348        message: e.to_string(),
349    })?;
350    let mss = MediaSourceStream::new(Box::new(file), Default::default());
351    let mut hint = Hint::new();
352    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
353        hint.with_extension(ext);
354    }
355    let probed = symphonia::default::get_probe()
356        .format(
357            &hint,
358            mss,
359            &FormatOptions::default(),
360            &MetadataOptions::default(),
361        )
362        .map_err(|e| MixError::Read {
363            file: path.display().to_string(),
364            message: e.to_string(),
365        })?;
366    let mut format = probed.format;
367    // A video file's first track is the picture; take the first one that
368    // has a sample rate, which only audio tracks have.
369    let track = format
370        .tracks()
371        .iter()
372        .find(|t| t.codec_params.sample_rate.is_some())
373        .ok_or_else(|| MixError::Read {
374            file: path.display().to_string(),
375            message: "no audio track".into(),
376        })?
377        .clone();
378    let track_id = track.id;
379    let rate = track.codec_params.sample_rate.unwrap_or(SAMPLE_RATE);
380    let dec_opts = DecoderOptions::default();
381    let mut decoder = symphonia::default::get_codecs()
382        .make(&track.codec_params, &dec_opts)
383        .map_err(|e| MixError::Read {
384            file: path.display().to_string(),
385            message: e.to_string(),
386        })?;
387    let mut pcm = Vec::new();
388    while let Ok(packet) = format.next_packet() {
389        if packet.track_id() != track_id {
390            continue;
391        }
392        let decoded = match decoder.decode(&packet) {
393            Ok(d) => d,
394            Err(SymError::DecodeError(_)) => continue,
395            Err(_) => break,
396        };
397        let spec = *decoded.spec();
398        let mut buf = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
399        buf.copy_interleaved_ref(decoded);
400        let channels = spec.channels.count().max(1);
401        for frame in buf.samples().chunks(channels) {
402            pcm.push(frame.first().copied().unwrap_or(0.0));
403        }
404    }
405    Ok((rate, pcm))
406}
407
408fn resample(input: &[f32], from: u32, to: u32) -> Vec<f32> {
409    if input.is_empty() || from == 0 {
410        return Vec::new();
411    }
412    if from == to {
413        return input.to_vec();
414    }
415    let out_len = (input.len() as u64) * u64::from(to) / u64::from(from);
416    let mut out = Vec::with_capacity(out_len as usize);
417    for i in 0..out_len {
418        let pos = i as f64 * f64::from(from) / f64::from(to);
419        let j = pos.floor() as usize;
420        let frac = (pos - j as f64) as f32;
421        let a = input.get(j).copied().unwrap_or(0.0);
422        let b = input.get(j + 1).copied().unwrap_or(a);
423        out.push(a + (b - a) * frac);
424    }
425    out
426}
427
428fn duck_envelope(n: usize, rate: u32, enabled: bool, voices: &[(f64, f64)]) -> Vec<f32> {
429    let mut env = vec![1.0f32; n];
430    if !enabled || voices.is_empty() || n == 0 {
431        return env;
432    }
433    let attack = (DUCK_ATTACK * f64::from(rate)).max(1.0) as f32;
434    let release = (DUCK_RELEASE * f64::from(rate)).max(1.0) as f32;
435    let mut gain = 1.0f32;
436    for (i, slot) in env.iter_mut().enumerate() {
437        let t = i as f64 / f64::from(rate);
438        let under = voices.iter().any(|(a, b)| t >= *a && t < *b);
439        let target = if under { DUCK_FLOOR } else { 1.0 };
440        let step = if target < gain { attack } else { release };
441        gain += (target - gain) / step;
442        *slot = gain;
443    }
444    env
445}
446
447fn add_clip(acc: &mut [f32], clip: &Loaded, rate: u32, duck: &[f32]) {
448    let dur = clip.samples.len() as f64 / f64::from(rate);
449    let origin = (clip.start * f64::from(rate)).round() as isize;
450    for (k, sample) in clip.samples.iter().enumerate() {
451        let i = origin + k as isize;
452        if i < 0 || i >= acc.len() as isize {
453            continue;
454        }
455        let pos = k as f64 / f64::from(rate);
456        let mut g = clip.volume * clip_fade(pos, dur, clip.fade_in, clip.fade_out);
457        if !duck.is_empty() {
458            g *= duck[i as usize];
459        }
460        acc[i as usize] += sample * g;
461    }
462}
463
464fn clip_fade(pos: f64, dur: f64, fade_in: f64, fade_out: f64) -> f32 {
465    let mut g = 1.0f32;
466    if fade_in > 0.0 && pos < fade_in {
467        g *= (pos / fade_in) as f32;
468    }
469    if fade_out > 0.0 && pos > dur - fade_out {
470        g *= ((dur - pos) / fade_out).max(0.0) as f32;
471    }
472    g
473}
474
475fn apply_master_fade(acc: &mut [f32], rate: u32, fade_in: f64, fade_out: f64) {
476    let n = acc.len();
477    let inn = (fade_in * f64::from(rate)).round().max(1.0) as usize;
478    let outn = (fade_out * f64::from(rate)).round().max(1.0) as usize;
479    for (i, s) in acc.iter_mut().enumerate() {
480        let mut g = 1.0f32;
481        if i < inn {
482            g *= i as f32 / inn as f32;
483        }
484        if i + outn >= n {
485            g *= (n.saturating_sub(1).saturating_sub(i)) as f32 / outn as f32;
486        }
487        *s *= g;
488    }
489}
490
491fn limit(input: &[f32], rate: u32) -> Vec<f32> {
492    let n = input.len();
493    let lookahead = ((LOOKAHEAD * f64::from(rate)).round() as usize).max(1);
494    let release = (LIMIT_RELEASE * f64::from(rate)).max(1.0) as f32;
495    let mut out = vec![0.0f32; n];
496    let mut gain = 1.0f32;
497    for i in 0..n {
498        let end = (i + lookahead).min(n);
499        let peak = input[i..end].iter().fold(0.0f32, |p, s| p.max(s.abs()));
500        let target = if peak > CEILING { CEILING / peak } else { 1.0 };
501        if target < gain {
502            gain = target;
503        } else {
504            gain += (target - gain) / release;
505        }
506        out[i] = (input[i] * gain).clamp(-CEILING, CEILING);
507    }
508    out
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    fn tone(path: &Path, hz: f32, seconds: f64, amp: f32) {
516        let n = (seconds * f64::from(SAMPLE_RATE)).round() as usize;
517        let samples: Vec<i16> = (0..n)
518            .map(|i| {
519                let t = i as f32 / SAMPLE_RATE as f32;
520                (amp * (t * hz * std::f32::consts::TAU).sin() * 32767.0).round() as i16
521            })
522            .collect();
523        write_wav(
524            path,
525            &Mixed {
526                rate: SAMPLE_RATE,
527                samples,
528            },
529        )
530        .unwrap();
531    }
532
533    #[test]
534    fn a_sine_at_minus_20_dbfs_is_minus_20_lufs() {
535        // BS.1770: a 997 Hz sine in both channels reads its peak level.
536        let n = 5 * SAMPLE_RATE as usize;
537        let samples: Vec<i16> = (0..n)
538            .map(|i| {
539                let t = i as f32 / SAMPLE_RATE as f32;
540                (0.1 * (t * 997.0 * std::f32::consts::TAU).sin() * 32767.0).round() as i16
541            })
542            .collect();
543        let l = loudness(&Mixed {
544            rate: SAMPLE_RATE,
545            samples,
546        })
547        .unwrap();
548        assert!((l + 20.0).abs() < 0.1, "{l} LUFS");
549        let silent = Mixed {
550            rate: SAMPLE_RATE,
551            samples: vec![0; n],
552        };
553        assert_eq!(loudness(&silent), None);
554    }
555
556    #[test]
557    fn a_quiet_mix_is_brought_to_the_target_loudness() {
558        let dir = std::env::temp_dir().join(format!("oxframe-loud-{}", std::process::id()));
559        std::fs::create_dir_all(&dir).unwrap();
560        let quiet = dir.join("quiet.wav");
561        tone(&quiet, 440.0, 4.0, 0.03);
562        let mut req = MixRequest {
563            seconds: 4.0,
564            duck: false,
565            master_fade_in: 0.0,
566            master_fade_out: 0.0,
567            loudness: None,
568            clips: vec![clip(&quiet, 0.0, false, false)],
569        };
570        let before = loudness(&mix(&req).unwrap()).unwrap();
571        req.loudness = Some(-14.0);
572        let after = loudness(&mix(&req).unwrap()).unwrap();
573        assert!(before < -25.0, "{before}");
574        assert!((after + 14.0).abs() < 0.2, "{after} LUFS");
575        let _ = std::fs::remove_dir_all(dir);
576    }
577
578    fn clip(file: &Path, start: f64, priority: bool, music: bool) -> MixClip {
579        MixClip {
580            file: file.into(),
581            start,
582            volume: 1.0,
583            fade_in: 0.0,
584            fade_out: 0.0,
585            trim_start: 0.0,
586            trim_end: None,
587            priority,
588            music,
589        }
590    }
591
592    #[test]
593    fn a_mix_is_the_same_bytes_twice_and_never_clips() {
594        let dir = std::env::temp_dir().join("oxframe-mix-det");
595        let _ = std::fs::create_dir_all(&dir);
596        let a = dir.join("a.wav");
597        let b = dir.join("b.wav");
598        tone(&a, 440.0, 0.4, 1.0);
599        tone(&b, 660.0, 0.4, 1.0);
600        let req = MixRequest {
601            seconds: 0.4,
602            duck: false,
603            master_fade_in: 0.0,
604            master_fade_out: 0.0,
605            loudness: None,
606            clips: vec![clip(&a, 0.0, false, false), clip(&b, 0.0, false, false)],
607        };
608        let once = mix(&req).unwrap();
609        let twice = mix(&req).unwrap();
610        assert_eq!(once.samples, twice.samples);
611        let ceiling = (CEILING * 32767.0).round() as i16;
612        assert!(once.samples.iter().all(|s| s.abs() <= ceiling));
613        assert_eq!(once.samples.first().copied(), Some(0));
614        assert_eq!(once.samples.last().copied(), Some(0));
615    }
616
617    #[test]
618    fn priority_ducks_the_music_without_a_click() {
619        let dir = std::env::temp_dir().join("oxframe-mix-duck");
620        let _ = std::fs::create_dir_all(&dir);
621        let music = dir.join("music.wav");
622        let voice = dir.join("voice.wav");
623        tone(&music, 220.0, 0.8, 0.5);
624        tone(&voice, 880.0, 0.2, 0.0);
625        let ducked = mix(&MixRequest {
626            seconds: 0.8,
627            duck: true,
628            master_fade_in: 0.0,
629            master_fade_out: 0.0,
630            loudness: None,
631            clips: vec![
632                clip(&music, 0.0, false, true),
633                clip(&voice, 0.3, true, false),
634            ],
635        })
636        .unwrap();
637        let plain = mix(&MixRequest {
638            seconds: 0.8,
639            duck: false,
640            master_fade_in: 0.0,
641            master_fade_out: 0.0,
642            loudness: None,
643            clips: vec![clip(&music, 0.0, false, true)],
644        })
645        .unwrap();
646        let at = |samples: &[i16], t: f64| {
647            let i = (t * f64::from(SAMPLE_RATE)) as usize;
648            samples[i].unsigned_abs()
649        };
650        assert!(at(&ducked.samples, 0.4) < at(&plain.samples, 0.4));
651        let window = |samples: &[i16], t: f64| {
652            let i = (t * f64::from(SAMPLE_RATE)) as usize;
653            samples[i..i + SAMPLE_RATE as usize / 20]
654                .iter()
655                .map(|s| u32::from(s.unsigned_abs()))
656                .max()
657                .unwrap_or(0)
658        };
659        assert!(window(&ducked.samples, 0.35) * 2 < window(&ducked.samples, 0.1));
660    }
661
662    #[test]
663    fn a_range_shifts_the_clip() {
664        let dir = std::env::temp_dir().join("oxframe-mix-range");
665        let _ = std::fs::create_dir_all(&dir);
666        let click = dir.join("click.wav");
667        tone(&click, 1000.0, 0.05, 0.5);
668        let mixed = mix(&MixRequest {
669            seconds: 0.5,
670            duck: false,
671            master_fade_in: 0.0,
672            master_fade_out: 0.0,
673            loudness: None,
674            clips: vec![clip(&click, 0.2, false, false)],
675        })
676        .unwrap();
677        let onset = mixed
678            .samples
679            .iter()
680            .position(|s| s.unsigned_abs() > 1000)
681            .expect("the clip is audible");
682        let at = onset as f64 / f64::from(SAMPLE_RATE);
683        assert!((at - 0.2).abs() < 0.01, "onset at {at}");
684    }
685}