Files
wz-phone/crates/wzp-android/src/stats.rs
Siavash Sameni 505a834c5b feat(codec): Phase 3c — Android engine.rs DRED reconstruction on packet loss
Phase 3c mirrors Phase 3b on the Android receive path. With Phase 0-3b
landed on desktop + Android encoder, this commit completes codec-layer
loss recovery on the Android decoder side.

Architectural difference vs desktop: engine.rs has NO jitter buffer.
The recv task reads packets directly from the transport via
recv_media().await and writes decoded audio straight into the playout
ring. There is no PlayoutResult::Missing equivalent. Gap detection
therefore has to be done via sequence-number tracking — when a packet
arrives with seq > expected_seq, the frames in between are missing and
we attempt to reconstruct them via DRED before decoding the newly-
arrived packet.

Implementation:

  Imports & types:
    - Added wzp_codec::AdaptiveDecoder, wzp_codec::dred_ffi::{
      DredDecoderHandle, DredState} imports.
    - Changed the `decoder` local from Box<dyn AudioDecoder> (via
      wzp_codec::create_decoder) to concrete AdaptiveDecoder::new(profile).
      Same reasoning as Phase 3b: reconstruct_from_dred is an inherent
      method, not a trait method, so we need the concrete type.

  Recv task state (all task-local, no new struct fields):
    - dred_decoder: DredDecoderHandle
    - dred_parse_scratch: DredState (reused, overwritten per parse)
    - last_good_dred: DredState (cached most-recent valid state)
    - last_good_dred_seq: Option<u16>
    - expected_seq: Option<u16> (for gap detection)
    - dred_reconstructions: u64 (telemetry)
    - classical_plc_invocations: u64 (telemetry)

  Recv loop body (Opus source packets only):
    1. Parse DRED from the new packet first so last_good_dred reflects
       the freshest state available for gap recovery.
    2. Detect a gap: gap = pkt.seq.wrapping_sub(expected_seq). Cap at
       MAX_GAP_FRAMES = 16 (320 ms) to avoid huge wraparound scenarios.
    3. For each missing seq in the gap:
         offset = (last_good_dred_seq - missing_seq) * frame_samples
         if 0 < offset <= last_good_dred.samples_available():
             reconstruct_from_dred + write to playout ring
             bump dred_reconstructions
         else:
             decoder.decode_lost (classical PLC) + write + bump plc counter
    4. Decode the current packet normally and write to playout ring
       (unchanged from Phase 2).
    5. Update expected_seq = pkt.seq.wrapping_add(1).

  Profile-switch handling: when the incoming codec changes (triggering
  decoder.set_profile), reset last_good_dred_seq and expected_seq to
  None. The cached DRED state is tied to the old profile's frame rate
  and would produce wrong offsets after the switch; starting fresh is
  correct.

  Decode-error fallback: the existing `Err(e) => decode_lost` branch
  now also increments classical_plc_invocations so the counter
  accurately reflects all PLC invocations (gap-detected AND decode-
  error-triggered).

Telemetry (CallStats additions):
  - stats.dred_reconstructions: u64
  - stats.classical_plc_invocations: u64
  Both updated on every packet arrival in the existing stats.lock()
  block alongside frames_decoded/fec_recovered, so the Android UI and
  JNI bridge already have these values without any further plumbing.
  The periodic recv stats log now includes both counters.

Ordering note: DRED gap reconstruction happens BEFORE decoding the new
packet's audio because the playout ring is FIFO. Gap samples must be
written before the new packet's samples so temporal order is preserved.
Out-of-order late arrivals (seq < expected_seq) are naturally dropped
as stale by the gap detection (gap would be a large wraparound value
exceeding MAX_GAP_FRAMES).

Verification:
- cargo check --workspace: zero errors
- cargo test -p wzp-codec --lib: 68 passing (unchanged from Phase 3b)
- cargo test -p wzp-client --lib: 35 passing (unchanged from Phase 3b)
- cargo check -p wzp-android --lib: zero errors
- cargo test -p wzp-android cannot run on macOS host (pre-existing
  -llog linker dep, unrelated). Real end-to-end verification happens
  via the Android APK build on the remote Docker builder
  (scripts/build-and-notify.sh).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:03:31 +04:00

