Files
netdesk/native/host/src/qos.rs
T

366 lines
13 KiB
Rust

//! 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);
}
}