File
Blob: firmware/vendor/str0m/src/bwe/alr_detector/detector.rs
| 1 | use std::time::Instant; |
| 2 | |
| 3 | use super::budget::IntervalBudget; |
| 4 | use crate::rtp_::{Bitrate, DataSize}; |
| 5 | |
| 6 | /// Application Limited Region detector. |
| 7 | /// |
| 8 | /// Detects when we're sending significantly less than network capacity, |
| 9 | /// using IntervalBudget to handle bursty encoder traffic over 500ms windows. |
| 10 | /// |
| 11 | /// This is critical for real-world usage where encoders produce bursty traffic |
| 12 | /// (keyframes, scene changes, temporal layers, etc.) The IntervalBudget smooths |
| 13 | /// these bursts over a 500ms window to detect sustained application-limited state. |
| 14 | /// |
| 15 | /// ## ALR State Transitions |
| 16 | /// |
| 17 | /// - **Enter ALR**: When budget ratio > 0.80 (sustained low usage for 500ms) |
| 18 | /// - **Exit ALR**: When budget ratio < 0.50 (increased usage) |
| 19 | /// - The 30% hysteresis gap prevents rapid state flapping |
| 20 | #[derive(Debug)] |
| 21 | pub struct AlrDetector { |
| 22 | /// Budget tracker with 500ms window |
| 23 | budget: IntervalBudget, |
| 24 | /// Current ALR state |
| 25 | state: AlrState, |
| 26 | /// Last time we sent bytes |
| 27 | last_send_time: Option<Instant>, |
| 28 | |
| 29 | // Configuration matching libWebRTC defaults |
| 30 | /// Target send rate as fraction of estimate (0.65 = 65%) |
| 31 | bandwidth_usage_ratio: f64, |
| 32 | /// Budget ratio threshold to enter ALR (0.80 = 80%) |
| 33 | start_budget_level_ratio: f64, |
| 34 | /// Budget ratio threshold to exit ALR (0.50 = 50%) |
| 35 | stop_budget_level_ratio: f64, |
| 36 | } |
| 37 | |
| 38 | /// ALR state for the detector. |
| 39 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 40 | enum AlrState { |
| 41 | /// Not currently in Application Limited Region |
| 42 | NotInAlr, |
| 43 | /// In Application Limited Region since the given instant |
| 44 | InAlr(Instant), |
| 45 | } |
| 46 | |
| 47 | impl AlrState { |
| 48 | fn alr_start_time(&self) -> Option<Instant> { |
| 49 | match self { |
| 50 | AlrState::InAlr(start) => Some(*start), |
| 51 | AlrState::NotInAlr => None, |
| 52 | } |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | impl AlrDetector { |
| 57 | /// Create a new ALR detector with libWebRTC default configuration. |
| 58 | pub fn new() -> Self { |
| 59 | Self { |
| 60 | // Start with zero bitrate, will be updated via set_estimated_bitrate() |
| 61 | // Use can_build_up_underuse=true for ALR detection |
| 62 | budget: IntervalBudget::new(Bitrate::ZERO, true), |
| 63 | state: AlrState::NotInAlr, |
| 64 | last_send_time: None, |
| 65 | |
| 66 | // libWebRTC defaults from alr_detector.cc:30-49 |
| 67 | bandwidth_usage_ratio: 0.65, |
| 68 | start_budget_level_ratio: 0.80, |
| 69 | stop_budget_level_ratio: 0.50, |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | /// Update with bytes sent. |
| 74 | /// |
| 75 | /// Should be called for every media packet sent (excluding padding and probes). |
| 76 | /// This updates the IntervalBudget and checks for ALR state transitions. |
| 77 | pub fn on_bytes_sent(&mut self, bytes: DataSize, now: Instant) { |
| 78 | // First call - just record time |
| 79 | let Some(last) = self.last_send_time else { |
| 80 | self.last_send_time = Some(now); |
| 81 | return; |
| 82 | }; |
| 83 | |
| 84 | // saturating_duration_since protects against time going backwards |
| 85 | let delta = now.saturating_duration_since(last); |
| 86 | self.last_send_time = Some(now); |
| 87 | |
| 88 | // Update budget based on time and usage |
| 89 | self.budget.use_budget(bytes); |
| 90 | self.budget.increase_budget(delta); |
| 91 | |
| 92 | // Check for state transitions with hysteresis |
| 93 | let ratio = self.budget.budget_ratio(); |
| 94 | |
| 95 | match self.state { |
| 96 | AlrState::NotInAlr => { |
| 97 | if ratio > self.start_budget_level_ratio { |
| 98 | // Enter ALR: budget accumulated because we're not sending much |
| 99 | self.state = AlrState::InAlr(now); |
| 100 | trace!( |
| 101 | "ALR: Entered ALR state (ratio={:.3} > threshold={:.3})", |
| 102 | ratio, self.start_budget_level_ratio |
| 103 | ); |
| 104 | } |
| 105 | } |
| 106 | AlrState::InAlr(_) => { |
| 107 | if ratio < self.stop_budget_level_ratio { |
| 108 | // Exit ALR: budget depleted because we're sending more |
| 109 | self.state = AlrState::NotInAlr; |
| 110 | trace!( |
| 111 | "ALR: Exited ALR state (ratio={:.3} < threshold={:.3})", |
| 112 | ratio, self.stop_budget_level_ratio |
| 113 | ); |
| 114 | } |
| 115 | } |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | /// Update the BWE estimate. |
| 120 | /// |
| 121 | /// This adjusts the IntervalBudget's target rate to 65% of the estimate. |
| 122 | /// Should be called whenever the bandwidth estimate changes. |
| 123 | pub fn set_estimated_bitrate(&mut self, estimate: Bitrate) { |
| 124 | let target = estimate * self.bandwidth_usage_ratio; |
| 125 | self.budget.set_target_rate(target); |
| 126 | } |
| 127 | |
| 128 | /// Get the time when ALR started, if currently in ALR. |
| 129 | /// |
| 130 | /// Returns `Some(Instant)` if in ALR, `None` otherwise. |
| 131 | /// Used by ProbeControl to determine if periodic ALR probes should be sent. |
| 132 | pub fn alr_start_time(&self) -> Option<Instant> { |
| 133 | self.state.alr_start_time() |
| 134 | } |
| 135 | |
| 136 | #[cfg(test)] |
| 137 | pub fn budget_ratio(&self) -> f64 { |
| 138 | self.budget.budget_ratio() |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | #[cfg(test)] |
| 143 | mod tests { |
| 144 | use super::*; |
| 145 | use std::time::Duration; |
| 146 | |
| 147 | #[test] |
| 148 | fn test_alr_not_detected_initially() { |
| 149 | let alr = AlrDetector::new(); |
| 150 | assert!(alr.alr_start_time().is_none()); |
| 151 | } |
| 152 | |
| 153 | #[test] |
| 154 | fn test_alr_enter_with_low_usage() { |
| 155 | let mut alr = AlrDetector::new(); |
| 156 | let now = Instant::now(); |
| 157 | |
| 158 | // Set estimate to 1 Mbps (target will be 650 kbps) |
| 159 | alr.set_estimated_bitrate(Bitrate::mbps(1)); |
| 160 | |
| 161 | // Send very little data (way below target) |
| 162 | // Over 500ms, we should accumulate budget |
| 163 | for i in 0..50 { |
| 164 | let t = now + Duration::from_millis(i * 10); |
| 165 | // Send 100 bytes every 10ms = 80 kbps (way below 650 kbps target) |
| 166 | alr.on_bytes_sent(DataSize::bytes(100), t); |
| 167 | } |
| 168 | |
| 169 | // Should enter ALR |
| 170 | assert!(alr.alr_start_time().is_some()); |
| 171 | assert!(alr.budget_ratio() > 0.80); |
| 172 | } |
| 173 | |
| 174 | #[test] |
| 175 | fn test_alr_exit_with_high_usage() { |
| 176 | let mut alr = AlrDetector::new(); |
| 177 | let now = Instant::now(); |
| 178 | |
| 179 | alr.set_estimated_bitrate(Bitrate::mbps(1)); |
| 180 | |
| 181 | // Enter ALR with low usage |
| 182 | for i in 0..50 { |
| 183 | let t = now + Duration::from_millis(i * 10); |
| 184 | alr.on_bytes_sent(DataSize::bytes(100), t); |
| 185 | } |
| 186 | assert!(alr.alr_start_time().is_some()); |
| 187 | |
| 188 | // Now send at high rate to exit ALR |
| 189 | let start_exit = now + Duration::from_millis(500); |
| 190 | for i in 0..50 { |
| 191 | let t = start_exit + Duration::from_millis(i * 10); |
| 192 | // Send 10,000 bytes every 10ms = 8 Mbps (way above 650 kbps target) |
| 193 | alr.on_bytes_sent(DataSize::bytes(10_000), t); |
| 194 | } |
| 195 | |
| 196 | // Should exit ALR |
| 197 | assert!(alr.alr_start_time().is_none()); |
| 198 | assert!(alr.budget_ratio() < 0.50); |
| 199 | } |
| 200 | |
| 201 | #[test] |
| 202 | fn test_alr_hysteresis() { |
| 203 | let mut alr = AlrDetector::new(); |
| 204 | let now = Instant::now(); |
| 205 | |
| 206 | alr.set_estimated_bitrate(Bitrate::mbps(1)); |
| 207 | |
| 208 | // Send at exactly 65% (the target) - should not enter ALR |
| 209 | for i in 0..100 { |
| 210 | let t = now + Duration::from_millis(i * 10); |
| 211 | // 65% of 1 Mbps = 650 kbps = 812.5 bytes per 10ms |
| 212 | alr.on_bytes_sent(DataSize::bytes(812), t); |
| 213 | } |
| 214 | |
| 215 | // Budget ratio should be near 0, not trigger ALR entry (needs > 0.80) |
| 216 | assert!(alr.alr_start_time().is_none()); |
| 217 | let ratio = alr.budget_ratio(); |
| 218 | assert!(ratio < 0.80, "ratio={}", ratio); |
| 219 | assert!(ratio > -0.20, "ratio={}", ratio); // Some tolerance |
| 220 | } |
| 221 | |
| 222 | #[test] |
| 223 | fn test_alr_handles_bursts() { |
| 224 | let mut alr = AlrDetector::new(); |
| 225 | let now = Instant::now(); |
| 226 | |
| 227 | alr.set_estimated_bitrate(Bitrate::mbps(1)); |
| 228 | |
| 229 | // Simulate bursty encoder: alternate between large and small frames |
| 230 | let mut time = now; |
| 231 | for i in 0..100 { |
| 232 | time = time + Duration::from_millis(33); // ~30fps |
| 233 | |
| 234 | if i % 10 == 0 { |
| 235 | // Keyframe: 10x normal size |
| 236 | alr.on_bytes_sent(DataSize::bytes(50_000), time); |
| 237 | } else { |
| 238 | // Normal frame: small |
| 239 | alr.on_bytes_sent(DataSize::bytes(5_000), time); |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | // Average is about 9.5 KB per frame = 285 KB/s = 2.28 Mbps |
| 244 | // This is above the 650 kbps target, so should NOT be in ALR |
| 245 | // The IntervalBudget's 500ms window should smooth out the bursts |
| 246 | assert!(alr.alr_start_time().is_none()); |
| 247 | } |
| 248 | |
| 249 | #[test] |
| 250 | fn test_alr_estimate_change() { |
| 251 | let mut alr = AlrDetector::new(); |
| 252 | let now = Instant::now(); |
| 253 | |
| 254 | // Start with 1 Mbps estimate (target = 650 kbps) |
| 255 | alr.set_estimated_bitrate(Bitrate::mbps(1)); |
| 256 | |
| 257 | // Send at 200 kbps (way below 650 kbps target) to accumulate budget quickly |
| 258 | for i in 0..100 { |
| 259 | let t = now + Duration::from_millis(i * 10); |
| 260 | alr.on_bytes_sent(DataSize::bytes(250), t); // 200 kbps |
| 261 | } |
| 262 | |
| 263 | // Should enter ALR after sending well below target |
| 264 | assert!(alr.alr_start_time().is_some()); |
| 265 | |
| 266 | // Now BWE drops to 300 kbps (target becomes 195 kbps) |
| 267 | alr.set_estimated_bitrate(Bitrate::kbps(300)); |
| 268 | |
| 269 | // Continue sending at 250 kbps - now above target (195 kbps) |
| 270 | let continue_time = now + Duration::from_millis(1000); |
| 271 | for i in 0..100 { |
| 272 | let t = continue_time + Duration::from_millis(i * 10); |
| 273 | alr.on_bytes_sent(DataSize::bytes(312), t); // 250 kbps |
| 274 | } |
| 275 | |
| 276 | // Should exit ALR (now sending above target) |
| 277 | assert!(alr.alr_start_time().is_none()); |
| 278 | } |
| 279 | |
| 280 | #[test] |
| 281 | fn test_alr_should_not_trigger_when_sending_at_target_rate() { |
| 282 | // This test verifies that ALR doesn't incorrectly trigger when sending |
| 283 | // at the target rate, even with small time advances between packets. |
| 284 | let mut alr = AlrDetector::new(); |
| 285 | let now = Instant::now(); |
| 286 | |
| 287 | // Set estimate to 8.25 Mbps (target will be 5.36 Mbps at 65%) |
| 288 | let estimate = Bitrate::kbps(8250); |
| 289 | alr.set_estimated_bitrate(estimate); |
| 290 | |
| 291 | // Send at exactly 8.25 Mbps with realistic packet timing |
| 292 | // Packet: 1150 bytes |
| 293 | // Interval: 1150 bytes × 8 bits / 8,250,000 bps = 1.115ms |
| 294 | let packet_size = DataSize::bytes(1150); |
| 295 | let packet_interval = Duration::from_micros(1115); // 1.115ms |
| 296 | |
| 297 | // Send 100 packets over ~111ms |
| 298 | for i in 0..100 { |
| 299 | let t = now + packet_interval * i; |
| 300 | alr.on_bytes_sent(packet_size, t); |
| 301 | } |
| 302 | |
| 303 | // Should NOT be in ALR - we're sending at 8.25 Mbps, which is above |
| 304 | // the target threshold of 5.36 Mbps (65% of estimate) |
| 305 | assert!( |
| 306 | alr.alr_start_time().is_none(), |
| 307 | "ALR should not trigger when sending at target rate. Budget ratio: {:.3}", |
| 308 | alr.budget_ratio() |
| 309 | ); |
| 310 | } |
| 311 | |
| 312 | #[test] |
| 313 | fn test_alr_handles_spurious_small_time_advances() { |
| 314 | // This test verifies that ALR works correctly even when time advances |
| 315 | // in very small increments (like the test harness's forced 0.2ms advances). |
| 316 | let mut alr = AlrDetector::new(); |
| 317 | let now = Instant::now(); |
| 318 | |
| 319 | // Set estimate to 8.25 Mbps |
| 320 | let estimate = Bitrate::kbps(8250); |
| 321 | alr.set_estimated_bitrate(estimate); |
| 322 | |
| 323 | // Simulate sending 1150 byte packets at 8.25 Mbps |
| 324 | // But with spurious 0.2ms time advances between on_bytes_sent calls |
| 325 | let packet_size = DataSize::bytes(1150); |
| 326 | let spurious_advance = Duration::from_micros(200); // 0.2ms |
| 327 | |
| 328 | let mut time = now; |
| 329 | // Send packets - but accumulate proper time even if called frequently |
| 330 | for _ in 0..100 { |
| 331 | alr.on_bytes_sent(packet_size, time); |
| 332 | time += spurious_advance; // Small spurious advance |
| 333 | } |
| 334 | |
| 335 | // With 100 packets over 20ms (100 × 0.2ms), we've sent: |
| 336 | // 115,000 bytes in 20ms = 46 Mbps |
| 337 | // This is WAY above target, so ALR should NOT trigger |
| 338 | assert!( |
| 339 | alr.alr_start_time().is_none(), |
| 340 | "ALR should not trigger with spurious small advances when sending fast. Budget ratio: {:.3}", |
| 341 | alr.budget_ratio() |
| 342 | ); |
| 343 | } |
| 344 | } |