110 lines
4.2 KiB
Rust

//! Call statistics for the Android engine.
/// State of the call.
/// Serializes as integer for easy parsing on the Kotlin side:
/// 0=Idle, 1=Connecting, 2=Active, 3=Reconnecting, 4=Closed
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum CallState {
#[default]
Idle,
Connecting,
Active,
Reconnecting,
Closed,
/// Connected to relay signal channel, registered for direct calls.
Registered,
/// Outgoing call ringing on callee's side.
Ringing,
/// Incoming call received, waiting for user to accept/reject.
IncomingCall,
}
impl serde::Serialize for CallState {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let n: u8 = match self {
CallState::Idle => 0,
CallState::Connecting => 1,
CallState::Active => 2,
CallState::Reconnecting => 3,
CallState::Closed => 4,
CallState::Registered => 5,
CallState::Ringing => 6,
CallState::IncomingCall => 7,
};
serializer.serialize_u8(n)
}
}
/// Aggregated call statistics, serializable for JNI bridge.
#[derive(Clone, Debug, Default, serde::Serialize)]
pub struct CallStats {
/// Current call state.
pub state: CallState,
/// Call duration in seconds.
pub duration_secs: f64,
/// Current quality tier (0=GOOD, 1=DEGRADED, 2=CATASTROPHIC).
pub quality_tier: u8,
/// Observed packet loss percentage.
pub loss_pct: f32,
/// Smoothed round-trip time in milliseconds.
pub rtt_ms: u32,
/// Jitter in milliseconds.
pub jitter_ms: u32,
/// Current jitter buffer depth in packets.
pub jitter_buffer_depth: usize,
/// Total frames encoded since call start.
pub frames_encoded: u64,
/// Total frames decoded since call start.
pub frames_decoded: u64,
/// Number of playout underruns (buffer empty when audio needed).
pub underruns: u64,
/// Frames recovered by RaptorQ FEC (Codec2 tiers only; Opus bypasses
/// RaptorQ per Phase 2).
pub fec_recovered: u64,
/// Phase 3c: Opus frames reconstructed via DRED side-channel data.
/// Only increments on the Opus tiers; always zero for Codec2.
pub dred_reconstructions: u64,
/// Phase 3c: Opus frames filled via classical Opus PLC because no DRED
/// state covered the gap, plus any decode-error fallbacks. Codec2 loss
/// also increments this counter via the Codec2 PLC path.
pub classical_plc_invocations: u64,
/// Playout ring overflow count (reader was lapped by writer).
pub playout_overflows: u64,
/// Playout ring underrun count (reader found empty buffer).
pub playout_underruns: u64,
/// Capture ring overflow count.
pub capture_overflows: u64,
/// Current mic audio level (RMS of i16 samples, 0-32767).
pub audio_level: u32,
/// Our current outgoing codec name (e.g. "Opus24k", "Codec2_1200").
pub current_codec: String,
/// Last seen incoming codec from other participants.
pub peer_codec: String,
/// Whether auto quality mode is active.
pub auto_mode: bool,
/// Number of participants in the room (from last RoomUpdate).
pub room_participant_count: u32,
/// Participant list (fingerprint + optional alias) serialized as JSON array.
pub room_participants: Vec<RoomMember>,
/// SAS code for verbal verification (None if not in a call).
#[serde(skip_serializing_if = "Option::is_none")]
pub sas_code: Option<u32>,
/// Incoming call info (present when state == IncomingCall).
#[serde(skip_serializing_if = "Option::is_none")]
pub incoming_call_id: Option<String>,
/// Fingerprint of the caller (present when state == IncomingCall).
#[serde(skip_serializing_if = "Option::is_none")]
pub incoming_caller_fp: Option<String>,
/// Alias of the caller (present when state == IncomingCall).
#[serde(skip_serializing_if = "Option::is_none")]
pub incoming_caller_alias: Option<String>,
}
/// A room member entry, serialized into the stats JSON.
#[derive(Clone, Debug, Default, serde::Serialize)]
pub struct RoomMember {
pub fingerprint: String,
pub alias: Option<String>,
pub relay_label: Option<String>,
}