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