moq_video/encode/rate.rs
1//! Rate control: turning a congestion-control bandwidth estimate into the
2//! bitrate the encoder should actually produce at.
3//!
4//! [`Control`] is the one place this policy lives, so every sender backs off the
5//! same way. It's a pure function of the estimate: feed it every value from a
6//! [`bandwidth::Consumer`](moq_net::bandwidth::Consumer) and hand what it
7//! returns to [`Encoder::set_bitrate`](super::Encoder::set_bitrate).
8
9use std::time::Instant;
10
11/// How a bandwidth estimate maps onto the bitrate a sender should produce at.
12///
13/// Build one with [`Policy::new`] and override what you need. The defaults are
14/// tuned for a live contribution encoder on a cellular uplink: give back
15/// bandwidth immediately when the pipe closes, take it back slowly when it
16/// opens, and don't twitch at every jitter in the estimate.
17///
18/// `#[non_exhaustive]`: construct via [`Policy::new`] and set fields, so new
19/// knobs stay additive.
20#[derive(Clone, Debug)]
21#[non_exhaustive]
22pub struct Policy {
23 /// Fraction of the estimate to target, reserving room for the other tracks
24 /// sharing this connection (audio) and for transport overhead. Defaults to
25 /// 0.9. Must be greater than 0; values above 1.0 target more than the link
26 /// is estimated to carry and are clamped away.
27 pub headroom: f64,
28
29 /// Upper bound in bits per second, normally the bitrate the caller asked
30 /// for. The estimate can only ever take the target *down* from here: an
31 /// optimistic estimate is not a reason to send more than was configured.
32 pub max: u64,
33
34 /// Lower bound in bits per second. Below some rate the picture isn't worth
35 /// sending, so the target holds here and the transport's priority queue
36 /// sheds the excess instead. Defaults to a tenth of `max`.
37 pub min: u64,
38
39 /// Ignore moves smaller than this fraction of the current target, so a
40 /// jittering estimate doesn't reconfigure the encoder every 100ms.
41 /// Defaults to 0.05 (5%).
42 pub hysteresis: f64,
43
44 /// How fast the target may climb back, as a fraction of the current target
45 /// per second. Defaults to 0.25 (25%/s, so ~3s from the floor back to a 2x
46 /// higher rate). Drops ignore this and apply at once: overshooting a closing
47 /// uplink costs a stalled picture, while undershooting an opening one costs
48 /// only a few seconds of lower quality.
49 pub ramp: f64,
50}
51
52impl Policy {
53 /// A policy targeting at most `max` bits per second, with the documented
54 /// defaults for every other knob.
55 pub fn new(max: u64) -> Self {
56 Self {
57 headroom: 0.9,
58 max,
59 // A tenth of the ceiling: low enough to ride out a bad uplink, high
60 // enough that what we do send is still worth decoding.
61 min: max / 10,
62 hysteresis: 0.05,
63 ramp: 0.25,
64 }
65 }
66}
67
68/// Maps bandwidth estimates onto a target bitrate, per a [`Policy`].
69///
70/// Feed it every estimate from a
71/// [`bandwidth::Consumer`](moq_net::bandwidth::Consumer); it returns a new
72/// target only when one is worth applying, so a caller can hand the result
73/// straight to an encoder without rate-limiting it further:
74///
75/// ```
76/// # use moq_video::encode::rate::{Control, Policy};
77/// # use std::time::Instant;
78/// let mut control = Control::new(Policy::new(4_000_000));
79/// // A 2 Mbps estimate takes the 4 Mbps target down to 2 Mbps * 0.9 headroom.
80/// assert_eq!(control.update(Some(2_000_000), Instant::now()), Some(1_800_000));
81/// ```
82///
83/// The time source is a parameter rather than an [`Instant::now`] call so the
84/// policy stays pure and testable. Pass the time the estimate was observed.
85#[derive(Clone, Debug)]
86pub struct Control {
87 policy: Policy,
88 target: u64,
89 /// When the target last moved, anchoring the [`Policy::ramp`] limit. `None`
90 /// until the first change, when there's nothing to ramp from.
91 applied: Option<Instant>,
92}
93
94impl Control {
95 /// Start at [`Policy::max`], the optimistic case: until an estimate says
96 /// otherwise, send what the caller configured.
97 pub fn new(policy: Policy) -> Self {
98 Self {
99 target: policy.max.max(policy.min),
100 policy,
101 applied: None,
102 }
103 }
104
105 /// The current target in bits per second.
106 pub fn target(&self) -> u64 {
107 self.target
108 }
109
110 /// Feed a new estimate, returning the new target when it moved enough to be
111 /// worth applying and `None` when it didn't.
112 ///
113 /// A `None` estimate (no congestion controller, or disconnected) holds the
114 /// current target rather than resetting to [`Policy::max`]: losing the
115 /// estimate is not evidence the uplink got better.
116 pub fn update(&mut self, estimate: Option<u64>, now: Instant) -> Option<u64> {
117 let estimate = estimate?;
118
119 // Normalize here rather than trusting the fields: `min > max` would make
120 // the clamp below panic, and a non-finite headroom would poison the cast.
121 let min = self.policy.min.min(self.policy.max);
122 let headroom = if self.policy.headroom.is_finite() {
123 self.policy.headroom.clamp(0.0, 1.0)
124 } else {
125 0.0
126 };
127
128 let desired = ((estimate as f64 * headroom) as u64).clamp(min, self.policy.max);
129
130 let next = if desired <= self.target {
131 // Attack: the pipe is closing, give the bandwidth back now.
132 desired
133 } else {
134 // Decay: climb back at no more than `ramp` per second since the last
135 // change. Before the first change there's nothing to ramp from.
136 match self.applied {
137 Some(applied) => {
138 let elapsed = now.saturating_duration_since(applied).as_secs_f64();
139 let ramp = self.policy.ramp.max(0.0);
140 let grown = self.target as f64 * (1.0 + ramp * elapsed);
141 (grown as u64).min(desired).clamp(min, self.policy.max)
142 }
143 None => desired,
144 }
145 };
146
147 // Hysteresis is checked against the *applied* target and deliberately
148 // does not touch `applied` when it suppresses a move. The ramp allowance
149 // therefore keeps growing while small raises are suppressed, so a raise
150 // lands once it clears the threshold instead of being starved forever by
151 // a per-tick allowance smaller than the threshold.
152 let hysteresis = self.policy.hysteresis.max(0.0);
153 if (next.abs_diff(self.target) as f64) < self.target as f64 * hysteresis {
154 return None;
155 }
156
157 self.target = next;
158 self.applied = Some(now);
159 Some(next)
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use std::time::Duration;
166
167 use super::*;
168
169 /// 4 Mbps ceiling, so the 0.9 headroom and the max/10 floor land on round
170 /// numbers: 400 kbps floor, and an estimate of E targets 0.9 * E.
171 fn control() -> Control {
172 Control::new(Policy::new(4_000_000))
173 }
174
175 #[test]
176 fn starts_optimistic() {
177 assert_eq!(control().target(), 4_000_000);
178 }
179
180 #[test]
181 fn drop_applies_immediately_with_headroom() {
182 let mut control = control();
183 // A 2 Mbps pipe: target 90% of it at once, no ramp, no waiting.
184 assert_eq!(control.update(Some(2_000_000), Instant::now()), Some(1_800_000));
185 assert_eq!(control.target(), 1_800_000);
186 }
187
188 #[test]
189 fn missing_estimate_holds_the_target() {
190 let mut control = control();
191 let now = Instant::now();
192 control.update(Some(2_000_000), now).unwrap();
193
194 // Losing the estimate (disconnected) is not evidence the uplink is
195 // healthy again, so the target must not jump back to max.
196 assert_eq!(control.update(None, now + Duration::from_secs(10)), None);
197 assert_eq!(control.target(), 1_800_000);
198 }
199
200 #[test]
201 fn estimate_never_raises_above_max() {
202 let mut control = control();
203 // A wildly optimistic estimate is not licence to exceed what was configured.
204 assert_eq!(control.update(Some(100_000_000), Instant::now()), None);
205 assert_eq!(control.target(), 4_000_000);
206 }
207
208 #[test]
209 fn target_never_falls_below_min() {
210 let mut control = control();
211 // A near-dead uplink floors at min (max/10) rather than chasing to zero.
212 assert_eq!(control.update(Some(1), Instant::now()), Some(400_000));
213 assert_eq!(control.target(), 400_000);
214 }
215
216 #[test]
217 fn raise_is_ramp_limited() {
218 let mut control = control();
219 let start = Instant::now();
220 control.update(Some(1_000_000), start).unwrap(); // target 900k
221
222 // The pipe reopens to 4 Mbps. One second later the default 25%/s ramp
223 // allows only 900k -> 1125k, not the full 3.6 Mbps the estimate wants.
224 let raised = control.update(Some(4_000_000), start + Duration::from_secs(1)).unwrap();
225 assert_eq!(raised, 1_125_000);
226 }
227
228 #[test]
229 fn raise_eventually_reaches_the_estimate() {
230 let mut control = control();
231 let start = Instant::now();
232 control.update(Some(1_000_000), start).unwrap(); // target 900k
233
234 // Feed a steady healthy estimate every 100ms; the ramp should walk the
235 // target up to the full 90% of it and then stop.
236 for tick in 1..=200 {
237 control.update(Some(4_000_000), start + Duration::from_millis(100 * tick));
238 }
239 assert_eq!(control.target(), 3_600_000);
240 }
241
242 /// Regression: the ramp allowance per tick (25%/s * 100ms = 2.5%) is smaller
243 /// than the hysteresis threshold (5%), so a raise is suppressed on any single
244 /// tick. Suppression must not reset the ramp anchor, or the allowance would be
245 /// recomputed from `now` every tick, never clear the threshold, and the target
246 /// would be starved at the floor forever while the uplink sat idle.
247 #[test]
248 fn suppressed_raises_do_not_starve_the_ramp() {
249 let mut control = control();
250 let start = Instant::now();
251 control.update(Some(1_000_000), start).unwrap(); // target 900k
252
253 // Tick at 100ms: each tick alone is under the 5% threshold.
254 let mut raised = None;
255 for tick in 1..=10 {
256 if let Some(next) = control.update(Some(4_000_000), start + Duration::from_millis(100 * tick)) {
257 raised = Some((tick, next));
258 break;
259 }
260 }
261
262 let (tick, next) = raised.expect("a raise must eventually clear hysteresis");
263 // 5% of 900k needs 0.05/0.25 = 0.2s of ramp, i.e. the tick at 200ms.
264 assert_eq!(tick, 2);
265 assert_eq!(next, 945_000);
266 }
267
268 #[test]
269 fn small_moves_are_suppressed() {
270 let mut control = control();
271 let now = Instant::now();
272 control.update(Some(2_000_000), now).unwrap(); // target 1.8M
273
274 // 2% under the current target: inside the 5% deadband, so no reconfigure.
275 assert_eq!(control.update(Some(1_960_000), now + Duration::from_secs(1)), None);
276 assert_eq!(control.target(), 1_800_000);
277
278 // 20% under: outside the deadband, so it applies.
279 assert_eq!(
280 control.update(Some(1_600_000), now + Duration::from_secs(2)),
281 Some(1_440_000)
282 );
283 }
284
285 /// `min > max` is a caller error, but it must clamp rather than panic: the
286 /// bound is fed straight to `clamp`, which panics on an inverted range.
287 #[test]
288 fn inverted_bounds_do_not_panic() {
289 let mut policy = Policy::new(1_000_000);
290 policy.min = 5_000_000;
291 let mut control = Control::new(policy);
292 control.update(Some(2_000_000), Instant::now());
293 assert!(control.target() <= 5_000_000);
294 }
295
296 /// A non-finite headroom would make the `as u64` cast produce garbage rather
297 /// than a rate, so it's normalized away.
298 #[test]
299 fn non_finite_headroom_does_not_poison_the_target() {
300 let mut policy = Policy::new(4_000_000);
301 policy.headroom = f64::NAN;
302 let mut control = Control::new(policy);
303 control.update(Some(2_000_000), Instant::now());
304 assert_eq!(control.target(), 400_000); // floored, not NaN-cast to 0
305 }
306}