T1.5: Migrate emit/parse sites to v2 wire format
This commit is contained in:
@@ -86,7 +86,7 @@ struct ParticipantStats {
|
||||
/// Detected lost packets (sequence gaps)
|
||||
lost: u64,
|
||||
/// Last seen sequence number
|
||||
last_seq: u16,
|
||||
last_seq: u32,
|
||||
/// Whether we've seen the first packet (for gap detection)
|
||||
seq_initialized: bool,
|
||||
/// EWMA jitter in ms
|
||||
@@ -181,7 +181,7 @@ impl ParticipantStats {
|
||||
/// distinguish streams by proximity of consecutive sequence numbers.
|
||||
fn find_or_create_participant(
|
||||
participants: &mut Vec<ParticipantStats>,
|
||||
seq: u16,
|
||||
seq: u32,
|
||||
codec: CodecId,
|
||||
) -> usize {
|
||||
for (i, p) in participants.iter().enumerate() {
|
||||
@@ -304,7 +304,7 @@ struct TimelineEntry {
|
||||
#[allow(dead_code)]
|
||||
codec: CodecId,
|
||||
#[allow(dead_code)]
|
||||
seq: u16,
|
||||
seq: u32,
|
||||
#[allow(dead_code)]
|
||||
payload_len: usize,
|
||||
loss_pct: f64,
|
||||
@@ -333,21 +333,25 @@ async fn run_replay(path: &str, args: &Args) -> anyhow::Result<()> {
|
||||
let mut timeline: Vec<TimelineEntry> = Vec::new();
|
||||
|
||||
// Decrypt session from --key (optional)
|
||||
let mut decrypt_session: Option<wzp_crypto::ChaChaSession> = args.key.as_ref().and_then(|hex| {
|
||||
if hex.len() != 64 { return None; }
|
||||
let mut key = [0u8; 32];
|
||||
for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
|
||||
let s = std::str::from_utf8(chunk).unwrap_or("00");
|
||||
key[i] = u8::from_str_radix(s, 16).unwrap_or(0);
|
||||
}
|
||||
Some(wzp_crypto::ChaChaSession::new(key))
|
||||
});
|
||||
let mut decrypt_session: Option<wzp_crypto::ChaChaSession> =
|
||||
args.key.as_ref().and_then(|hex| {
|
||||
if hex.len() != 64 {
|
||||
return None;
|
||||
}
|
||||
let mut key = [0u8; 32];
|
||||
for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
|
||||
let s = std::str::from_utf8(chunk).unwrap_or("00");
|
||||
key[i] = u8::from_str_radix(s, 16).unwrap_or(0);
|
||||
}
|
||||
Some(wzp_crypto::ChaChaSession::new(key))
|
||||
});
|
||||
let mut decrypt_ok: u64 = 0;
|
||||
let mut decrypt_fail: u64 = 0;
|
||||
|
||||
while let Some((ts_us, pkt)) = reader.next_packet()? {
|
||||
let now = Instant::now();
|
||||
let idx = find_or_create_participant(&mut participants, pkt.header.seq, pkt.header.codec_id);
|
||||
let idx =
|
||||
find_or_create_participant(&mut participants, pkt.header.seq, pkt.header.codec_id);
|
||||
participants[idx].ingest(&pkt, now);
|
||||
total_packets += 1;
|
||||
|
||||
@@ -362,8 +366,10 @@ async fn run_replay(path: &str, args: &Args) -> anyhow::Result<()> {
|
||||
if decrypt_ok <= 5 || decrypt_ok % 100 == 0 {
|
||||
eprintln!(
|
||||
" decrypt ok: seq={} codec={:?} payload={}B → plaintext={}B",
|
||||
pkt.header.seq, pkt.header.codec_id,
|
||||
pkt.payload.len(), plaintext.len()
|
||||
pkt.header.seq,
|
||||
pkt.header.codec_id,
|
||||
pkt.payload.len(),
|
||||
plaintext.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -402,7 +408,13 @@ async fn run_replay(path: &str, args: &Args) -> anyhow::Result<()> {
|
||||
|
||||
// Generate HTML if requested
|
||||
if let Some(html_path) = &args.html {
|
||||
generate_html_report(html_path, &participants, &timeline, total_packets, &reader.header)?;
|
||||
generate_html_report(
|
||||
html_path,
|
||||
&participants,
|
||||
&timeline,
|
||||
total_packets,
|
||||
&reader.header,
|
||||
)?;
|
||||
eprintln!("HTML report: {}", html_path);
|
||||
}
|
||||
|
||||
@@ -587,12 +599,12 @@ async fn run_no_tui(
|
||||
w.write_packet(&pkt, now)?;
|
||||
}
|
||||
}
|
||||
Ok(Ok(None)) => break, // connection closed
|
||||
Ok(Ok(None)) => break, // connection closed
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!("recv error: {e}");
|
||||
break;
|
||||
}
|
||||
Err(_) => {} // timeout, loop again
|
||||
Err(_) => {} // timeout, loop again
|
||||
}
|
||||
if print_timer.elapsed() >= Duration::from_secs(2) {
|
||||
print_stats(participants, *total_packets);
|
||||
@@ -603,7 +615,11 @@ async fn run_no_tui(
|
||||
}
|
||||
|
||||
fn print_stats(participants: &[ParticipantStats], total: u64) {
|
||||
eprintln!("--- {} participants | {} total packets ---", participants.len(), total);
|
||||
eprintln!(
|
||||
"--- {} participants | {} total packets ---",
|
||||
participants.len(),
|
||||
total
|
||||
);
|
||||
for p in participants {
|
||||
eprintln!(
|
||||
" {}: {} pkts, {:.1}% loss, {:.0}ms jitter, {:?}, {:.0}s",
|
||||
@@ -693,10 +709,7 @@ async fn run_tui(
|
||||
|
||||
// Always restore terminal, even on error
|
||||
crossterm::terminal::disable_raw_mode()?;
|
||||
crossterm::execute!(
|
||||
std::io::stdout(),
|
||||
crossterm::terminal::LeaveAlternateScreen
|
||||
)?;
|
||||
crossterm::execute!(std::io::stdout(), crossterm::terminal::LeaveAlternateScreen)?;
|
||||
|
||||
result
|
||||
}
|
||||
@@ -723,7 +736,7 @@ fn draw_ui(
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // header
|
||||
Constraint::Min(5), // participant table
|
||||
Constraint::Min(5), // participant table
|
||||
Constraint::Length(3), // footer
|
||||
])
|
||||
.split(f.area());
|
||||
@@ -735,7 +748,11 @@ fn draw_ui(
|
||||
total_packets,
|
||||
elapsed_str
|
||||
))
|
||||
.block(Block::default().borders(Borders::ALL).title(" Protocol Analyzer "));
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Protocol Analyzer "),
|
||||
);
|
||||
f.render_widget(header, chunks[0]);
|
||||
|
||||
// Participant table
|
||||
@@ -780,9 +797,11 @@ fn draw_ui(
|
||||
Constraint::Length(10), // Duration
|
||||
];
|
||||
|
||||
let table = Table::new(rows, widths)
|
||||
.header(header_row)
|
||||
.block(Block::default().borders(Borders::ALL).title(" Participants "));
|
||||
let table = Table::new(rows, widths).header(header_row).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Participants "),
|
||||
);
|
||||
f.render_widget(table, chunks[1]);
|
||||
|
||||
// Footer
|
||||
@@ -832,7 +851,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
let _crypto_session: Option<std::sync::Mutex<wzp_crypto::ChaChaSession>> =
|
||||
if let Some(ref key_hex) = args.key {
|
||||
if key_hex.len() != 64 {
|
||||
eprintln!("Error: --key must be 64 hex characters (32 bytes). Got {} chars.", key_hex.len());
|
||||
eprintln!(
|
||||
"Error: --key must be 64 hex characters (32 bytes). Got {} chars.",
|
||||
key_hex.len()
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
let mut key_bytes = [0u8; 32];
|
||||
@@ -841,9 +863,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
key_bytes[i] = u8::from_str_radix(hex_str, 16).unwrap_or(0);
|
||||
}
|
||||
eprintln!("Encrypted payload decoding enabled (key loaded).");
|
||||
Some(std::sync::Mutex::new(
|
||||
wzp_crypto::ChaChaSession::new(key_bytes),
|
||||
))
|
||||
Some(std::sync::Mutex::new(wzp_crypto::ChaChaSession::new(
|
||||
key_bytes,
|
||||
)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -854,14 +876,12 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
// Live mode requires relay and room
|
||||
let relay = args
|
||||
.relay
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("relay address required for live mode (use --replay for offline)"))?;
|
||||
let room = args
|
||||
.room
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("--room required for live mode (use --replay for offline)"))?;
|
||||
let relay = args.relay.as_deref().ok_or_else(|| {
|
||||
anyhow::anyhow!("relay address required for live mode (use --replay for offline)")
|
||||
})?;
|
||||
let room = args.room.as_deref().ok_or_else(|| {
|
||||
anyhow::anyhow!("--room required for live mode (use --replay for offline)")
|
||||
})?;
|
||||
|
||||
// TLS crypto provider
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
//! Audio callbacks are **lock-free**: they read/write directly to an `AudioRing`
|
||||
//! (atomic SPSC ring buffer). No Mutex, no channel, no allocation on the hot path.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use anyhow::{anyhow, Context};
|
||||
use anyhow::{Context, anyhow};
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use cpal::{SampleFormat, SampleRate, StreamConfig};
|
||||
use tracing::{info, warn};
|
||||
@@ -78,7 +78,10 @@ impl AudioCapture {
|
||||
return;
|
||||
}
|
||||
if !logged.swap(true, Ordering::Relaxed) {
|
||||
eprintln!("[audio] capture callback: {} f32 samples", data.len());
|
||||
eprintln!(
|
||||
"[audio] capture callback: {} f32 samples",
|
||||
data.len()
|
||||
);
|
||||
}
|
||||
let mut tmp = [0i16; FRAME_SAMPLES];
|
||||
for chunk in data.chunks(FRAME_SAMPLES) {
|
||||
@@ -103,7 +106,10 @@ impl AudioCapture {
|
||||
return;
|
||||
}
|
||||
if !logged.swap(true, Ordering::Relaxed) {
|
||||
eprintln!("[audio] capture callback: {} i16 samples", data.len());
|
||||
eprintln!(
|
||||
"[audio] capture callback: {} i16 samples",
|
||||
data.len()
|
||||
);
|
||||
}
|
||||
ring.write(data);
|
||||
},
|
||||
|
||||
@@ -54,13 +54,13 @@
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use anyhow::{anyhow, Context};
|
||||
use anyhow::{Context, anyhow};
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use cpal::{SampleFormat, SampleRate, StreamConfig};
|
||||
use tracing::{info, warn};
|
||||
use webrtc_audio_processing::{
|
||||
Config, EchoCancellation, EchoCancellationSuppressionLevel, InitializationConfig,
|
||||
NoiseSuppression, NoiseSuppressionLevel, Processor, NUM_SAMPLES_PER_FRAME,
|
||||
NUM_SAMPLES_PER_FRAME, NoiseSuppression, NoiseSuppressionLevel, Processor,
|
||||
};
|
||||
|
||||
use crate::audio_ring::AudioRing;
|
||||
@@ -97,8 +97,8 @@ fn get_or_init_processor() -> anyhow::Result<Arc<Mutex<Processor>>> {
|
||||
num_render_channels: APM_NUM_CHANNELS as i32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut processor = Processor::new(&init_config)
|
||||
.map_err(|e| anyhow!("webrtc APM init failed: {e:?}"))?;
|
||||
let mut processor =
|
||||
Processor::new(&init_config).map_err(|e| anyhow!("webrtc APM init failed: {e:?}"))?;
|
||||
|
||||
let config = Config {
|
||||
echo_cancellation: Some(EchoCancellation {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
//! to the speaker, so it can cancel the echo from the mic signal internally.
|
||||
//! This is the same engine FaceTime and other Apple apps use.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use anyhow::Context;
|
||||
use coreaudio::audio_unit::audio_format::LinearPcmFlags;
|
||||
@@ -146,7 +146,8 @@ impl VpioAudio {
|
||||
)
|
||||
.context("failed to set render callback")?;
|
||||
|
||||
au.initialize().context("failed to initialize VoiceProcessingIO")?;
|
||||
au.initialize()
|
||||
.context("failed to initialize VoiceProcessingIO")?;
|
||||
au.start().context("failed to start VoiceProcessingIO")?;
|
||||
|
||||
info!("VoiceProcessingIO started (OS-level AEC enabled)");
|
||||
|
||||
@@ -15,24 +15,24 @@
|
||||
//! `wzp-client`'s lib.rs can transparently re-export either one as
|
||||
//! `AudioCapture`.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use anyhow::{anyhow, Context};
|
||||
use anyhow::{Context, anyhow};
|
||||
use tracing::{info, warn};
|
||||
use windows::core::{Interface, GUID};
|
||||
use windows::Win32::Foundation::{CloseHandle, BOOL, WAIT_OBJECT_0};
|
||||
use windows::Win32::Foundation::{BOOL, CloseHandle, WAIT_OBJECT_0};
|
||||
use windows::Win32::Media::Audio::{
|
||||
eCapture, eCommunications, AudioCategory_Communications, AudioClientProperties,
|
||||
IAudioCaptureClient, IAudioClient, IAudioClient2, IMMDeviceEnumerator, MMDeviceEnumerator,
|
||||
AUDCLNT_SHAREMODE_SHARED, AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM,
|
||||
AUDCLNT_STREAMFLAGS_EVENTCALLBACK, AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY, WAVEFORMATEX,
|
||||
WAVE_FORMAT_PCM,
|
||||
AUDCLNT_STREAMFLAGS_EVENTCALLBACK, AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY,
|
||||
AudioCategory_Communications, AudioClientProperties, IAudioCaptureClient, IAudioClient,
|
||||
IAudioClient2, IMMDeviceEnumerator, MMDeviceEnumerator, WAVE_FORMAT_PCM, WAVEFORMATEX,
|
||||
eCapture, eCommunications,
|
||||
};
|
||||
use windows::Win32::System::Com::{
|
||||
CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_ALL, COINIT_MULTITHREADED,
|
||||
CLSCTX_ALL, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, CoUninitialize,
|
||||
};
|
||||
use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject, INFINITE};
|
||||
use windows::Win32::System::Threading::{CreateEventW, INFINITE, WaitForSingleObject};
|
||||
use windows::core::{GUID, Interface};
|
||||
|
||||
use crate::audio_ring::AudioRing;
|
||||
|
||||
@@ -138,9 +138,8 @@ unsafe fn capture_thread_main(
|
||||
}
|
||||
let _com_guard = ComGuard;
|
||||
|
||||
let enumerator: IMMDeviceEnumerator =
|
||||
CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)
|
||||
.context("CoCreateInstance(MMDeviceEnumerator) failed")?;
|
||||
let enumerator: IMMDeviceEnumerator = CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)
|
||||
.context("CoCreateInstance(MMDeviceEnumerator) failed")?;
|
||||
|
||||
// eCommunications role (not eConsole) — this picks the device the user
|
||||
// has designated for communications in Sound Settings. It's the one
|
||||
@@ -206,12 +205,13 @@ unsafe fn capture_thread_main(
|
||||
&wave_format,
|
||||
Some(&GUID::zeroed()),
|
||||
)
|
||||
.context("IAudioClient::Initialize failed — Windows rejected communications-mode 48k mono i16")?;
|
||||
.context(
|
||||
"IAudioClient::Initialize failed — Windows rejected communications-mode 48k mono i16",
|
||||
)?;
|
||||
|
||||
// Event-driven capture: Windows signals this handle each time a new
|
||||
// audio packet is available. We wait on it from the loop below.
|
||||
let event = CreateEventW(None, false, false, None)
|
||||
.context("CreateEventW failed")?;
|
||||
let event = CreateEventW(None, false, false, None).context("CreateEventW failed")?;
|
||||
audio_client
|
||||
.SetEventHandle(event)
|
||||
.context("SetEventHandle failed")?;
|
||||
@@ -285,10 +285,8 @@ unsafe fn capture_thread_main(
|
||||
// Because we asked for 48 kHz mono i16, each frame is
|
||||
// exactly one i16. Windows's AUTOCONVERTPCM handles the
|
||||
// conversion from whatever the engine mix format is.
|
||||
let samples = std::slice::from_raw_parts(
|
||||
buffer_ptr as *const i16,
|
||||
num_frames as usize,
|
||||
);
|
||||
let samples =
|
||||
std::slice::from_raw_parts(buffer_ptr as *const i16, num_frames as usize);
|
||||
ring.write(samples);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use wzp_crypto::ChaChaSession;
|
||||
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
||||
use wzp_proto::traits::{CryptoSession, FecDecoder, FecEncoder};
|
||||
use wzp_proto::QualityProfile;
|
||||
use wzp_proto::traits::{CryptoSession, FecDecoder, FecEncoder};
|
||||
|
||||
use crate::call::{CallConfig, CallDecoder, CallEncoder};
|
||||
|
||||
@@ -201,9 +201,13 @@ pub fn bench_fec_recovery(loss_pct: f32) -> FecResult {
|
||||
// Deterministic shuffle for reproducibility using a simple seed
|
||||
// We use a basic Fisher-Yates with a fixed-per-block seed
|
||||
let mut indices: Vec<usize> = (0..all_symbols.len()).collect();
|
||||
let mut seed = (block_idx as u64).wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||
let mut seed = (block_idx as u64)
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1);
|
||||
for i in (1..indices.len()).rev() {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
seed = seed
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
let j = (seed >> 33) as usize % (i + 1);
|
||||
indices.swap(i, j);
|
||||
}
|
||||
|
||||
@@ -24,8 +24,14 @@ fn run_codec() {
|
||||
print_header("Codec Roundtrip (Opus 24kbps)");
|
||||
let r = bench::bench_codec_roundtrip();
|
||||
print_row("Frames", &format!("{}", r.frames));
|
||||
print_row("Encode total", &format!("{:.2} ms", r.total_encode.as_secs_f64() * 1000.0));
|
||||
print_row("Decode total", &format!("{:.2} ms", r.total_decode.as_secs_f64() * 1000.0));
|
||||
print_row(
|
||||
"Encode total",
|
||||
&format!("{:.2} ms", r.total_encode.as_secs_f64() * 1000.0),
|
||||
);
|
||||
print_row(
|
||||
"Decode total",
|
||||
&format!("{:.2} ms", r.total_decode.as_secs_f64() * 1000.0),
|
||||
);
|
||||
print_row("Avg encode", &format!("{:.1} us", r.avg_encode_us));
|
||||
print_row("Avg decode", &format!("{:.1} us", r.avg_decode_us));
|
||||
print_row("Throughput", &format!("{:.0} frames/sec", r.frames_per_sec));
|
||||
@@ -41,7 +47,10 @@ fn run_fec(loss_pct: f32) {
|
||||
print_row("Recovery rate", &format!("{:.1}%", r.recovery_rate_pct));
|
||||
print_row("Source bytes", &format!("{}", r.total_source_bytes));
|
||||
print_row("Repair (overhead) bytes", &format!("{}", r.overhead_bytes));
|
||||
print_row("Total time", &format!("{:.2} ms", r.total_time.as_secs_f64() * 1000.0));
|
||||
print_row(
|
||||
"Total time",
|
||||
&format!("{:.2} ms", r.total_time.as_secs_f64() * 1000.0),
|
||||
);
|
||||
print_footer();
|
||||
}
|
||||
|
||||
@@ -49,7 +58,10 @@ fn run_crypto() {
|
||||
print_header("Crypto (ChaCha20-Poly1305)");
|
||||
let r = bench::bench_encrypt_decrypt();
|
||||
print_row("Packets", &format!("{}", r.packets));
|
||||
print_row("Total time", &format!("{:.2} ms", r.total_time.as_secs_f64() * 1000.0));
|
||||
print_row(
|
||||
"Total time",
|
||||
&format!("{:.2} ms", r.total_time.as_secs_f64() * 1000.0),
|
||||
);
|
||||
print_row("Throughput", &format!("{:.0} pkt/sec", r.packets_per_sec));
|
||||
print_row("Bandwidth", &format!("{:.2} MB/sec", r.megabytes_per_sec));
|
||||
print_row("Avg latency", &format!("{:.2} us", r.avg_latency_us));
|
||||
@@ -60,9 +72,18 @@ fn run_pipeline() {
|
||||
print_header("Full Pipeline (E2E)");
|
||||
let r = bench::bench_full_pipeline();
|
||||
print_row("Frames", &format!("{}", r.frames));
|
||||
print_row("Encode pipeline", &format!("{:.2} ms", r.total_encode_pipeline.as_secs_f64() * 1000.0));
|
||||
print_row("Decode pipeline", &format!("{:.2} ms", r.total_decode_pipeline.as_secs_f64() * 1000.0));
|
||||
print_row("Avg E2E latency", &format!("{:.1} us/frame", r.avg_e2e_latency_us));
|
||||
print_row(
|
||||
"Encode pipeline",
|
||||
&format!("{:.2} ms", r.total_encode_pipeline.as_secs_f64() * 1000.0),
|
||||
);
|
||||
print_row(
|
||||
"Decode pipeline",
|
||||
&format!("{:.2} ms", r.total_decode_pipeline.as_secs_f64() * 1000.0),
|
||||
);
|
||||
print_row(
|
||||
"Avg E2E latency",
|
||||
&format!("{:.1} us/frame", r.avg_e2e_latency_us),
|
||||
);
|
||||
print_row("PCM in", &format!("{} bytes", r.pcm_bytes_in));
|
||||
print_row("Wire out", &format!("{} bytes", r.wire_bytes_out));
|
||||
print_row("Overhead ratio", &format!("{:.3}x", r.overhead_ratio));
|
||||
|
||||
@@ -165,10 +165,7 @@ pub fn generate_dialer_targets(
|
||||
|
||||
// First: all known ports (guaranteed targets)
|
||||
for &port in known_ports {
|
||||
targets.push(SocketAddr::new(
|
||||
std::net::IpAddr::V4(acceptor_ip),
|
||||
port,
|
||||
));
|
||||
targets.push(SocketAddr::new(std::net::IpAddr::V4(acceptor_ip), port));
|
||||
}
|
||||
|
||||
// Fill remaining with random ports (birthday attack)
|
||||
@@ -178,10 +175,7 @@ pub fn generate_dialer_targets(
|
||||
let mut rng = rand::thread_rng();
|
||||
for _ in 0..remaining {
|
||||
let port = rng.gen_range(1024..=65535u16);
|
||||
let addr = SocketAddr::new(
|
||||
std::net::IpAddr::V4(acceptor_ip),
|
||||
port,
|
||||
);
|
||||
let addr = SocketAddr::new(std::net::IpAddr::V4(acceptor_ip), port);
|
||||
if !targets.contains(&addr) {
|
||||
targets.push(addr);
|
||||
}
|
||||
@@ -339,7 +333,10 @@ mod tests {
|
||||
fn acceptor_ports_serializes() {
|
||||
let result = AcceptorPorts {
|
||||
external_ip: Some(Ipv4Addr::new(203, 0, 113, 5)),
|
||||
ports: vec![PortMapping { local_port: 12345, external_port: 54321 }],
|
||||
ports: vec![PortMapping {
|
||||
local_port: 12345,
|
||||
external_port: 54321,
|
||||
}],
|
||||
attempted: 32,
|
||||
succeeded: 1,
|
||||
};
|
||||
|
||||
@@ -13,11 +13,11 @@ use wzp_codec::{
|
||||
};
|
||||
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
||||
use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
|
||||
use wzp_proto::packet::QualityReport;
|
||||
use wzp_proto::packet::{MediaHeader, MediaPacket, MiniFrameContext};
|
||||
use wzp_proto::quality::AdaptiveQualityController;
|
||||
use wzp_proto::traits::{AudioDecoder, AudioEncoder, FecDecoder, FecEncoder};
|
||||
use wzp_proto::packet::QualityReport;
|
||||
use wzp_proto::{CodecId, QualityProfile};
|
||||
use wzp_proto::{CodecId, MediaType, QualityProfile};
|
||||
|
||||
/// Configuration for a call session.
|
||||
pub struct CallConfig {
|
||||
@@ -205,7 +205,7 @@ pub struct CallEncoder {
|
||||
/// Current profile.
|
||||
profile: QualityProfile,
|
||||
/// Outbound sequence counter.
|
||||
seq: u16,
|
||||
seq: u32,
|
||||
/// Current FEC block.
|
||||
block_id: u8,
|
||||
/// Frame index within current block.
|
||||
@@ -318,17 +318,15 @@ impl CallEncoder {
|
||||
if self.cn_counter % 10 == 0 {
|
||||
let cn_pkt = MediaPacket {
|
||||
header: MediaHeader {
|
||||
version: 0,
|
||||
is_repair: false,
|
||||
version: 2,
|
||||
flags: 0,
|
||||
media_type: MediaType::Audio,
|
||||
codec_id: CodecId::ComfortNoise,
|
||||
has_quality_report: false,
|
||||
fec_ratio_encoded: 0,
|
||||
stream_id: 0,
|
||||
fec_ratio: 0,
|
||||
seq: self.seq,
|
||||
timestamp: self.timestamp_ms,
|
||||
fec_block: self.block_id,
|
||||
fec_symbol: 0,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
fec_block: u16::from(self.block_id),
|
||||
},
|
||||
payload: Bytes::from(vec![self.cn_level as u8]),
|
||||
quality_report: None,
|
||||
@@ -354,30 +352,31 @@ impl CallEncoder {
|
||||
// can cleanly identify "no RaptorQ block to assemble" and new
|
||||
// receivers can short-circuit their FEC ingest path.
|
||||
let is_opus = self.profile.codec.is_opus();
|
||||
let (fec_block, fec_symbol, fec_ratio_encoded) = if is_opus {
|
||||
(0u8, 0u8, 0u8)
|
||||
let (fec_block, fec_ratio) = if is_opus {
|
||||
(0u16, 0u8)
|
||||
} else {
|
||||
(
|
||||
self.block_id,
|
||||
self.frame_in_block,
|
||||
u16::from(self.block_id) | (u16::from(self.frame_in_block) << 8),
|
||||
MediaHeader::encode_fec_ratio(self.profile.fec_ratio),
|
||||
)
|
||||
};
|
||||
|
||||
// Build source media packet
|
||||
let mut flags = 0u8;
|
||||
if self.pending_quality_report.is_some() {
|
||||
flags |= MediaHeader::FLAG_QUALITY;
|
||||
}
|
||||
let source_pkt = MediaPacket {
|
||||
header: MediaHeader {
|
||||
version: 0,
|
||||
is_repair: false,
|
||||
version: 2,
|
||||
flags,
|
||||
media_type: MediaType::Audio,
|
||||
codec_id: self.profile.codec,
|
||||
has_quality_report: self.pending_quality_report.is_some(),
|
||||
fec_ratio_encoded,
|
||||
stream_id: 0,
|
||||
fec_ratio,
|
||||
seq: self.seq,
|
||||
timestamp: self.timestamp_ms,
|
||||
fec_block,
|
||||
fec_symbol,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
},
|
||||
payload: Bytes::from(encoded.clone()),
|
||||
quality_report: self.pending_quality_report.take(),
|
||||
@@ -402,19 +401,15 @@ impl CallEncoder {
|
||||
for (sym_idx, repair_data) in repairs {
|
||||
output.push(MediaPacket {
|
||||
header: MediaHeader {
|
||||
version: 0,
|
||||
is_repair: true,
|
||||
version: 2,
|
||||
flags: MediaHeader::FLAG_REPAIR,
|
||||
media_type: MediaType::Audio,
|
||||
codec_id: self.profile.codec,
|
||||
has_quality_report: false,
|
||||
fec_ratio_encoded: MediaHeader::encode_fec_ratio(
|
||||
self.profile.fec_ratio,
|
||||
),
|
||||
stream_id: 0,
|
||||
fec_ratio: MediaHeader::encode_fec_ratio(self.profile.fec_ratio),
|
||||
seq: self.seq,
|
||||
timestamp: self.timestamp_ms,
|
||||
fec_block: self.block_id,
|
||||
fec_symbol: sym_idx,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
fec_block: u16::from(self.block_id) | (u16::from(sym_idx) << 8),
|
||||
},
|
||||
payload: Bytes::from(repair_data),
|
||||
quality_report: None,
|
||||
@@ -508,7 +503,7 @@ pub struct CallDecoder {
|
||||
last_good_dred: DredState,
|
||||
/// Sequence number of the packet that produced `last_good_dred`. `None`
|
||||
/// if no packet has yielded DRED state yet (cold start or legacy sender).
|
||||
last_good_dred_seq: Option<u16>,
|
||||
last_good_dred_seq: Option<u32>,
|
||||
/// Phase 4 telemetry counter: gaps recovered via DRED reconstruction.
|
||||
pub dred_reconstructions: u64,
|
||||
/// Phase 4 telemetry counter: gaps filled via classical Opus PLC
|
||||
@@ -570,9 +565,9 @@ impl CallDecoder {
|
||||
// ignored — a graceful mixed-version degradation).
|
||||
if !packet.header.codec_id.is_opus() {
|
||||
let _ = self.fec_dec.add_symbol(
|
||||
packet.header.fec_block,
|
||||
packet.header.fec_symbol,
|
||||
packet.header.is_repair,
|
||||
(packet.header.fec_block & 0xFF) as u8,
|
||||
(packet.header.fec_block >> 8) as u8,
|
||||
packet.header.is_repair(),
|
||||
&packet.payload,
|
||||
);
|
||||
}
|
||||
@@ -582,7 +577,7 @@ impl CallDecoder {
|
||||
// swap with the cached `last_good_dred` so later gap reconstruction
|
||||
// has fresh neural redundancy to draw from. Parsing happens before
|
||||
// the jitter push because the jitter buffer consumes the packet.
|
||||
if packet.header.codec_id.is_opus() && !packet.header.is_repair {
|
||||
if packet.header.codec_id.is_opus() && !packet.header.is_repair() {
|
||||
match self
|
||||
.dred_decoder
|
||||
.parse_into(&mut self.dred_parse_scratch, &packet.payload)
|
||||
@@ -611,7 +606,7 @@ impl CallDecoder {
|
||||
// Source packets (Opus or Codec2) go to the jitter buffer for decode.
|
||||
// Repair packets never reach the jitter buffer; for Codec2 they're
|
||||
// used by the FEC decoder above, for Opus they're dropped here.
|
||||
if !packet.header.is_repair {
|
||||
if !packet.header.is_repair() {
|
||||
self.jitter.push(packet);
|
||||
}
|
||||
}
|
||||
@@ -711,12 +706,12 @@ impl CallDecoder {
|
||||
if let Some(last_seq) = self.last_good_dred_seq {
|
||||
// How many frames ahead of the missing seq is the
|
||||
// last-good packet? Use wrapping arithmetic for the
|
||||
// u16 seq space.
|
||||
// u32 seq space.
|
||||
let seq_delta = last_seq.wrapping_sub(seq);
|
||||
// Reject stale or backward state. u16 wraparound
|
||||
// Reject stale or backward state. u32 wraparound
|
||||
// would make a "seq went backward" delta very large;
|
||||
// cap at a sane forward-looking window.
|
||||
const MAX_SEQ_DELTA: u16 = 128;
|
||||
const MAX_SEQ_DELTA: u32 = 128;
|
||||
if seq_delta > 0 && seq_delta <= MAX_SEQ_DELTA {
|
||||
let frame_samples =
|
||||
(48_000 * self.profile.frame_duration_ms as i32) / 1000;
|
||||
@@ -785,7 +780,7 @@ impl CallDecoder {
|
||||
/// Phase 3b introspection: sequence number of the most recently parsed
|
||||
/// valid DRED state, or `None` if no Opus packet has yielded DRED data
|
||||
/// yet. Used by tests to debug reconstruction eligibility.
|
||||
pub fn last_good_dred_seq(&self) -> Option<u16> {
|
||||
pub fn last_good_dred_seq(&self) -> Option<u32> {
|
||||
self.last_good_dred_seq
|
||||
}
|
||||
|
||||
@@ -852,7 +847,7 @@ mod tests {
|
||||
let packets = enc.encode_frame(&pcm).unwrap();
|
||||
assert!(!packets.is_empty());
|
||||
assert_eq!(packets[0].header.seq, 0);
|
||||
assert!(!packets[0].header.is_repair);
|
||||
assert!(!packets[0].header.is_repair());
|
||||
}
|
||||
|
||||
/// Phase 2: Opus packets have zero FEC header fields — no block, no
|
||||
@@ -875,10 +870,9 @@ mod tests {
|
||||
assert_eq!(packets.len(), 1, "Opus must emit exactly 1 source packet");
|
||||
let hdr = &packets[0].header;
|
||||
assert!(hdr.codec_id.is_opus());
|
||||
assert!(!hdr.is_repair);
|
||||
assert!(!hdr.is_repair());
|
||||
assert_eq!(hdr.fec_block, 0, "Opus fec_block must be 0");
|
||||
assert_eq!(hdr.fec_symbol, 0, "Opus fec_symbol must be 0");
|
||||
assert_eq!(hdr.fec_ratio_encoded, 0, "Opus fec_ratio_encoded must be 0");
|
||||
assert_eq!(hdr.fec_ratio, 0, "Opus fec_ratio must be 0");
|
||||
}
|
||||
|
||||
/// Phase 2: Opus never emits repair packets, regardless of how many
|
||||
@@ -902,7 +896,7 @@ mod tests {
|
||||
for _ in 0..20 {
|
||||
let packets = enc.encode_frame(&pcm).unwrap();
|
||||
total_packets += packets.len();
|
||||
repair_count += packets.iter().filter(|p| p.header.is_repair).count();
|
||||
repair_count += packets.iter().filter(|p| p.header.is_repair()).count();
|
||||
}
|
||||
assert_eq!(repair_count, 0, "Opus must emit zero repair packets");
|
||||
assert_eq!(
|
||||
@@ -934,7 +928,7 @@ mod tests {
|
||||
for _ in 0..16 {
|
||||
let packets = enc.encode_frame(&pcm).unwrap();
|
||||
for p in &packets {
|
||||
if p.header.is_repair {
|
||||
if p.header.is_repair() {
|
||||
repair_count += 1;
|
||||
}
|
||||
}
|
||||
@@ -953,17 +947,15 @@ mod tests {
|
||||
|
||||
let pkt = MediaPacket {
|
||||
header: MediaHeader {
|
||||
version: 0,
|
||||
is_repair: false,
|
||||
version: 2,
|
||||
flags: 0,
|
||||
media_type: MediaType::Audio,
|
||||
codec_id: CodecId::Opus24k,
|
||||
has_quality_report: false,
|
||||
fec_ratio_encoded: 0,
|
||||
stream_id: 0,
|
||||
fec_ratio: 0,
|
||||
seq: 0,
|
||||
timestamp: 0,
|
||||
fec_block: 0,
|
||||
fec_symbol: 0,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
},
|
||||
payload: Bytes::from(vec![0u8; 60]),
|
||||
quality_report: None,
|
||||
@@ -1025,17 +1017,15 @@ mod tests {
|
||||
encoded.truncate(n);
|
||||
let pkt = MediaPacket {
|
||||
header: MediaHeader {
|
||||
version: 0,
|
||||
is_repair: false,
|
||||
version: 2,
|
||||
flags: 0,
|
||||
media_type: MediaType::Audio,
|
||||
codec_id: CodecId::Opus24k,
|
||||
has_quality_report: false,
|
||||
fec_ratio_encoded: 0,
|
||||
seq: i,
|
||||
stream_id: 0,
|
||||
fec_ratio: 0,
|
||||
seq: i as u32,
|
||||
timestamp: (i as u32) * 20,
|
||||
fec_block: 0,
|
||||
fec_symbol: 0,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
},
|
||||
payload: Bytes::from(encoded),
|
||||
quality_report: None,
|
||||
@@ -1105,9 +1095,7 @@ mod tests {
|
||||
|
||||
let dred_delta = dec.dred_reconstructions - baseline_dred;
|
||||
let plc_delta = dec.classical_plc_invocations - baseline_plc;
|
||||
eprintln!(
|
||||
"[phase3b probe] post-drain: dred_delta={dred_delta} plc_delta={plc_delta}"
|
||||
);
|
||||
eprintln!("[phase3b probe] post-drain: dred_delta={dred_delta} plc_delta={plc_delta}");
|
||||
assert!(
|
||||
dred_delta >= 1,
|
||||
"expected ≥1 DRED reconstruction on single-packet loss, \
|
||||
@@ -1168,7 +1156,7 @@ mod tests {
|
||||
let packets = enc.encode_frame(&pcm).unwrap();
|
||||
for pkt in packets {
|
||||
// Drop every 5th source packet to simulate loss.
|
||||
if !pkt.header.is_repair && i % 5 == 3 {
|
||||
if !pkt.header.is_repair() && i % 5 == 3 {
|
||||
continue;
|
||||
}
|
||||
dec.ingest(pkt);
|
||||
@@ -1322,20 +1310,18 @@ mod tests {
|
||||
|
||||
// ---- JitterStats telemetry tests ----
|
||||
|
||||
fn make_test_packet(seq: u16) -> MediaPacket {
|
||||
fn make_test_packet(seq: u32) -> MediaPacket {
|
||||
MediaPacket {
|
||||
header: MediaHeader {
|
||||
version: 0,
|
||||
is_repair: false,
|
||||
version: 2,
|
||||
flags: 0,
|
||||
media_type: MediaType::Audio,
|
||||
codec_id: CodecId::Opus24k,
|
||||
has_quality_report: false,
|
||||
fec_ratio_encoded: 0,
|
||||
stream_id: 0,
|
||||
fec_ratio: 0,
|
||||
seq,
|
||||
timestamp: seq as u32 * 20,
|
||||
timestamp: seq * 20,
|
||||
fec_block: 0,
|
||||
fec_symbol: seq as u8,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
},
|
||||
payload: Bytes::from(vec![0u8; 60]),
|
||||
quality_report: None,
|
||||
@@ -1347,7 +1333,7 @@ mod tests {
|
||||
let config = CallConfig::default();
|
||||
let mut dec = CallDecoder::new(&config);
|
||||
|
||||
for i in 0..5u16 {
|
||||
for i in 0..5u32 {
|
||||
dec.ingest(make_test_packet(i));
|
||||
}
|
||||
|
||||
@@ -1377,7 +1363,7 @@ mod tests {
|
||||
let mut dec = CallDecoder::new(&config);
|
||||
|
||||
// Generate some stats: ingest packets and trigger underruns on empty buffer
|
||||
for i in 0..3u16 {
|
||||
for i in 0..3u32 {
|
||||
dec.ingest(make_test_packet(i));
|
||||
}
|
||||
// Also call decode on empty decoder to get underruns
|
||||
@@ -1456,10 +1442,7 @@ mod tests {
|
||||
cn_packets >= 1,
|
||||
"should have at least one CN packet, got {cn_packets}"
|
||||
);
|
||||
assert!(
|
||||
enc.frames_suppressed > 0,
|
||||
"frames_suppressed should be > 0"
|
||||
);
|
||||
assert!(enc.frames_suppressed > 0, "frames_suppressed should be > 0");
|
||||
}
|
||||
|
||||
// ---- DredTuner integration tests ----
|
||||
@@ -1506,7 +1489,10 @@ mod tests {
|
||||
// Verify the encoder still works after tuning.
|
||||
let pcm = voice_frame_20ms(0);
|
||||
let packets = enc.encode_frame(&pcm).unwrap();
|
||||
assert!(!packets.is_empty(), "encoder must still produce packets after DRED tuning");
|
||||
assert!(
|
||||
!packets.is_empty(),
|
||||
"encoder must still produce packets after DRED tuning"
|
||||
);
|
||||
}
|
||||
|
||||
/// DredTuner jitter spike triggers pre-emptive DRED boost to ceiling.
|
||||
@@ -1524,11 +1510,15 @@ mod tests {
|
||||
|
||||
// Jitter spikes to 40ms (8x baseline of ~5ms).
|
||||
let tuning = tuner.update(0.0, 50, 40);
|
||||
assert!(tuner.spike_boost_active(), "jitter spike should activate boost");
|
||||
assert!(
|
||||
tuner.spike_boost_active(),
|
||||
"jitter spike should activate boost"
|
||||
);
|
||||
assert!(tuning.is_some());
|
||||
// Ceiling for Opus24k is 50 frames = 500 ms.
|
||||
assert_eq!(
|
||||
tuning.unwrap().dred_frames, 50,
|
||||
tuning.unwrap().dred_frames,
|
||||
50,
|
||||
"spike should push to ceiling"
|
||||
);
|
||||
}
|
||||
@@ -1604,12 +1594,18 @@ mod tests {
|
||||
let pcm = voice_frame_20ms(0);
|
||||
let packets = enc.encode_frame(&pcm).unwrap();
|
||||
assert!(!packets.is_empty());
|
||||
assert!(packets[0].header.has_quality_report, "first packet should have quality report");
|
||||
assert!(
|
||||
packets[0].header.has_quality(),
|
||||
"first packet should have quality report"
|
||||
);
|
||||
assert!(packets[0].quality_report.is_some());
|
||||
|
||||
// Next frame should NOT have quality_report (it was consumed)
|
||||
let packets2 = enc.encode_frame(&voice_frame_20ms(960)).unwrap();
|
||||
assert!(!packets2[0].header.has_quality_report, "second packet should not have quality report");
|
||||
assert!(
|
||||
!packets2[0].header.has_quality(),
|
||||
"second packet should not have quality report"
|
||||
);
|
||||
assert!(packets2[0].quality_report.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,11 @@ fn parse_args() -> CliArgs {
|
||||
"--signal" => signal = true,
|
||||
"--call" => {
|
||||
i += 1;
|
||||
call_target = Some(args.get(i).expect("--call requires a fingerprint").to_string());
|
||||
call_target = Some(
|
||||
args.get(i)
|
||||
.expect("--call requires a fingerprint")
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
"--send-tone" => {
|
||||
i += 1;
|
||||
@@ -185,8 +189,12 @@ fn parse_args() -> CliArgs {
|
||||
);
|
||||
}
|
||||
"--sweep" => sweep = true,
|
||||
"--netcheck" => { netcheck = true; }
|
||||
"--version-check" => { version_check = true; }
|
||||
"--netcheck" => {
|
||||
netcheck = true;
|
||||
}
|
||||
"--version-check" => {
|
||||
version_check = true;
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
eprintln!("Usage: wzp-client [options] [relay-addr]");
|
||||
eprintln!();
|
||||
@@ -197,13 +205,19 @@ fn parse_args() -> CliArgs {
|
||||
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!(
|
||||
" --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)");
|
||||
eprintln!(" --token <token> featherChat bearer token for relay auth");
|
||||
eprintln!(" --metrics-file <path> Write JSONL telemetry to file (1 line/sec)");
|
||||
eprintln!(" (48kHz mono s16le, play with ffplay -f s16le -ar 48000 -ch_layout mono file.raw)");
|
||||
eprintln!(
|
||||
" (48kHz mono s16le, play with ffplay -f s16le -ar 48000 -ch_layout mono file.raw)"
|
||||
);
|
||||
eprintln!();
|
||||
eprintln!("Default relay: 127.0.0.1:4433");
|
||||
std::process::exit(0);
|
||||
@@ -265,9 +279,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
if cli.netcheck {
|
||||
let config = wzp_client::netcheck::NetcheckConfig {
|
||||
stun_config: wzp_client::stun::StunConfig::default(),
|
||||
relays: vec![
|
||||
("relay".into(), cli.relay_addr),
|
||||
],
|
||||
relays: vec![("relay".into(), cli.relay_addr)],
|
||||
timeout: std::time::Duration::from_secs(5),
|
||||
test_portmap: true,
|
||||
test_ipv6: true,
|
||||
@@ -283,7 +295,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
let client_config = wzp_transport::client_config();
|
||||
let bind_addr: SocketAddr = "0.0.0.0:0".parse()?;
|
||||
let endpoint = wzp_transport::create_endpoint(bind_addr, None)?;
|
||||
let conn = wzp_transport::connect(&endpoint, cli.relay_addr, "version", client_config).await?;
|
||||
let conn =
|
||||
wzp_transport::connect(&endpoint, cli.relay_addr, "version", client_config).await?;
|
||||
match conn.accept_uni().await {
|
||||
Ok(mut recv) => {
|
||||
let data = recv.read_to_end(256).await.unwrap_or_default();
|
||||
@@ -291,7 +304,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
println!("{} {}", cli.relay_addr, version.trim());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("relay {} does not support version query: {e}", cli.relay_addr);
|
||||
eprintln!(
|
||||
"relay {} does not support version query: {e}",
|
||||
cli.relay_addr
|
||||
);
|
||||
}
|
||||
}
|
||||
endpoint.close(0u32.into(), b"done");
|
||||
@@ -331,8 +347,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
"0.0.0.0:0".parse()?
|
||||
};
|
||||
let endpoint = wzp_transport::create_endpoint(bind_addr, None)?;
|
||||
let connection =
|
||||
wzp_transport::connect(&endpoint, cli.relay_addr, &sni, client_config).await?;
|
||||
let connection = wzp_transport::connect(&endpoint, cli.relay_addr, &sni, client_config).await?;
|
||||
|
||||
info!("Connected to relay");
|
||||
|
||||
@@ -343,10 +358,12 @@ async fn main() -> anyhow::Result<()> {
|
||||
{
|
||||
let shutdown_transport = transport.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
.expect("failed to register SIGTERM handler");
|
||||
let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
|
||||
.expect("failed to register SIGINT handler");
|
||||
let mut sigterm =
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
.expect("failed to register SIGTERM handler");
|
||||
let mut sigint =
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
|
||||
.expect("failed to register SIGINT handler");
|
||||
tokio::select! {
|
||||
_ = sigterm.recv() => { info!("SIGTERM received, closing connection..."); }
|
||||
_ = sigint.recv() => { info!("SIGINT received, closing connection..."); }
|
||||
@@ -354,7 +371,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
// Close the QUIC connection immediately (APPLICATION_CLOSE frame).
|
||||
// Don't call process::exit — let the main task detect the closed
|
||||
// connection and perform clean shutdown (e.g., save recordings).
|
||||
shutdown_transport.connection().close(0u32.into(), b"shutdown");
|
||||
shutdown_transport
|
||||
.connection()
|
||||
.close(0u32.into(), b"shutdown");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -372,7 +391,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
&*transport,
|
||||
&seed.0,
|
||||
None, // alias — desktop client doesn't set one yet
|
||||
).await?;
|
||||
)
|
||||
.await?;
|
||||
info!("crypto handshake complete");
|
||||
|
||||
if cli.live {
|
||||
@@ -382,7 +402,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
{
|
||||
anyhow::bail!("--live requires the 'audio' feature (build with: cargo build --features audio)");
|
||||
anyhow::bail!(
|
||||
"--live requires the 'audio' feature (build with: cargo build --features audio)"
|
||||
);
|
||||
}
|
||||
} else if let Some(secs) = cli.echo_test_secs {
|
||||
let result = wzp_client::echo_test::run_echo_test(&*transport, secs, 5.0).await?;
|
||||
@@ -399,7 +421,13 @@ async fn main() -> anyhow::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
|
||||
run_file_mode(
|
||||
transport,
|
||||
cli.send_tone_secs,
|
||||
cli.send_file,
|
||||
cli.record_file,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
run_silence(transport).await
|
||||
}
|
||||
@@ -420,7 +448,7 @@ async fn run_silence(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::R
|
||||
for i in 0..250u32 {
|
||||
let packets = encoder.encode_frame(&pcm)?;
|
||||
for pkt in &packets {
|
||||
if pkt.header.is_repair {
|
||||
if pkt.header.is_repair() {
|
||||
total_repair += 1;
|
||||
} else {
|
||||
total_source += 1;
|
||||
@@ -470,21 +498,28 @@ async fn run_file_mode(
|
||||
// Read raw PCM file (48kHz mono s16le)
|
||||
let bytes = match std::fs::read(path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => { error!("read {path}: {e}"); return; }
|
||||
Err(e) => {
|
||||
error!("read {path}: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let samples: Vec<i16> = bytes.chunks_exact(2)
|
||||
let samples: Vec<i16> = bytes
|
||||
.chunks_exact(2)
|
||||
.map(|c| i16::from_le_bytes([c[0], c[1]]))
|
||||
.collect();
|
||||
let duration = samples.len() as f64 / 48_000.0;
|
||||
info!(file = %path, duration = format!("{:.1}s", duration), "sending audio file");
|
||||
samples.chunks(FRAME_SAMPLES)
|
||||
samples
|
||||
.chunks(FRAME_SAMPLES)
|
||||
.filter(|c| c.len() == FRAME_SAMPLES)
|
||||
.map(|c| c.to_vec())
|
||||
.collect()
|
||||
} else if let Some(secs) = send_tone_secs {
|
||||
let total = (secs as u64) * 50;
|
||||
info!(seconds = secs, frames = total, "sending 440Hz tone");
|
||||
(0..total).map(|i| generate_sine_frame(440.0, 48_000, i)).collect()
|
||||
(0..total)
|
||||
.map(|i| generate_sine_frame(440.0, 48_000, i))
|
||||
.collect()
|
||||
} else {
|
||||
// No sending, just wait
|
||||
tokio::signal::ctrl_c().await.ok();
|
||||
@@ -508,7 +543,7 @@ async fn run_file_mode(
|
||||
}
|
||||
};
|
||||
for pkt in &packets {
|
||||
if pkt.header.is_repair {
|
||||
if pkt.header.is_repair() {
|
||||
total_repair += 1;
|
||||
} else {
|
||||
total_source += 1;
|
||||
@@ -556,7 +591,7 @@ async fn run_file_mode(
|
||||
result = recv_transport.recv_media() => {
|
||||
match result {
|
||||
Ok(Some(pkt)) => {
|
||||
let is_repair = pkt.header.is_repair;
|
||||
let is_repair = pkt.header.is_repair();
|
||||
decoder.ingest(pkt);
|
||||
if !is_repair {
|
||||
if let Some(n) = decoder.decode_next(&mut pcm_buf) {
|
||||
@@ -756,22 +791,30 @@ async fn run_signal_mode(
|
||||
|
||||
// Auth if token provided
|
||||
if let Some(ref tok) = token {
|
||||
transport.send_signal(&SignalMessage::AuthToken { token: tok.clone() }).await?;
|
||||
transport
|
||||
.send_signal(&SignalMessage::AuthToken { token: tok.clone() })
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Register presence (signature not verified in Phase 1)
|
||||
transport.send_signal(&SignalMessage::RegisterPresence {
|
||||
identity_pub,
|
||||
signature: vec![], // Phase 1: not verified
|
||||
alias: None,
|
||||
}).await?;
|
||||
transport
|
||||
.send_signal(&SignalMessage::RegisterPresence {
|
||||
identity_pub,
|
||||
signature: vec![], // Phase 1: not verified
|
||||
alias: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Wait for ack
|
||||
match transport.recv_signal().await? {
|
||||
Some(SignalMessage::RegisterPresenceAck { success: true, .. }) => {
|
||||
info!(fingerprint = %fp, "registered on relay — waiting for calls");
|
||||
}
|
||||
Some(SignalMessage::RegisterPresenceAck { success: false, error, .. }) => {
|
||||
Some(SignalMessage::RegisterPresenceAck {
|
||||
success: false,
|
||||
error,
|
||||
..
|
||||
}) => {
|
||||
anyhow::bail!("registration failed: {}", error.unwrap_or_default());
|
||||
}
|
||||
other => {
|
||||
@@ -782,25 +825,32 @@ async fn run_signal_mode(
|
||||
// If --call specified, place the call
|
||||
if let Some(ref target) = call_target {
|
||||
info!(target = %target, "placing direct call...");
|
||||
let call_id = format!("{:016x}", std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos());
|
||||
let call_id = format!(
|
||||
"{:016x}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
);
|
||||
|
||||
transport.send_signal(&SignalMessage::DirectCallOffer {
|
||||
caller_fingerprint: fp.clone(),
|
||||
caller_alias: None,
|
||||
target_fingerprint: target.clone(),
|
||||
call_id: call_id.clone(),
|
||||
identity_pub,
|
||||
ephemeral_pub: [0u8; 32], // Phase 1: not used for key exchange
|
||||
signature: vec![],
|
||||
supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
|
||||
// CLI client doesn't attempt hole-punching; always
|
||||
// relay-path.
|
||||
caller_reflexive_addr: None,
|
||||
caller_local_addrs: Vec::new(),
|
||||
caller_mapped_addr: None,
|
||||
caller_build_version: None,
|
||||
}).await?;
|
||||
transport
|
||||
.send_signal(&SignalMessage::DirectCallOffer {
|
||||
caller_fingerprint: fp.clone(),
|
||||
caller_alias: None,
|
||||
target_fingerprint: target.clone(),
|
||||
call_id: call_id.clone(),
|
||||
identity_pub,
|
||||
ephemeral_pub: [0u8; 32], // Phase 1: not used for key exchange
|
||||
signature: vec![],
|
||||
supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
|
||||
// CLI client doesn't attempt hole-punching; always
|
||||
// relay-path.
|
||||
caller_reflexive_addr: None,
|
||||
caller_local_addrs: Vec::new(),
|
||||
caller_mapped_addr: None,
|
||||
caller_build_version: None,
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Signal recv loop — handle incoming signals
|
||||
@@ -814,7 +864,12 @@ async fn run_signal_mode(
|
||||
SignalMessage::CallRinging { call_id } => {
|
||||
info!(call_id = %call_id, "ringing...");
|
||||
}
|
||||
SignalMessage::DirectCallOffer { caller_fingerprint, caller_alias, call_id, .. } => {
|
||||
SignalMessage::DirectCallOffer {
|
||||
caller_fingerprint,
|
||||
caller_alias,
|
||||
call_id,
|
||||
..
|
||||
} => {
|
||||
info!(
|
||||
from = %caller_fingerprint,
|
||||
alias = ?caller_alias,
|
||||
@@ -822,25 +877,38 @@ async fn run_signal_mode(
|
||||
"incoming call — auto-accepting (generic)"
|
||||
);
|
||||
// Auto-accept for CLI testing
|
||||
let _ = signal_transport.send_signal(&SignalMessage::DirectCallAnswer {
|
||||
call_id,
|
||||
accept_mode: wzp_proto::CallAcceptMode::AcceptGeneric,
|
||||
identity_pub: Some(identity_pub),
|
||||
ephemeral_pub: None,
|
||||
signature: None,
|
||||
chosen_profile: Some(wzp_proto::QualityProfile::GOOD),
|
||||
// CLI auto-accept uses generic (privacy) mode,
|
||||
// so callee addr stays hidden from the caller.
|
||||
callee_reflexive_addr: None,
|
||||
callee_local_addrs: Vec::new(),
|
||||
callee_mapped_addr: None,
|
||||
callee_build_version: None,
|
||||
}).await;
|
||||
let _ = signal_transport
|
||||
.send_signal(&SignalMessage::DirectCallAnswer {
|
||||
call_id,
|
||||
accept_mode: wzp_proto::CallAcceptMode::AcceptGeneric,
|
||||
identity_pub: Some(identity_pub),
|
||||
ephemeral_pub: None,
|
||||
signature: None,
|
||||
chosen_profile: Some(wzp_proto::QualityProfile::GOOD),
|
||||
// CLI auto-accept uses generic (privacy) mode,
|
||||
// so callee addr stays hidden from the caller.
|
||||
callee_reflexive_addr: None,
|
||||
callee_local_addrs: Vec::new(),
|
||||
callee_mapped_addr: None,
|
||||
callee_build_version: None,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
SignalMessage::DirectCallAnswer { call_id, accept_mode, .. } => {
|
||||
SignalMessage::DirectCallAnswer {
|
||||
call_id,
|
||||
accept_mode,
|
||||
..
|
||||
} => {
|
||||
info!(call_id = %call_id, mode = ?accept_mode, "call answered");
|
||||
}
|
||||
SignalMessage::CallSetup { call_id, room, relay_addr: setup_relay, peer_direct_addr: _, peer_local_addrs: _, peer_mapped_addr: _ } => {
|
||||
SignalMessage::CallSetup {
|
||||
call_id,
|
||||
room,
|
||||
relay_addr: setup_relay,
|
||||
peer_direct_addr: _,
|
||||
peer_local_addrs: _,
|
||||
peer_mapped_addr: _,
|
||||
} => {
|
||||
info!(call_id = %call_id, room = %room, relay = %setup_relay, "call setup — connecting to media room");
|
||||
|
||||
// Connect to the media room
|
||||
@@ -848,18 +916,28 @@ async fn run_signal_mode(
|
||||
let media_cfg = wzp_transport::client_config();
|
||||
match wzp_transport::connect(&endpoint, media_relay, &room, media_cfg).await {
|
||||
Ok(media_conn) => {
|
||||
let media_transport = Arc::new(wzp_transport::QuinnTransport::new(media_conn));
|
||||
let media_transport =
|
||||
Arc::new(wzp_transport::QuinnTransport::new(media_conn));
|
||||
|
||||
// Crypto handshake
|
||||
match wzp_client::handshake::perform_handshake(&*media_transport, &my_seed, None).await {
|
||||
match wzp_client::handshake::perform_handshake(
|
||||
&*media_transport,
|
||||
&my_seed,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_session) => {
|
||||
info!("media connected — sending tone (press Ctrl+C to hang up)");
|
||||
info!(
|
||||
"media connected — sending tone (press Ctrl+C to hang up)"
|
||||
);
|
||||
|
||||
// Simple tone sender for testing
|
||||
let mt = media_transport.clone();
|
||||
let send_task = tokio::spawn(async move {
|
||||
let config = wzp_client::call::CallConfig::default();
|
||||
let mut encoder = wzp_client::call::CallEncoder::new(&config);
|
||||
let mut encoder =
|
||||
wzp_client::call::CallEncoder::new(&config);
|
||||
let duration = tokio::time::Duration::from_millis(20);
|
||||
loop {
|
||||
let pcm: Vec<i16> = (0..FRAME_SAMPLES)
|
||||
@@ -867,7 +945,9 @@ async fn run_signal_mode(
|
||||
.collect();
|
||||
if let Ok(pkts) = encoder.encode_frame(&pcm) {
|
||||
for pkt in &pkts {
|
||||
if mt.send_media(pkt).await.is_err() { return; }
|
||||
if mt.send_media(pkt).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(duration).await;
|
||||
|
||||
@@ -144,7 +144,7 @@ pub async fn run_drift_test(
|
||||
}
|
||||
match tokio::time::timeout(Duration::from_millis(2), transport.recv_media()).await {
|
||||
Ok(Ok(Some(pkt))) => {
|
||||
let is_repair = pkt.header.is_repair;
|
||||
let is_repair = pkt.header.is_repair();
|
||||
decoder.ingest(pkt);
|
||||
if !is_repair {
|
||||
if let Some(_n) = decoder.decode_next(&mut pcm_buf) {
|
||||
@@ -180,7 +180,7 @@ pub async fn run_drift_test(
|
||||
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;
|
||||
let is_repair = pkt.header.is_repair();
|
||||
decoder.ingest(pkt);
|
||||
if !is_repair {
|
||||
if let Some(_n) = decoder.decode_next(&mut pcm_buf) {
|
||||
@@ -234,7 +234,10 @@ pub fn print_drift_report(result: &DriftResult) {
|
||||
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!(
|
||||
"Drift: {} ms ({:+.4}%)",
|
||||
result.drift_ms, result.drift_pct
|
||||
);
|
||||
println!();
|
||||
|
||||
// Interpretation
|
||||
@@ -246,9 +249,15 @@ pub fn print_drift_report(result: &DriftResult) {
|
||||
} 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);
|
||||
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!(
|
||||
"Result: POOR -- significant drift ({} ms). Investigate clock sources.",
|
||||
abs_drift
|
||||
);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ pub enum WinningPath {
|
||||
pub struct CandidateDiag {
|
||||
pub index: usize,
|
||||
pub addr: String,
|
||||
pub result: String, // "ok", "skipped:ipv6", "error:..."
|
||||
pub result: String, // "ok", "skipped:ipv6", "error:..."
|
||||
pub elapsed_ms: Option<u32>,
|
||||
}
|
||||
|
||||
@@ -299,10 +299,16 @@ pub async fn race(
|
||||
socket2::Domain::IPV4,
|
||||
socket2::Type::DGRAM,
|
||||
Some(socket2::Protocol::UDP),
|
||||
).map_err(|e| format!("socket: {e}"))?;
|
||||
sock.set_reuse_address(true).map_err(|e| format!("reuseaddr: {e}"))?;
|
||||
)
|
||||
.map_err(|e| format!("socket: {e}"))?;
|
||||
sock.set_reuse_address(true)
|
||||
.map_err(|e| format!("reuseaddr: {e}"))?;
|
||||
// macOS/BSD/Linux also need SO_REUSEPORT
|
||||
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "android"))]
|
||||
#[cfg(any(
|
||||
target_os = "macos",
|
||||
target_os = "linux",
|
||||
target_os = "android"
|
||||
))]
|
||||
{
|
||||
// socket2 exposes set_reuse_port on unix
|
||||
unsafe {
|
||||
@@ -316,12 +322,14 @@ pub async fn race(
|
||||
);
|
||||
}
|
||||
}
|
||||
sock.set_nonblocking(true).map_err(|e| format!("nonblock: {e}"))?;
|
||||
sock.set_nonblocking(true)
|
||||
.map_err(|e| format!("nonblock: {e}"))?;
|
||||
let bind_addr: SocketAddr = SocketAddr::new(
|
||||
std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
|
||||
local_addr.port(),
|
||||
);
|
||||
sock.bind(&bind_addr.into()).map_err(|e| format!("bind :{}: {e}", local_addr.port()))?;
|
||||
sock.bind(&bind_addr.into())
|
||||
.map_err(|e| format!("bind :{}: {e}", local_addr.port()))?;
|
||||
let std_sock: StdUdpSocket = sock.into();
|
||||
for addr in &tickle_addrs {
|
||||
let _ = std_sock.send_to(&[0u8; 1], addr);
|
||||
@@ -469,13 +477,8 @@ pub async fn race(
|
||||
candidate_idx = idx,
|
||||
"dual_path: dialing candidate"
|
||||
);
|
||||
let result = wzp_transport::connect(
|
||||
&ep,
|
||||
candidate,
|
||||
&sni,
|
||||
client_cfg,
|
||||
)
|
||||
.await;
|
||||
let result =
|
||||
wzp_transport::connect(&ep, candidate, &sni, client_cfg).await;
|
||||
let elapsed = start.elapsed().as_millis() as u32;
|
||||
let diag_result = match &result {
|
||||
Ok(_) => "ok".to_string(),
|
||||
@@ -604,9 +607,7 @@ pub async fn race(
|
||||
"dual_path: racing direct vs relay"
|
||||
);
|
||||
|
||||
let mut direct_task = tokio::spawn(
|
||||
tokio::time::timeout(Duration::from_secs(4), direct_fut),
|
||||
);
|
||||
let mut direct_task = tokio::spawn(tokio::time::timeout(Duration::from_secs(4), direct_fut));
|
||||
let mut relay_task = tokio::spawn(async move {
|
||||
// Keep the 500ms head start so direct has a chance
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
@@ -695,8 +696,12 @@ pub async fn race(
|
||||
// If it doesn't, we still proceed with just the winner.
|
||||
if direct_result.is_none() {
|
||||
match tokio::time::timeout(Duration::from_secs(1), direct_task).await {
|
||||
Ok(Ok(Ok(Ok(t)))) => { direct_result = Some(Ok(t)); }
|
||||
Ok(Ok(Ok(Err(e)))) => { direct_result = Some(Err(anyhow::anyhow!("{e}"))); }
|
||||
Ok(Ok(Ok(Ok(t)))) => {
|
||||
direct_result = Some(Ok(t));
|
||||
}
|
||||
Ok(Ok(Ok(Err(e)))) => {
|
||||
direct_result = Some(Err(anyhow::anyhow!("{e}")));
|
||||
}
|
||||
_ => {
|
||||
direct_result = Some(Err(anyhow::anyhow!("direct: no result in grace period")));
|
||||
// Fill timeout diags for candidates that never reported.
|
||||
@@ -719,9 +724,15 @@ pub async fn race(
|
||||
}
|
||||
if relay_result.is_none() {
|
||||
match tokio::time::timeout(Duration::from_secs(1), relay_task).await {
|
||||
Ok(Ok(Ok(Ok(t)))) => { relay_result = Some(Ok(t)); }
|
||||
Ok(Ok(Ok(Err(e)))) => { relay_result = Some(Err(anyhow::anyhow!("{e}"))); }
|
||||
_ => { relay_result = Some(Err(anyhow::anyhow!("relay: no result in grace period"))); }
|
||||
Ok(Ok(Ok(Ok(t)))) => {
|
||||
relay_result = Some(Ok(t));
|
||||
}
|
||||
Ok(Ok(Ok(Err(e)))) => {
|
||||
relay_result = Some(Err(anyhow::anyhow!("{e}")));
|
||||
}
|
||||
_ => {
|
||||
relay_result = Some(Err(anyhow::anyhow!("relay: no result in grace period")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -736,22 +747,21 @@ pub async fn race(
|
||||
);
|
||||
|
||||
if !direct_ok && !relay_ok {
|
||||
return Err(anyhow::anyhow!("both paths failed: no media transport available"));
|
||||
return Err(anyhow::anyhow!(
|
||||
"both paths failed: no media transport available"
|
||||
));
|
||||
}
|
||||
|
||||
let _ = (direct_ep, relay_ep, ipv6_endpoint);
|
||||
|
||||
let candidate_diags = diags_collector.lock()
|
||||
let candidate_diags = diags_collector
|
||||
.lock()
|
||||
.map(|d| d.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(RaceResult {
|
||||
direct_transport: direct_result
|
||||
.and_then(|r| r.ok())
|
||||
.map(|t| Arc::new(t)),
|
||||
relay_transport: relay_result
|
||||
.and_then(|r| r.ok())
|
||||
.map(|t| Arc::new(t)),
|
||||
direct_transport: direct_result.and_then(|r| r.ok()).map(|t| Arc::new(t)),
|
||||
relay_transport: relay_result.and_then(|r| r.ok()).map(|t| Arc::new(t)),
|
||||
local_winner,
|
||||
candidate_diags,
|
||||
})
|
||||
@@ -777,7 +787,10 @@ mod tests {
|
||||
assert_eq!(order.len(), 4);
|
||||
assert_eq!(order[0], "192.168.1.10:4433".parse::<SocketAddr>().unwrap());
|
||||
assert_eq!(order[1], "10.0.0.5:4433".parse::<SocketAddr>().unwrap());
|
||||
assert_eq!(order[2], "198.51.100.42:12345".parse::<SocketAddr>().unwrap());
|
||||
assert_eq!(
|
||||
order[2],
|
||||
"198.51.100.42:12345".parse::<SocketAddr>().unwrap()
|
||||
);
|
||||
assert_eq!(order[3], "203.0.113.5:4433".parse::<SocketAddr>().unwrap());
|
||||
}
|
||||
|
||||
@@ -805,7 +818,10 @@ mod tests {
|
||||
|
||||
let order = candidates.dial_order();
|
||||
assert_eq!(order.len(), 1);
|
||||
assert_eq!(order[0], "198.51.100.42:12345".parse::<SocketAddr>().unwrap());
|
||||
assert_eq!(
|
||||
order[0],
|
||||
"198.51.100.42:12345".parse::<SocketAddr>().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -166,7 +166,7 @@ pub async fn run_echo_test(
|
||||
match tokio::time::timeout(Duration::from_millis(2), transport.recv_media()).await {
|
||||
Ok(Ok(Some(pkt))) => {
|
||||
total_packets_received += 1;
|
||||
let is_repair = pkt.header.is_repair;
|
||||
let is_repair = pkt.header.is_repair();
|
||||
decoder.ingest(pkt);
|
||||
if !is_repair {
|
||||
if let Some(n) = decoder.decode_next(&mut pcm_buf) {
|
||||
@@ -184,7 +184,8 @@ pub async fn run_echo_test(
|
||||
let time_offset = start.elapsed().as_secs_f64();
|
||||
|
||||
// Compare sent vs received for this window
|
||||
let sent_start = (window_idx as u64 * frames_per_window * FRAME_SAMPLES as u64) as usize;
|
||||
let sent_start =
|
||||
(window_idx as u64 * frames_per_window * FRAME_SAMPLES as u64) as usize;
|
||||
let sent_end = sent_start + (window_frames_sent as usize * FRAME_SAMPLES);
|
||||
let sent_window = if sent_end <= sent_pcm.len() {
|
||||
&sent_pcm[sent_start..sent_end]
|
||||
@@ -192,7 +193,9 @@ pub async fn run_echo_test(
|
||||
&sent_pcm[sent_start..]
|
||||
};
|
||||
|
||||
let recv_start = recv_pcm.len().saturating_sub(window_frames_received as usize * FRAME_SAMPLES);
|
||||
let recv_start = recv_pcm
|
||||
.len()
|
||||
.saturating_sub(window_frames_received as usize * FRAME_SAMPLES);
|
||||
let recv_window = &recv_pcm[recv_start..];
|
||||
|
||||
let peak = recv_window.iter().map(|s| s.abs()).max().unwrap_or(0);
|
||||
@@ -256,7 +259,7 @@ pub async fn run_echo_test(
|
||||
match tokio::time::timeout(Duration::from_millis(100), transport.recv_media()).await {
|
||||
Ok(Ok(Some(pkt))) => {
|
||||
total_packets_received += 1;
|
||||
let is_repair = pkt.header.is_repair;
|
||||
let is_repair = pkt.header.is_repair();
|
||||
decoder.ingest(pkt);
|
||||
if !is_repair {
|
||||
decoder.decode_next(&mut pcm_buf);
|
||||
@@ -310,8 +313,14 @@ pub fn print_report(result: &EchoTestResult) {
|
||||
let status = if w.is_silent { " !" } else { " " };
|
||||
println!(
|
||||
"│ {:>3}{} │ {:>5.1}s │ {:>4} │ {:>4} │ {:>5.1}% │ {:>5.1} │ {:.3} │",
|
||||
w.index, status, w.time_offset_secs, w.frames_sent, w.frames_received,
|
||||
w.loss_pct, w.snr_db, w.correlation
|
||||
w.index,
|
||||
status,
|
||||
w.time_offset_secs,
|
||||
w.frames_sent,
|
||||
w.frames_received,
|
||||
w.loss_pct,
|
||||
w.snr_db,
|
||||
w.correlation
|
||||
);
|
||||
}
|
||||
println!("└───────┴─────────┴──────┴──────┴─────────┴───────┴───────┘");
|
||||
@@ -321,18 +330,28 @@ pub fn print_report(result: &EchoTestResult) {
|
||||
let first_half: Vec<_> = result.windows[..result.windows.len() / 2].to_vec();
|
||||
let second_half: Vec<_> = result.windows[result.windows.len() / 2..].to_vec();
|
||||
|
||||
let avg_loss_first = first_half.iter().map(|w| w.loss_pct).sum::<f32>() / first_half.len() as f32;
|
||||
let avg_loss_second = second_half.iter().map(|w| w.loss_pct).sum::<f32>() / second_half.len() as f32;
|
||||
let avg_corr_first = first_half.iter().map(|w| w.correlation).sum::<f32>() / first_half.len() as f32;
|
||||
let avg_corr_second = second_half.iter().map(|w| w.correlation).sum::<f32>() / second_half.len() as f32;
|
||||
let avg_loss_first =
|
||||
first_half.iter().map(|w| w.loss_pct).sum::<f32>() / first_half.len() as f32;
|
||||
let avg_loss_second =
|
||||
second_half.iter().map(|w| w.loss_pct).sum::<f32>() / second_half.len() as f32;
|
||||
let avg_corr_first =
|
||||
first_half.iter().map(|w| w.correlation).sum::<f32>() / first_half.len() as f32;
|
||||
let avg_corr_second =
|
||||
second_half.iter().map(|w| w.correlation).sum::<f32>() / second_half.len() as f32;
|
||||
|
||||
println!();
|
||||
if avg_loss_second > avg_loss_first + 5.0 {
|
||||
println!("WARNING: Quality degradation detected!");
|
||||
println!(" Loss increased from {:.1}% to {:.1}% over time", avg_loss_first, avg_loss_second);
|
||||
println!(
|
||||
" Loss increased from {:.1}% to {:.1}% over time",
|
||||
avg_loss_first, avg_loss_second
|
||||
);
|
||||
}
|
||||
if avg_corr_second < avg_corr_first - 0.1 {
|
||||
println!("WARNING: Signal correlation dropped from {:.3} to {:.3}", avg_corr_first, avg_corr_second);
|
||||
println!(
|
||||
"WARNING: Signal correlation dropped from {:.3} to {:.3}",
|
||||
avg_corr_first, avg_corr_second
|
||||
);
|
||||
}
|
||||
if avg_loss_second <= avg_loss_first + 5.0 && avg_corr_second >= avg_corr_first - 0.1 {
|
||||
println!("Quality is STABLE over the test duration.");
|
||||
|
||||
@@ -118,14 +118,14 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
|
||||
SignalMessage::DirectCallAnswer { .. } => CallSignalType::Answer,
|
||||
SignalMessage::CallSetup { .. } => CallSignalType::Offer, // relay-only
|
||||
SignalMessage::CallRinging { .. } => CallSignalType::Ringing,
|
||||
SignalMessage::RegisterPresence { .. }
|
||||
| SignalMessage::RegisterPresenceAck { .. } => CallSignalType::Offer, // relay-only
|
||||
SignalMessage::RegisterPresence { .. } | SignalMessage::RegisterPresenceAck { .. } => {
|
||||
CallSignalType::Offer
|
||||
} // relay-only
|
||||
// NAT reflection is a client↔relay control exchange that
|
||||
// never crosses the featherChat bridge — if it ever reaches
|
||||
// this mapper something is wrong, but we still have to give
|
||||
// an answer. "Offer" is the generic catch-all.
|
||||
SignalMessage::Reflect
|
||||
| SignalMessage::ReflectResponse { .. } => CallSignalType::Offer, // control-plane
|
||||
SignalMessage::Reflect | SignalMessage::ReflectResponse { .. } => CallSignalType::Offer, // control-plane
|
||||
// Phase 4 cross-relay forwarding envelope — strictly a
|
||||
// relay-to-relay message, never rides the featherChat
|
||||
// bridge. Catch-all mapping for completeness.
|
||||
@@ -181,17 +181,35 @@ mod tests {
|
||||
reason: wzp_proto::HangupReason::Normal,
|
||||
call_id: None,
|
||||
};
|
||||
assert!(matches!(signal_to_call_type(&hangup), CallSignalType::Hangup));
|
||||
assert!(matches!(
|
||||
signal_to_call_type(&hangup),
|
||||
CallSignalType::Hangup
|
||||
));
|
||||
|
||||
assert!(matches!(signal_to_call_type(&SignalMessage::Hold), CallSignalType::Hold));
|
||||
assert!(matches!(signal_to_call_type(&SignalMessage::Unhold), CallSignalType::Unhold));
|
||||
assert!(matches!(signal_to_call_type(&SignalMessage::Mute), CallSignalType::Mute));
|
||||
assert!(matches!(signal_to_call_type(&SignalMessage::Unmute), CallSignalType::Unmute));
|
||||
assert!(matches!(
|
||||
signal_to_call_type(&SignalMessage::Hold),
|
||||
CallSignalType::Hold
|
||||
));
|
||||
assert!(matches!(
|
||||
signal_to_call_type(&SignalMessage::Unhold),
|
||||
CallSignalType::Unhold
|
||||
));
|
||||
assert!(matches!(
|
||||
signal_to_call_type(&SignalMessage::Mute),
|
||||
CallSignalType::Mute
|
||||
));
|
||||
assert!(matches!(
|
||||
signal_to_call_type(&SignalMessage::Unmute),
|
||||
CallSignalType::Unmute
|
||||
));
|
||||
|
||||
let transfer = SignalMessage::Transfer {
|
||||
target_fingerprint: "abc".to_string(),
|
||||
relay_addr: None,
|
||||
};
|
||||
assert!(matches!(signal_to_call_type(&transfer), CallSignalType::Transfer));
|
||||
assert!(matches!(
|
||||
signal_to_call_type(&transfer),
|
||||
CallSignalType::Transfer
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,21 +55,21 @@ pub async fn perform_handshake(
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("connection closed before receiving CallAnswer"))?;
|
||||
|
||||
let (callee_identity_pub, callee_ephemeral_pub, callee_signature, _chosen_profile) = match answer
|
||||
{
|
||||
SignalMessage::CallAnswer {
|
||||
identity_pub,
|
||||
ephemeral_pub,
|
||||
signature,
|
||||
chosen_profile,
|
||||
} => (identity_pub, ephemeral_pub, signature, chosen_profile),
|
||||
other => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"expected CallAnswer, got {:?}",
|
||||
std::mem::discriminant(&other)
|
||||
))
|
||||
}
|
||||
};
|
||||
let (callee_identity_pub, callee_ephemeral_pub, callee_signature, _chosen_profile) =
|
||||
match answer {
|
||||
SignalMessage::CallAnswer {
|
||||
identity_pub,
|
||||
ephemeral_pub,
|
||||
signature,
|
||||
chosen_profile,
|
||||
} => (identity_pub, ephemeral_pub, signature, chosen_profile),
|
||||
other => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"expected CallAnswer, got {:?}",
|
||||
std::mem::discriminant(&other)
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// 6. Verify callee's signature over (ephemeral_pub || "call-answer")
|
||||
let mut verify_data = Vec::with_capacity(32 + 11);
|
||||
|
||||
@@ -106,14 +106,9 @@ impl IceAgent {
|
||||
);
|
||||
|
||||
let reflexive = stun_result.ok().and_then(|r| r.ok());
|
||||
let mapped = portmap_result
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|m| m.external_addr);
|
||||
let local = reflect::local_host_candidates(
|
||||
self.config.local_v4_port,
|
||||
self.config.local_v6_port,
|
||||
);
|
||||
let mapped = portmap_result.ok().flatten().map(|m| m.external_addr);
|
||||
let local =
|
||||
reflect::local_host_candidates(self.config.local_v4_port, self.config.local_v6_port);
|
||||
|
||||
tracing::info!(
|
||||
generation,
|
||||
@@ -151,10 +146,7 @@ impl IceAgent {
|
||||
/// Process a peer's candidate update. Returns `Some(PeerCandidates)`
|
||||
/// if the update is newer than the last-seen generation, `None`
|
||||
/// if it's stale.
|
||||
pub fn apply_peer_update(
|
||||
&self,
|
||||
update: &SignalMessage,
|
||||
) -> Option<PeerCandidates> {
|
||||
pub fn apply_peer_update(&self, update: &SignalMessage) -> Option<PeerCandidates> {
|
||||
let (reflexive_addr, local_addrs, mapped_addr, generation) = match update {
|
||||
SignalMessage::CandidateUpdate {
|
||||
reflexive_addr,
|
||||
@@ -177,16 +169,9 @@ impl IceAgent {
|
||||
return None;
|
||||
}
|
||||
|
||||
let reflexive = reflexive_addr
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse().ok());
|
||||
let local: Vec<SocketAddr> = local_addrs
|
||||
.iter()
|
||||
.filter_map(|s| s.parse().ok())
|
||||
.collect();
|
||||
let mapped = mapped_addr
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse().ok());
|
||||
let reflexive = reflexive_addr.as_deref().and_then(|s| s.parse().ok());
|
||||
let local: Vec<SocketAddr> = local_addrs.iter().filter_map(|s| s.parse().ok()).collect();
|
||||
let mapped = mapped_addr.as_deref().and_then(|s| s.parse().ok());
|
||||
|
||||
tracing::info!(
|
||||
generation,
|
||||
@@ -304,10 +289,7 @@ mod tests {
|
||||
let update = SignalMessage::CandidateUpdate {
|
||||
call_id: "test-call".into(),
|
||||
reflexive_addr: Some("203.0.113.5:4433".into()),
|
||||
local_addrs: vec![
|
||||
"192.168.1.10:4433".into(),
|
||||
"10.0.0.5:4433".into(),
|
||||
],
|
||||
local_addrs: vec!["192.168.1.10:4433".into(), "10.0.0.5:4433".into()],
|
||||
mapped_addr: Some("198.51.100.42:12345".into()),
|
||||
generation: 1,
|
||||
};
|
||||
@@ -382,16 +364,19 @@ mod tests {
|
||||
async fn gather_returns_candidates_even_with_no_stun() {
|
||||
// With default config (port 0 = no portmap, STUN will timeout
|
||||
// quickly on loopback), gather should still return host candidates.
|
||||
let agent = IceAgent::new("test".into(), IceAgentConfig {
|
||||
stun_config: stun::StunConfig {
|
||||
servers: vec![], // no servers = quick failure
|
||||
timeout: Duration::from_millis(100),
|
||||
let agent = IceAgent::new(
|
||||
"test".into(),
|
||||
IceAgentConfig {
|
||||
stun_config: stun::StunConfig {
|
||||
servers: vec![], // no servers = quick failure
|
||||
timeout: Duration::from_millis(100),
|
||||
},
|
||||
enable_portmap: false,
|
||||
gather_timeout: Duration::from_millis(200),
|
||||
local_v4_port: 12345,
|
||||
local_v6_port: None,
|
||||
},
|
||||
enable_portmap: false,
|
||||
gather_timeout: Duration::from_millis(200),
|
||||
local_v4_port: 12345,
|
||||
local_v6_port: None,
|
||||
});
|
||||
);
|
||||
|
||||
let candidates = agent.gather().await;
|
||||
assert_eq!(candidates.generation, 0);
|
||||
@@ -405,16 +390,19 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn re_gather_produces_signal_message() {
|
||||
let agent = IceAgent::new("call-42".into(), IceAgentConfig {
|
||||
stun_config: stun::StunConfig {
|
||||
servers: vec![],
|
||||
timeout: Duration::from_millis(50),
|
||||
let agent = IceAgent::new(
|
||||
"call-42".into(),
|
||||
IceAgentConfig {
|
||||
stun_config: stun::StunConfig {
|
||||
servers: vec![],
|
||||
timeout: Duration::from_millis(50),
|
||||
},
|
||||
enable_portmap: false,
|
||||
gather_timeout: Duration::from_millis(100),
|
||||
local_v4_port: 4433,
|
||||
local_v6_port: None,
|
||||
},
|
||||
enable_portmap: false,
|
||||
gather_timeout: Duration::from_millis(100),
|
||||
local_v4_port: 4433,
|
||||
local_v6_port: None,
|
||||
});
|
||||
);
|
||||
|
||||
let (candidates, signal) = agent.re_gather().await;
|
||||
assert_eq!(candidates.generation, 0);
|
||||
|
||||
@@ -27,15 +27,15 @@ pub mod audio_wasapi;
|
||||
#[cfg(all(feature = "linux-aec", target_os = "linux"))]
|
||||
pub mod audio_linux_aec;
|
||||
pub mod bench;
|
||||
pub mod birthday;
|
||||
pub mod call;
|
||||
pub mod drift_test;
|
||||
pub mod dual_path;
|
||||
pub mod echo_test;
|
||||
pub mod featherchat;
|
||||
pub mod handshake;
|
||||
pub mod dual_path;
|
||||
pub mod metrics;
|
||||
pub mod birthday;
|
||||
pub mod ice_agent;
|
||||
pub mod metrics;
|
||||
pub mod netcheck;
|
||||
pub mod portmap;
|
||||
pub mod reflect;
|
||||
|
||||
@@ -178,7 +178,10 @@ mod tests {
|
||||
|
||||
// Immediate second write should be skipped (60s interval).
|
||||
let second = writer.maybe_write(&snap).unwrap();
|
||||
assert!(!second, "second write should be skipped — interval not elapsed");
|
||||
assert!(
|
||||
!second,
|
||||
"second write should be skipped — interval not elapsed"
|
||||
);
|
||||
|
||||
// Clean up.
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
@@ -112,22 +112,30 @@ pub async fn run_netcheck(config: &NetcheckConfig) -> NetcheckReport {
|
||||
let ipv6_fut = test_ipv6(config.test_ipv6, config.timeout);
|
||||
let port_alloc_fut = stun::detect_port_allocation(&config.stun_config);
|
||||
|
||||
let (stun_probes, relay_latencies, portmap_result, gateway_result, ipv6_reachable, port_alloc_result) =
|
||||
tokio::join!(stun_fut, relay_fut, portmap_fut, gateway_result_fut(gateway_fut), ipv6_fut, port_alloc_fut);
|
||||
let (
|
||||
stun_probes,
|
||||
relay_latencies,
|
||||
portmap_result,
|
||||
gateway_result,
|
||||
ipv6_reachable,
|
||||
port_alloc_result,
|
||||
) = tokio::join!(
|
||||
stun_fut,
|
||||
relay_fut,
|
||||
portmap_fut,
|
||||
gateway_result_fut(gateway_fut),
|
||||
ipv6_fut,
|
||||
port_alloc_fut
|
||||
);
|
||||
|
||||
// Classify NAT from STUN probes.
|
||||
let (nat_type, consensus_addr) = reflect::classify_nat(&stun_probes);
|
||||
|
||||
// Determine STUN latency (first successful probe).
|
||||
let stun_latency_ms = stun_probes
|
||||
.iter()
|
||||
.filter_map(|p| p.latency_ms)
|
||||
.min();
|
||||
let stun_latency_ms = stun_probes.iter().filter_map(|p| p.latency_ms).min();
|
||||
|
||||
// IPv4 reachable if any STUN probe succeeded.
|
||||
let ipv4_reachable = stun_probes
|
||||
.iter()
|
||||
.any(|p| p.observed_addr.is_some());
|
||||
let ipv4_reachable = stun_probes.iter().any(|p| p.observed_addr.is_some());
|
||||
|
||||
// Preferred relay = lowest RTT.
|
||||
let preferred_relay = relay_latencies
|
||||
@@ -176,10 +184,7 @@ pub async fn run_netcheck(config: &NetcheckConfig) -> NetcheckReport {
|
||||
}
|
||||
|
||||
/// Probe relay latencies via reflect.
|
||||
async fn probe_relays(
|
||||
relays: &[(String, SocketAddr)],
|
||||
timeout: Duration,
|
||||
) -> Vec<RelayLatency> {
|
||||
async fn probe_relays(relays: &[(String, SocketAddr)], timeout: Duration) -> Vec<RelayLatency> {
|
||||
if relays.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -223,10 +228,7 @@ async fn probe_relays(
|
||||
}
|
||||
|
||||
/// Attempt port mapping and return the mapping if successful.
|
||||
async fn probe_portmap(
|
||||
enabled: bool,
|
||||
local_port: u16,
|
||||
) -> Option<portmap::PortMapping> {
|
||||
async fn probe_portmap(enabled: bool, local_port: u16) -> Option<portmap::PortMapping> {
|
||||
if !enabled || local_port == 0 {
|
||||
return None;
|
||||
}
|
||||
@@ -251,7 +253,9 @@ async fn test_ipv6(enabled: bool, timeout: Duration) -> bool {
|
||||
let sock = tokio::net::UdpSocket::bind("[::]:0").await.ok()?;
|
||||
// Try Google's IPv6 STUN — if DNS resolves to an AAAA record
|
||||
// and we can send a packet, IPv6 is working.
|
||||
let addr = stun::resolve_stun_server("stun.l.google.com:19302").await.ok()?;
|
||||
let addr = stun::resolve_stun_server("stun.l.google.com:19302")
|
||||
.await
|
||||
.ok()?;
|
||||
if addr.is_ipv6() {
|
||||
sock.send_to(&[0u8; 1], addr).await.ok()?;
|
||||
Some(true)
|
||||
@@ -276,10 +280,7 @@ pub fn format_report(report: &NetcheckReport) -> String {
|
||||
let mut out = String::new();
|
||||
|
||||
out.push_str(&format!("=== WarzonePhone Netcheck ===\n\n"));
|
||||
out.push_str(&format!(
|
||||
"NAT Type: {:?}\n",
|
||||
report.nat_type
|
||||
));
|
||||
out.push_str(&format!("NAT Type: {:?}\n", report.nat_type));
|
||||
out.push_str(&format!(
|
||||
"Reflexive Addr: {}\n",
|
||||
report.reflexive_addr.as_deref().unwrap_or("(unknown)")
|
||||
@@ -298,15 +299,17 @@ pub fn format_report(report: &NetcheckReport) -> String {
|
||||
));
|
||||
|
||||
if let Some(ref alloc) = report.port_allocation {
|
||||
out.push_str(&format!(
|
||||
"Port Alloc: {alloc}\n"
|
||||
));
|
||||
out.push_str(&format!("Port Alloc: {alloc}\n"));
|
||||
}
|
||||
|
||||
out.push_str(&format!("\n--- Port Mapping ---\n"));
|
||||
out.push_str(&format!(
|
||||
"NAT-PMP: {} PCP: {} UPnP: {}\n",
|
||||
if report.nat_pmp_available { "yes" } else { "no" },
|
||||
if report.nat_pmp_available {
|
||||
"yes"
|
||||
} else {
|
||||
"no"
|
||||
},
|
||||
if report.pcp_available { "yes" } else { "no" },
|
||||
if report.upnp_available { "yes" } else { "no" },
|
||||
));
|
||||
@@ -321,8 +324,13 @@ pub fn format_report(report: &NetcheckReport) -> String {
|
||||
" {} → {} ({}ms){}\n",
|
||||
p.relay_name,
|
||||
p.observed_addr.as_deref().unwrap_or("failed"),
|
||||
p.latency_ms.map(|ms| ms.to_string()).unwrap_or_else(|| "-".into()),
|
||||
p.error.as_ref().map(|e| format!(" [{e}]")).unwrap_or_default(),
|
||||
p.latency_ms
|
||||
.map(|ms| ms.to_string())
|
||||
.unwrap_or_else(|| "-".into()),
|
||||
p.error
|
||||
.as_ref()
|
||||
.map(|e| format!(" [{e}]"))
|
||||
.unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -334,8 +342,13 @@ pub fn format_report(report: &NetcheckReport) -> String {
|
||||
" {} ({}) → {}ms{}\n",
|
||||
r.name,
|
||||
r.addr,
|
||||
r.rtt_ms.map(|ms| ms.to_string()).unwrap_or_else(|| "-".into()),
|
||||
r.error.as_ref().map(|e| format!(" [{e}]")).unwrap_or_default(),
|
||||
r.rtt_ms
|
||||
.map(|ms| ms.to_string())
|
||||
.unwrap_or_else(|| "-".into()),
|
||||
r.error
|
||||
.as_ref()
|
||||
.map(|e| format!(" [{e}]"))
|
||||
.unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
if let Some(ref pref) = report.preferred_relay {
|
||||
|
||||
@@ -279,8 +279,15 @@ async fn try_natpmp(
|
||||
|
||||
// Step 2: request port mapping
|
||||
// Request same port as internal (preferred); 7200s lifetime (standard)
|
||||
let (mapped_port, lifetime) =
|
||||
natpmp_map_udp(&socket, gw_addr, internal_port, internal_port, 7200, timeout).await?;
|
||||
let (mapped_port, lifetime) = natpmp_map_udp(
|
||||
&socket,
|
||||
gw_addr,
|
||||
internal_port,
|
||||
internal_port,
|
||||
7200,
|
||||
timeout,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let lifetime_dur = Duration::from_secs(lifetime as u64);
|
||||
Ok(PortMapping {
|
||||
@@ -533,17 +540,12 @@ async fn fetch_url_simple(url: &str, timeout: Duration) -> Result<String, PortMa
|
||||
.map_err(|e| PortMapError::Protocol(format!("parse {host_port}:80: {e}")))?
|
||||
};
|
||||
|
||||
let mut stream = tokio::time::timeout(
|
||||
timeout,
|
||||
tokio::net::TcpStream::connect(addr),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| PortMapError::Timeout)?
|
||||
.map_err(|e| PortMapError::Io(e.to_string()))?;
|
||||
let mut stream = tokio::time::timeout(timeout, tokio::net::TcpStream::connect(addr))
|
||||
.await
|
||||
.map_err(|_| PortMapError::Timeout)?
|
||||
.map_err(|e| PortMapError::Io(e.to_string()))?;
|
||||
|
||||
let request = format!(
|
||||
"GET {path} HTTP/1.1\r\nHost: {host_port}\r\nConnection: close\r\n\r\n"
|
||||
);
|
||||
let request = format!("GET {path} HTTP/1.1\r\nHost: {host_port}\r\nConnection: close\r\n\r\n");
|
||||
stream
|
||||
.write_all(request.as_bytes())
|
||||
.await
|
||||
@@ -593,13 +595,10 @@ async fn soap_post(
|
||||
.map_err(|e| PortMapError::Protocol(format!("parse {host_port}:80: {e}")))?
|
||||
};
|
||||
|
||||
let mut stream = tokio::time::timeout(
|
||||
timeout,
|
||||
tokio::net::TcpStream::connect(addr),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| PortMapError::Timeout)?
|
||||
.map_err(|e| PortMapError::Io(e.to_string()))?;
|
||||
let mut stream = tokio::time::timeout(timeout, tokio::net::TcpStream::connect(addr))
|
||||
.await
|
||||
.map_err(|_| PortMapError::Timeout)?
|
||||
.map_err(|e| PortMapError::Io(e.to_string()))?;
|
||||
|
||||
let soap_body = format!(
|
||||
"<?xml version=\"1.0\"?>\
|
||||
@@ -662,9 +661,7 @@ fn extract_control_url(xml: &str, base_url: &str) -> Result<String, PortMapError
|
||||
return Ok(control_path.to_string());
|
||||
}
|
||||
// Build absolute URL from base
|
||||
let base = base_url
|
||||
.strip_prefix("http://")
|
||||
.unwrap_or(base_url);
|
||||
let base = base_url.strip_prefix("http://").unwrap_or(base_url);
|
||||
let host_port = base.split('/').next().unwrap_or(base);
|
||||
return Ok(format!("http://{host_port}{control_path}"));
|
||||
}
|
||||
@@ -681,7 +678,8 @@ async fn upnp_get_external_ip(
|
||||
control_url: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<Ipv4Addr, PortMapError> {
|
||||
let body = "<u:GetExternalIPAddress xmlns:u=\"urn:schemas-upnp-org:service:WANIPConnection:1\"/>";
|
||||
let body =
|
||||
"<u:GetExternalIPAddress xmlns:u=\"urn:schemas-upnp-org:service:WANIPConnection:1\"/>";
|
||||
let action = "urn:schemas-upnp-org:service:WANIPConnection:1#GetExternalIPAddress";
|
||||
|
||||
let response = soap_post(control_url, action, body, timeout).await?;
|
||||
@@ -933,7 +931,10 @@ mod tests {
|
||||
assert_eq!(request[0], 0);
|
||||
assert_eq!(request[1], 1);
|
||||
assert_eq!(u16::from_be_bytes([request[4], request[5]]), 12345);
|
||||
assert_eq!(u32::from_be_bytes([request[8], request[9], request[10], request[11]]), 7200);
|
||||
assert_eq!(
|
||||
u32::from_be_bytes([request[8], request[9], request[10], request[11]]),
|
||||
7200
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -31,7 +31,7 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use serde::Serialize;
|
||||
use wzp_proto::{MediaTransport, SignalMessage};
|
||||
use wzp_transport::{client_config, create_endpoint, QuinnTransport};
|
||||
use wzp_transport::{QuinnTransport, client_config, create_endpoint};
|
||||
|
||||
/// Result of one probe against one relay. Always returned so the
|
||||
/// UI can render per-relay status even when some fail.
|
||||
@@ -110,10 +110,9 @@ pub async fn probe_reflect_addr(
|
||||
let start = Instant::now();
|
||||
let probe = async {
|
||||
// Open the signal connection.
|
||||
let conn =
|
||||
wzp_transport::connect(&endpoint, relay, "_signal", client_config())
|
||||
.await
|
||||
.map_err(|e| format!("connect: {e}"))?;
|
||||
let conn = wzp_transport::connect(&endpoint, relay, "_signal", client_config())
|
||||
.await
|
||||
.map_err(|e| format!("connect: {e}"))?;
|
||||
let transport = QuinnTransport::new(conn);
|
||||
|
||||
// The relay signal handler waits for a RegisterPresence
|
||||
@@ -540,10 +539,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn classify_two_identical_is_cone() {
|
||||
let probes = vec![
|
||||
mk(Some("192.0.2.1:4433")),
|
||||
mk(Some("192.0.2.1:4433")),
|
||||
];
|
||||
let probes = vec![mk(Some("192.0.2.1:4433")), mk(Some("192.0.2.1:4433"))];
|
||||
let (nt, addr) = classify_nat(&probes);
|
||||
assert_eq!(nt, NatType::Cone);
|
||||
assert_eq!(addr.as_deref(), Some("192.0.2.1:4433"));
|
||||
@@ -551,10 +547,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn classify_same_ip_different_ports_is_symmetric() {
|
||||
let probes = vec![
|
||||
mk(Some("192.0.2.1:4433")),
|
||||
mk(Some("192.0.2.1:51234")),
|
||||
];
|
||||
let probes = vec![mk(Some("192.0.2.1:4433")), mk(Some("192.0.2.1:51234"))];
|
||||
let (nt, addr) = classify_nat(&probes);
|
||||
assert_eq!(nt, NatType::SymmetricPort);
|
||||
assert!(addr.is_none());
|
||||
@@ -562,10 +555,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn classify_different_ips_is_multiple() {
|
||||
let probes = vec![
|
||||
mk(Some("192.0.2.1:4433")),
|
||||
mk(Some("198.51.100.9:4433")),
|
||||
];
|
||||
let probes = vec![mk(Some("192.0.2.1:4433")), mk(Some("198.51.100.9:4433"))];
|
||||
let (nt, addr) = classify_nat(&probes);
|
||||
assert_eq!(nt, NatType::Multiple);
|
||||
assert!(addr.is_none());
|
||||
@@ -591,9 +581,9 @@ mod tests {
|
||||
#[test]
|
||||
fn classify_drops_loopback_probes() {
|
||||
let probes = vec![
|
||||
mk(Some("127.0.0.1:4433")), // loopback — must be dropped
|
||||
mk(Some("203.0.113.5:4433")), // public
|
||||
mk(Some("203.0.113.5:4433")), // public, same addr
|
||||
mk(Some("127.0.0.1:4433")), // loopback — must be dropped
|
||||
mk(Some("203.0.113.5:4433")), // public
|
||||
mk(Some("203.0.113.5:4433")), // public, same addr
|
||||
];
|
||||
let (nt, addr) = classify_nat(&probes);
|
||||
// Two public probes with identical addrs → Cone.
|
||||
@@ -608,9 +598,9 @@ mod tests {
|
||||
// client with a 100.64/10 addr is on the same CGNAT
|
||||
// network and can't contribute to public NAT classification.
|
||||
let probes = vec![
|
||||
mk(Some("100.64.0.42:4433")), // CGNAT — dropped
|
||||
mk(Some("203.0.113.5:4433")), // public
|
||||
mk(Some("203.0.113.5:12345")), // public, different port
|
||||
mk(Some("100.64.0.42:4433")), // CGNAT — dropped
|
||||
mk(Some("203.0.113.5:4433")), // public
|
||||
mk(Some("203.0.113.5:12345")), // public, different port
|
||||
];
|
||||
let (nt, _) = classify_nat(&probes);
|
||||
// Two public probes same IP different port → SymmetricPort.
|
||||
|
||||
@@ -109,11 +109,9 @@ impl RelayMap {
|
||||
|
||||
/// Check if any entry has a stale probe (older than `max_age`).
|
||||
pub fn needs_reprobe(&self, max_age: Duration) -> bool {
|
||||
self.entries.iter().any(|e| {
|
||||
match e.last_probed {
|
||||
None => true,
|
||||
Some(t) => t.elapsed() > max_age,
|
||||
}
|
||||
self.entries.iter().any(|e| match e.last_probed {
|
||||
None => true,
|
||||
Some(t) => t.elapsed() > max_age,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -223,9 +223,7 @@ pub fn parse_binding_response(
|
||||
pos = value_end + ((4 - (attr_len % 4)) % 4);
|
||||
}
|
||||
|
||||
xor_mapped
|
||||
.or(mapped)
|
||||
.ok_or(StunError::NoMappedAddress)
|
||||
xor_mapped.or(mapped).ok_or(StunError::NoMappedAddress)
|
||||
}
|
||||
|
||||
/// Parse a MAPPED-ADDRESS attribute value (RFC 5389 §15.1).
|
||||
@@ -279,10 +277,7 @@ fn parse_mapped_address(value: &[u8]) -> Result<SocketAddr, StunError> {
|
||||
/// - Port: XOR with top 16 bits of magic cookie
|
||||
/// - IPv4 address: XOR with magic cookie
|
||||
/// - IPv6 address: XOR with magic cookie || transaction ID
|
||||
fn parse_xor_mapped_address(
|
||||
value: &[u8],
|
||||
txn_id: &[u8; 12],
|
||||
) -> Result<SocketAddr, StunError> {
|
||||
fn parse_xor_mapped_address(value: &[u8], txn_id: &[u8; 12]) -> Result<SocketAddr, StunError> {
|
||||
if value.len() < 4 {
|
||||
return Err(StunError::Malformed("XOR-MAPPED-ADDRESS too short".into()));
|
||||
}
|
||||
@@ -471,9 +466,7 @@ pub async fn discover_reflexive(config: &StunConfig) -> Result<SocketAddr, StunE
|
||||
/// Unlike `discover_reflexive` (which returns on first success), this
|
||||
/// waits for ALL servers and returns individual results — needed for
|
||||
/// NAT type classification which requires 2+ observations.
|
||||
pub async fn probe_stun_servers(
|
||||
config: &StunConfig,
|
||||
) -> Vec<crate::reflect::NatProbeResult> {
|
||||
pub async fn probe_stun_servers(config: &StunConfig) -> Vec<crate::reflect::NatProbeResult> {
|
||||
use std::time::Instant;
|
||||
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
@@ -596,9 +589,7 @@ pub struct PortAllocationResult {
|
||||
/// - No pattern → `Random`
|
||||
///
|
||||
/// Requires at least 3 servers for reliable classification.
|
||||
pub async fn detect_port_allocation(
|
||||
config: &StunConfig,
|
||||
) -> PortAllocationResult {
|
||||
pub async fn detect_port_allocation(config: &StunConfig) -> PortAllocationResult {
|
||||
if config.servers.len() < 2 {
|
||||
return PortAllocationResult {
|
||||
allocation: PortAllocation::Unknown,
|
||||
@@ -696,11 +687,15 @@ pub fn classify_port_allocation(ports: &[u16]) -> PortAllocation {
|
||||
|
||||
// Allow small jitter: if all deltas are within ±1 of each other,
|
||||
// consider it sequential with the median delta.
|
||||
let all_close = deltas.iter().all(|&d| (d - first_delta).unsigned_abs() <= 1);
|
||||
let all_close = deltas
|
||||
.iter()
|
||||
.all(|&d| (d - first_delta).unsigned_abs() <= 1);
|
||||
if all_close {
|
||||
// Use the most common delta (mode).
|
||||
let median_delta = first_delta;
|
||||
return PortAllocation::Sequential { delta: median_delta };
|
||||
return PortAllocation::Sequential {
|
||||
delta: median_delta,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for consistent delta with occasional skip (some NATs
|
||||
@@ -727,12 +722,7 @@ pub fn classify_port_allocation(ports: &[u16]) -> PortAllocation {
|
||||
/// predicted ports centered around the most likely next value.
|
||||
/// The `offset` parameter accounts for additional flows that may
|
||||
/// open between the probe and the actual connection attempt.
|
||||
pub fn predict_ports(
|
||||
last_port: u16,
|
||||
delta: i16,
|
||||
offset: u16,
|
||||
spread: u16,
|
||||
) -> Vec<u16> {
|
||||
pub fn predict_ports(last_port: u16, delta: i16, offset: u16, spread: u16) -> Vec<u16> {
|
||||
let base = last_port as i32 + (delta as i32 * (offset as i32 + 1));
|
||||
let mut ports = Vec::with_capacity((spread * 2 + 1) as usize);
|
||||
for i in -(spread as i32)..=(spread as i32) {
|
||||
@@ -1217,7 +1207,11 @@ mod tests {
|
||||
assert!(StunError::TxnMismatch.to_string().contains("mismatch"));
|
||||
assert!(StunError::NoMappedAddress.to_string().contains("MAPPED"));
|
||||
assert!(StunError::Io("test".into()).to_string().contains("test"));
|
||||
assert!(StunError::DnsError("bad".into()).to_string().contains("bad"));
|
||||
assert!(
|
||||
StunError::DnsError("bad".into())
|
||||
.to_string()
|
||||
.contains("bad")
|
||||
);
|
||||
assert!(StunError::ErrorResponse(420).to_string().contains("420"));
|
||||
assert!(StunError::Malformed("x".into()).to_string().contains("x"));
|
||||
}
|
||||
@@ -1244,7 +1238,10 @@ mod tests {
|
||||
#[test]
|
||||
fn classify_port_preserving() {
|
||||
let ports = vec![4433, 4433, 4433, 4433, 4433];
|
||||
assert_eq!(classify_port_allocation(&ports), PortAllocation::PortPreserving);
|
||||
assert_eq!(
|
||||
classify_port_allocation(&ports),
|
||||
PortAllocation::PortPreserving
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1290,7 +1287,10 @@ mod tests {
|
||||
#[test]
|
||||
fn classify_two_same_is_preserving() {
|
||||
let ports = vec![4433, 4433];
|
||||
assert_eq!(classify_port_allocation(&ports), PortAllocation::PortPreserving);
|
||||
assert_eq!(
|
||||
classify_port_allocation(&ports),
|
||||
PortAllocation::PortPreserving
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1359,8 +1359,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn port_allocation_display() {
|
||||
assert_eq!(PortAllocation::PortPreserving.to_string(), "port-preserving");
|
||||
assert_eq!(PortAllocation::Sequential { delta: 1 }.to_string(), "sequential(delta=1)");
|
||||
assert_eq!(
|
||||
PortAllocation::PortPreserving.to_string(),
|
||||
"port-preserving"
|
||||
);
|
||||
assert_eq!(
|
||||
PortAllocation::Sequential { delta: 1 }.to_string(),
|
||||
"sequential(delta=1)"
|
||||
);
|
||||
assert_eq!(PortAllocation::Random.to_string(), "random");
|
||||
assert_eq!(PortAllocation::Unknown.to_string(), "unknown");
|
||||
}
|
||||
@@ -1421,7 +1427,10 @@ mod tests {
|
||||
let config = StunConfig::default();
|
||||
let probes = probe_stun_servers(&config).await;
|
||||
assert!(!probes.is_empty());
|
||||
let successes: Vec<_> = probes.iter().filter(|p| p.observed_addr.is_some()).collect();
|
||||
let successes: Vec<_> = probes
|
||||
.iter()
|
||||
.filter(|p| p.observed_addr.is_some())
|
||||
.collect();
|
||||
assert!(
|
||||
!successes.is_empty(),
|
||||
"at least one STUN server should respond"
|
||||
|
||||
@@ -72,8 +72,7 @@ fn sine_frame(freq_hz: f32, frame_offset: u64) -> Vec<i16> {
|
||||
/// 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 frames_per_config = (config.test_duration_secs as u64) * (1000 / FRAME_DURATION_MS as u64);
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
|
||||
use wzp_client::dual_path::{race, PeerCandidates, WinningPath};
|
||||
use wzp_client::dual_path::{PeerCandidates, WinningPath, race};
|
||||
use wzp_client::reflect::Role;
|
||||
use wzp_transport::{create_endpoint, server_config};
|
||||
|
||||
@@ -125,8 +125,15 @@ async fn dual_path_direct_wins_on_loopback() {
|
||||
.await
|
||||
.expect("race must succeed");
|
||||
|
||||
assert!(result.direct_transport.is_some(), "direct transport should be available");
|
||||
assert_eq!(result.local_winner, WinningPath::Direct, "direct should win on loopback");
|
||||
assert!(
|
||||
result.direct_transport.is_some(),
|
||||
"direct transport should be available"
|
||||
);
|
||||
assert_eq!(
|
||||
result.local_winner,
|
||||
WinningPath::Direct,
|
||||
"direct should win on loopback"
|
||||
);
|
||||
|
||||
// Cancel the acceptor accept task so the test finishes.
|
||||
acceptor_accept_task.abort();
|
||||
@@ -170,7 +177,10 @@ async fn dual_path_relay_wins_when_direct_is_dead() {
|
||||
.await
|
||||
.expect("race must succeed via relay fallback");
|
||||
|
||||
assert!(result.relay_transport.is_some(), "relay transport should be available");
|
||||
assert!(
|
||||
result.relay_transport.is_some(),
|
||||
"relay transport should be available"
|
||||
);
|
||||
assert_eq!(
|
||||
result.local_winner,
|
||||
WinningPath::Relay,
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use wzp_proto::packet::MediaPacket;
|
||||
use wzp_proto::traits::{MediaTransport, PathQuality};
|
||||
@@ -83,7 +83,11 @@ async fn full_handshake_both_sides_derive_same_session() {
|
||||
|
||||
// Run client and relay handshakes concurrently.
|
||||
let (client_result, relay_result) = tokio::join!(
|
||||
wzp_client::handshake::perform_handshake(client_transport_clone.as_ref(), &client_seed, None),
|
||||
wzp_client::handshake::perform_handshake(
|
||||
client_transport_clone.as_ref(),
|
||||
&client_seed,
|
||||
None
|
||||
),
|
||||
wzp_relay::handshake::accept_handshake(relay_transport_clone.as_ref(), &relay_seed),
|
||||
);
|
||||
|
||||
|
||||
@@ -83,8 +83,12 @@ fn long_session_no_drift() {
|
||||
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,
|
||||
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
|
||||
@@ -123,7 +127,7 @@ fn long_session_with_simulated_loss() {
|
||||
|
||||
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 {
|
||||
if !pkt.header.is_repair() && i % 20 == 0 && j == 0 {
|
||||
continue; // drop this packet
|
||||
}
|
||||
decoder.ingest(pkt);
|
||||
@@ -139,8 +143,12 @@ fn long_session_with_simulated_loss() {
|
||||
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,
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user