Skip to main content

sipx_rtp/
quality.rs

1//! What a call actually sounded like: loss, jitter, round-trip time, and an estimate of how
2//! bad that combination was.
3//!
4//! Everything here is derived from numbers the RTCP exchange already carries. Nothing is
5//! guessed, and the one figure that *is* an estimate — the mean opinion score — says so in its
6//! own documentation, because a number between 1 and 5 that looks like a measurement is
7//! exactly the kind of number someone will make a decision on.
8
9use std::time::{Duration, SystemTime, UNIX_EPOCH};
10
11/// Seconds between the NTP epoch (1900-01-01) and the Unix one (1970-01-01).
12///
13/// Including the leap days: NTP counts 70 years of which 17 were leap years.
14const NTP_EPOCH_OFFSET: u64 = 2_208_988_800;
15
16/// Now, as a 64-bit NTP timestamp: seconds in the high half, fraction in the low half.
17///
18/// A clock that has never been set gives a time near the Unix epoch, which becomes an NTP
19/// timestamp in 1970 — wrong, but consistently wrong, and the round-trip calculation below
20/// works on *differences* of these, so a constant offset cancels. What would not cancel is a
21/// clock that steps mid-call, which is why round-trip times are reported as a most recent
22/// sample rather than accumulated into an average.
23#[must_use]
24pub fn ntp_now() -> u64 {
25    let since_epoch = SystemTime::now()
26        .duration_since(UNIX_EPOCH)
27        .unwrap_or(Duration::ZERO);
28    let seconds = since_epoch.as_secs().saturating_add(NTP_EPOCH_OFFSET);
29    // The fraction is in units of 2^-32 seconds.
30    let fraction = (u64::from(since_epoch.subsec_nanos()) << 32) / 1_000_000_000;
31    (seconds << 32) | fraction
32}
33
34/// The middle 32 bits of an NTP timestamp: 16 bits of seconds, 16 of fraction.
35///
36/// This is what a report block echoes back (RFC 3550 §6.4.1), and the truncation is why the
37/// round trip below wraps rather than saturates: the field rolls over every 18 hours.
38#[must_use]
39pub fn middle_32(ntp: u64) -> u32 {
40    ((ntp >> 16) & 0xFFFF_FFFF) as u32
41}
42
43/// The round-trip time from a report block, per RFC 3550 §6.4.1.
44///
45/// `now` is the middle 32 bits of our clock when the report arrived, `last_sender_report` is
46/// what the peer echoed of ours, and `delay` is how long the peer sat on it. Subtracting the
47/// peer's own delay is the whole point: without it, an implementation that reports every five
48/// seconds looks five seconds away.
49///
50/// `None` when there is nothing to compute from — a peer that has had no sender report from us
51/// echoes zero — or when the arithmetic comes out negative, which means one of the two clocks
52/// moved and the answer would be fiction.
53#[must_use]
54pub fn round_trip(now: u32, last_sender_report: u32, delay: u32) -> Option<Duration> {
55    if last_sender_report == 0 {
56        return None;
57    }
58    // Wrapping, because the field is a truncation of a larger counter and rolls over.
59    let elapsed = now.wrapping_sub(last_sender_report);
60    let round_trip = elapsed.checked_sub(delay)?;
61    // The units are 1/65536 of a second.
62    Some(Duration::from_nanos(
63        u64::from(round_trip) * 1_000_000_000 / 65_536,
64    ))
65}
66
67/// How a call is going.
68#[derive(Debug, Clone, Copy, PartialEq)]
69pub struct Quality {
70    /// Loss since the last report, as a fraction between 0 and 1.
71    pub loss: f64,
72    /// Packets lost since the stream began. Signed, because duplicates can make it go down.
73    pub cumulative_lost: i64,
74    /// Interarrival jitter (RFC 3550 §6.4.1).
75    pub jitter: Duration,
76    /// The most recent round-trip time, if a report has come back with one.
77    pub round_trip: Option<Duration>,
78    /// An estimated mean opinion score. See [`Quality::mos`] for what it is and is not.
79    pub mos: f64,
80}
81
82impl Quality {
83    /// Estimate a mean opinion score from loss, jitter and round-trip time.
84    ///
85    /// **This is an estimate, not a measurement.** A real MOS comes from people listening. What
86    /// this computes is the ITU-T G.107 E-model's transmission rating `R`, converted to a score
87    /// by G.107's own formula, from impairment terms that are the common simplification rather
88    /// than the full model: delay is folded into one term, and loss is charged at a flat rate
89    /// that is roughly right for G.711 and wrong for a codec with packet loss concealment.
90    ///
91    /// It is worth having anyway, because it collapses three numbers that trade against each
92    /// other into one that can be compared between calls. It is not worth reporting to four
93    /// decimal places, and sipx does not.
94    #[must_use]
95    pub fn mos(loss: f64, jitter: Duration, round_trip: Option<Duration>) -> f64 {
96        let latency_ms = round_trip.unwrap_or(Duration::ZERO).as_secs_f64() * 1000.0;
97        let jitter_ms = jitter.as_secs_f64() * 1000.0;
98        // Two jitter buffers' worth of jitter, plus a nominal 10 ms for everything else in the
99        // path that is not measured here.
100        let effective = latency_ms + jitter_ms * 2.0 + 10.0;
101
102        // The knee at 160 ms is where added delay starts to hurt sharply rather than gently —
103        // the point conversation stops feeling immediate.
104        let mut rating = if effective < 160.0 {
105            93.2 - effective / 40.0
106        } else {
107            93.2 - (effective - 120.0) / 10.0
108        };
109        // 2.5 rating points per percent lost. Flat, which is the simplification: real
110        // impairment is steeply non-linear and depends on the codec's concealment.
111        rating -= loss * 100.0 * 2.5;
112
113        Self::score_from_rating(rating)
114    }
115
116    /// G.107's own conversion from the transmission rating `R` to a score.
117    ///
118    /// Exact, unlike the impairment terms feeding it: this part is the standard's formula.
119    #[must_use]
120    pub fn score_from_rating(rating: f64) -> f64 {
121        if rating <= 0.0 {
122            return 1.0;
123        }
124        if rating >= 100.0 {
125            return 4.5;
126        }
127        let score = 1.0 + 0.035 * rating + 7.0e-6 * rating * (rating - 60.0) * (100.0 - rating);
128        score.clamp(1.0, 4.5)
129    }
130}
131
132#[cfg(test)]
133#[allow(
134    clippy::unwrap_used,
135    clippy::expect_used,
136    clippy::panic,
137    clippy::indexing_slicing
138)]
139mod tests {
140    use super::*;
141
142    /// The units are the point. A quarter of a second is 16384 in 1/65536ths, and getting the
143    /// scale wrong here produces round-trip times that are plausible and off by 65536.
144    #[test]
145    fn a_round_trip_is_the_gap_minus_the_peers_own_delay() {
146        // The peer held the report for 1/8 s; a further 1/8 s of that gap was the network.
147        let lsr = 1_000_000;
148        let now = lsr + 16_384; // a quarter second later
149        let delay = 8_192; // an eighth of it was the peer thinking
150
151        let trip = round_trip(now, lsr, delay).expect("computable");
152        assert!(
153            (trip.as_secs_f64() - 0.125).abs() < 0.001,
154            "expected an eighth of a second, got {trip:?}"
155        );
156    }
157
158    /// Without subtracting the peer's delay, an implementation that reports every five seconds
159    /// would look five seconds away.
160    #[test]
161    fn the_peers_own_delay_does_not_count_as_distance() {
162        let lsr = 500_000;
163        let now = lsr + 5 * 65_536 + 655; // five seconds and ten milliseconds
164        let trip = round_trip(now, lsr, 5 * 65_536).expect("computable");
165        assert!(
166            trip < Duration::from_millis(50),
167            "the five seconds were the peer's, not the network's: {trip:?}"
168        );
169    }
170
171    #[test]
172    fn a_peer_that_has_heard_no_sender_report_yields_nothing() {
173        assert!(round_trip(1_000, 0, 0).is_none());
174    }
175
176    /// Rather than a number that is fiction. If the arithmetic goes negative, one of the two
177    /// clocks moved, and reporting a plausible-looking round trip would be worse than
178    /// reporting none.
179    #[test]
180    fn nonsense_arithmetic_yields_nothing_rather_than_a_guess() {
181        assert!(
182            round_trip(1_000, 900, 500).is_none(),
183            "delay exceeds the gap"
184        );
185    }
186
187    /// The field is a truncation of a wider counter and rolls over every 18 hours. A call
188    /// spanning the rollover must not report a round trip of half a day.
189    #[test]
190    fn the_calculation_survives_the_field_wrapping() {
191        let lsr = u32::MAX - 100;
192        let now = lsr.wrapping_add(6_553); // a tenth of a second later, across the wrap
193        let trip = round_trip(now, lsr, 0).expect("computable");
194        assert!(
195            trip < Duration::from_millis(200),
196            "the wrap is a continuation, not 18 hours: {trip:?}"
197        );
198    }
199
200    /// A perfect call scores at the top of the scale, and the scale tops out at 4.5 — which is
201    /// what G.711 can achieve, not 5. A stack reporting 5.0 for a toll-quality call is
202    /// reporting something the codec cannot deliver.
203    #[test]
204    fn a_clean_call_scores_near_the_top() {
205        let mos = Quality::mos(0.0, Duration::ZERO, Some(Duration::from_millis(20)));
206        assert!(mos > 4.2, "a clean call should score well: {mos}");
207        assert!(
208            mos <= 4.5,
209            "and never above what the codec can deliver: {mos}"
210        );
211    }
212
213    #[test]
214    fn loss_lowers_the_score_and_more_loss_lowers_it_further() {
215        let clean = Quality::mos(0.0, Duration::ZERO, Some(Duration::from_millis(20)));
216        let some = Quality::mos(0.02, Duration::ZERO, Some(Duration::from_millis(20)));
217        let lots = Quality::mos(0.10, Duration::ZERO, Some(Duration::from_millis(20)));
218        assert!(some < clean, "{some} should be worse than {clean}");
219        assert!(lots < some, "{lots} should be worse than {some}");
220    }
221
222    #[test]
223    fn delay_lowers_the_score_too() {
224        let near = Quality::mos(0.0, Duration::ZERO, Some(Duration::from_millis(20)));
225        let far = Quality::mos(0.0, Duration::ZERO, Some(Duration::from_millis(600)));
226        assert!(
227            far < near,
228            "a satellite hop should score worse: {far} vs {near}"
229        );
230    }
231
232    /// The scale has a bottom. A call this bad is unusable, and saying 0.3 rather than 1.0
233    /// would be reporting a score off the end of the scale it claims to be on.
234    #[test]
235    fn the_score_never_leaves_its_scale() {
236        let dreadful = Quality::mos(
237            0.9,
238            Duration::from_millis(500),
239            Some(Duration::from_secs(3)),
240        );
241        assert!((1.0..=4.5).contains(&dreadful), "{dreadful}");
242        assert!((1.0..=4.5).contains(&Quality::score_from_rating(-50.0)));
243        assert!((1.0..=4.5).contains(&Quality::score_from_rating(200.0)));
244    }
245
246    /// A constant offset between the two clocks cancels, so a machine whose clock has never
247    /// been set still measures round trips correctly.
248    #[test]
249    fn an_ntp_timestamp_is_in_the_right_century() {
250        let now = ntp_now();
251        let seconds = now >> 32;
252        // 2020-01-01 and 2200-01-01 in NTP seconds. Wide on purpose: this is a check that the
253        // epoch offset was applied at all, not a check on the system clock.
254        assert!(
255            (3_786_825_600..9_467_308_800).contains(&seconds),
256            "NTP seconds {seconds} is not a plausible date; the epoch offset is wrong"
257        );
258    }
259
260    #[test]
261    fn the_middle_bits_are_the_middle_bits() {
262        assert_eq!(middle_32(0x0000_1234_5678_0000), 0x1234_5678);
263    }
264}