Skip to main content

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}