sipx_audio/mix.rs
1//! Adding audio streams together.
2//!
3//! Two things decide whether a conference sounds like a conference.
4//!
5//! **Saturation, not wrapping.** Two people talking loudly at once produce sums outside the
6//! range of an `i16`. Wrapping turns the loudest moment of a call — the moment two people
7//! interrupt each other — into a full-scale discontinuity, which is heard as a bang. Clipping
8//! the sum instead sounds like a loud moment, which is what it is.
9//!
10//! **N-1, not N.** Each participant hears everyone *except themselves*. Including their own
11//! audio sends their voice back to them delayed by the round trip, and delayed sidetone is
12//! close to unbearable: it is the effect used deliberately to stop people speaking. Anyone
13//! building a mixer discovers this within a minute of trying it, and the reason it is worth
14//! writing down is that N-1 costs a mix per participant rather than one for everybody, and it
15//! is tempting to do the cheap thing.
16
17/// Add `source` into `into`, clipping rather than wrapping.
18///
19/// The shorter of the two decides how much is mixed: a participant whose frame is short has
20/// nothing to contribute past its end, and padding it with silence would be the same thing at
21/// more cost.
22pub fn mix_into(into: &mut [i16], source: &[i16]) {
23 for (target, add) in into.iter_mut().zip(source) {
24 *target = saturating_add(*target, *add);
25 }
26}
27
28/// Add two samples, clipping at the ends of the range.
29///
30/// `i16::saturating_add` already does exactly this. It is worth having a named function anyway,
31/// because the mistake this prevents is not writing the wrong addition — it is writing `+`.
32#[must_use]
33pub fn saturating_add(one: i16, two: i16) -> i16 {
34 one.saturating_add(two)
35}
36
37/// Mix everything in `sources` except the one at `exclude`.
38///
39/// This is the N-1 mix, done for one participant. `exclude` out of range mixes everything,
40/// which is what a listener who is not a contributor wants.
41#[must_use]
42pub fn mix_excluding(sources: &[Vec<i16>], exclude: usize, samples: usize) -> Vec<i16> {
43 let mut out = vec![0i16; samples];
44 for (index, source) in sources.iter().enumerate() {
45 if index == exclude {
46 continue;
47 }
48 mix_into(&mut out, source);
49 }
50 out
51}
52
53#[cfg(test)]
54#[allow(
55 clippy::unwrap_used,
56 clippy::expect_used,
57 clippy::panic,
58 clippy::indexing_slicing
59)]
60mod tests {
61 use super::*;
62
63 #[test]
64 fn mixing_adds_the_samples() {
65 let mut into = vec![100, 200, 300];
66 mix_into(&mut into, &[10, 20, 30]);
67 assert_eq!(into, vec![110, 220, 330]);
68 }
69
70 /// The case the whole module exists for. Two loud speakers sum past the range, and wrapping
71 /// would turn the loudest instant of the call into a full-scale discontinuity — heard as a
72 /// bang, at the exact moment two people are trying to talk over each other.
73 #[test]
74 fn a_sum_past_the_range_clips_rather_than_wrapping() {
75 let mut into = vec![i16::MAX, i16::MIN];
76 mix_into(&mut into, &[i16::MAX, i16::MIN]);
77 assert_eq!(
78 into,
79 vec![i16::MAX, i16::MIN],
80 "wrapping would send +32767 to -2 and be heard as a bang"
81 );
82 }
83
84 #[test]
85 fn clipping_is_symmetric() {
86 assert_eq!(saturating_add(30_000, 10_000), i16::MAX);
87 assert_eq!(saturating_add(-30_000, -10_000), i16::MIN);
88 }
89
90 /// A short frame contributes what it has and nothing more. Reading past it would be an
91 /// out-of-bounds read; padding it would be the same result at more cost.
92 #[test]
93 fn a_short_source_mixes_only_as_far_as_it_goes() {
94 let mut into = vec![1, 1, 1, 1];
95 mix_into(&mut into, &[10, 10]);
96 assert_eq!(into, vec![11, 11, 1, 1]);
97 }
98
99 #[test]
100 fn a_long_source_does_not_overrun_the_target() {
101 let mut into = vec![1, 1];
102 mix_into(&mut into, &[10, 10, 10, 10]);
103 assert_eq!(into, vec![11, 11]);
104 }
105
106 /// N-1. A participant hearing themselves hears their own voice a round trip late, which is
107 /// the single most disorienting thing a conference can do.
108 #[test]
109 fn a_participant_is_excluded_from_their_own_mix() {
110 let sources = vec![vec![100; 4], vec![20; 4], vec![3; 4]];
111 assert_eq!(mix_excluding(&sources, 0, 4), vec![23; 4]);
112 assert_eq!(mix_excluding(&sources, 1, 4), vec![103; 4]);
113 assert_eq!(mix_excluding(&sources, 2, 4), vec![120; 4]);
114 }
115
116 /// A listener who contributes nothing hears everybody.
117 #[test]
118 fn excluding_nobody_mixes_everybody() {
119 let sources = vec![vec![100; 4], vec![20; 4], vec![3; 4]];
120 assert_eq!(mix_excluding(&sources, usize::MAX, 4), vec![123; 4]);
121 }
122
123 #[test]
124 fn one_participant_hears_silence_rather_than_themselves() {
125 let sources = vec![vec![1000; 4]];
126 assert_eq!(mix_excluding(&sources, 0, 4), vec![0; 4]);
127 }
128
129 /// The N-1 mix clips too. A mix that saturated per-pair but not overall would still bang
130 /// with three loud speakers.
131 #[test]
132 fn the_excluding_mix_clips_as_well() {
133 let sources = vec![vec![20_000; 2], vec![20_000; 2], vec![20_000; 2]];
134 assert_eq!(mix_excluding(&sources, 2, 2), vec![i16::MAX; 2]);
135 }
136}