Files
wz-phone/crates/wzp-client/src/sweep.rs
Siavash Sameni 34cd1017c1 feat: IAX2-inspired protocol improvements — trunking, mini-frames, silence suppression, call control (P2-T6/T7/T8/T9)
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>
2026-03-28 14:13:05 +04:00

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