366 lines
13 KiB
Rust
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);
|
|
}
|
|
}
|