feat(p2p): adaptive quality on direct calls (#23)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 27s
Build Release Binaries / build-amd64 (push) Failing after 3m37s

P2P calls now adapt codec quality based on observed network conditions,
matching what relay calls already had.

Three-layer implementation:
- QualityReport::from_path_stats(): construct reports from local quinn
  stats (loss%, RTT, jitter) without needing relay-generated reports
- CallEncoder.pending_quality_report: one-shot attachment to next
  source packet (consumed on encode, not repeated)
- Engine send tasks: generate quality report every 50 frames (~1s)
  from quinn_path_stats() and attach via set_pending_quality_report()
- Engine recv tasks: self-observe from own QUIC path stats every 50
  packets, feed to AdaptiveQualityController for P2P adaptation
  (works even if peer isn't sending quality reports yet)

Both relay and P2P calls now have adaptive quality. On relay calls,
both peer-sent reports AND local observations feed the controller.
Hysteresis (3 consecutive bad reports to downgrade) prevents thrashing.

372 tests passing (+4 new: from_path_stats encoding, clamping, zero
values, encoder quality report attachment).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-04-13 16:14:06 +04:00
parent 81b5522942
commit 1e82811cc1
3 changed files with 157 additions and 2 deletions

View File

@@ -234,6 +234,8 @@ pub struct CallEncoder {
mini_frames_enabled: bool,
/// Frames encoded since the last full header was emitted.
frames_since_full: u32,
/// Pending quality report to attach to the next source packet.
pending_quality_report: Option<QualityReport>,
}
impl CallEncoder {
@@ -264,6 +266,7 @@ impl CallEncoder {
mini_context: MiniFrameContext::default(),
mini_frames_enabled: config.mini_frames_enabled,
frames_since_full: 0,
pending_quality_report: None,
}
}
@@ -367,7 +370,7 @@ impl CallEncoder {
version: 0,
is_repair: false,
codec_id: self.profile.codec,
has_quality_report: false,
has_quality_report: self.pending_quality_report.is_some(),
fec_ratio_encoded,
seq: self.seq,
timestamp: self.timestamp_ms,
@@ -377,7 +380,7 @@ impl CallEncoder {
csrc_count: 0,
},
payload: Bytes::from(encoded.clone()),
quality_report: None,
quality_report: self.pending_quality_report.take(),
};
self.seq = self.seq.wrapping_add(1);
@@ -454,6 +457,13 @@ impl CallEncoder {
self.audio_enc.set_expected_loss(tuning.expected_loss_pct);
}
/// Queue a quality report for attachment to the next source packet.
/// Used by the send task to embed locally-observed path quality so
/// the peer can drive adaptive quality switching.
pub fn set_pending_quality_report(&mut self, report: QualityReport) {
self.pending_quality_report = Some(report);
}
/// Enable or disable acoustic echo cancellation.
pub fn set_aec_enabled(&mut self, enabled: bool) {
self.aec.set_enabled(enabled);
@@ -1578,4 +1588,28 @@ mod tests {
let packets = enc.encode_frame(&pcm).unwrap();
assert!(!packets.is_empty());
}
#[test]
fn encoder_attaches_quality_report() {
let mut enc = CallEncoder::new(&CallConfig {
profile: QualityProfile::GOOD,
suppression_enabled: false,
..Default::default()
});
// Set a quality report
enc.set_pending_quality_report(QualityReport::from_path_stats(5.0, 80, 10));
// Encode a frame — should have quality_report attached
let pcm = voice_frame_20ms(0);
let packets = enc.encode_frame(&pcm).unwrap();
assert!(!packets.is_empty());
assert!(packets[0].header.has_quality_report, "first packet should have quality report");
assert!(packets[0].quality_report.is_some());
// Next frame should NOT have quality_report (it was consumed)
let packets2 = enc.encode_frame(&voice_frame_20ms(960)).unwrap();
assert!(!packets2[0].header.has_quality_report, "second packet should not have quality report");
assert!(packets2[0].quality_report.is_none());
}
}