WZP-P2-T6: Trunking - TrunkFrame/TrunkEntry: pack N session packets into one datagram - Wire format: [count:u16][session_id:2][len:u16][payload]... - TrunkBatcher: batches by count (10) or bytes (1200), flushes on limit - 5 tests: encode/decode roundtrip, empty frame, batcher fill/flush, byte limit WZP-P2-T7: Mini-frames - MiniHeader: 4-byte delta header (timestamp_delta + payload_len) - FRAME_TYPE_FULL (0x00) / FRAME_TYPE_MINI (0x01) discriminator - MiniFrameContext: expands mini-headers to full by tracking baseline - Saves 8 bytes per packet (5 vs 13 bytes with type prefix) - 5 tests: encode/decode, wire size, context expand, no baseline, size comparison WZP-P2-T8: Silence suppression - SilenceDetector: RMS-based detection with hangover (5 frames = 100ms) - ComfortNoise: low-level random noise generator - CodecId::ComfortNoise variant for CN packets - CallEncoder: suppresses silent frames, sends 1-byte CN every 200ms - CallDecoder: generates comfort noise on CN packets - ~50% bandwidth savings in typical conversations - 6 tests: silence/speech detection, hangover, CN generation, RMS math, suppression WZP-P2-T9: Call control signals - SignalMessage: Hold, Unhold, Mute, Unmute, Transfer, TransferAck - CallSignalType mapping in featherchat.rs for all new variants - 4 serde roundtrip tests + signal type mapping tests 255 tests passing across all crates. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
255 lines
8.1 KiB
Rust
255 lines
8.1 KiB
Rust
//! 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),
|
|
..Default::default()
|
|
};
|
|
|
|
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);
|
|
}
|
|
}
|