feat: adaptive quality in desktop, relay quality directive, Oboe state polling

- Wire AdaptiveQualityController into desktop engine send/recv tasks
  (mirrors Android pattern: AtomicU8 pending_profile, auto-mode check)
- Wire same into Android engine send task (was only in recv before)
- QualityDirective SignalMessage variant for relay-initiated codec switch
- ParticipantQuality tracking in relay RoomManager (per-participant
  AdaptiveQualityController, weakest-link tier computation)
- Relay broadcasts QualityDirective to all participants when room-wide
  tier degrades (coordinated codec switching)
- Oboe stream state polling: poll getState() for up to 2s after
  requestStart() to ensure both streams reach Started before proceeding
  (fixes intermittent silent calls on cold start, Nothing Phone A059)

Tasks: #7, #25, #26, #31, #35

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-04-12 19:54:04 +04:00
parent 766c9df442
commit 22045bc5e6
5 changed files with 266 additions and 10 deletions

View File

@@ -9,7 +9,7 @@
//! still fails cleanly but the rest of the engine code links in.
use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Instant;
use tauri::Emitter;
@@ -26,11 +26,38 @@ use wzp_client::audio_io::{AudioCapture, AudioPlayback};
// Android (where wzp-client is pulled in with default-features=false).
use wzp_client::call::{CallConfig, CallEncoder};
use wzp_proto::traits::AudioDecoder;
use wzp_proto::{CodecId, MediaTransport, QualityProfile};
use wzp_proto::traits::{AudioDecoder, QualityController};
use wzp_proto::{AdaptiveQualityController, CodecId, MediaTransport, QualityProfile};
const FRAME_SAMPLES_40MS: usize = 1920;
/// Profile index mapping for the AtomicU8 adaptive-quality bridge.
const PROFILE_NO_CHANGE: u8 = 0xFF;
fn profile_to_index(p: &QualityProfile) -> u8 {
match p.codec {
CodecId::Opus64k => 0,
CodecId::Opus48k => 1,
CodecId::Opus32k => 2,
CodecId::Opus24k => 3,
CodecId::Opus6k => 4,
CodecId::Codec2_1200 => 5,
_ => 3, // default to GOOD
}
}
fn index_to_profile(idx: u8) -> Option<QualityProfile> {
match idx {
0 => Some(QualityProfile::STUDIO_64K),
1 => Some(QualityProfile::STUDIO_48K),
2 => Some(QualityProfile::STUDIO_32K),
3 => Some(QualityProfile::GOOD),
4 => Some(QualityProfile::DEGRADED),
5 => Some(QualityProfile::CATASTROPHIC),
_ => None,
}
}
/// Resolve a quality string from the UI to a QualityProfile.
/// Returns None for "auto" (use default adaptive behavior).
fn resolve_quality(quality: &str) -> Option<QualityProfile> {
@@ -480,6 +507,10 @@ impl CallEngine {
let tx_codec = Arc::new(Mutex::new(String::new()));
let rx_codec = Arc::new(Mutex::new(String::new()));
// Adaptive quality: shared pending-profile bridge between recv → send.
let pending_profile = Arc::new(AtomicU8::new(PROFILE_NO_CHANGE));
let auto_profile = resolve_quality(&quality).is_none();
// Send task — drain Oboe capture ring, Opus-encode, push to transport.
let send_t = transport.clone();
let send_r = running.clone();
@@ -492,6 +523,7 @@ impl CallEngine {
let send_tx_codec = tx_codec.clone();
let send_t0 = call_t0;
let send_app = app.clone();
let send_pending_profile = pending_profile.clone();
tokio::spawn(async move {
let profile = resolve_quality(&send_quality);
let config = match profile {
@@ -609,6 +641,20 @@ impl CallEngine {
Err(e) => error!("encode: {e}"),
}
// Adaptive quality: check if recv task recommended a profile switch.
if auto_profile {
let p = send_pending_profile.swap(PROFILE_NO_CHANGE, Ordering::Acquire);
if p != PROFILE_NO_CHANGE {
if let Some(new_profile) = index_to_profile(p) {
info!(to = ?new_profile.codec, "auto: switching encoder profile");
if encoder.set_profile(new_profile).is_ok() {
dred_tuner.set_codec(new_profile.codec);
*send_tx_codec.lock().await = format!("{:?}", new_profile.codec);
}
}
}
}
// DRED tuner: poll quinn path stats periodically and
// adjust encoder DRED duration + expected-loss hint.
frames_since_dred_poll += 1;
@@ -682,6 +728,7 @@ impl CallEngine {
let recv_rx_codec = rx_codec.clone();
let recv_t0 = call_t0;
let recv_app = app.clone();
let pending_profile_recv = pending_profile.clone();
tokio::spawn(async move {
let initial_profile = resolve_quality(&quality).unwrap_or(QualityProfile::GOOD);
// Phase 3b/3c: use concrete AdaptiveDecoder (not Box<dyn
@@ -696,6 +743,7 @@ impl CallEngine {
// Phase 3b/3c DRED reconstruction state — see DredRecvState
// above for the full flow.
let mut dred_recv = DredRecvState::new();
let mut quality_ctrl = AdaptiveQualityController::new();
info!(codec = ?current_codec, t_ms = recv_t0.elapsed().as_millis(), "first-join diag: recv task spawned (android/oboe)");
// First-join diagnostic latches — see send task above for the
// sibling capture milestones.
@@ -845,6 +893,15 @@ impl CallEngine {
);
}
// Adaptive quality: ingest quality reports from peer
if let Some(ref qr) = pkt.quality_report {
if let Some(new_profile) = quality_ctrl.observe(qr) {
let idx = profile_to_index(&new_profile);
info!(to = ?new_profile.codec, "auto: quality adapter recommends switch");
pending_profile_recv.store(idx, Ordering::Release);
}
}
match decoder.decode(&pkt.payload, &mut pcm) {
Ok(n) => {
last_decode_n = n;
@@ -1255,6 +1312,10 @@ impl CallEngine {
let tx_codec = Arc::new(Mutex::new(String::new()));
let rx_codec = Arc::new(Mutex::new(String::new()));
// Adaptive quality: shared pending-profile bridge between recv → send.
let pending_profile = Arc::new(AtomicU8::new(PROFILE_NO_CHANGE));
let auto_profile = resolve_quality(&quality).is_none();
// Send task
let send_t = transport.clone();
let send_r = running.clone();
@@ -1264,6 +1325,7 @@ impl CallEngine {
let send_drops = Arc::new(AtomicU64::new(0));
let send_quality = quality.clone();
let send_tx_codec = tx_codec.clone();
let send_pending_profile = pending_profile.clone();
tokio::spawn(async move {
let profile = resolve_quality(&send_quality);
let config = match profile {
@@ -1326,6 +1388,20 @@ impl CallEngine {
Err(e) => error!("encode: {e}"),
}
// Adaptive quality: check if recv task recommended a profile switch.
if auto_profile {
let p = send_pending_profile.swap(PROFILE_NO_CHANGE, Ordering::Acquire);
if p != PROFILE_NO_CHANGE {
if let Some(new_profile) = index_to_profile(p) {
info!(to = ?new_profile.codec, "auto: switching encoder profile");
if encoder.set_profile(new_profile).is_ok() {
dred_tuner.set_codec(new_profile.codec);
*send_tx_codec.lock().await = format!("{:?}", new_profile.codec);
}
}
}
}
// DRED tuner: poll quinn path stats periodically.
frames_since_dred_poll += 1;
if frames_since_dred_poll >= DRED_POLL_INTERVAL {
@@ -1349,6 +1425,7 @@ impl CallEngine {
let recv_spk = spk_muted.clone();
let recv_fr = frames_received.clone();
let recv_rx_codec = rx_codec.clone();
let pending_profile_recv = pending_profile.clone();
tokio::spawn(async move {
let initial_profile = resolve_quality(&quality).unwrap_or(QualityProfile::GOOD);
// Phase 3b/3c: concrete AdaptiveDecoder (not Box<dyn>) so we
@@ -1361,6 +1438,7 @@ impl CallEngine {
let mut agc = wzp_codec::AutoGainControl::new();
let mut pcm = vec![0i16; FRAME_SAMPLES_40MS]; // big enough for any codec
let mut dred_recv = DredRecvState::new();
let mut quality_ctrl = AdaptiveQualityController::new();
loop {
if !recv_r.load(Ordering::Relaxed) {
@@ -1425,6 +1503,15 @@ impl CallEngine {
);
}
// Adaptive quality: ingest quality reports from peer
if let Some(ref qr) = pkt.quality_report {
if let Some(new_profile) = quality_ctrl.observe(qr) {
let idx = profile_to_index(&new_profile);
info!(to = ?new_profile.codec, "auto: quality adapter recommends switch");
pending_profile_recv.store(idx, Ordering::Release);
}
}
if let Ok(n) = decoder.decode(&pkt.payload, &mut pcm) {
agc.process_frame(&mut pcm[..n]);
if !recv_spk.load(Ordering::Relaxed) {