1use crate::Profile;
45use std::collections::VecDeque;
46
47#[derive(Debug, Clone, Copy, Default, PartialEq)]
48pub struct Sample {
49 pub rtt_ms: f64,
50 pub min_rtt_ms: f64,
51 pub delivery_kbps: u64,
53 pub lost_packets_delta: u64,
54 pub frames_dropped_backlog_delta: u64,
55 pub pilot_chunk_loss_pct: Option<f64>,
59 pub pilot_frames_incomplete_delta: u64,
62 pub send_kbps: u64,
63 pub keyframe_just_sent: bool,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct Decision {
70 pub max_bitrate_kbps: u32,
71 pub fec_delta_pct: u32,
72 pub fec_key_pct: u32,
73 pub suggested_fps: u32,
74 pub reason: &'static str,
77 pub bitrate_changed: bool,
79 pub fec_changed: bool,
81}
82
83const FEC_LEVELS: [u32; 4] = [0, 25, 38, 50]; const EMIT_INTERVAL_S: u64 = 5;
85const CLEAN_SECONDS_TO_RAISE: u32 = 5;
86const CLEAN_SECONDS_TO_LOWER_FEC: u32 = 10;
87const LATENCY_SECONDS_TO_CUT: u32 = 2;
88const FLOOR_FRACTION: f64 = 0.25;
89
90pub fn effective_ceiling_kbps(profile: &Profile, publisher_max_kbps: u32) -> u32 {
93 if publisher_max_kbps == 0 {
94 profile.max_bitrate_kbps
95 } else {
96 profile.max_bitrate_kbps.min(publisher_max_kbps)
97 }
98}
99
100#[derive(Debug, Clone)]
101pub struct AbrController {
102 profile: &'static Profile,
103 ceiling_kbps: u32,
106 tick: u64,
107 target_kbps: f64,
109 fec_level: usize,
110 clean_seconds: u32,
111 clean_seconds_fec: u32,
112 congested_at_floor_seconds: u32,
113 last_cut_tick: Option<u64>,
114 last_emit_tick: Option<u64>,
115 last_emitted_kbps: u32,
116 loss_window: VecDeque<f64>,
117 rtt_window: VecDeque<f64>,
118 latency_seconds: u32,
119 pending_reason: Option<&'static str>,
121 current: Decision,
122}
123
124impl AbrController {
125 pub fn new(profile: &'static Profile) -> Self {
126 Self::with_ceiling(profile, 0)
127 }
128
129 pub fn with_ceiling(profile: &'static Profile, publisher_max_kbps: u32) -> Self {
138 let ceiling_kbps = effective_ceiling_kbps(profile, publisher_max_kbps);
139 let d = Decision {
140 max_bitrate_kbps: ceiling_kbps,
141 fec_delta_pct: profile.fec_delta_pct,
142 fec_key_pct: profile.fec_key_pct,
143 suggested_fps: 0,
144 reason: "steady",
145 bitrate_changed: false,
146 fec_changed: false,
147 };
148 Self {
149 profile,
150 ceiling_kbps,
151 tick: 0,
152 target_kbps: ceiling_kbps as f64,
153 fec_level: 0,
154 clean_seconds: 0,
155 clean_seconds_fec: 0,
156 congested_at_floor_seconds: 0,
157 last_cut_tick: None,
158 last_emit_tick: None,
159 last_emitted_kbps: ceiling_kbps,
160 loss_window: VecDeque::new(),
161 rtt_window: VecDeque::new(),
162 latency_seconds: 0,
163 pending_reason: None,
164 current: d,
165 }
166 }
167
168 pub fn profile(&self) -> &'static Profile {
169 self.profile
170 }
171
172 pub fn current(&self) -> Decision {
174 self.current
175 }
176
177 fn fec_for_level(&self, level: usize) -> (u32, u32) {
178 let delta = if level == 0 {
179 self.profile.fec_delta_pct
180 } else {
181 FEC_LEVELS[level].max(self.profile.fec_delta_pct)
182 };
183 let key = self.profile.fec_key_pct.max(delta + 10).min(50);
184 (delta, key)
185 }
186
187 fn floor_kbps(&self) -> f64 {
188 (self.ceiling_kbps as f64 * FLOOR_FRACTION).round()
189 }
190
191 pub fn step(&mut self, s: &Sample) -> Option<Decision> {
193 self.tick += 1;
194 let ceiling = self.ceiling_kbps as f64;
195
196 let loss_now = s.pilot_chunk_loss_pct.unwrap_or(0.0).max(0.0);
198 self.loss_window.push_back(loss_now);
199 if self.loss_window.len() > 5 {
200 self.loss_window.pop_front();
201 }
202 let loss_signal = self.loss_window.iter().sum::<f64>() / self.loss_window.len() as f64;
205
206 let wanted_level = if loss_signal >= 5.0 {
208 3
209 } else if loss_signal >= 2.0 {
210 2
211 } else if loss_signal >= 0.2 {
212 1
213 } else {
214 0
215 };
216 let mut fec_changed = false;
217 let mut reason: &'static str = "steady";
218 if wanted_level > self.fec_level {
219 self.fec_level = wanted_level;
220 self.clean_seconds_fec = 0;
221 fec_changed = true;
222 reason = "loss";
223 self.pending_reason = Some("loss");
226 } else if wanted_level < self.fec_level {
227 self.clean_seconds_fec += 1;
228 if self.clean_seconds_fec >= CLEAN_SECONDS_TO_LOWER_FEC {
229 self.fec_level -= 1;
230 self.clean_seconds_fec = 0;
231 fec_changed = true;
232 reason = "fec-down";
233 }
234 } else {
235 self.clean_seconds_fec = 0;
236 }
237
238 if !s.keyframe_just_sent {
242 self.rtt_window.push_back(s.rtt_ms);
243 if self.rtt_window.len() > 3 {
244 self.rtt_window.pop_front();
245 }
246 }
247 let rtt_smoothed = {
248 let mut v: Vec<f64> = self.rtt_window.iter().cloned().collect();
249 v.sort_by(|a, b| a.partial_cmp(b).unwrap());
250 v.get(v.len() / 2).cloned().unwrap_or(s.rtt_ms)
251 };
252 let inflated_now = s.min_rtt_ms > 0.0
253 && !s.keyframe_just_sent
254 && (rtt_smoothed - s.min_rtt_ms) > self.profile.latency_budget_ms as f64 / 2.0;
255 if inflated_now {
256 self.latency_seconds += 1;
257 } else {
258 self.latency_seconds = 0;
259 }
260 let latency_inflated = self.latency_seconds >= LATENCY_SECONDS_TO_CUT;
261 let backlog = s.frames_dropped_backlog_delta > 0;
262 let residual = s.pilot_frames_incomplete_delta >= 2;
266 if residual && !latency_inflated && !backlog && self.fec_level < FEC_LEVELS.len() - 1 {
268 self.fec_level += 1;
269 self.clean_seconds_fec = 0;
270 fec_changed = true;
271 reason = "residual";
272 self.pending_reason = Some("residual");
273 }
274 let residual_cuts = residual && self.fec_level == FEC_LEVELS.len() - 1;
275 let congested = latency_inflated || backlog || residual_cuts;
276
277 if congested {
278 self.clean_seconds = 0;
279 let can_cut = self
280 .last_cut_tick
281 .map_or(true, |t| self.tick - t >= EMIT_INTERVAL_S);
282 if can_cut && self.target_kbps > self.floor_kbps() {
283 self.target_kbps = (self.target_kbps * 0.75).max(self.floor_kbps());
284 self.last_cut_tick = Some(self.tick);
285 self.latency_seconds = 0;
286 reason = if backlog {
287 "backlog"
288 } else if latency_inflated {
289 "latency"
290 } else {
291 "residual"
292 };
293 self.pending_reason = Some(reason);
294 }
295 if self.target_kbps <= self.floor_kbps() {
296 self.congested_at_floor_seconds += 1;
297 }
298 } else {
299 self.congested_at_floor_seconds = 0;
300 self.clean_seconds += 1;
301 if self.clean_seconds >= CLEAN_SECONDS_TO_RAISE && self.target_kbps < ceiling {
302 self.target_kbps = (self.target_kbps * 1.10).min(ceiling);
303 self.clean_seconds = 0;
304 if reason == "steady" {
305 reason = "recover";
306 }
307 if self.pending_reason.is_none() {
308 self.pending_reason = Some("recover");
309 }
310 }
311 }
312
313 let (fec_delta, fec_key) = self.fec_for_level(self.fec_level);
315 let budget_scale = (100.0 + self.profile.fec_delta_pct as f64) / (100.0 + fec_delta as f64);
316 let bitrate = (self.target_kbps * budget_scale)
317 .clamp(self.floor_kbps(), ceiling)
318 .round() as u32;
319 let suggested_fps = if self.congested_at_floor_seconds >= 5 {
320 15
321 } else {
322 0
323 };
324
325 let delta_pct = (bitrate as f64 - self.last_emitted_kbps as f64).abs()
327 / self.last_emitted_kbps.max(1) as f64;
328 let emit_ok = self
329 .last_emit_tick
330 .map_or(true, |t| self.tick - t >= EMIT_INTERVAL_S);
331 let bitrate_changed = emit_ok
335 && bitrate != self.last_emitted_kbps
336 && (delta_pct >= 0.10
337 || fec_changed
338 || self.pending_reason.is_some()
339 || bitrate == ceiling as u32
340 || bitrate == self.floor_kbps() as u32);
341 if bitrate_changed {
342 self.last_emitted_kbps = bitrate;
343 self.last_emit_tick = Some(self.tick);
344 if reason == "steady" {
346 reason = self.pending_reason.unwrap_or("recover");
347 }
348 self.pending_reason = None;
349 }
350 let fps_changed = suggested_fps != self.current.suggested_fps;
351
352 if bitrate_changed || fec_changed || fps_changed {
353 self.current = Decision {
354 max_bitrate_kbps: self.last_emitted_kbps,
355 fec_delta_pct: fec_delta,
356 fec_key_pct: fec_key,
357 suggested_fps,
358 reason,
359 bitrate_changed: bitrate_changed || fps_changed,
360 fec_changed,
361 };
362 Some(self.current)
363 } else {
364 self.current.reason = reason;
365 self.current.bitrate_changed = false;
366 self.current.fec_changed = false;
367 None
368 }
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375 use crate::BALANCED;
376
377 fn clean() -> Sample {
378 Sample {
379 rtt_ms: 25.0,
380 min_rtt_ms: 24.0,
381 delivery_kbps: 3000,
382 send_kbps: 3000,
383 pilot_chunk_loss_pct: Some(0.0),
384 ..Default::default()
385 }
386 }
387
388 fn run(c: &mut AbrController, samples: impl IntoIterator<Item = Sample>) -> Vec<Decision> {
389 samples.into_iter().filter_map(|s| c.step(&s)).collect()
390 }
391
392 #[test]
393 fn a_publisher_limit_lowers_the_ceiling_and_the_floor() {
394 let c = AbrController::with_ceiling(&crate::QUALITY, 4000);
396 assert_eq!(c.current().max_bitrate_kbps, 4000);
397 assert_eq!(c.floor_kbps(), 1000.0);
398 assert_eq!(
400 AbrController::with_ceiling(&crate::LATENCY, 4000)
401 .current()
402 .max_bitrate_kbps,
403 1500
404 );
405 assert_eq!(
406 AbrController::with_ceiling(&crate::QUALITY, 0)
407 .current()
408 .max_bitrate_kbps,
409 6000
410 );
411 }
412
413 #[test]
414 fn clean_link_stays_at_ceiling() {
415 let mut c = AbrController::new(&BALANCED);
416 let out = run(&mut c, std::iter::repeat(clean()).take(60));
417 assert!(out.is_empty(), "no decisions on a clean link: {out:?}");
418 assert_eq!(c.current().max_bitrate_kbps, 3000);
419 assert_eq!(c.current().fec_delta_pct, 15);
420 }
421
422 #[test]
423 fn sustained_loss_raises_fec_and_lowers_bitrate() {
424 let mut c = AbrController::new(&BALANCED);
425 let lossy = Sample {
426 pilot_chunk_loss_pct: Some(1.0),
427 ..clean()
428 };
429 let out = run(&mut c, std::iter::repeat(lossy).take(10));
430 let first = out.first().expect("a decision");
431 assert_eq!(first.fec_delta_pct, 25, "k=2 at any measurable loss");
432 assert!(first.fec_key_pct >= 35);
433 assert_eq!(first.reason, "loss");
434 assert_eq!(c.current().max_bitrate_kbps, 2760);
436 let out = run(&mut c, std::iter::repeat(clean()).take(40));
438 assert!(out.iter().any(|d| d.reason == "fec-down"));
439 assert_eq!(c.current().fec_delta_pct, 15);
440 assert_eq!(c.current().max_bitrate_kbps, 3000);
441 }
442
443 #[test]
444 fn heavy_loss_reaches_top_fec_level() {
445 let mut c = AbrController::new(&BALANCED);
446 let bad = Sample {
447 pilot_chunk_loss_pct: Some(6.0),
448 ..clean()
449 };
450 run(&mut c, std::iter::repeat(bad).take(3));
451 assert_eq!(c.current().fec_delta_pct, 50);
452 assert_eq!(c.current().fec_key_pct, 50);
453 }
454
455 #[test]
456 fn rtt_inflation_cuts_then_recovers() {
457 let mut c = AbrController::new(&BALANCED);
458 let inflated = Sample {
459 rtt_ms: 90.0,
460 min_rtt_ms: 24.0,
461 ..clean()
462 };
463 let out = run(&mut c, std::iter::repeat(inflated).take(12));
464 assert!(out
465 .iter()
466 .any(|d| d.reason == "latency" && d.bitrate_changed));
467 let low = c.current().max_bitrate_kbps;
468 assert!(
469 (750..=2250).contains(&low),
470 "one cut per 5 s, never below the floor: {low}"
471 );
472 let mut c2 = AbrController::new(&BALANCED);
474 let mut seq = vec![clean(); 5];
475 seq.push(Sample {
476 rtt_ms: 90.0,
477 min_rtt_ms: 24.0,
478 ..clean()
479 });
480 seq.extend(vec![clean(); 5]);
481 assert!(
482 run(&mut c2, seq).is_empty(),
483 "one RTT spike is not congestion"
484 );
485 let mut c3 = AbrController::new(&BALANCED);
486 let kf = Sample {
487 rtt_ms: 90.0,
488 min_rtt_ms: 24.0,
489 keyframe_just_sent: true,
490 ..clean()
491 };
492 assert!(
493 run(&mut c3, vec![kf; 10]).is_empty(),
494 "keyframe-polluted samples are ignored"
495 );
496 let out = run(&mut c, std::iter::repeat(clean()).take(120));
497 assert!(out.iter().any(|d| d.reason == "recover"));
498 assert!(
499 out.iter()
500 .all(|d| d.reason != "steady" || !d.bitrate_changed),
501 "steady never moves bitrate"
502 );
503 assert_eq!(c.current().max_bitrate_kbps, 3000);
504 }
505
506 #[test]
507 fn floor_and_suggested_fps() {
508 let mut c = AbrController::new(&BALANCED);
509 let backlog = Sample {
510 frames_dropped_backlog_delta: 2,
511 ..clean()
512 };
513 run(&mut c, std::iter::repeat(backlog).take(30));
514 assert_eq!(c.current().max_bitrate_kbps, 750);
515 assert_eq!(c.current().suggested_fps, 15);
516 }
517
518 #[test]
519 fn noisy_but_fine_link_does_not_oscillate() {
520 let mut c = AbrController::new(&BALANCED);
521 let samples = (0..120).map(|i| Sample {
523 rtt_ms: 24.0 + ((i * 7) % 30) as f64,
524 pilot_chunk_loss_pct: Some(if i % 9 == 0 { 0.1 } else { 0.0 }),
525 ..clean()
526 });
527 let out = run(&mut c, samples);
528 assert!(out.is_empty(), "spurious decisions: {out:?}");
529 assert_eq!(c.current().max_bitrate_kbps, 3000);
530 }
531
532 #[test]
533 fn residual_loss_raises_fec_before_cutting_bitrate() {
534 let mut c = AbrController::new(&BALANCED);
535 let residual = Sample {
536 pilot_frames_incomplete_delta: 2,
537 ..clean()
538 };
539 let mut out = run(&mut c, [residual]);
540 let d = out.pop().unwrap();
541 assert_eq!((d.reason, d.fec_delta_pct), ("residual", 25));
542 assert_eq!(d.max_bitrate_kbps, 2760, "budget scaling only, no 25 % cut");
543 run(&mut c, std::iter::repeat(residual).take(2));
545 assert_eq!(c.current().fec_delta_pct, 50);
546 run(&mut c, std::iter::repeat(residual).take(3));
547 assert!(
548 c.current().max_bitrate_kbps < 2300,
549 "cut once FEC is maxed: {}",
550 c.current().max_bitrate_kbps
551 );
552 }
553
554 #[test]
555 fn emission_needs_ten_percent() {
556 let mut c = AbrController::new(&BALANCED);
557 let mut out = run(
559 &mut c,
560 [Sample {
561 frames_dropped_backlog_delta: 1,
562 ..clean()
563 }],
564 );
565 assert_eq!(out.pop().unwrap().max_bitrate_kbps, 2250);
566 let out = run(&mut c, std::iter::repeat(clean()).take(20));
567 assert!(out
568 .iter()
569 .all(|d| d.max_bitrate_kbps >= 2475 && d.reason == "recover"));
570 }
571}