From 95a905e1b507497fd1b03dc795b674f79e4e4931 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Tue, 7 Apr 2026 15:23:36 +0400 Subject: [PATCH] feat: add --profile/--codec flag to CLI for forcing codec selection Enables debugging Codec2 by allowing forced codec selection from CLI. Supports: good, degraded, catastrophic, codec2-3200, codec2-1200. Frame size, timing, and jitter buffer are all adjusted dynamically based on the selected profile. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/wzp-client/src/cli.rs | 146 +++++++++++++++++++++++++++-------- 1 file changed, 113 insertions(+), 33 deletions(-) diff --git a/crates/wzp-client/src/cli.rs b/crates/wzp-client/src/cli.rs index 937ce5a..6b767bb 100644 --- a/crates/wzp-client/src/cli.rs +++ b/crates/wzp-client/src/cli.rs @@ -19,12 +19,18 @@ use tracing::{error, info, warn}; use wzp_client::call::{CallConfig, CallDecoder, CallEncoder}; use wzp_proto::MediaTransport; -const FRAME_SAMPLES: usize = 960; // 20ms @ 48kHz +const FRAME_SAMPLES_20MS: usize = 960; // 20ms @ 48kHz +const FRAME_SAMPLES_40MS: usize = 1920; // 40ms @ 48kHz + +/// Compute frame samples at 48kHz for a given profile. +fn frame_samples_for(profile: &wzp_proto::QualityProfile) -> usize { + (profile.frame_duration_ms as usize) * 48 // 48000 / 1000 +} /// Generate a sine wave tone. -fn generate_sine_frame(freq_hz: f32, sample_rate: u32, frame_offset: u64) -> Vec { - let start_sample = frame_offset * FRAME_SAMPLES as u64; - (0..FRAME_SAMPLES) +fn generate_sine_frame(freq_hz: f32, sample_rate: u32, frame_offset: u64, frame_samples: usize) -> Vec { + let start_sample = frame_offset * frame_samples as u64; + (0..frame_samples) .map(|i| { let t = (start_sample + i as u64) as f32 / sample_rate as f32; (f32::sin(2.0 * std::f32::consts::PI * freq_hz * t) * 16000.0) as i16 @@ -57,6 +63,8 @@ struct CliArgs { os_aec: bool, token: Option, _metrics_file: Option, + /// Force a quality profile: "good", "degraded", "catastrophic", "codec2-3200" + profile_override: Option, } /// Default identity file path: ~/.wzp/identity @@ -112,6 +120,27 @@ impl CliArgs { } } +/// Resolve a profile name to a QualityProfile. +fn resolve_profile(name: &str) -> wzp_proto::QualityProfile { + use wzp_proto::{CodecId, QualityProfile}; + match name.to_lowercase().as_str() { + "good" | "opus" | "opus24k" => QualityProfile::GOOD, + "degraded" | "opus6k" => QualityProfile::DEGRADED, + "catastrophic" | "codec2-1200" | "c2-1200" | "1200" => QualityProfile::CATASTROPHIC, + "codec2-3200" | "c2-3200" | "3200" => QualityProfile { + codec: CodecId::Codec2_3200, + fec_ratio: 0.5, + frame_duration_ms: 20, + frames_per_block: 5, + }, + other => { + eprintln!("unknown profile: {other}"); + eprintln!("valid: good, degraded, catastrophic, codec2-3200, codec2-1200"); + std::process::exit(1); + } + } +} + fn parse_args() -> CliArgs { let args: Vec = std::env::args().collect(); let mut live = false; @@ -136,6 +165,7 @@ fn parse_args() -> CliArgs { let mut os_aec = false; let mut token = None; let mut metrics_file = None; + let mut profile_override = None; let mut relay_str = None; let mut i = 1; @@ -237,6 +267,14 @@ fn parse_args() -> CliArgs { .expect("--drift-test value must be a number"), ); } + "--profile" | "--codec" => { + i += 1; + profile_override = Some( + args.get(i) + .expect("--profile requires a value (good, degraded, catastrophic, codec2-3200)") + .to_string(), + ); + } "--sweep" => sweep = true, "--help" | "-h" => { eprintln!("Usage: wzp-client [options] [relay-addr]"); @@ -248,6 +286,8 @@ fn parse_args() -> CliArgs { eprintln!(" --record Record received audio to raw PCM file"); eprintln!(" --echo-test Run automated echo quality test"); eprintln!(" --drift-test Run automated clock-drift measurement"); + eprintln!(" --profile Force quality profile: good, degraded, catastrophic, codec2-3200"); + eprintln!(" --codec Alias for --profile"); eprintln!(" --sweep Run jitter buffer parameter sweep (local, no network)"); eprintln!(" --seed Identity seed (64 hex chars, featherChat compatible)"); eprintln!(" --mnemonic Identity seed as BIP39 mnemonic (24 words)"); @@ -312,6 +352,7 @@ fn parse_args() -> CliArgs { os_aec, token, _metrics_file: metrics_file, + profile_override, } } @@ -332,12 +373,19 @@ async fn main() -> anyhow::Result<()> { let seed = cli.resolve_seed(); + // Resolve profile override + let profile = cli.profile_override.as_deref().map(resolve_profile); + if let Some(ref p) = profile { + info!(codec = ?p.codec, frame_ms = p.frame_duration_ms, fec = p.fec_ratio, "forced profile"); + } + info!( relay = %cli.relay_addr, live = cli.live, send_tone = ?cli.send_tone_secs, record = ?cli.record_file, room = ?cli.room, + profile = ?cli.profile_override, "WarzonePhone client" ); @@ -400,6 +448,7 @@ async fn main() -> anyhow::Result<()> { direct_playout: cli.direct_playout, aec_delay_ms: cli.aec_delay_ms, os_aec: cli.os_aec, + profile_override: profile, }; return run_live(transport, audio_opts).await; } @@ -422,19 +471,23 @@ 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, profile).await } else { - run_silence(transport).await + run_silence(transport, profile).await } } /// Send silence frames (connectivity test). -async fn run_silence(transport: Arc) -> anyhow::Result<()> { - let config = CallConfig::default(); +async fn run_silence(transport: Arc, profile: Option) -> anyhow::Result<()> { + let config = match profile { + Some(p) => CallConfig::from_profile(p), + None => CallConfig::default(), + }; + let frame_samples = frame_samples_for(&config.profile); let mut encoder = CallEncoder::new(&config); - let frame_duration = tokio::time::Duration::from_millis(20); - let pcm = vec![0i16; FRAME_SAMPLES]; + let frame_duration = tokio::time::Duration::from_millis(config.profile.frame_duration_ms as u64); + let pcm = vec![0i16; frame_samples]; let mut total_source = 0u64; let mut total_repair = 0u64; @@ -480,13 +533,20 @@ async fn run_file_mode( send_tone_secs: Option, send_file: Option, record_file: Option, + profile: Option, ) -> anyhow::Result<()> { - let config = CallConfig::default(); + let config = match profile { + Some(p) => CallConfig::from_profile(p), + None => CallConfig::default(), + }; + let frame_samples = frame_samples_for(&config.profile); + let frame_duration_ms = config.profile.frame_duration_ms as u64; // --- Send task: generate tone or play file --- let send_transport = transport.clone(); let send_handle = tokio::spawn(async move { // Load PCM frames from file or generate tone + let frames_per_sec = 1000 / frame_duration_ms; let pcm_frames: Vec> = if let Some(ref path) = send_file { // Read raw PCM file (48kHz mono s16le) let bytes = match std::fs::read(path) { @@ -498,14 +558,14 @@ async fn run_file_mode( .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) - .filter(|c| c.len() == 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() + let total = (secs as u64) * frames_per_sec; + info!(seconds = secs, frames = total, frame_samples, frame_ms = frame_duration_ms, "sending 440Hz tone"); + (0..total).map(|i| generate_sine_frame(440.0, 48_000, i, frame_samples)).collect() } else { // No sending, just wait tokio::signal::ctrl_c().await.ok(); @@ -514,7 +574,7 @@ async fn run_file_mode( let mut encoder = CallEncoder::new(&config); let _total_frames = pcm_frames.len() as u64; - let frame_duration = tokio::time::Duration::from_millis(20); + let frame_duration = tokio::time::Duration::from_millis(frame_duration_ms); let mut total_source = 0u64; let mut total_repair = 0u64; @@ -564,8 +624,13 @@ async fn run_file_mode( } }; - let mut decoder = CallDecoder::new(&CallConfig::default()); - let mut pcm_buf = vec![0i16; FRAME_SAMPLES]; + let recv_config = match profile { + Some(p) => CallConfig::from_profile(p), + None => CallConfig::default(), + }; + let recv_frame_samples = frame_samples_for(&recv_config.profile); + let mut decoder = CallDecoder::new(&recv_config); + let mut pcm_buf = vec![0i16; recv_frame_samples.max(FRAME_SAMPLES_40MS)]; let mut all_pcm: Vec = Vec::new(); let mut frames_received = 0u64; @@ -704,6 +769,7 @@ struct AudioOpts { direct_playout: bool, aec_delay_ms: Option, os_aec: bool, + profile_override: Option, } #[cfg(feature = "audio")] @@ -788,12 +854,18 @@ async fn run_live( std::process::exit(1); }); + let base_config = match opts.profile_override { + Some(p) => CallConfig::from_profile(p), + None => CallConfig::default(), + }; let config = CallConfig { noise_suppression: !opts.no_denoise, suppression_enabled: !opts.no_silence, aec_delay_ms: opts.aec_delay_ms.unwrap_or(40), - ..CallConfig::default() + ..base_config }; + let frame_samples = frame_samples_for(&config.profile); + info!(codec = ?config.profile.codec, frame_samples, frame_ms = config.profile.frame_duration_ms, "call config"); { let mut flags = Vec::new(); if opts.no_denoise { flags.push("denoise"); } @@ -819,8 +891,8 @@ async fn run_live( let mut encoder = CallEncoder::new(&config); if no_aec { encoder.set_aec_enabled(false); } if no_agc { encoder.set_agc_enabled(false); } - let mut capture_buf = vec![0i16; FRAME_SAMPLES]; - let mut farend_buf = vec![0i16; FRAME_SAMPLES]; + let mut capture_buf = vec![0i16; frame_samples]; + let mut farend_buf = vec![0i16; frame_samples]; let mut frames_sent: u64 = 0; let mut frames_dropped: u64 = 0; let mut send_errors: u64 = 0; @@ -834,19 +906,19 @@ async fn run_live( } let avail = capture_ring.available(); - if avail < FRAME_SAMPLES { + if avail < frame_samples { tokio::time::sleep(std::time::Duration::from_millis(5)).await; polls += 1; // Diagnostic every 2 seconds if last_diag.elapsed().as_secs() >= 2 { - info!(avail, polls, frames_sent, "send: ring starved (avail < {FRAME_SAMPLES})"); + info!(avail, polls, frames_sent, frame_samples, "send: ring starved"); last_diag = std::time::Instant::now(); } continue; } let read = capture_ring.read(&mut capture_buf); - if read < FRAME_SAMPLES { + if read < frame_samples { continue; } @@ -858,7 +930,7 @@ async fn run_live( // Feed AEC far-end reference: what was played through the speaker. // Must be called BEFORE encode_frame processes the mic signal. if !no_aec { - while send_farend.available() >= FRAME_SAMPLES { + while send_farend.available() >= frame_samples { send_farend.read(&mut farend_buf); encoder.feed_aec_farend(&farend_buf); } @@ -903,6 +975,8 @@ async fn run_live( let recv_running = running.clone(); let recv_spk_muted = spk_muted.clone(); let direct_playout = opts.direct_playout; + let recv_profile = opts.profile_override; + let playout_profile = recv_profile; // Copy for playout_task // Direct playout: decode on recv, write straight to playout ring (like Android). // Jitter buffer mode: ingest into jitter buffer, decode on 20ms tick. @@ -917,14 +991,15 @@ async fn run_live( let mut packets_received: u64 = 0; let mut recv_errors: u64 = 0; let mut timeouts: u64 = 0; - // For direct playout: raw Opus decoder + AGC + // For direct playout: raw codec decoder + AGC + let direct_profile = recv_profile.unwrap_or(wzp_proto::QualityProfile::GOOD); let mut opus_dec = if direct_playout { - Some(wzp_codec::create_decoder(wzp_proto::QualityProfile::GOOD)) + Some(wzp_codec::create_decoder(direct_profile)) } else { None }; let mut playout_agc = wzp_codec::AutoGainControl::new(); - let mut pcm_buf = vec![0i16; FRAME_SAMPLES]; + let mut pcm_buf = vec![0i16; frame_samples.max(FRAME_SAMPLES_40MS)]; loop { if !recv_running.load(Ordering::Relaxed) { @@ -1019,10 +1094,15 @@ async fn run_live( return; } - let config = CallConfig::default(); - let mut decoder = CallDecoder::new(&config); - let mut pcm_buf = vec![0i16; FRAME_SAMPLES]; - let mut interval = tokio::time::interval(std::time::Duration::from_millis(20)); + let playout_config = match playout_profile { + Some(p) => CallConfig::from_profile(p), + None => CallConfig::default(), + }; + let playout_frame_ms = playout_config.profile.frame_duration_ms as u64; + let playout_frame_samples = frame_samples_for(&playout_config.profile); + let mut decoder = CallDecoder::new(&playout_config); + let mut pcm_buf = vec![0i16; playout_frame_samples.max(FRAME_SAMPLES_40MS)]; + let mut interval = tokio::time::interval(std::time::Duration::from_millis(playout_frame_ms)); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); let mut telemetry = JitterTelemetry::new(5); loop {