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    /// 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    let rate = SAMPLE_RATE;
122    let n = (req.seconds.max(0.0) * f64::from(rate)).round() as usize;
123    let mut loaded: Vec<Loaded> = Vec::with_capacity(req.clips.len());
124    for clip in &req.clips {
125        let pcm = load_clip(clip)?;
126        loaded.push(Loaded {
127            samples: pcm,
128            start: clip.start,
129            volume: clip.volume.max(0.0),
130            fade_in: clip.fade_in.max(0.0),
131            fade_out: clip.fade_out.max(0.0),
132            priority: clip.priority,
133            music: clip.music,
134        });
135    }
136    let voices: Vec<(f64, f64)> = loaded
137        .iter()
138        .filter(|c| c.priority)
139        .map(|c| {
140            let dur = c.samples.len() as f64 / f64::from(rate);
141            (c.start, c.start + dur)
142        })
143        .collect();
144    let duck = duck_envelope(n, rate, req.duck, &voices);
145    let mut acc = vec![0.0f32; n];
146    for clip in &loaded {
147        add_clip(&mut acc, clip, rate, if clip.music { &duck } else { &[] });
148    }
149    apply_master_fade(
150        &mut acc,
151        rate,
152        req.master_fade_in.max(EDGE),
153        req.master_fade_out.max(EDGE),
154    );
155    let limited = limit(&acc, rate);
156    let samples = limited
157        .into_iter()
158        .map(|s| (s.clamp(-1.0, 1.0) * 32767.0).round() as i16)
159        .collect();
160    Ok(Mixed { rate, samples })
161}
162
163/// Write mono PCM as a stereo 16-bit WAV (both channels identical).
164///
165/// # Errors
166///
167/// If the file cannot be created.
168pub fn write_wav(path: &Path, mixed: &Mixed) -> Result<(), MixError> {
169    let channels: u16 = 2;
170    let bits: u16 = 16;
171    let block = channels * bits / 8;
172    let data_len = (mixed.samples.len() * usize::from(block)) as u32;
173    let mut out = File::create(path).map_err(|e| MixError::Read {
174        file: path.display().to_string(),
175        message: e.to_string(),
176    })?;
177    let write = |f: &mut File, bytes: &[u8]| {
178        f.write_all(bytes).map_err(|e| MixError::Read {
179            file: path.display().to_string(),
180            message: e.to_string(),
181        })
182    };
183    write(&mut out, b"RIFF")?;
184    write(&mut out, &(36 + data_len).to_le_bytes())?;
185    write(&mut out, b"WAVEfmt ")?;
186    write(&mut out, &16u32.to_le_bytes())?;
187    write(&mut out, &1u16.to_le_bytes())?;
188    write(&mut out, &channels.to_le_bytes())?;
189    write(&mut out, &mixed.rate.to_le_bytes())?;
190    write(&mut out, &(mixed.rate * u32::from(block)).to_le_bytes())?;
191    write(&mut out, &block.to_le_bytes())?;
192    write(&mut out, &bits.to_le_bytes())?;
193    write(&mut out, b"data")?;
194    write(&mut out, &data_len.to_le_bytes())?;
195    for s in &mixed.samples {
196        let b = s.to_le_bytes();
197        write(&mut out, &b)?;
198        write(&mut out, &b)?;
199    }
200    Ok(())
201}
202
203/// Peak envelope in `0..=1`, one value per bucket.
204pub fn envelope(samples: &[i16], buckets: usize) -> Vec<f32> {
205    if buckets == 0 {
206        return Vec::new();
207    }
208    if samples.is_empty() {
209        return vec![0.0; buckets];
210    }
211    (0..buckets)
212        .map(|i| {
213            let a = i * samples.len() / buckets;
214            let b = ((i + 1) * samples.len() / buckets)
215                .max(a + 1)
216                .min(samples.len());
217            let peak = samples[a..b]
218                .iter()
219                .map(|s| s.unsigned_abs())
220                .max()
221                .unwrap_or(0);
222            f32::from(peak) / 32768.0
223        })
224        .collect()
225}
226
227/// Average `from` Hz samples down to `to` Hz.
228pub fn downsample(samples: &[i16], from: u32, to: u32) -> Vec<i16> {
229    if samples.is_empty() || to == 0 || from == 0 || from == to {
230        return samples.to_vec();
231    }
232    let ratio = f64::from(from) / f64::from(to);
233    let n = (samples.len() as f64 / ratio).floor() as usize;
234    (0..n)
235        .map(|i| {
236            let a = (i as f64 * ratio).floor() as usize;
237            let b = (((i + 1) as f64) * ratio).floor() as usize;
238            let b = b.max(a + 1).min(samples.len());
239            let sum: i32 = samples[a..b].iter().map(|s| i32::from(*s)).sum();
240            (sum / (b - a) as i32) as i16
241        })
242        .collect()
243}
244
245struct Loaded {
246    samples: Vec<f32>,
247    start: f64,
248    volume: f32,
249    fade_in: f64,
250    fade_out: f64,
251    priority: bool,
252    music: bool,
253}
254
255fn load_clip(clip: &MixClip) -> Result<Vec<f32>, MixError> {
256    let (rate, pcm) = decode(&clip.file)?;
257    let at_rate = resample(&pcm, rate, SAMPLE_RATE);
258    let start = (clip.trim_start.max(0.0) * f64::from(SAMPLE_RATE)).round() as usize;
259    let end = clip
260        .trim_end
261        .map(|t| (t.max(0.0) * f64::from(SAMPLE_RATE)).round() as usize)
262        .unwrap_or(at_rate.len())
263        .min(at_rate.len());
264    if end <= start || start >= at_rate.len() {
265        return Ok(Vec::new());
266    }
267    Ok(at_rate[start..end].to_vec())
268}
269
270fn decode(path: &Path) -> Result<(u32, Vec<f32>), MixError> {
271    let file = File::open(path).map_err(|e| MixError::Read {
272        file: path.display().to_string(),
273        message: e.to_string(),
274    })?;
275    let mss = MediaSourceStream::new(Box::new(file), Default::default());
276    let mut hint = Hint::new();
277    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
278        hint.with_extension(ext);
279    }
280    let probed = symphonia::default::get_probe()
281        .format(
282            &hint,
283            mss,
284            &FormatOptions::default(),
285            &MetadataOptions::default(),
286        )
287        .map_err(|e| MixError::Read {
288            file: path.display().to_string(),
289            message: e.to_string(),
290        })?;
291    let mut format = probed.format;
292    // A video file's first track is the picture; take the first one that
293    // has a sample rate, which only audio tracks have.
294    let track = format
295        .tracks()
296        .iter()
297        .find(|t| t.codec_params.sample_rate.is_some())
298        .ok_or_else(|| MixError::Read {
299            file: path.display().to_string(),
300            message: "no audio track".into(),
301        })?
302        .clone();
303    let track_id = track.id;
304    let rate = track.codec_params.sample_rate.unwrap_or(SAMPLE_RATE);
305    let dec_opts = DecoderOptions::default();
306    let mut decoder = symphonia::default::get_codecs()
307        .make(&track.codec_params, &dec_opts)
308        .map_err(|e| MixError::Read {
309            file: path.display().to_string(),
310            message: e.to_string(),
311        })?;
312    let mut pcm = Vec::new();
313    while let Ok(packet) = format.next_packet() {
314        if packet.track_id() != track_id {
315            continue;
316        }
317        let decoded = match decoder.decode(&packet) {
318            Ok(d) => d,
319            Err(SymError::DecodeError(_)) => continue,
320            Err(_) => break,
321        };
322        let spec = *decoded.spec();
323        let mut buf = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
324        buf.copy_interleaved_ref(decoded);
325        let channels = spec.channels.count().max(1);
326        for frame in buf.samples().chunks(channels) {
327            pcm.push(frame.first().copied().unwrap_or(0.0));
328        }
329    }
330    Ok((rate, pcm))
331}
332
333fn resample(input: &[f32], from: u32, to: u32) -> Vec<f32> {
334    if input.is_empty() || from == 0 {
335        return Vec::new();
336    }
337    if from == to {
338        return input.to_vec();
339    }
340    let out_len = (input.len() as u64) * u64::from(to) / u64::from(from);
341    let mut out = Vec::with_capacity(out_len as usize);
342    for i in 0..out_len {
343        let pos = i as f64 * f64::from(from) / f64::from(to);
344        let j = pos.floor() as usize;
345        let frac = (pos - j as f64) as f32;
346        let a = input.get(j).copied().unwrap_or(0.0);
347        let b = input.get(j + 1).copied().unwrap_or(a);
348        out.push(a + (b - a) * frac);
349    }
350    out
351}
352
353fn duck_envelope(n: usize, rate: u32, enabled: bool, voices: &[(f64, f64)]) -> Vec<f32> {
354    let mut env = vec![1.0f32; n];
355    if !enabled || voices.is_empty() || n == 0 {
356        return env;
357    }
358    let attack = (DUCK_ATTACK * f64::from(rate)).max(1.0) as f32;
359    let release = (DUCK_RELEASE * f64::from(rate)).max(1.0) as f32;
360    let mut gain = 1.0f32;
361    for (i, slot) in env.iter_mut().enumerate() {
362        let t = i as f64 / f64::from(rate);
363        let under = voices.iter().any(|(a, b)| t >= *a && t < *b);
364        let target = if under { DUCK_FLOOR } else { 1.0 };
365        let step = if target < gain { attack } else { release };
366        gain += (target - gain) / step;
367        *slot = gain;
368    }
369    env
370}
371
372fn add_clip(acc: &mut [f32], clip: &Loaded, rate: u32, duck: &[f32]) {
373    let dur = clip.samples.len() as f64 / f64::from(rate);
374    let origin = (clip.start * f64::from(rate)).round() as isize;
375    for (k, sample) in clip.samples.iter().enumerate() {
376        let i = origin + k as isize;
377        if i < 0 || i >= acc.len() as isize {
378            continue;
379        }
380        let pos = k as f64 / f64::from(rate);
381        let mut g = clip.volume * clip_fade(pos, dur, clip.fade_in, clip.fade_out);
382        if !duck.is_empty() {
383            g *= duck[i as usize];
384        }
385        acc[i as usize] += sample * g;
386    }
387}
388
389fn clip_fade(pos: f64, dur: f64, fade_in: f64, fade_out: f64) -> f32 {
390    let mut g = 1.0f32;
391    if fade_in > 0.0 && pos < fade_in {
392        g *= (pos / fade_in) as f32;
393    }
394    if fade_out > 0.0 && pos > dur - fade_out {
395        g *= ((dur - pos) / fade_out).max(0.0) as f32;
396    }
397    g
398}
399
400fn apply_master_fade(acc: &mut [f32], rate: u32, fade_in: f64, fade_out: f64) {
401    let n = acc.len();
402    let inn = (fade_in * f64::from(rate)).round().max(1.0) as usize;
403    let outn = (fade_out * f64::from(rate)).round().max(1.0) as usize;
404    for (i, s) in acc.iter_mut().enumerate() {
405        let mut g = 1.0f32;
406        if i < inn {
407            g *= i as f32 / inn as f32;
408        }
409        if i + outn >= n {
410            g *= (n.saturating_sub(1).saturating_sub(i)) as f32 / outn as f32;
411        }
412        *s *= g;
413    }
414}
415
416fn limit(input: &[f32], rate: u32) -> Vec<f32> {
417    let n = input.len();
418    let lookahead = ((LOOKAHEAD * f64::from(rate)).round() as usize).max(1);
419    let release = (LIMIT_RELEASE * f64::from(rate)).max(1.0) as f32;
420    let mut out = vec![0.0f32; n];
421    let mut gain = 1.0f32;
422    for i in 0..n {
423        let end = (i + lookahead).min(n);
424        let peak = input[i..end].iter().fold(0.0f32, |p, s| p.max(s.abs()));
425        let target = if peak > CEILING { CEILING / peak } else { 1.0 };
426        if target < gain {
427            gain = target;
428        } else {
429            gain += (target - gain) / release;
430        }
431        out[i] = (input[i] * gain).clamp(-CEILING, CEILING);
432    }
433    out
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    fn tone(path: &Path, hz: f32, seconds: f64, amp: f32) {
441        let n = (seconds * f64::from(SAMPLE_RATE)).round() as usize;
442        let samples: Vec<i16> = (0..n)
443            .map(|i| {
444                let t = i as f32 / SAMPLE_RATE as f32;
445                (amp * (t * hz * std::f32::consts::TAU).sin() * 32767.0).round() as i16
446            })
447            .collect();
448        write_wav(
449            path,
450            &Mixed {
451                rate: SAMPLE_RATE,
452                samples,
453            },
454        )
455        .unwrap();
456    }
457
458    fn clip(file: &Path, start: f64, priority: bool, music: bool) -> MixClip {
459        MixClip {
460            file: file.into(),
461            start,
462            volume: 1.0,
463            fade_in: 0.0,
464            fade_out: 0.0,
465            trim_start: 0.0,
466            trim_end: None,
467            priority,
468            music,
469        }
470    }
471
472    #[test]
473    fn a_mix_is_the_same_bytes_twice_and_never_clips() {
474        let dir = std::env::temp_dir().join("oxframe-mix-det");
475        let _ = std::fs::create_dir_all(&dir);
476        let a = dir.join("a.wav");
477        let b = dir.join("b.wav");
478        tone(&a, 440.0, 0.4, 1.0);
479        tone(&b, 660.0, 0.4, 1.0);
480        let req = MixRequest {
481            seconds: 0.4,
482            duck: false,
483            master_fade_in: 0.0,
484            master_fade_out: 0.0,
485            clips: vec![clip(&a, 0.0, false, false), clip(&b, 0.0, false, false)],
486        };
487        let once = mix(&req).unwrap();
488        let twice = mix(&req).unwrap();
489        assert_eq!(once.samples, twice.samples);
490        let ceiling = (CEILING * 32767.0).round() as i16;
491        assert!(once.samples.iter().all(|s| s.abs() <= ceiling));
492        assert_eq!(once.samples.first().copied(), Some(0));
493        assert_eq!(once.samples.last().copied(), Some(0));
494    }
495
496    #[test]
497    fn priority_ducks_the_music_without_a_click() {
498        let dir = std::env::temp_dir().join("oxframe-mix-duck");
499        let _ = std::fs::create_dir_all(&dir);
500        let music = dir.join("music.wav");
501        let voice = dir.join("voice.wav");
502        tone(&music, 220.0, 0.8, 0.5);
503        tone(&voice, 880.0, 0.2, 0.0);
504        let ducked = mix(&MixRequest {
505            seconds: 0.8,
506            duck: true,
507            master_fade_in: 0.0,
508            master_fade_out: 0.0,
509            clips: vec![
510                clip(&music, 0.0, false, true),
511                clip(&voice, 0.3, true, false),
512            ],
513        })
514        .unwrap();
515        let plain = mix(&MixRequest {
516            seconds: 0.8,
517            duck: false,
518            master_fade_in: 0.0,
519            master_fade_out: 0.0,
520            clips: vec![clip(&music, 0.0, false, true)],
521        })
522        .unwrap();
523        let at = |samples: &[i16], t: f64| {
524            let i = (t * f64::from(SAMPLE_RATE)) as usize;
525            samples[i].unsigned_abs()
526        };
527        assert!(at(&ducked.samples, 0.4) < at(&plain.samples, 0.4));
528        let window = |samples: &[i16], t: f64| {
529            let i = (t * f64::from(SAMPLE_RATE)) as usize;
530            samples[i..i + SAMPLE_RATE as usize / 20]
531                .iter()
532                .map(|s| u32::from(s.unsigned_abs()))
533                .max()
534                .unwrap_or(0)
535        };
536        assert!(window(&ducked.samples, 0.35) * 2 < window(&ducked.samples, 0.1));
537    }
538
539    #[test]
540    fn a_range_shifts_the_clip() {
541        let dir = std::env::temp_dir().join("oxframe-mix-range");
542        let _ = std::fs::create_dir_all(&dir);
543        let click = dir.join("click.wav");
544        tone(&click, 1000.0, 0.05, 0.5);
545        let mixed = mix(&MixRequest {
546            seconds: 0.5,
547            duck: false,
548            master_fade_in: 0.0,
549            master_fade_out: 0.0,
550            clips: vec![clip(&click, 0.2, false, false)],
551        })
552        .unwrap();
553        let onset = mixed
554            .samples
555            .iter()
556            .position(|s| s.unsigned_abs() > 1000)
557            .expect("the clip is audible");
558        let at = onset as f64 / f64::from(SAMPLE_RATE);
559        assert!((at - 0.2).abs() < 0.01, "onset at {at}");
560    }
561}