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 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
163pub 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
203pub 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
227pub 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 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}