2 Commits

Author SHA1 Message Date
Siavash Sameni
073756ed4b fix: auto-switch decoder codec to match incoming packets
The CallDecoder now inspects each incoming packet's codec_id and
automatically switches the audio decoder if it differs from the
current profile. This enables cross-codec interop where one client
sends Opus and the other sends Codec2 — previously the receiver
would try to decode with the wrong codec, producing garbled audio.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:25:24 +04:00
Siavash Sameni
2fcc2d77cf 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) <noreply@anthropic.com>
2026-04-07 15:23:36 +04:00
2 changed files with 159 additions and 33 deletions

View File

@@ -500,6 +500,49 @@ impl CallDecoder {
} }
} }
/// Switch the decoder to match an incoming packet's codec if it differs
/// from the current profile. This enables cross-codec interop (e.g. one
/// client sends Opus, the other sends Codec2).
fn switch_decoder_if_needed(&mut self, incoming_codec: CodecId) {
if incoming_codec == self.profile.codec || incoming_codec == CodecId::ComfortNoise {
return;
}
let new_profile = Self::profile_for_codec(incoming_codec);
info!(
from = ?self.profile.codec,
to = ?incoming_codec,
"decoder switching codec to match incoming packet"
);
if let Err(e) = self.audio_dec.set_profile(new_profile) {
warn!("failed to switch decoder profile: {e}");
return;
}
self.fec_dec = wzp_fec::create_decoder(&new_profile);
self.profile = new_profile;
}
/// Map a `CodecId` to a reasonable `QualityProfile` for decoding.
fn profile_for_codec(codec: CodecId) -> QualityProfile {
match codec {
CodecId::Opus24k => QualityProfile::GOOD,
CodecId::Opus16k => QualityProfile {
codec: CodecId::Opus16k,
fec_ratio: 0.3,
frame_duration_ms: 20,
frames_per_block: 5,
},
CodecId::Opus6k => QualityProfile::DEGRADED,
CodecId::Codec2_3200 => QualityProfile {
codec: CodecId::Codec2_3200,
fec_ratio: 0.5,
frame_duration_ms: 20,
frames_per_block: 5,
},
CodecId::Codec2_1200 => QualityProfile::CATASTROPHIC,
CodecId::ComfortNoise => QualityProfile::GOOD,
}
}
/// Decode the next audio frame from the jitter buffer. /// Decode the next audio frame from the jitter buffer.
/// ///
/// Returns PCM samples (48kHz mono) or None if not ready. /// Returns PCM samples (48kHz mono) or None if not ready.
@@ -514,6 +557,9 @@ impl CallDecoder {
return Some(pcm.len()); return Some(pcm.len());
} }
// Auto-switch decoder if incoming codec differs from current.
self.switch_decoder_if_needed(pkt.header.codec_id);
self.last_was_cn = false; self.last_was_cn = false;
let result = match self.audio_dec.decode(&pkt.payload, pcm) { let result = match self.audio_dec.decode(&pkt.payload, pcm) {
Ok(n) => Some(n), Ok(n) => Some(n),

View File

@@ -19,12 +19,18 @@ use tracing::{error, info, warn};
use wzp_client::call::{CallConfig, CallDecoder, CallEncoder}; use wzp_client::call::{CallConfig, CallDecoder, CallEncoder};
use wzp_proto::MediaTransport; 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. /// Generate a sine wave tone.
fn generate_sine_frame(freq_hz: f32, sample_rate: u32, frame_offset: u64) -> Vec<i16> { fn generate_sine_frame(freq_hz: f32, sample_rate: u32, frame_offset: u64, frame_samples: usize) -> Vec<i16> {
let start_sample = frame_offset * FRAME_SAMPLES as u64; let start_sample = frame_offset * frame_samples as u64;
(0..FRAME_SAMPLES) (0..frame_samples)
.map(|i| { .map(|i| {
let t = (start_sample + i as u64) as f32 / sample_rate as f32; 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 (f32::sin(2.0 * std::f32::consts::PI * freq_hz * t) * 16000.0) as i16
@@ -57,6 +63,8 @@ struct CliArgs {
os_aec: bool, os_aec: bool,
token: Option<String>, token: Option<String>,
_metrics_file: Option<String>, _metrics_file: Option<String>,
/// Force a quality profile: "good", "degraded", "catastrophic", "codec2-3200"
profile_override: Option<String>,
} }
/// Default identity file path: ~/.wzp/identity /// 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 { fn parse_args() -> CliArgs {
let args: Vec<String> = std::env::args().collect(); let args: Vec<String> = std::env::args().collect();
let mut live = false; let mut live = false;
@@ -136,6 +165,7 @@ fn parse_args() -> CliArgs {
let mut os_aec = false; let mut os_aec = false;
let mut token = None; let mut token = None;
let mut metrics_file = None; let mut metrics_file = None;
let mut profile_override = None;
let mut relay_str = None; let mut relay_str = None;
let mut i = 1; let mut i = 1;
@@ -237,6 +267,14 @@ fn parse_args() -> CliArgs {
.expect("--drift-test value must be a number"), .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, "--sweep" => sweep = true,
"--help" | "-h" => { "--help" | "-h" => {
eprintln!("Usage: wzp-client [options] [relay-addr]"); eprintln!("Usage: wzp-client [options] [relay-addr]");
@@ -248,6 +286,8 @@ fn parse_args() -> CliArgs {
eprintln!(" --record <file.raw> Record received audio to raw PCM file"); eprintln!(" --record <file.raw> Record received audio to raw PCM file");
eprintln!(" --echo-test <secs> Run automated echo quality test"); eprintln!(" --echo-test <secs> Run automated echo quality test");
eprintln!(" --drift-test <secs> Run automated clock-drift measurement"); eprintln!(" --drift-test <secs> Run automated clock-drift measurement");
eprintln!(" --profile <name> Force quality profile: good, degraded, catastrophic, codec2-3200");
eprintln!(" --codec <name> Alias for --profile");
eprintln!(" --sweep Run jitter buffer parameter sweep (local, no network)"); eprintln!(" --sweep Run jitter buffer parameter sweep (local, no network)");
eprintln!(" --seed <hex> Identity seed (64 hex chars, featherChat compatible)"); eprintln!(" --seed <hex> Identity seed (64 hex chars, featherChat compatible)");
eprintln!(" --mnemonic <words...> Identity seed as BIP39 mnemonic (24 words)"); eprintln!(" --mnemonic <words...> Identity seed as BIP39 mnemonic (24 words)");
@@ -312,6 +352,7 @@ fn parse_args() -> CliArgs {
os_aec, os_aec,
token, token,
_metrics_file: metrics_file, _metrics_file: metrics_file,
profile_override,
} }
} }
@@ -332,12 +373,19 @@ async fn main() -> anyhow::Result<()> {
let seed = cli.resolve_seed(); 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!( info!(
relay = %cli.relay_addr, relay = %cli.relay_addr,
live = cli.live, live = cli.live,
send_tone = ?cli.send_tone_secs, send_tone = ?cli.send_tone_secs,
record = ?cli.record_file, record = ?cli.record_file,
room = ?cli.room, room = ?cli.room,
profile = ?cli.profile_override,
"WarzonePhone client" "WarzonePhone client"
); );
@@ -400,6 +448,7 @@ async fn main() -> anyhow::Result<()> {
direct_playout: cli.direct_playout, direct_playout: cli.direct_playout,
aec_delay_ms: cli.aec_delay_ms, aec_delay_ms: cli.aec_delay_ms,
os_aec: cli.os_aec, os_aec: cli.os_aec,
profile_override: profile,
}; };
return run_live(transport, audio_opts).await; return run_live(transport, audio_opts).await;
} }
@@ -422,19 +471,23 @@ async fn main() -> anyhow::Result<()> {
transport.close().await?; transport.close().await?;
Ok(()) Ok(())
} else if cli.send_tone_secs.is_some() || cli.send_file.is_some() || cli.record_file.is_some() { } 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 { } else {
run_silence(transport).await run_silence(transport, profile).await
} }
} }
/// Send silence frames (connectivity test). /// Send silence frames (connectivity test).
async fn run_silence(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Result<()> { async fn run_silence(transport: Arc<wzp_transport::QuinnTransport>, profile: Option<wzp_proto::QualityProfile>) -> 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 mut encoder = CallEncoder::new(&config); let mut encoder = CallEncoder::new(&config);
let frame_duration = tokio::time::Duration::from_millis(20); let frame_duration = tokio::time::Duration::from_millis(config.profile.frame_duration_ms as u64);
let pcm = vec![0i16; FRAME_SAMPLES]; let pcm = vec![0i16; frame_samples];
let mut total_source = 0u64; let mut total_source = 0u64;
let mut total_repair = 0u64; let mut total_repair = 0u64;
@@ -480,13 +533,20 @@ async fn run_file_mode(
send_tone_secs: Option<u32>, send_tone_secs: Option<u32>,
send_file: Option<String>, send_file: Option<String>,
record_file: Option<String>, record_file: Option<String>,
profile: Option<wzp_proto::QualityProfile>,
) -> anyhow::Result<()> { ) -> 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 --- // --- Send task: generate tone or play file ---
let send_transport = transport.clone(); let send_transport = transport.clone();
let send_handle = tokio::spawn(async move { let send_handle = tokio::spawn(async move {
// Load PCM frames from file or generate tone // Load PCM frames from file or generate tone
let frames_per_sec = 1000 / frame_duration_ms;
let pcm_frames: Vec<Vec<i16>> = if let Some(ref path) = send_file { let pcm_frames: Vec<Vec<i16>> = if let Some(ref path) = send_file {
// Read raw PCM file (48kHz mono s16le) // Read raw PCM file (48kHz mono s16le)
let bytes = match std::fs::read(path) { let bytes = match std::fs::read(path) {
@@ -498,14 +558,14 @@ async fn run_file_mode(
.collect(); .collect();
let duration = samples.len() as f64 / 48_000.0; let duration = samples.len() as f64 / 48_000.0;
info!(file = %path, duration = format!("{:.1}s", duration), "sending audio file"); 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) .filter(|c| c.len() == frame_samples)
.map(|c| c.to_vec()) .map(|c| c.to_vec())
.collect() .collect()
} else if let Some(secs) = send_tone_secs { } else if let Some(secs) = send_tone_secs {
let total = (secs as u64) * 50; let total = (secs as u64) * frames_per_sec;
info!(seconds = secs, frames = total, "sending 440Hz tone"); 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)).collect() (0..total).map(|i| generate_sine_frame(440.0, 48_000, i, frame_samples)).collect()
} else { } else {
// No sending, just wait // No sending, just wait
tokio::signal::ctrl_c().await.ok(); tokio::signal::ctrl_c().await.ok();
@@ -514,7 +574,7 @@ async fn run_file_mode(
let mut encoder = CallEncoder::new(&config); let mut encoder = CallEncoder::new(&config);
let _total_frames = pcm_frames.len() as u64; 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_source = 0u64;
let mut total_repair = 0u64; let mut total_repair = 0u64;
@@ -564,8 +624,13 @@ async fn run_file_mode(
} }
}; };
let mut decoder = CallDecoder::new(&CallConfig::default()); let recv_config = match profile {
let mut pcm_buf = vec![0i16; FRAME_SAMPLES]; 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<i16> = Vec::new(); let mut all_pcm: Vec<i16> = Vec::new();
let mut frames_received = 0u64; let mut frames_received = 0u64;
@@ -704,6 +769,7 @@ struct AudioOpts {
direct_playout: bool, direct_playout: bool,
aec_delay_ms: Option<u32>, aec_delay_ms: Option<u32>,
os_aec: bool, os_aec: bool,
profile_override: Option<wzp_proto::QualityProfile>,
} }
#[cfg(feature = "audio")] #[cfg(feature = "audio")]
@@ -788,12 +854,18 @@ async fn run_live(
std::process::exit(1); std::process::exit(1);
}); });
let base_config = match opts.profile_override {
Some(p) => CallConfig::from_profile(p),
None => CallConfig::default(),
};
let config = CallConfig { let config = CallConfig {
noise_suppression: !opts.no_denoise, noise_suppression: !opts.no_denoise,
suppression_enabled: !opts.no_silence, suppression_enabled: !opts.no_silence,
aec_delay_ms: opts.aec_delay_ms.unwrap_or(40), 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(); let mut flags = Vec::new();
if opts.no_denoise { flags.push("denoise"); } if opts.no_denoise { flags.push("denoise"); }
@@ -819,8 +891,8 @@ async fn run_live(
let mut encoder = CallEncoder::new(&config); let mut encoder = CallEncoder::new(&config);
if no_aec { encoder.set_aec_enabled(false); } if no_aec { encoder.set_aec_enabled(false); }
if no_agc { encoder.set_agc_enabled(false); } if no_agc { encoder.set_agc_enabled(false); }
let mut capture_buf = vec![0i16; FRAME_SAMPLES]; let mut capture_buf = vec![0i16; frame_samples];
let mut farend_buf = vec![0i16; FRAME_SAMPLES]; let mut farend_buf = vec![0i16; frame_samples];
let mut frames_sent: u64 = 0; let mut frames_sent: u64 = 0;
let mut frames_dropped: u64 = 0; let mut frames_dropped: u64 = 0;
let mut send_errors: u64 = 0; let mut send_errors: u64 = 0;
@@ -834,19 +906,19 @@ async fn run_live(
} }
let avail = capture_ring.available(); let avail = capture_ring.available();
if avail < FRAME_SAMPLES { if avail < frame_samples {
tokio::time::sleep(std::time::Duration::from_millis(5)).await; tokio::time::sleep(std::time::Duration::from_millis(5)).await;
polls += 1; polls += 1;
// Diagnostic every 2 seconds // Diagnostic every 2 seconds
if last_diag.elapsed().as_secs() >= 2 { 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(); last_diag = std::time::Instant::now();
} }
continue; continue;
} }
let read = capture_ring.read(&mut capture_buf); let read = capture_ring.read(&mut capture_buf);
if read < FRAME_SAMPLES { if read < frame_samples {
continue; continue;
} }
@@ -858,7 +930,7 @@ async fn run_live(
// Feed AEC far-end reference: what was played through the speaker. // Feed AEC far-end reference: what was played through the speaker.
// Must be called BEFORE encode_frame processes the mic signal. // Must be called BEFORE encode_frame processes the mic signal.
if !no_aec { if !no_aec {
while send_farend.available() >= FRAME_SAMPLES { while send_farend.available() >= frame_samples {
send_farend.read(&mut farend_buf); send_farend.read(&mut farend_buf);
encoder.feed_aec_farend(&farend_buf); encoder.feed_aec_farend(&farend_buf);
} }
@@ -903,6 +975,8 @@ async fn run_live(
let recv_running = running.clone(); let recv_running = running.clone();
let recv_spk_muted = spk_muted.clone(); let recv_spk_muted = spk_muted.clone();
let direct_playout = opts.direct_playout; 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). // Direct playout: decode on recv, write straight to playout ring (like Android).
// Jitter buffer mode: ingest into jitter buffer, decode on 20ms tick. // 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 packets_received: u64 = 0;
let mut recv_errors: u64 = 0; let mut recv_errors: u64 = 0;
let mut timeouts: 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 { let mut opus_dec = if direct_playout {
Some(wzp_codec::create_decoder(wzp_proto::QualityProfile::GOOD)) Some(wzp_codec::create_decoder(direct_profile))
} else { } else {
None None
}; };
let mut playout_agc = wzp_codec::AutoGainControl::new(); 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 { loop {
if !recv_running.load(Ordering::Relaxed) { if !recv_running.load(Ordering::Relaxed) {
@@ -1019,10 +1094,15 @@ async fn run_live(
return; return;
} }
let config = CallConfig::default(); let playout_config = match playout_profile {
let mut decoder = CallDecoder::new(&config); Some(p) => CallConfig::from_profile(p),
let mut pcm_buf = vec![0i16; FRAME_SAMPLES]; None => CallConfig::default(),
let mut interval = tokio::time::interval(std::time::Duration::from_millis(20)); };
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); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut telemetry = JitterTelemetry::new(5); let mut telemetry = JitterTelemetry::new(5);
loop { loop {