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:
Siavash Sameni
2026-03-28 10:26:40 +04:00
parent 524d1145bb
commit 59a00d371b
7 changed files with 776 additions and 7 deletions

View File

@@ -2,8 +2,10 @@
//!
//! Pipeline: mic → encode → FEC → encrypt → send / recv → decrypt → FEC → decode → speaker
use std::time::{Duration, Instant};
use bytes::Bytes;
use tracing::{debug, warn};
use tracing::{debug, info, warn};
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
@@ -323,25 +325,37 @@ impl CallDecoder {
pub fn decode_next(&mut self, pcm: &mut [i16]) -> Option<usize> {
match self.jitter.pop() {
PlayoutResult::Packet(pkt) => {
match self.audio_dec.decode(&pkt.payload, pcm) {
let result = match self.audio_dec.decode(&pkt.payload, pcm) {
Ok(n) => Some(n),
Err(e) => {
warn!("decode error: {e}, using PLC");
self.audio_dec.decode_lost(pcm).ok()
}
};
if result.is_some() {
self.jitter.record_decode();
}
result
}
PlayoutResult::Missing { seq } => {
// Only generate PLC if there are still packets buffered ahead.
// Otherwise we've drained everything — return None to stop.
if self.jitter.depth() > 0 {
debug!(seq, "packet loss, generating PLC");
self.audio_dec.decode_lost(pcm).ok()
let result = self.audio_dec.decode_lost(pcm).ok();
if result.is_some() {
self.jitter.record_decode();
}
result
} else {
self.jitter.record_underrun();
None
}
}
PlayoutResult::NotReady => None,
PlayoutResult::NotReady => {
self.jitter.record_underrun();
None
}
}
}
@@ -351,8 +365,54 @@ impl CallDecoder {
}
/// Get jitter buffer statistics.
pub fn jitter_stats(&self) -> wzp_proto::jitter::JitterStats {
self.jitter.stats().clone()
pub fn stats(&self) -> &wzp_proto::jitter::JitterStats {
self.jitter.stats()
}
/// Reset jitter buffer statistics counters.
pub fn reset_stats(&mut self) {
self.jitter.reset_stats();
}
}
/// Periodic telemetry logger for jitter buffer statistics.
///
/// Call `maybe_log` on each decode tick; it will emit a `tracing::info!` event
/// no more frequently than the configured interval.
pub struct JitterTelemetry {
interval: Duration,
last_report: Instant,
}
impl JitterTelemetry {
/// Create a new telemetry logger that reports at most once per `interval_secs`.
pub fn new(interval_secs: u64) -> Self {
Self {
interval: Duration::from_secs(interval_secs),
last_report: Instant::now(),
}
}
/// Log jitter statistics if the interval has elapsed. Returns `true` when a
/// log line was emitted.
pub fn maybe_log(&mut self, stats: &wzp_proto::jitter::JitterStats) -> bool {
let now = Instant::now();
if now.duration_since(self.last_report) >= self.interval {
info!(
buffer_depth = stats.current_depth,
underruns = stats.underruns,
overruns = stats.overruns,
late_packets = stats.packets_late,
total_received = stats.packets_received,
total_decoded = stats.total_decoded,
max_depth_seen = stats.max_depth_seen,
"jitter buffer telemetry"
);
self.last_report = now;
true
} else {
false
}
}
}
@@ -558,4 +618,101 @@ mod tests {
assert_eq!(catastrophic.profile, QualityProfile::CATASTROPHIC);
assert!(catastrophic.jitter_max > degraded.jitter_max);
}
// ---- JitterStats telemetry tests ----
fn make_test_packet(seq: u16) -> MediaPacket {
MediaPacket {
header: MediaHeader {
version: 0,
is_repair: false,
codec_id: CodecId::Opus24k,
has_quality_report: false,
fec_ratio_encoded: 0,
seq,
timestamp: seq as u32 * 20,
fec_block: 0,
fec_symbol: seq as u8,
reserved: 0,
csrc_count: 0,
},
payload: Bytes::from(vec![0u8; 60]),
quality_report: None,
}
}
#[test]
fn stats_track_ingestion() {
let config = CallConfig::default();
let mut dec = CallDecoder::new(&config);
for i in 0..5u16 {
dec.ingest(make_test_packet(i));
}
let stats = dec.stats();
assert_eq!(stats.packets_received, 5);
assert_eq!(stats.current_depth, 5);
assert_eq!(stats.max_depth_seen, 5);
}
#[test]
fn stats_track_underruns() {
let config = CallConfig::default();
let mut dec = CallDecoder::new(&config);
// Empty buffer — decode_next should record underruns
let mut pcm = vec![0i16; 960];
dec.decode_next(&mut pcm);
dec.decode_next(&mut pcm);
dec.decode_next(&mut pcm);
assert_eq!(dec.stats().underruns, 3);
}
#[test]
fn stats_reset() {
let config = CallConfig::default();
let mut dec = CallDecoder::new(&config);
// Generate some stats: ingest packets and trigger underruns on empty buffer
for i in 0..3u16 {
dec.ingest(make_test_packet(i));
}
// Also call decode on empty decoder to get underruns
let config2 = CallConfig::default();
let mut dec2 = CallDecoder::new(&config2);
let mut pcm = vec![0i16; 960];
dec2.decode_next(&mut pcm); // underrun — nothing in buffer
assert!(dec.stats().packets_received > 0);
assert!(dec2.stats().underruns > 0);
// Test reset on the decoder with ingested packets
dec.reset_stats();
let stats = dec.stats();
assert_eq!(stats.packets_received, 0);
assert_eq!(stats.underruns, 0);
assert_eq!(stats.overruns, 0);
assert_eq!(stats.total_decoded, 0);
assert_eq!(stats.packets_late, 0);
assert_eq!(stats.max_depth_seen, 0);
// Test reset on the decoder with underruns
dec2.reset_stats();
assert_eq!(dec2.stats().underruns, 0);
}
#[test]
fn telemetry_respects_interval() {
use wzp_proto::jitter::JitterStats;
let mut telemetry = JitterTelemetry::new(60); // 60-second interval
let stats = JitterStats::default();
// First call right after creation — should not log because no time has passed
// (the interval hasn't elapsed since construction)
let logged = telemetry.maybe_log(&stats);
assert!(!logged, "should not log before interval elapses");
}
}

View File

@@ -40,6 +40,8 @@ struct CliArgs {
send_file: Option<String>,
record_file: Option<String>,
echo_test_secs: Option<u32>,
drift_test_secs: Option<u32>,
sweep: bool,
seed_hex: Option<String>,
mnemonic: Option<String>,
room: Option<String>,
@@ -78,6 +80,8 @@ fn parse_args() -> CliArgs {
let mut send_file = None;
let mut record_file = None;
let mut echo_test_secs = None;
let mut drift_test_secs = None;
let mut sweep = false;
let mut seed_hex = None;
let mut mnemonic = None;
let mut room = None;
@@ -145,6 +149,16 @@ fn parse_args() -> CliArgs {
.expect("--echo-test value must be a number"),
);
}
"--drift-test" => {
i += 1;
drift_test_secs = Some(
args.get(i)
.expect("--drift-test requires seconds")
.parse()
.expect("--drift-test value must be a number"),
);
}
"--sweep" => sweep = true,
"--help" | "-h" => {
eprintln!("Usage: wzp-client [options] [relay-addr]");
eprintln!();
@@ -154,6 +168,8 @@ fn parse_args() -> CliArgs {
eprintln!(" --send-file <file> Send a raw PCM file (48kHz mono s16le)");
eprintln!(" --record <file.raw> Record received audio to raw PCM file");
eprintln!(" --echo-test <secs> Run automated echo quality test");
eprintln!(" --drift-test <secs> Run automated clock-drift measurement");
eprintln!(" --sweep Run jitter buffer parameter sweep (local, no network)");
eprintln!(" --seed <hex> Identity seed (64 hex chars, featherChat compatible)");
eprintln!(" --mnemonic <words...> Identity seed as BIP39 mnemonic (24 words)");
eprintln!(" --room <name> Room name (hashed for privacy before sending)");
@@ -187,6 +203,8 @@ fn parse_args() -> CliArgs {
send_file,
record_file,
echo_test_secs,
drift_test_secs,
sweep,
seed_hex,
mnemonic,
room,
@@ -199,6 +217,13 @@ async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt().init();
let cli = parse_args();
// --sweep runs locally (no network), so handle it before connecting.
if cli.sweep {
wzp_client::sweep::run_and_print_default_sweep();
return Ok(());
}
let seed = cli.resolve_seed();
info!(
@@ -264,6 +289,15 @@ async fn main() -> anyhow::Result<()> {
wzp_client::echo_test::print_report(&result);
transport.close().await?;
Ok(())
} else if let Some(secs) = cli.drift_test_secs {
let config = wzp_client::drift_test::DriftTestConfig {
duration_secs: secs,
tone_freq_hz: 440.0,
};
let result = wzp_client::drift_test::run_drift_test(&*transport, &config).await?;
wzp_client::drift_test::print_drift_report(&result);
transport.close().await?;
Ok(())
} else if cli.send_tone_secs.is_some() || cli.send_file.is_some() || cli.record_file.is_some() {
run_file_mode(transport, cli.send_tone_secs, cli.send_file, cli.record_file).await
} else {

View File

@@ -0,0 +1,293 @@
//! Automated clock-drift measurement tool.
//!
//! Sends N seconds of a known 440 Hz tone through the transport, records
//! received frame timestamps on the other side, and compares actual received
//! duration vs expected duration to quantify timing drift and packet loss.
use std::time::{Duration, Instant};
use tracing::info;
use wzp_proto::MediaTransport;
use crate::call::{CallConfig, CallDecoder, CallEncoder};
const FRAME_SAMPLES: usize = 960; // 20ms @ 48kHz
const SAMPLE_RATE: u32 = 48_000;
/// Configuration for a drift measurement run.
#[derive(Debug, Clone)]
pub struct DriftTestConfig {
/// How many seconds of tone to send.
pub duration_secs: u32,
/// Frequency of the test tone (Hz).
pub tone_freq_hz: f32,
}
impl Default for DriftTestConfig {
fn default() -> Self {
Self {
duration_secs: 10,
tone_freq_hz: 440.0,
}
}
}
/// Results from a drift measurement run.
#[derive(Debug, Clone)]
pub struct DriftResult {
/// Expected duration in milliseconds (`duration_secs * 1000`).
pub expected_duration_ms: u64,
/// Actual measured duration in milliseconds (last_recv - first_recv).
pub actual_duration_ms: u64,
/// Drift: `actual - expected` (positive = receiver clock ran slow / packets delayed).
pub drift_ms: i64,
/// Drift as a percentage of expected duration.
pub drift_pct: f64,
/// Total frames sent by the sender.
pub frames_sent: u64,
/// Total frames successfully received and decoded.
pub frames_received: u64,
/// Packet loss percentage: `(1 - frames_received / frames_sent) * 100`.
pub loss_pct: f64,
}
impl DriftResult {
/// Compute a `DriftResult` from raw counters and timestamps.
pub fn compute(
expected_duration_ms: u64,
actual_duration_ms: u64,
frames_sent: u64,
frames_received: u64,
) -> Self {
let drift_ms = actual_duration_ms as i64 - expected_duration_ms as i64;
let drift_pct = if expected_duration_ms > 0 {
drift_ms as f64 / expected_duration_ms as f64 * 100.0
} else {
0.0
};
let loss_pct = if frames_sent > 0 {
(1.0 - frames_received as f64 / frames_sent as f64) * 100.0
} else {
0.0
};
Self {
expected_duration_ms,
actual_duration_ms,
drift_ms,
drift_pct,
frames_sent,
frames_received,
loss_pct,
}
}
}
/// Generate a sine wave frame at a given frequency.
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 the drift measurement test.
///
/// 1. Spawns a send task that encodes `duration_secs` of tone at 20 ms intervals.
/// 2. Spawns a recv task that counts decoded frames and tracks first/last timestamps.
/// 3. After the sender finishes, waits 2 seconds for trailing packets.
/// 4. Computes and returns the `DriftResult`.
pub async fn run_drift_test(
transport: &(dyn MediaTransport + Send + Sync),
config: &DriftTestConfig,
) -> anyhow::Result<DriftResult> {
let call_config = CallConfig::default();
let mut encoder = CallEncoder::new(&call_config);
let mut decoder = CallDecoder::new(&call_config);
let total_frames: u64 = config.duration_secs as u64 * 50; // 50 frames/s at 20 ms
let frame_duration = Duration::from_millis(20);
let mut pcm_buf = vec![0i16; FRAME_SAMPLES];
let mut frames_sent: u64 = 0;
let mut frames_received: u64 = 0;
let mut first_recv_time: Option<Instant> = None;
let mut last_recv_time: Option<Instant> = None;
info!(
duration_secs = config.duration_secs,
tone_hz = config.tone_freq_hz,
total_frames = total_frames,
"starting drift measurement"
);
let start = Instant::now();
// Send + interleaved receive loop (same pattern as echo_test)
for frame_idx in 0..total_frames {
// --- send ---
let pcm = sine_frame(config.tone_freq_hz, frame_idx);
let packets = encoder.encode_frame(&pcm)?;
for pkt in &packets {
transport.send_media(pkt).await?;
}
frames_sent += 1;
// --- try to receive (short window so we don't block the sender) ---
let recv_deadline = Instant::now() + Duration::from_millis(5);
loop {
if Instant::now() >= recv_deadline {
break;
}
match tokio::time::timeout(Duration::from_millis(2), transport.recv_media()).await {
Ok(Ok(Some(pkt))) => {
let is_repair = pkt.header.is_repair;
decoder.ingest(pkt);
if !is_repair {
if let Some(_n) = decoder.decode_next(&mut pcm_buf) {
let now = Instant::now();
if first_recv_time.is_none() {
first_recv_time = Some(now);
}
last_recv_time = Some(now);
frames_received += 1;
}
}
}
_ => break,
}
}
if (frame_idx + 1) % 250 == 0 {
info!(
frame = frame_idx + 1,
sent = frames_sent,
recv = frames_received,
elapsed = format!("{:.1}s", start.elapsed().as_secs_f64()),
"drift-test progress"
);
}
tokio::time::sleep(frame_duration).await;
}
// Drain trailing packets for 2 seconds
info!("sender done, draining trailing packets for 2s...");
let drain_deadline = Instant::now() + Duration::from_secs(2);
while Instant::now() < drain_deadline {
match tokio::time::timeout(Duration::from_millis(100), transport.recv_media()).await {
Ok(Ok(Some(pkt))) => {
let is_repair = pkt.header.is_repair;
decoder.ingest(pkt);
if !is_repair {
if let Some(_n) = decoder.decode_next(&mut pcm_buf) {
let now = Instant::now();
if first_recv_time.is_none() {
first_recv_time = Some(now);
}
last_recv_time = Some(now);
frames_received += 1;
}
}
}
_ => break,
}
}
// Compute result
let expected_duration_ms = config.duration_secs as u64 * 1000;
let actual_duration_ms = match (first_recv_time, last_recv_time) {
(Some(first), Some(last)) => last.duration_since(first).as_millis() as u64,
_ => 0,
};
let result = DriftResult::compute(
expected_duration_ms,
actual_duration_ms,
frames_sent,
frames_received,
);
info!(
expected_ms = result.expected_duration_ms,
actual_ms = result.actual_duration_ms,
drift_ms = result.drift_ms,
drift_pct = format!("{:.4}%", result.drift_pct),
loss_pct = format!("{:.1}%", result.loss_pct),
"drift measurement complete"
);
Ok(result)
}
/// Pretty-print the drift measurement results.
pub fn print_drift_report(result: &DriftResult) {
println!();
println!("=== Drift Measurement Report ===");
println!();
println!("Frames sent: {}", result.frames_sent);
println!("Frames received: {}", result.frames_received);
println!("Packet loss: {:.1}%", result.loss_pct);
println!();
println!("Expected duration: {} ms", result.expected_duration_ms);
println!("Actual duration: {} ms", result.actual_duration_ms);
println!("Drift: {} ms ({:+.4}%)", result.drift_ms, result.drift_pct);
println!();
// Interpretation
let abs_drift = result.drift_ms.unsigned_abs();
if result.frames_received == 0 {
println!("WARNING: No frames received. Transport may be non-functional.");
} else if abs_drift < 5 {
println!("Result: EXCELLENT -- drift is negligible (<5 ms).");
} else if abs_drift < 20 {
println!("Result: GOOD -- drift is within acceptable bounds (<20 ms).");
} else if abs_drift < 100 {
println!("Result: FAIR -- noticeable drift ({} ms). Clock sync may be needed.", abs_drift);
} else {
println!("Result: POOR -- significant drift ({} ms). Investigate clock sources.", abs_drift);
}
println!();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn drift_result_calculations() {
// Perfect case: no drift, no loss
let r = DriftResult::compute(10_000, 10_000, 500, 500);
assert_eq!(r.drift_ms, 0);
assert!((r.drift_pct - 0.0).abs() < f64::EPSILON);
assert!((r.loss_pct - 0.0).abs() < f64::EPSILON);
// Positive drift (receiver duration longer than expected)
let r = DriftResult::compute(10_000, 10_050, 500, 490);
assert_eq!(r.drift_ms, 50);
assert!((r.drift_pct - 0.5).abs() < 1e-9); // 50/10000 * 100 = 0.5%
assert!((r.loss_pct - 2.0).abs() < 1e-9); // (1 - 490/500) * 100 = 2.0%
// Negative drift (receiver duration shorter than expected)
let r = DriftResult::compute(10_000, 9_900, 500, 450);
assert_eq!(r.drift_ms, -100);
assert!((r.drift_pct - (-1.0)).abs() < 1e-9); // -100/10000 * 100 = -1.0%
assert!((r.loss_pct - 10.0).abs() < 1e-9); // (1 - 450/500) * 100 = 10.0%
// Edge: zero frames sent (avoid division by zero)
let r = DriftResult::compute(0, 0, 0, 0);
assert_eq!(r.drift_ms, 0);
assert!((r.drift_pct - 0.0).abs() < f64::EPSILON);
assert!((r.loss_pct - 0.0).abs() < f64::EPSILON);
}
#[test]
fn drift_config_defaults() {
let cfg = DriftTestConfig::default();
assert_eq!(cfg.duration_secs, 10);
assert!((cfg.tone_freq_hz - 440.0).abs() < f32::EPSILON);
}
}

View File

@@ -266,7 +266,7 @@ pub async fn run_echo_test(
}
}
let jitter_stats = decoder.jitter_stats();
let jitter_stats = decoder.stats().clone();
let total_frames_received = recv_pcm.len() as u64 / FRAME_SAMPLES as u64;
let overall_loss = if total_frames > 0 {
(1.0 - total_frames_received as f32 / total_frames as f32) * 100.0

View File

@@ -10,9 +10,11 @@
pub mod audio_io;
pub mod bench;
pub mod call;
pub mod drift_test;
pub mod echo_test;
pub mod featherchat;
pub mod handshake;
pub mod sweep;
#[cfg(feature = "audio")]
pub use audio_io::{AudioCapture, AudioPlayback};

View 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);
}
}

View File

@@ -32,6 +32,14 @@ pub struct JitterStats {
pub packets_late: u64,
pub packets_duplicate: u64,
pub current_depth: usize,
/// Total frames decoded by the consumer (tracked externally via `record_decode`).
pub total_decoded: u64,
/// Number of times the consumer tried to decode but the buffer was empty/not-ready.
pub underruns: u64,
/// Number of packets dropped because the buffer exceeded max depth.
pub overruns: u64,
/// High water mark — maximum buffer depth observed.
pub max_depth_seen: usize,
}
/// Result of attempting to get the next packet for playout.
@@ -105,6 +113,7 @@ impl JitterBuffer {
while self.buffer.len() > self.max_depth {
if let Some((&oldest_seq, _)) = self.buffer.first_key_value() {
self.buffer.remove(&oldest_seq);
self.stats.overruns += 1;
// Advance playout seq past evicted packet
if seq_before(self.next_playout_seq, oldest_seq.wrapping_add(1)) {
self.next_playout_seq = oldest_seq.wrapping_add(1);
@@ -114,6 +123,9 @@ impl JitterBuffer {
}
self.stats.current_depth = self.buffer.len();
if self.stats.current_depth > self.stats.max_depth_seen {
self.stats.max_depth_seen = self.stats.current_depth;
}
}
/// Get the next packet for playout.
@@ -163,6 +175,24 @@ impl JitterBuffer {
self.stats = JitterStats::default();
}
/// Record that the consumer attempted to decode but the buffer was empty/not-ready.
pub fn record_underrun(&mut self) {
self.stats.underruns += 1;
}
/// Record a successful frame decode by the consumer.
pub fn record_decode(&mut self) {
self.stats.total_decoded += 1;
}
/// Reset statistics counters (preserves buffer contents and playout state).
pub fn reset_stats(&mut self) {
self.stats = JitterStats {
current_depth: self.buffer.len(),
..JitterStats::default()
};
}
/// Adjust target depth based on observed jitter.
pub fn set_target_depth(&mut self, depth: usize) {
self.target_depth = depth.min(self.max_depth);