Files
wz-phone/crates/wzp-client/tests/long_session.rs
2026-05-11 21:19:03 +04:00

258 lines
8.4 KiB
Rust

//! WZP-P2-T1-S5: 60-second long-session regression tests.
//!
//! Verifies that the full codec + FEC + jitter buffer pipeline does not drift
//! or degrade over a sustained 60-second (3000-frame) session. Runs entirely
//! in-process with no network — packets flow directly from encoder to decoder.
use wzp_client::call::{CallConfig, CallDecoder, CallEncoder};
use wzp_proto::QualityProfile;
const FRAME_SAMPLES: usize = 960; // 20ms @ 48kHz
const SAMPLE_RATE: f32 = 48_000.0;
const TOTAL_FRAMES: u64 = 3_000; // 60 seconds at 50 fps
/// Build a CallConfig tuned for direct-loopback testing (no network).
///
/// Disables silence suppression and noise suppression (which would mangle
/// or squelch the synthetic tone), uses a fixed (non-adaptive) jitter buffer
/// with min_depth=1 so that packets are played out as soon as they arrive.
fn test_config() -> CallConfig {
CallConfig {
profile: QualityProfile::GOOD,
jitter_target: 4,
jitter_max: 500,
jitter_min: 1,
suppression_enabled: false,
noise_suppression: false,
adaptive_jitter: false,
..Default::default()
}
}
/// Generate a 20ms frame of 440 Hz sine tone.
fn sine_frame(frame_offset: u64) -> Vec<i16> {
let start_sample = frame_offset * FRAME_SAMPLES as u64;
(0..FRAME_SAMPLES)
.map(|i| {
let t = (start_sample + i as u64) as f32 / SAMPLE_RATE;
(f32::sin(2.0 * std::f32::consts::PI * 440.0 * t) * 16000.0) as i16
})
.collect()
}
/// 60-second session with a perfect (lossless, in-order) channel.
///
/// Encodes 3000 frames of 440 Hz tone, feeds every packet directly into the
/// decoder, and verifies:
/// - frame loss < 5% (>2850 of 3000 source frames decoded or PLC'd)
/// - no panics
///
/// Note: the encoder shares a single sequence counter between source and
/// repair packets. Since repair packets are NOT pushed into the jitter
/// buffer, each FEC block creates a gap in the playout sequence. GOOD
/// profile (5 frames/block, fec_ratio=0.2) generates 1 repair per block,
/// so every 6th seq number is a "phantom" Missing in the jitter buffer.
/// The jitter buffer correctly fills these gaps with PLC. We call
/// `decode_next` once per encode tick; the buffer stays shallow because
/// PLC frames consume the phantom seqs at the same rate they're created.
#[test]
fn long_session_no_drift() {
let config = test_config();
let mut encoder = CallEncoder::new(&config);
let mut decoder = CallDecoder::new(&config);
let mut frames_decoded = 0u64;
let mut pcm_buf = vec![0i16; FRAME_SAMPLES];
for i in 0..TOTAL_FRAMES {
let pcm = sine_frame(i);
let packets = encoder.encode_frame(&pcm).expect("encode should not fail");
for pkt in packets {
decoder.ingest(pkt);
}
// Decode one frame per tick (mirrors real-time 50 fps cadence).
if decoder.decode_next(&mut pcm_buf).is_some() {
frames_decoded += 1;
}
}
let stats = decoder.stats();
println!(
"long_session_no_drift: decoded={frames_decoded}/{TOTAL_FRAMES}, \
underruns={}, overruns={}, depth={}, max_depth={}, late={}, lost={}",
stats.underruns,
stats.overruns,
stats.current_depth,
stats.max_depth_seen,
stats.packets_late,
stats.packets_lost,
);
// With 1 decode per tick over 3000 ticks, we expect ~3000 decoded frames
// (some via PLC for repair-seq gaps). Allow up to 5% gap.
assert!(
frames_decoded > 2850,
"frame loss too high: decoded {frames_decoded}/3000 (need >2850 = <5% loss)"
);
}
/// 60-second session with simulated 5% packet loss and reordering.
///
/// Every 20th source packet is dropped; pairs of adjacent packets are swapped
/// every 7 frames. Verifies that FEC + jitter buffer recover gracefully:
/// - frame loss < 10% (FEC should recover some of the 5% artificial loss)
/// - no panics
#[test]
fn long_session_with_simulated_loss() {
let config = test_config();
let mut encoder = CallEncoder::new(&config);
let mut decoder = CallDecoder::new(&config);
let mut frames_decoded = 0u64;
let mut pcm_buf = vec![0i16; FRAME_SAMPLES];
for i in 0..TOTAL_FRAMES {
let pcm = sine_frame(i);
let packets = encoder.encode_frame(&pcm).expect("encode should not fail");
let mut batch: Vec<_> = packets.into_iter().collect();
// Simulate reordering: swap first two packets in the batch every 7 frames.
if i % 7 == 0 && batch.len() >= 2 {
batch.swap(0, 1);
}
for (j, pkt) in batch.into_iter().enumerate() {
// Drop every 20th *source* (non-repair) packet to simulate ~5% loss.
if !pkt.header.is_repair() && i % 20 == 0 && j == 0 {
continue; // drop this packet
}
decoder.ingest(pkt);
}
if decoder.decode_next(&mut pcm_buf).is_some() {
frames_decoded += 1;
}
}
let stats = decoder.stats();
println!(
"long_session_with_simulated_loss: decoded={frames_decoded}/{TOTAL_FRAMES}, \
underruns={}, overruns={}, depth={}, max_depth={}, late={}, lost={}",
stats.underruns,
stats.overruns,
stats.current_depth,
stats.max_depth_seen,
stats.packets_late,
stats.packets_lost,
);
// With 5% artificial loss + FEC recovery + PLC, we should still get >90% decoded.
assert!(
frames_decoded > 2700,
"frame loss too high under simulated loss: decoded {frames_decoded}/3000 (need >2700 = <10%)"
);
}
/// Verify that `MediaHeader::timestamp` continues monotonically across
/// rekey boundaries. Rekey is a crypto-layer operation (key material
/// rotation) and must not reset or interfere with framing state.
///
/// We simulate a 3000-frame session with two conceptual rekeys at frames
/// 1000 and 2000. The encoder's timestamp counter must advance
/// monotonically throughout.
#[test]
fn rekey_timestamp_monotonic() {
let config = test_config();
let mut encoder = CallEncoder::new(&config);
let mut timestamps = Vec::new();
// Phase 1: before first rekey
for i in 0..1000 {
let pcm = sine_frame(i);
let packets = encoder.encode_frame(&pcm).expect("encode");
for pkt in packets {
timestamps.push(pkt.header.timestamp);
}
}
// Phase 2: between first and second rekey
for i in 1000..2000 {
let pcm = sine_frame(i);
let packets = encoder.encode_frame(&pcm).expect("encode");
for pkt in packets {
timestamps.push(pkt.header.timestamp);
}
}
// Phase 3: after second rekey
for i in 2000..3000 {
let pcm = sine_frame(i);
let packets = encoder.encode_frame(&pcm).expect("encode");
for pkt in packets {
timestamps.push(pkt.header.timestamp);
}
}
// Assert strict monotonicity (non-decreasing) across all three phases.
for window in timestamps.windows(2) {
assert!(
window[1] >= window[0],
"timestamp not monotonic across rekey boundary: {} -> {}",
window[0],
window[1]
);
}
// Sanity: we should have collected at least 3000 timestamps.
assert!(
timestamps.len() >= 3000,
"expected >= 3000 timestamps, got {}",
timestamps.len()
);
}
/// Verify that the jitter buffer's decoded-frame count is consistent with its
/// own internal statistics over a long session.
#[test]
fn long_session_stats_consistency() {
let config = test_config();
let mut encoder = CallEncoder::new(&config);
let mut decoder = CallDecoder::new(&config);
let mut frames_decoded = 0u64;
let mut pcm_buf = vec![0i16; FRAME_SAMPLES];
for i in 0..TOTAL_FRAMES {
let pcm = sine_frame(i);
let packets = encoder.encode_frame(&pcm).expect("encode");
for pkt in packets {
decoder.ingest(pkt);
}
if decoder.decode_next(&mut pcm_buf).is_some() {
frames_decoded += 1;
}
}
let stats = decoder.stats();
// total_decoded should match our manual counter.
assert_eq!(
stats.total_decoded, frames_decoded,
"stats.total_decoded ({}) != manually counted frames_decoded ({frames_decoded})",
stats.total_decoded,
);
// packets_received should be > 0.
assert!(
stats.packets_received > 0,
"stats.packets_received should be > 0"
);
}