1use 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
19pub const SAMPLE_RATE: u32 = 48_000;
21
22pub 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;
31const EDGE: f64 = 0.002;
33
34#[derive(Debug, Clone)]
36pub struct MixClip {
37 pub file: std::path::PathBuf,
39 pub start: f64,
41 pub volume: f32,
43 pub fade_in: f64,
45 pub fade_out: f64,
47 pub trim_start: f64,
49 pub trim_end: Option<f64>,
51 pub priority: bool,
53 pub music: bool,
55}
56
57#[derive(Debug, Clone)]
59pub struct MixRequest {
60 pub seconds: f64,
62 pub duck: bool,
64 pub master_fade_in: f64,
66 pub master_fade_out: f64,
68 pub clips: Vec<MixClip>,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct Mixed {
75 pub rate: u32,
77 pub samples: Vec<i16>,
79}
80
81#[derive(Debug)]
83pub enum MixError {
84 Read {
86 file: String,
88 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
105pub 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
115pub fn mix(req: &MixRequest) -> Result<Mixed, MixError> {
121 mix_with_loudness(req, None)
122}
123
124pub 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
184pub 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
196fn integrated_loudness(samples: &[f32], rate: u32) -> Option<f64> {
200 debug_assert_eq!(rate, SAMPLE_RATE, "K-weighting coefficients are for 48 kHz");
201 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
248pub 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 out = File::create(path).map_err(|e| MixError::Read {
259 file: path.display().to_string(),
260 message: e.to_string(),
261 })?;
262 let write = |f: &mut File, bytes: &[u8]| {
263 f.write_all(bytes).map_err(|e| MixError::Read {
264 file: path.display().to_string(),
265 message: e.to_string(),
266 })
267 };
268 write(&mut out, b"RIFF")?;
269 write(&mut out, &(36 + data_len).to_le_bytes())?;
270 write(&mut out, b"WAVEfmt ")?;
271 write(&mut out, &16u32.to_le_bytes())?;
272 write(&mut out, &1u16.to_le_bytes())?;
273 write(&mut out, &channels.to_le_bytes())?;
274 write(&mut out, &mixed.rate.to_le_bytes())?;
275 write(&mut out, &(mixed.rate * u32::from(block)).to_le_bytes())?;
276 write(&mut out, &block.to_le_bytes())?;
277 write(&mut out, &bits.to_le_bytes())?;
278 write(&mut out, b"data")?;
279 write(&mut out, &data_len.to_le_bytes())?;
280 for s in &mixed.samples {
281 let b = s.to_le_bytes();
282 write(&mut out, &b)?;
283 write(&mut out, &b)?;
284 }
285 Ok(())
286}
287
288pub fn envelope(samples: &[i16], buckets: usize) -> Vec<f32> {
290 if buckets == 0 {
291 return Vec::new();
292 }
293 if samples.is_empty() {
294 return vec![0.0; buckets];
295 }
296 (0..buckets)
297 .map(|i| {
298 let a = i * samples.len() / buckets;
299 let b = ((i + 1) * samples.len() / buckets)
300 .max(a + 1)
301 .min(samples.len());
302 let peak = samples[a..b]
303 .iter()
304 .map(|s| s.unsigned_abs())
305 .max()
306 .unwrap_or(0);
307 f32::from(peak) / 32768.0
308 })
309 .collect()
310}
311
312pub fn downsample(samples: &[i16], from: u32, to: u32) -> Vec<i16> {
314 if samples.is_empty() || to == 0 || from == 0 || from == to {
315 return samples.to_vec();
316 }
317 let ratio = f64::from(from) / f64::from(to);
318 let n = (samples.len() as f64 / ratio).floor() as usize;
319 (0..n)
320 .map(|i| {
321 let a = (i as f64 * ratio).floor() as usize;
322 let b = (((i + 1) as f64) * ratio).floor() as usize;
323 let b = b.max(a + 1).min(samples.len());
324 let sum: i32 = samples[a..b].iter().map(|s| i32::from(*s)).sum();
325 (sum / (b - a) as i32) as i16
326 })
327 .collect()
328}
329
330struct Loaded {
331 samples: Vec<f32>,
332 start: f64,
333 volume: f32,
334 fade_in: f64,
335 fade_out: f64,
336 priority: bool,
337 music: bool,
338}
339
340fn load_clip(clip: &MixClip) -> Result<Vec<f32>, MixError> {
341 let (rate, pcm) = decode(&clip.file)?;
342 let at_rate = resample(&pcm, rate, SAMPLE_RATE);
343 let start = (clip.trim_start.max(0.0) * f64::from(SAMPLE_RATE)).round() as usize;
344 let end = clip
345 .trim_end
346 .map(|t| (t.max(0.0) * f64::from(SAMPLE_RATE)).round() as usize)
347 .unwrap_or(at_rate.len())
348 .min(at_rate.len());
349 if end <= start || start >= at_rate.len() {
350 return Ok(Vec::new());
351 }
352 Ok(at_rate[start..end].to_vec())
353}
354
355fn decode(path: &Path) -> Result<(u32, Vec<f32>), MixError> {
356 let file = File::open(path).map_err(|e| MixError::Read {
357 file: path.display().to_string(),
358 message: e.to_string(),
359 })?;
360 let mss = MediaSourceStream::new(Box::new(file), Default::default());
361 let mut hint = Hint::new();
362 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
363 hint.with_extension(ext);
364 }
365 let probed = symphonia::default::get_probe()
366 .format(
367 &hint,
368 mss,
369 &FormatOptions::default(),
370 &MetadataOptions::default(),
371 )
372 .map_err(|e| MixError::Read {
373 file: path.display().to_string(),
374 message: e.to_string(),
375 })?;
376 let mut format = probed.format;
377 let track = format
380 .tracks()
381 .iter()
382 .find(|t| t.codec_params.sample_rate.is_some())
383 .ok_or_else(|| MixError::Read {
384 file: path.display().to_string(),
385 message: "no audio track".into(),
386 })?
387 .clone();
388 let track_id = track.id;
389 let rate = track.codec_params.sample_rate.unwrap_or(SAMPLE_RATE);
390 let dec_opts = DecoderOptions::default();
391 let mut decoder = symphonia::default::get_codecs()
392 .make(&track.codec_params, &dec_opts)
393 .map_err(|e| MixError::Read {
394 file: path.display().to_string(),
395 message: e.to_string(),
396 })?;
397 let mut pcm = Vec::new();
398 while let Ok(packet) = format.next_packet() {
399 if packet.track_id() != track_id {
400 continue;
401 }
402 let decoded = match decoder.decode(&packet) {
403 Ok(d) => d,
404 Err(SymError::DecodeError(_)) => continue,
405 Err(_) => break,
406 };
407 let spec = *decoded.spec();
408 let mut buf = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
409 buf.copy_interleaved_ref(decoded);
410 let channels = spec.channels.count().max(1);
411 for frame in buf.samples().chunks(channels) {
412 pcm.push(frame.first().copied().unwrap_or(0.0));
413 }
414 }
415 Ok((rate, pcm))
416}
417
418fn resample(input: &[f32], from: u32, to: u32) -> Vec<f32> {
419 if input.is_empty() || from == 0 {
420 return Vec::new();
421 }
422 if from == to {
423 return input.to_vec();
424 }
425 let out_len = (input.len() as u64) * u64::from(to) / u64::from(from);
426 let mut out = Vec::with_capacity(out_len as usize);
427 for i in 0..out_len {
428 let pos = i as f64 * f64::from(from) / f64::from(to);
429 let j = pos.floor() as usize;
430 let frac = (pos - j as f64) as f32;
431 let a = input.get(j).copied().unwrap_or(0.0);
432 let b = input.get(j + 1).copied().unwrap_or(a);
433 out.push(a + (b - a) * frac);
434 }
435 out
436}
437
438fn duck_envelope(n: usize, rate: u32, enabled: bool, voices: &[(f64, f64)]) -> Vec<f32> {
439 let mut env = vec![1.0f32; n];
440 if !enabled || voices.is_empty() || n == 0 {
441 return env;
442 }
443 let attack = (DUCK_ATTACK * f64::from(rate)).max(1.0) as f32;
444 let release = (DUCK_RELEASE * f64::from(rate)).max(1.0) as f32;
445 let mut gain = 1.0f32;
446 for (i, slot) in env.iter_mut().enumerate() {
447 let t = i as f64 / f64::from(rate);
448 let under = voices.iter().any(|(a, b)| t >= *a && t < *b);
449 let target = if under { DUCK_FLOOR } else { 1.0 };
450 let step = if target < gain { attack } else { release };
451 gain += (target - gain) / step;
452 *slot = gain;
453 }
454 env
455}
456
457fn add_clip(acc: &mut [f32], clip: &Loaded, rate: u32, duck: &[f32]) {
458 let dur = clip.samples.len() as f64 / f64::from(rate);
459 let origin = (clip.start * f64::from(rate)).round() as isize;
460 for (k, sample) in clip.samples.iter().enumerate() {
461 let i = origin + k as isize;
462 if i < 0 || i >= acc.len() as isize {
463 continue;
464 }
465 let pos = k as f64 / f64::from(rate);
466 let mut g = clip.volume * clip_fade(pos, dur, clip.fade_in, clip.fade_out);
467 if !duck.is_empty() {
468 g *= duck[i as usize];
469 }
470 acc[i as usize] += sample * g;
471 }
472}
473
474fn clip_fade(pos: f64, dur: f64, fade_in: f64, fade_out: f64) -> f32 {
475 let mut g = 1.0f32;
476 if fade_in > 0.0 && pos < fade_in {
477 g *= (pos / fade_in) as f32;
478 }
479 if fade_out > 0.0 && pos > dur - fade_out {
480 g *= ((dur - pos) / fade_out).max(0.0) as f32;
481 }
482 g
483}
484
485fn apply_master_fade(acc: &mut [f32], rate: u32, fade_in: f64, fade_out: f64) {
486 let n = acc.len();
487 let inn = (fade_in * f64::from(rate)).round().max(1.0) as usize;
488 let outn = (fade_out * f64::from(rate)).round().max(1.0) as usize;
489 for (i, s) in acc.iter_mut().enumerate() {
490 let mut g = 1.0f32;
491 if i < inn {
492 g *= i as f32 / inn as f32;
493 }
494 if i + outn >= n {
495 g *= (n.saturating_sub(1).saturating_sub(i)) as f32 / outn as f32;
496 }
497 *s *= g;
498 }
499}
500
501fn limit(input: &[f32], rate: u32) -> Vec<f32> {
502 let n = input.len();
503 let lookahead = ((LOOKAHEAD * f64::from(rate)).round() as usize).max(1);
504 let release = (LIMIT_RELEASE * f64::from(rate)).max(1.0) as f32;
505 let mut out = vec![0.0f32; n];
506 let mut gain = 1.0f32;
507 for i in 0..n {
508 let end = (i + lookahead).min(n);
509 let peak = input[i..end].iter().fold(0.0f32, |p, s| p.max(s.abs()));
510 let target = if peak > CEILING { CEILING / peak } else { 1.0 };
511 if target < gain {
512 gain = target;
513 } else {
514 gain += (target - gain) / release;
515 }
516 out[i] = (input[i] * gain).clamp(-CEILING, CEILING);
517 }
518 out
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524
525 fn tone(path: &Path, hz: f32, seconds: f64, amp: f32) {
526 let n = (seconds * f64::from(SAMPLE_RATE)).round() as usize;
527 let samples: Vec<i16> = (0..n)
528 .map(|i| {
529 let t = i as f32 / SAMPLE_RATE as f32;
530 (amp * (t * hz * std::f32::consts::TAU).sin() * 32767.0).round() as i16
531 })
532 .collect();
533 write_wav(
534 path,
535 &Mixed {
536 rate: SAMPLE_RATE,
537 samples,
538 },
539 )
540 .unwrap();
541 }
542
543 #[test]
544 fn a_sine_at_minus_20_dbfs_is_minus_20_lufs() {
545 let n = 5 * SAMPLE_RATE as usize;
547 let samples: Vec<i16> = (0..n)
548 .map(|i| {
549 let t = i as f32 / SAMPLE_RATE as f32;
550 (0.1 * (t * 997.0 * std::f32::consts::TAU).sin() * 32767.0).round() as i16
551 })
552 .collect();
553 let l = loudness(&Mixed {
554 rate: SAMPLE_RATE,
555 samples,
556 })
557 .unwrap();
558 assert!((l + 20.0).abs() < 0.1, "{l} LUFS");
559 let silent = Mixed {
560 rate: SAMPLE_RATE,
561 samples: vec![0; n],
562 };
563 assert_eq!(loudness(&silent), None);
564 }
565
566 #[test]
567 fn a_quiet_mix_is_brought_to_the_target_loudness() {
568 let dir = std::env::temp_dir().join(format!("oxframe-loud-{}", std::process::id()));
569 std::fs::create_dir_all(&dir).unwrap();
570 let quiet = dir.join("quiet.wav");
571 tone(&quiet, 440.0, 4.0, 0.03);
572 let req = MixRequest {
573 seconds: 4.0,
574 duck: false,
575 master_fade_in: 0.0,
576 master_fade_out: 0.0,
577 clips: vec![clip(&quiet, 0.0, false, false)],
578 };
579 let before = loudness(&mix(&req).unwrap()).unwrap();
580 let after = loudness(&mix_with_loudness(&req, Some(-14.0)).unwrap()).unwrap();
581 assert!(before < -25.0, "{before}");
582 assert!((after + 14.0).abs() < 0.2, "{after} LUFS");
583 let _ = std::fs::remove_dir_all(dir);
584 }
585
586 fn clip(file: &Path, start: f64, priority: bool, music: bool) -> MixClip {
587 MixClip {
588 file: file.into(),
589 start,
590 volume: 1.0,
591 fade_in: 0.0,
592 fade_out: 0.0,
593 trim_start: 0.0,
594 trim_end: None,
595 priority,
596 music,
597 }
598 }
599
600 #[test]
601 fn a_mix_is_the_same_bytes_twice_and_never_clips() {
602 let dir = std::env::temp_dir().join("oxframe-mix-det");
603 let _ = std::fs::create_dir_all(&dir);
604 let a = dir.join("a.wav");
605 let b = dir.join("b.wav");
606 tone(&a, 440.0, 0.4, 1.0);
607 tone(&b, 660.0, 0.4, 1.0);
608 let req = MixRequest {
609 seconds: 0.4,
610 duck: false,
611 master_fade_in: 0.0,
612 master_fade_out: 0.0,
613 clips: vec![clip(&a, 0.0, false, false), clip(&b, 0.0, false, false)],
614 };
615 let once = mix(&req).unwrap();
616 let twice = mix(&req).unwrap();
617 assert_eq!(once.samples, twice.samples);
618 let ceiling = (CEILING * 32767.0).round() as i16;
619 assert!(once.samples.iter().all(|s| s.abs() <= ceiling));
620 assert_eq!(once.samples.first().copied(), Some(0));
621 assert_eq!(once.samples.last().copied(), Some(0));
622 }
623
624 #[test]
625 fn priority_ducks_the_music_without_a_click() {
626 let dir = std::env::temp_dir().join("oxframe-mix-duck");
627 let _ = std::fs::create_dir_all(&dir);
628 let music = dir.join("music.wav");
629 let voice = dir.join("voice.wav");
630 tone(&music, 220.0, 0.8, 0.5);
631 tone(&voice, 880.0, 0.2, 0.0);
632 let ducked = mix(&MixRequest {
633 seconds: 0.8,
634 duck: true,
635 master_fade_in: 0.0,
636 master_fade_out: 0.0,
637 clips: vec![
638 clip(&music, 0.0, false, true),
639 clip(&voice, 0.3, true, false),
640 ],
641 })
642 .unwrap();
643 let plain = mix(&MixRequest {
644 seconds: 0.8,
645 duck: false,
646 master_fade_in: 0.0,
647 master_fade_out: 0.0,
648 clips: vec![clip(&music, 0.0, false, true)],
649 })
650 .unwrap();
651 let at = |samples: &[i16], t: f64| {
652 let i = (t * f64::from(SAMPLE_RATE)) as usize;
653 samples[i].unsigned_abs()
654 };
655 assert!(at(&ducked.samples, 0.4) < at(&plain.samples, 0.4));
656 let window = |samples: &[i16], t: f64| {
657 let i = (t * f64::from(SAMPLE_RATE)) as usize;
658 samples[i..i + SAMPLE_RATE as usize / 20]
659 .iter()
660 .map(|s| u32::from(s.unsigned_abs()))
661 .max()
662 .unwrap_or(0)
663 };
664 assert!(window(&ducked.samples, 0.35) * 2 < window(&ducked.samples, 0.1));
665 }
666
667 #[test]
668 fn a_range_shifts_the_clip() {
669 let dir = std::env::temp_dir().join("oxframe-mix-range");
670 let _ = std::fs::create_dir_all(&dir);
671 let click = dir.join("click.wav");
672 tone(&click, 1000.0, 0.05, 0.5);
673 let mixed = mix(&MixRequest {
674 seconds: 0.5,
675 duck: false,
676 master_fade_in: 0.0,
677 master_fade_out: 0.0,
678 clips: vec![clip(&click, 0.2, false, false)],
679 })
680 .unwrap();
681 let onset = mixed
682 .samples
683 .iter()
684 .position(|s| s.unsigned_abs() > 1000)
685 .expect("the clip is audible");
686 let at = onset as f64 / f64::from(SAMPLE_RATE);
687 assert!((at - 0.2).abs() < 0.01, "onset at {at}");
688 }
689}