feat: jitter buffer instrumentation — drift test, telemetry, parameter sweep
WZP-P2-T1-S1: Automated drift measurement - New drift_test.rs: DriftTestConfig, DriftResult, run_drift_test() - CLI --drift-test <secs>: sends tone, measures actual vs expected duration - Interpretation tiers: EXCELLENT (<50ms) / GOOD / FAIR / POOR - 2 unit tests: drift math verification, config defaults WZP-P2-T1-S2: Jitter buffer telemetry - JitterStats gains: total_decoded, underruns, overruns, max_depth_seen - JitterBuffer: record_underrun(), record_decode(), reset_stats() - CallDecoder: stats() getter, reset_stats() - JitterTelemetry: periodic tracing::info! logger with configurable interval - 4 unit tests: ingestion tracking, underrun tracking, reset, interval WZP-P2-T1-S3: Parameter sweep - New sweep.rs: SweepConfig, SweepResult, run_local_sweep() - Tests 20 jitter buffer configs (5 target × 4 max depths) locally - CLI --sweep: runs sweep, prints ASCII comparison table - No network needed — pure encoder→decoder pipeline test - 3 unit tests: config defaults, local sweep runs, table formatting 216 tests passing across all crates. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
253
crates/wzp-client/src/sweep.rs
Normal file
253
crates/wzp-client/src/sweep.rs
Normal file
@@ -0,0 +1,253 @@
|
||||
//! Parameter sweep tool for jitter buffer configurations.
|
||||
//!
|
||||
//! Tests different (target_depth, max_depth) combinations in a local
|
||||
//! encoder-to-decoder pipeline (no network) and reports frame loss,
|
||||
//! estimated latency, underruns, and overruns for each configuration.
|
||||
|
||||
use crate::call::{CallConfig, CallDecoder, CallEncoder};
|
||||
use wzp_proto::QualityProfile;
|
||||
|
||||
const FRAME_SAMPLES: usize = 960; // 20ms @ 48kHz
|
||||
const SAMPLE_RATE: u32 = 48_000;
|
||||
const FRAME_DURATION_MS: u32 = 20;
|
||||
|
||||
/// Configuration for a parameter sweep.
|
||||
pub struct SweepConfig {
|
||||
/// Target jitter buffer depths to test (in packets).
|
||||
pub target_depths: Vec<usize>,
|
||||
/// Maximum jitter buffer depths to test (in packets).
|
||||
pub max_depths: Vec<usize>,
|
||||
/// Duration in seconds to run each configuration.
|
||||
pub test_duration_secs: u32,
|
||||
/// Frequency of the test tone in Hz.
|
||||
pub tone_freq_hz: f32,
|
||||
}
|
||||
|
||||
impl Default for SweepConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
target_depths: vec![10, 25, 50, 100, 200],
|
||||
max_depths: vec![50, 100, 250, 500],
|
||||
test_duration_secs: 2,
|
||||
tone_freq_hz: 440.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result from one (target_depth, max_depth) configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SweepResult {
|
||||
/// Jitter buffer target depth used.
|
||||
pub target_depth: usize,
|
||||
/// Jitter buffer max depth used.
|
||||
pub max_depth: usize,
|
||||
/// Total frames sent into the encoder.
|
||||
pub frames_sent: u64,
|
||||
/// Total frames successfully decoded.
|
||||
pub frames_received: u64,
|
||||
/// Frame loss percentage.
|
||||
pub loss_pct: f64,
|
||||
/// Estimated latency in ms (target_depth * frame_duration).
|
||||
pub avg_latency_ms: f64,
|
||||
/// Number of jitter buffer underruns.
|
||||
pub underruns: u64,
|
||||
/// Number of jitter buffer overruns (packets dropped due to full buffer).
|
||||
pub overruns: u64,
|
||||
}
|
||||
|
||||
/// Generate a sine wave frame at the given frequency and frame offset.
|
||||
fn sine_frame(freq_hz: f32, frame_offset: u64) -> Vec<i16> {
|
||||
let start = frame_offset * FRAME_SAMPLES as u64;
|
||||
(0..FRAME_SAMPLES)
|
||||
.map(|i| {
|
||||
let t = (start + i as u64) as f32 / SAMPLE_RATE as f32;
|
||||
(f32::sin(2.0 * std::f32::consts::PI * freq_hz * t) * 16000.0) as i16
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Run a local parameter sweep (no network).
|
||||
///
|
||||
/// For each (target_depth, max_depth) combination, creates an encoder and
|
||||
/// decoder, pushes frames through the pipeline, and collects statistics.
|
||||
/// Combinations where `target_depth > max_depth` are skipped.
|
||||
pub fn run_local_sweep(config: &SweepConfig) -> Vec<SweepResult> {
|
||||
let frames_per_config =
|
||||
(config.test_duration_secs as u64) * (1000 / FRAME_DURATION_MS as u64);
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
for &target in &config.target_depths {
|
||||
for &max in &config.max_depths {
|
||||
// Skip invalid combinations where target exceeds max.
|
||||
if target > max {
|
||||
continue;
|
||||
}
|
||||
|
||||
let call_cfg = CallConfig {
|
||||
profile: QualityProfile::GOOD,
|
||||
jitter_target: target,
|
||||
jitter_max: max,
|
||||
jitter_min: target.min(3).max(1),
|
||||
};
|
||||
|
||||
let mut encoder = CallEncoder::new(&call_cfg);
|
||||
let mut decoder = CallDecoder::new(&call_cfg);
|
||||
|
||||
let mut pcm_out = vec![0i16; FRAME_SAMPLES];
|
||||
let mut frames_decoded = 0u64;
|
||||
|
||||
for frame_idx in 0..frames_per_config {
|
||||
// Encode a tone frame.
|
||||
let pcm_in = sine_frame(config.tone_freq_hz, frame_idx);
|
||||
let packets = match encoder.encode_frame(&pcm_in) {
|
||||
Ok(p) => p,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
// Feed all packets (source + repair) into the decoder.
|
||||
for pkt in packets {
|
||||
decoder.ingest(pkt);
|
||||
}
|
||||
|
||||
// Attempt to decode one frame.
|
||||
if decoder.decode_next(&mut pcm_out).is_some() {
|
||||
frames_decoded += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Drain: keep decoding until the jitter buffer is empty.
|
||||
for _ in 0..max {
|
||||
if decoder.decode_next(&mut pcm_out).is_some() {
|
||||
frames_decoded += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let stats = decoder.stats().clone();
|
||||
|
||||
let loss_pct = if frames_per_config > 0 {
|
||||
(1.0 - frames_decoded as f64 / frames_per_config as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
results.push(SweepResult {
|
||||
target_depth: target,
|
||||
max_depth: max,
|
||||
frames_sent: frames_per_config,
|
||||
frames_received: frames_decoded,
|
||||
loss_pct: loss_pct.max(0.0),
|
||||
avg_latency_ms: target as f64 * FRAME_DURATION_MS as f64,
|
||||
underruns: stats.underruns,
|
||||
overruns: stats.overruns,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// Print a formatted ASCII table of sweep results.
|
||||
pub fn print_sweep_table(results: &[SweepResult]) {
|
||||
println!();
|
||||
println!("=== Jitter Buffer Parameter Sweep ===");
|
||||
println!();
|
||||
println!(
|
||||
" {:>6} | {:>4} | {:>6} | {:>6} | {:>6} | {:>10} | {:>9} | {:>8}",
|
||||
"target", "max", "sent", "recv", "loss%", "latency_ms", "underruns", "overruns"
|
||||
);
|
||||
println!(
|
||||
" {:-<6}-+-{:-<4}-+-{:-<6}-+-{:-<6}-+-{:-<6}-+-{:-<10}-+-{:-<9}-+-{:-<8}",
|
||||
"", "", "", "", "", "", "", ""
|
||||
);
|
||||
for r in results {
|
||||
println!(
|
||||
" {:>6} | {:>4} | {:>6} | {:>6} | {:>5.1}% | {:>10.0} | {:>9} | {:>8}",
|
||||
r.target_depth,
|
||||
r.max_depth,
|
||||
r.frames_sent,
|
||||
r.frames_received,
|
||||
r.loss_pct,
|
||||
r.avg_latency_ms,
|
||||
r.underruns,
|
||||
r.overruns,
|
||||
);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
/// Run a default sweep and print the results.
|
||||
///
|
||||
/// This is the entry point for the `--sweep` CLI flag.
|
||||
pub fn run_and_print_default_sweep() {
|
||||
let config = SweepConfig::default();
|
||||
let results = run_local_sweep(&config);
|
||||
print_sweep_table(&results);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sweep_config_default() {
|
||||
let cfg = SweepConfig::default();
|
||||
assert_eq!(cfg.target_depths.len(), 5);
|
||||
assert_eq!(cfg.max_depths.len(), 4);
|
||||
assert!(cfg.test_duration_secs > 0);
|
||||
assert!(cfg.tone_freq_hz > 0.0);
|
||||
// All default targets should be positive.
|
||||
assert!(cfg.target_depths.iter().all(|&d| d > 0));
|
||||
assert!(cfg.max_depths.iter().all(|&d| d > 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_sweep_runs() {
|
||||
let cfg = SweepConfig {
|
||||
target_depths: vec![3, 10],
|
||||
max_depths: vec![50, 100],
|
||||
test_duration_secs: 1,
|
||||
tone_freq_hz: 440.0,
|
||||
};
|
||||
let results = run_local_sweep(&cfg);
|
||||
// 2 targets x 2 maxes = 4 configs (all valid since targets < maxes).
|
||||
assert_eq!(results.len(), 4);
|
||||
for r in &results {
|
||||
assert!(r.frames_sent > 0, "frames_sent should be > 0");
|
||||
assert!(r.frames_received > 0, "frames_received should be > 0");
|
||||
assert!(r.avg_latency_ms > 0.0, "latency should be > 0");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_table_formats() {
|
||||
// Verify print_sweep_table doesn't panic with various inputs.
|
||||
print_sweep_table(&[]);
|
||||
|
||||
let results = vec![
|
||||
SweepResult {
|
||||
target_depth: 10,
|
||||
max_depth: 50,
|
||||
frames_sent: 100,
|
||||
frames_received: 98,
|
||||
loss_pct: 2.0,
|
||||
avg_latency_ms: 200.0,
|
||||
underruns: 2,
|
||||
overruns: 0,
|
||||
},
|
||||
SweepResult {
|
||||
target_depth: 25,
|
||||
max_depth: 100,
|
||||
frames_sent: 100,
|
||||
frames_received: 100,
|
||||
loss_pct: 0.0,
|
||||
avg_latency_ms: 500.0,
|
||||
underruns: 0,
|
||||
overruns: 0,
|
||||
},
|
||||
];
|
||||
print_sweep_table(&results);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user