feat: complete WZP Phase 2 (T2/T3/T4) — adaptive quality, AudioWorklet, sessions
WZP-P2-T2: Adaptive quality switching - QualityAdapter with sliding window of QualityReports - Hysteresis: 3 consecutive reports before switching profiles - Thresholds: loss>15%/rtt>200ms→CATASTROPHIC, loss>5%/rtt>100ms→DEGRADED - CallConfig::from_profile() constructor - 5 unit tests: good/degraded/catastrophic conditions, hysteresis, recovery WZP-P2-T3: AudioWorklet migration (web bridge) - audio-processor.js: WZPCaptureProcessor + WZPPlaybackProcessor - Capture: buffers 128-sample AudioWorklet blocks → 960-sample frames - Playback: ring buffer, Int16→Float32 conversion in worklet - ScriptProcessorNode fallback if AudioWorklet unavailable - Existing UI preserved (connect, room, PTT) WZP-P2-T4: Concurrent session management (relay) - SessionManager tracks active sessions with HashMap - Enforces max_sessions limit from RelayConfig - create_session/remove_session lifecycle - Wired into relay main: session created after auth+handshake, cleaned up after run_participant returns - 7 unit tests: create/remove, max enforced, room tracking, info, expiry 207 tests passing across all crates. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ use wzp_proto::quality::AdaptiveQualityController;
|
||||
use wzp_proto::traits::{
|
||||
AudioDecoder, AudioEncoder, FecDecoder, FecEncoder,
|
||||
};
|
||||
use wzp_proto::packet::QualityReport;
|
||||
use wzp_proto::QualityProfile;
|
||||
|
||||
/// Configuration for a call session.
|
||||
@@ -37,6 +38,129 @@ impl Default for CallConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl CallConfig {
|
||||
/// Build a `CallConfig` tuned for the given quality profile.
|
||||
pub fn from_profile(profile: QualityProfile) -> Self {
|
||||
let (jitter_target, jitter_max, jitter_min) = if profile == QualityProfile::CATASTROPHIC {
|
||||
// Catastrophic: larger jitter buffer to absorb spikes
|
||||
(20, 500, 8)
|
||||
} else if profile == QualityProfile::DEGRADED {
|
||||
// Degraded: moderately deeper buffer
|
||||
(15, 350, 5)
|
||||
} else {
|
||||
// Good: low-latency defaults
|
||||
(10, 250, 3)
|
||||
};
|
||||
Self {
|
||||
profile,
|
||||
jitter_target,
|
||||
jitter_max,
|
||||
jitter_min,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sliding-window quality adapter that reacts to relay `QualityReport`s.
|
||||
///
|
||||
/// Thresholds (per-report):
|
||||
/// - loss > 15% OR rtt > 200ms => CATASTROPHIC
|
||||
/// - loss > 5% OR rtt > 100ms => DEGRADED
|
||||
/// - otherwise => GOOD
|
||||
///
|
||||
/// Hysteresis: a profile switch is only recommended after the new profile
|
||||
/// has been the recommendation for 3 or more consecutive reports.
|
||||
pub struct QualityAdapter {
|
||||
/// Sliding window of the last N reports.
|
||||
window: std::collections::VecDeque<QualityReport>,
|
||||
/// Maximum window size.
|
||||
max_window: usize,
|
||||
/// Number of consecutive reports recommending the same (non-current) profile.
|
||||
consecutive_same: u32,
|
||||
/// The profile that the last `consecutive_same` reports recommended.
|
||||
pending_profile: Option<QualityProfile>,
|
||||
}
|
||||
|
||||
/// Number of consecutive reports required before accepting a switch.
|
||||
const HYSTERESIS_COUNT: u32 = 3;
|
||||
/// Default sliding window capacity.
|
||||
const ADAPTER_WINDOW: usize = 10;
|
||||
|
||||
impl QualityAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
window: std::collections::VecDeque::with_capacity(ADAPTER_WINDOW),
|
||||
max_window: ADAPTER_WINDOW,
|
||||
consecutive_same: 0,
|
||||
pending_profile: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a new quality report from the relay.
|
||||
pub fn ingest(&mut self, report: &QualityReport) {
|
||||
if self.window.len() >= self.max_window {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(*report);
|
||||
}
|
||||
|
||||
/// Classify a single report into a recommended profile.
|
||||
fn classify(report: &QualityReport) -> QualityProfile {
|
||||
let loss = report.loss_percent();
|
||||
let rtt = report.rtt_ms();
|
||||
|
||||
if loss > 15.0 || rtt > 200 {
|
||||
QualityProfile::CATASTROPHIC
|
||||
} else if loss > 5.0 || rtt > 100 {
|
||||
QualityProfile::DEGRADED
|
||||
} else {
|
||||
QualityProfile::GOOD
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the best profile based on the most recent report in the window.
|
||||
pub fn recommended_profile(&self) -> QualityProfile {
|
||||
match self.window.back() {
|
||||
Some(report) => Self::classify(report),
|
||||
None => QualityProfile::GOOD,
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine if a profile switch should happen, applying hysteresis.
|
||||
///
|
||||
/// Returns `Some(new_profile)` only when the recommendation has differed
|
||||
/// from `current` for at least `HYSTERESIS_COUNT` consecutive reports.
|
||||
pub fn should_switch(&mut self, current: &QualityProfile) -> Option<QualityProfile> {
|
||||
let recommended = self.recommended_profile();
|
||||
|
||||
if recommended == *current {
|
||||
// Conditions match current profile — reset pending state.
|
||||
self.consecutive_same = 0;
|
||||
self.pending_profile = None;
|
||||
return None;
|
||||
}
|
||||
|
||||
// Recommended differs from current.
|
||||
match self.pending_profile {
|
||||
Some(pending) if pending == recommended => {
|
||||
self.consecutive_same += 1;
|
||||
}
|
||||
_ => {
|
||||
// New or changed recommendation — restart counter.
|
||||
self.pending_profile = Some(recommended);
|
||||
self.consecutive_same = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if self.consecutive_same >= HYSTERESIS_COUNT {
|
||||
self.consecutive_same = 0;
|
||||
self.pending_profile = None;
|
||||
Some(recommended)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages the encode/send side of a call.
|
||||
pub struct CallEncoder {
|
||||
/// Audio encoder (Opus or Codec2).
|
||||
@@ -301,4 +425,137 @@ mod tests {
|
||||
let mut pcm = vec![0i16; 960];
|
||||
assert!(dec.decode_next(&mut pcm).is_none());
|
||||
}
|
||||
|
||||
// ---- QualityAdapter tests ----
|
||||
|
||||
/// Helper: build a QualityReport from human-readable loss% and RTT ms.
|
||||
fn make_report(loss_pct_f: f32, rtt_ms: u16) -> QualityReport {
|
||||
QualityReport {
|
||||
loss_pct: (loss_pct_f / 100.0 * 255.0) as u8,
|
||||
rtt_4ms: (rtt_ms / 4) as u8,
|
||||
jitter_ms: 10,
|
||||
bitrate_cap_kbps: 200,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn good_conditions_stays_good() {
|
||||
let mut adapter = QualityAdapter::new();
|
||||
let good = make_report(1.0, 40);
|
||||
for _ in 0..10 {
|
||||
adapter.ingest(&good);
|
||||
}
|
||||
assert_eq!(adapter.recommended_profile(), QualityProfile::GOOD);
|
||||
|
||||
let current = QualityProfile::GOOD;
|
||||
for _ in 0..10 {
|
||||
adapter.ingest(&good);
|
||||
assert!(adapter.should_switch(¤t).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_loss_degrades() {
|
||||
let mut adapter = QualityAdapter::new();
|
||||
// 8% loss, low RTT => DEGRADED
|
||||
let degraded = make_report(8.0, 40);
|
||||
let mut current = QualityProfile::GOOD;
|
||||
|
||||
// Feed 3 consecutive degraded reports to pass hysteresis
|
||||
for _ in 0..3 {
|
||||
adapter.ingest(°raded);
|
||||
if let Some(new) = adapter.should_switch(¤t) {
|
||||
current = new;
|
||||
}
|
||||
}
|
||||
assert_eq!(current, QualityProfile::DEGRADED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catastrophic_conditions() {
|
||||
let mut adapter = QualityAdapter::new();
|
||||
// 20% loss => CATASTROPHIC
|
||||
let terrible = make_report(20.0, 50);
|
||||
let mut current = QualityProfile::GOOD;
|
||||
|
||||
for _ in 0..3 {
|
||||
adapter.ingest(&terrible);
|
||||
if let Some(new) = adapter.should_switch(¤t) {
|
||||
current = new;
|
||||
}
|
||||
}
|
||||
assert_eq!(current, QualityProfile::CATASTROPHIC);
|
||||
|
||||
// Also test via high RTT alone (250ms > 200ms threshold)
|
||||
let mut adapter2 = QualityAdapter::new();
|
||||
let high_rtt = make_report(1.0, 252); // rtt_4ms rounds to 63 => 252ms
|
||||
let mut current2 = QualityProfile::GOOD;
|
||||
|
||||
for _ in 0..3 {
|
||||
adapter2.ingest(&high_rtt);
|
||||
if let Some(new) = adapter2.should_switch(¤t2) {
|
||||
current2 = new;
|
||||
}
|
||||
}
|
||||
assert_eq!(current2, QualityProfile::CATASTROPHIC);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hysteresis_prevents_flapping() {
|
||||
let mut adapter = QualityAdapter::new();
|
||||
let good = make_report(1.0, 40);
|
||||
let bad = make_report(8.0, 40); // DEGRADED
|
||||
let current = QualityProfile::GOOD;
|
||||
|
||||
// Alternate good/bad — should never trigger a switch because
|
||||
// we never get 3 consecutive same-recommendation reports.
|
||||
for _ in 0..20 {
|
||||
adapter.ingest(&bad);
|
||||
assert!(adapter.should_switch(¤t).is_none());
|
||||
adapter.ingest(&good);
|
||||
assert!(adapter.should_switch(¤t).is_none());
|
||||
}
|
||||
assert_eq!(current, QualityProfile::GOOD);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_to_good() {
|
||||
let mut adapter = QualityAdapter::new();
|
||||
let bad = make_report(20.0, 50);
|
||||
let good = make_report(1.0, 40);
|
||||
|
||||
// Drive to CATASTROPHIC first
|
||||
let mut current = QualityProfile::GOOD;
|
||||
for _ in 0..3 {
|
||||
adapter.ingest(&bad);
|
||||
if let Some(new) = adapter.should_switch(¤t) {
|
||||
current = new;
|
||||
}
|
||||
}
|
||||
assert_eq!(current, QualityProfile::CATASTROPHIC);
|
||||
|
||||
// Now feed good reports — should recover to GOOD after 3 consecutive
|
||||
for _ in 0..3 {
|
||||
adapter.ingest(&good);
|
||||
if let Some(new) = adapter.should_switch(¤t) {
|
||||
current = new;
|
||||
}
|
||||
}
|
||||
assert_eq!(current, QualityProfile::GOOD);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_config_from_profile() {
|
||||
let good = CallConfig::from_profile(QualityProfile::GOOD);
|
||||
assert_eq!(good.profile, QualityProfile::GOOD);
|
||||
assert_eq!(good.jitter_min, 3);
|
||||
|
||||
let degraded = CallConfig::from_profile(QualityProfile::DEGRADED);
|
||||
assert_eq!(degraded.profile, QualityProfile::DEGRADED);
|
||||
assert!(degraded.jitter_target > good.jitter_target);
|
||||
|
||||
let catastrophic = CallConfig::from_profile(QualityProfile::CATASTROPHIC);
|
||||
assert_eq!(catastrophic.profile, QualityProfile::CATASTROPHIC);
|
||||
assert!(catastrophic.jitter_max > degraded.jitter_max);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user