258 lines
8.4 KiB
Rust
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"
|
|
);
|
|
}
|