//! Adaptive quality controller — the smoothness engine. A faithful adaptation //! of RustDesk's `src/server/video_qos.rs` (delay-threshold ABR + FPS/scale //! backoff with congestion confirmation and gradual recovery), distilled to a //! pure, deterministic state machine the host feeds with viewer telemetry. //! //! The viewer reports, ~once a second, how far behind it is — a `delay` (how //! long frames queue/decode in front of it) plus the transport RTT and packet //! loss. [`Qos::on_sample`] turns that into the next `(fps, bitrate, scale)` for //! the live encoder (the same knobs the manual `set.quality` lever drives). The //! user's manual quality is the CEILING; the controller only ever trims BELOW it //! under congestion and restores back toward it when the link is healthy. //! //! Priority mirrors RustDesk: **reduce bitrate first** (keep motion smooth), //! then frame-rate (down to a floor), then resolution scale as the last resort; //! recover in the reverse-ish order (frame-rate, then bitrate, then full //! resolution). Congestion must be CONFIRMED (several bad samples in a row) //! before cutting, so a single jittery reply does not thrash the stream. /// A good-vs-bad network boundary: frames more than this far behind mean the /// link cannot keep up (RustDesk's `DELAY_THRESHOLD_150MS`). pub const DELAY_THRESHOLD_MS: u32 = 150; /// Packet loss above this also counts as congestion. pub const LOSS_THRESHOLD: f32 = 0.05; /// Consecutive bad samples required before a reduction (confirm, don't thrash). const BAD_CONFIRM: u8 = 3; /// Consecutive good samples required before a step of recovery. const GOOD_CONFIRM: u8 = 3; /// Multiplicative bitrate cut on MILD congestion (keep 80%). Severe congestion /// cuts harder (see [`severity_cut`]) so the stream sheds load fast when it is /// badly behind, then trims gently near the threshold — RustDesk's graduated /// backoff rather than one fixed step. const BITRATE_CUT: f32 = 0.8; /// Bitrate retention factor for how far past the delay/loss threshold a sample /// is: gentle (0.8) just over the line, aggressive (0.6) when badly congested. fn severity_cut(delay_ms: u32, loss: f32, threshold_ms: u32) -> f32 { let t = threshold_ms.max(1); if delay_ms >= t * 3 || loss >= 0.15 { 0.6 } else if delay_ms >= t * 2 || loss >= 0.10 { 0.7 } else { BITRATE_CUT } } /// FPS step down/up. const FPS_STEP: u32 = 5; /// Scale step down/up (resolution), last resort. const SCALE_STEP: f32 = 0.1; /// Startup frame-rate the ramp begins at (RustDesk-style slow start). pub const INIT_FPS: u32 = 15; /// How long the startup ramp takes to climb from [`INIT_FPS`] to the target. pub const RAMP_MS: u64 = 3000; /// Startup FPS ramp: a fresh session begins at [`INIT_FPS`] and climbs linearly /// to the target over the first [`RAMP_MS`], so the initial keyframe + buffer /// fill don't arrive as a full-rate burst that stutters before the decode / /// jitter pipeline has warmed up. After [`RAMP_MS`] it returns the target /// unchanged (no limiting), and a target already at/below [`INIT_FPS`] is never /// slowed. Pure + deterministic; the capture loop caps its pacing fps with this. pub fn startup_fps(elapsed_ms: u64, target_fps: u32) -> u32 { let target = target_fps.max(1); if elapsed_ms >= RAMP_MS || target <= INIT_FPS { return target; } // Linear climb INIT_FPS -> target across RAMP_MS. let span = (target - INIT_FPS) as u64; let add = (span * elapsed_ms / RAMP_MS) as u32; (INIT_FPS + add).min(target) } /// Static bounds + the user's manual ceiling. `ceil_*` are the manual quality /// (the maximum the controller will restore to). #[derive(Debug, Clone, Copy)] pub struct QosConfig { pub ceil_fps: u32, pub ceil_bitrate: u32, // kbps pub min_fps: u32, pub min_bitrate: u32, // kbps pub min_scale: f32, pub delay_threshold_ms: u32, } impl QosConfig { /// Defaults around a 1080p/30fps/6Mbps session, floors that stay usable on a /// bad link (5fps, 400kbps, half resolution). pub fn new(ceil_fps: u32, ceil_bitrate: u32) -> Self { Self { ceil_fps: ceil_fps.max(1), ceil_bitrate: ceil_bitrate.max(1), min_fps: 5, min_bitrate: 400, min_scale: 0.5, delay_threshold_ms: DELAY_THRESHOLD_MS, } } } /// What the controller wants the encoder set to right now. #[derive(Debug, Clone, Copy, PartialEq)] pub struct QosTarget { pub fps: u32, pub bitrate: u32, // kbps pub scale: f32, } /// The adaptive controller. Pure + deterministic: feed samples, read targets. #[derive(Debug, Clone)] pub struct Qos { cfg: QosConfig, fps: u32, bitrate: u32, scale: f32, bad_run: u8, good_run: u8, } impl Qos { /// Start at the user's ceiling (full quality) — the controller only trims /// below it under measured congestion. pub fn new(cfg: QosConfig) -> Self { Self { fps: cfg.ceil_fps, bitrate: cfg.ceil_bitrate, scale: 1.0, cfg, bad_run: 0, good_run: 0, } } /// The current target. pub fn target(&self) -> QosTarget { QosTarget { fps: self.fps, bitrate: self.bitrate, scale: self.scale, } } /// Raise the ceiling (the user moved the manual quality lever). Current /// values are re-clamped under the new ceiling; a raised ceiling lets the /// controller recover higher. pub fn set_ceiling(&mut self, ceil_fps: u32, ceil_bitrate: u32) { self.cfg.ceil_fps = ceil_fps.max(1); self.cfg.ceil_bitrate = ceil_bitrate.max(1); self.fps = self.fps.min(self.cfg.ceil_fps); self.bitrate = self.bitrate.min(self.cfg.ceil_bitrate); } /// Feed one telemetry sample (delay in ms, packet loss 0..1). Returns the /// new target. Congestion is confirmed over [`BAD_CONFIRM`] samples before a /// cut; recovery takes [`GOOD_CONFIRM`] good samples per step. pub fn on_sample(&mut self, delay_ms: u32, loss: f32) -> QosTarget { let congested = delay_ms >= self.cfg.delay_threshold_ms || loss >= LOSS_THRESHOLD; if congested { self.good_run = 0; self.bad_run = self.bad_run.saturating_add(1); if self.bad_run >= BAD_CONFIRM { let cut = severity_cut(delay_ms, loss, self.cfg.delay_threshold_ms); self.reduce(cut); self.bad_run = 0; // require fresh confirmation before the next cut } } else { self.bad_run = 0; self.good_run = self.good_run.saturating_add(1); if self.good_run >= GOOD_CONFIRM { self.grow(); self.good_run = 0; } } self.target() } /// Trim one step: bitrate first (keep motion), then fps, then resolution. /// `cut` is the bitrate retention factor for this sample's severity. fn reduce(&mut self, cut: f32) { if self.bitrate > self.cfg.min_bitrate { let next = (self.bitrate as f32 * cut) as u32; self.bitrate = next.max(self.cfg.min_bitrate); } else if self.fps > self.cfg.min_fps { self.fps = self.fps.saturating_sub(FPS_STEP).max(self.cfg.min_fps); } else if self.scale > self.cfg.min_scale + 1e-3 { self.scale = (self.scale - SCALE_STEP).max(self.cfg.min_scale); } } /// Restore one step toward the ceiling: full resolution, then frame-rate, /// then bitrate. fn grow(&mut self) { if self.scale < 1.0 - 1e-3 { self.scale = (self.scale + SCALE_STEP).min(1.0); } else if self.fps < self.cfg.ceil_fps { self.fps = (self.fps + FPS_STEP).min(self.cfg.ceil_fps); } else if self.bitrate < self.cfg.ceil_bitrate { // +≤ a quarter of the ceiling per step, like RustDesk's capped ramp. let step = (self.cfg.ceil_bitrate / 4).max(200); self.bitrate = (self.bitrate + step).min(self.cfg.ceil_bitrate); } } } #[cfg(test)] mod tests { use super::*; fn qos() -> Qos { Qos::new(QosConfig::new(30, 6000)) } #[test] fn starts_at_ceiling() { let t = qos().target(); assert_eq!((t.fps, t.bitrate, t.scale), (30, 6000, 1.0)); } #[test] fn healthy_link_stays_at_ceiling() { let mut q = qos(); for _ in 0..50 { q.on_sample(20, 0.0); } let t = q.target(); assert_eq!((t.fps, t.bitrate, t.scale), (30, 6000, 1.0)); } #[test] fn single_bad_sample_does_not_cut() { let mut q = qos(); q.on_sample(300, 0.0); // one bad reply assert_eq!(q.target().bitrate, 6000, "must confirm congestion before cutting"); } #[test] fn confirmed_congestion_cuts_bitrate_first() { let mut q = qos(); for _ in 0..BAD_CONFIRM { q.on_sample(400, 0.0); } let t = q.target(); assert!(t.bitrate < 6000, "bitrate cut first"); assert_eq!(t.fps, 30, "fps untouched while bitrate has room"); assert_eq!(t.scale, 1.0, "scale untouched while bitrate has room"); } #[test] fn heavy_congestion_walks_bitrate_then_fps_then_scale() { let mut q = qos(); for _ in 0..400 { q.on_sample(500, 0.1); // relentless congestion } let t = q.target(); assert_eq!(t.bitrate, 400, "bitrate at floor"); assert_eq!(t.fps, 5, "fps at floor"); assert!((t.scale - 0.5).abs() < 1e-3, "scale at floor"); } #[test] fn severe_congestion_cuts_harder_than_mild() { // One confirmed cut at severe delay (>=3x threshold) must drop bitrate // further than one confirmed cut just over the threshold. let mut mild = qos(); for _ in 0..BAD_CONFIRM { mild.on_sample(DELAY_THRESHOLD_MS + 5, 0.0); // ~threshold -> 0.8 } let mut severe = qos(); for _ in 0..BAD_CONFIRM { severe.on_sample(DELAY_THRESHOLD_MS * 4, 0.0); // deep -> 0.6 } assert_eq!(mild.target().bitrate, (6000.0 * 0.8) as u32); assert_eq!(severe.target().bitrate, (6000.0 * 0.6) as u32); assert!( severe.target().bitrate < mild.target().bitrate, "severe congestion should shed more bitrate per cut" ); } #[test] fn loss_alone_triggers_congestion() { let mut q = qos(); for _ in 0..BAD_CONFIRM { q.on_sample(10, 0.2); // low delay but 20% loss } assert!(q.target().bitrate < 6000, "packet loss counts as congestion"); } #[test] fn recovers_toward_ceiling_after_congestion() { let mut q = qos(); for _ in 0..400 { q.on_sample(500, 0.1); } assert!(q.target().bitrate <= 400); // link heals for _ in 0..1000 { q.on_sample(20, 0.0); } let t = q.target(); assert_eq!(t.fps, 30, "fps recovered to ceiling"); assert_eq!(t.bitrate, 6000, "bitrate recovered to ceiling"); assert!((t.scale - 1.0).abs() < 1e-3, "scale recovered to full"); } #[test] fn never_exceeds_ceiling_on_recovery() { let mut q = qos(); for _ in 0..5000 { q.on_sample(5, 0.0); } let t = q.target(); assert!(t.fps <= 30 && t.bitrate <= 6000 && t.scale <= 1.0); } #[test] fn raising_ceiling_lets_it_climb() { let mut q = Qos::new(QosConfig::new(15, 3000)); for _ in 0..200 { q.on_sample(10, 0.0); } assert_eq!(q.target().fps, 15); q.set_ceiling(60, 12000); for _ in 0..1000 { q.on_sample(10, 0.0); } assert_eq!(q.target().fps, 60, "climbs to the raised ceiling"); } #[test] fn startup_ramp_begins_at_init_fps() { assert_eq!(startup_fps(0, 60), INIT_FPS, "a fresh session starts at the ramp floor"); assert_eq!(startup_fps(0, 30), INIT_FPS); } #[test] fn startup_ramp_reaches_target_by_ramp_ms() { assert_eq!(startup_fps(RAMP_MS, 60), 60, "full target once the ramp window elapses"); assert_eq!(startup_fps(RAMP_MS + 1000, 60), 60, "stays at target after the ramp"); } #[test] fn startup_ramp_is_monotonic_nondecreasing() { let mut prev = 0; for ms in (0..=RAMP_MS).step_by(100) { let f = startup_fps(ms, 60); assert!(f >= prev, "ramp must never step backward (ms={ms})"); assert!(f >= INIT_FPS && f <= 60, "ramp stays within [INIT_FPS, target]"); prev = f; } } #[test] fn startup_ramp_midpoint_is_between_floor_and_target() { let mid = startup_fps(RAMP_MS / 2, 60); assert!(mid > INIT_FPS && mid < 60, "half-way through, fps is between floor and target (got {mid})"); } #[test] fn startup_ramp_never_slows_a_low_target() { // A target already at/below the floor is served at full rate immediately. assert_eq!(startup_fps(0, 15), 15); assert_eq!(startup_fps(0, 10), 10); assert_eq!(startup_fps(500, 5), 5); } }