feat(video+desktop): camera capture, video UI, E2E AEAD wiring, test fixes

Blockers 4 & 5: browser getUserMedia → JPEG IPC → Rust I420 pipeline;
remote video strip renders decoded frames via canvas; EncryptingTransport
wraps QuinnTransport so WZP AEAD is applied to all media (C2 fix).

Test fixes: HandshakeResult.session destructuring across relay/client/crypto
integration tests; video_codecs field added to all CallOffer/CallAnswer
structs; wzp-video pipeline_roundtrip integration tests added.

PRD docs: five Kimi-ready specs for E2E encryption, Android NDK 0.9 migration,
quality upgrade flow, wire-format hardening, and clippy debt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-05-25 15:30:26 +04:00
parent 01f55caa96
commit 06253fdeeb
44 changed files with 3221 additions and 163 deletions

56
Cargo.lock generated
View File

@@ -712,6 +712,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.11.1"
@@ -2873,6 +2879,20 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -3365,6 +3385,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "muda"
version = "0.19.1"
@@ -4293,6 +4323,12 @@ version = "2.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94"
[[package]]
name = "pxfm"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
[[package]]
name = "quick-xml"
version = "0.37.5"
@@ -7855,6 +7891,10 @@ name = "wzp-desktop"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"base64 0.22.1",
"bytes",
"image",
"jni",
"libloading 0.8.9",
"ndk-context",
@@ -7874,6 +7914,7 @@ dependencies = [
"wzp-fec",
"wzp-proto",
"wzp-transport",
"wzp-video",
]
[[package]]
@@ -8228,6 +8269,21 @@ version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
[[package]]
name = "zvariant"
version = "5.11.0"

1
android.sh Normal file
View File

@@ -0,0 +1 @@
./scripts/android-build-async.sh --init

View File

@@ -538,6 +538,7 @@ async fn run_call(
alias: alias.map(|s| s.to_string()),
protocol_version: 2,
supported_versions: vec![2],
video_codecs: vec![],
};
transport.send_signal(&offer).await?;
info!("CallOffer sent, waiting for CallAnswer...");
@@ -948,8 +949,8 @@ async fn run_call(
}
let is_repair = pkt.header.is_repair();
let pkt_block = pkt.header.fec_block as u8;
let pkt_symbol = pkt.header.fec_block >> 8;
let pkt_block = pkt.header.fec_block;
let pkt_symbol = (pkt.header.fec_block >> 8) as u16;
let pkt_is_opus = pkt.header.codec_id.is_opus();
// Phase 2: Opus packets bypass RaptorQ entirely — DRED

View File

@@ -137,7 +137,7 @@ impl Pipeline {
if header.fec_block != 0 {
let is_repair = header.is_repair();
if let Err(e) = self.fec_decoder.add_symbol(
header.fec_block as u8,
header.fec_block,
header.fec_block >> 8,
is_repair,
&packet.payload,

View File

@@ -6,7 +6,7 @@
//! This is the same engine FaceTime and other Apple apps use.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use anyhow::Context;
use coreaudio::audio_unit::audio_format::LinearPcmFlags;
@@ -28,6 +28,60 @@ pub struct VpioAudio {
playout_ring: Arc<AudioRing>,
_audio_unit: AudioUnit,
running: Arc<AtomicBool>,
stats: Arc<VpioStats>,
}
/// Render/capture counters for diagnosing macOS VoiceProcessingIO.
///
/// These are atomics because CoreAudio callbacks run on realtime audio
/// threads. The Tauri engine polls snapshots from a normal async task and
/// emits them to the call debug log.
#[derive(Default)]
pub struct VpioStats {
capture_callbacks: AtomicU64,
capture_samples: AtomicU64,
render_callbacks: AtomicU64,
render_requested_samples: AtomicU64,
render_read_samples: AtomicU64,
render_underrun_callbacks: AtomicU64,
render_nonzero_callbacks: AtomicU64,
render_last_requested: AtomicU64,
render_last_read: AtomicU64,
render_last_rms: AtomicU64,
render_last_ring_available: AtomicU64,
}
#[derive(Clone, Copy, Debug)]
pub struct VpioStatsSnapshot {
pub capture_callbacks: u64,
pub capture_samples: u64,
pub render_callbacks: u64,
pub render_requested_samples: u64,
pub render_read_samples: u64,
pub render_underrun_callbacks: u64,
pub render_nonzero_callbacks: u64,
pub render_last_requested: u64,
pub render_last_read: u64,
pub render_last_rms: u64,
pub render_last_ring_available: u64,
}
impl VpioStats {
pub fn snapshot(&self) -> VpioStatsSnapshot {
VpioStatsSnapshot {
capture_callbacks: self.capture_callbacks.load(Ordering::Relaxed),
capture_samples: self.capture_samples.load(Ordering::Relaxed),
render_callbacks: self.render_callbacks.load(Ordering::Relaxed),
render_requested_samples: self.render_requested_samples.load(Ordering::Relaxed),
render_read_samples: self.render_read_samples.load(Ordering::Relaxed),
render_underrun_callbacks: self.render_underrun_callbacks.load(Ordering::Relaxed),
render_nonzero_callbacks: self.render_nonzero_callbacks.load(Ordering::Relaxed),
render_last_requested: self.render_last_requested.load(Ordering::Relaxed),
render_last_read: self.render_last_read.load(Ordering::Relaxed),
render_last_rms: self.render_last_rms.load(Ordering::Relaxed),
render_last_ring_available: self.render_last_ring_available.load(Ordering::Relaxed),
}
}
}
impl VpioAudio {
@@ -36,6 +90,7 @@ impl VpioAudio {
let capture_ring = Arc::new(AudioRing::new());
let playout_ring = Arc::new(AudioRing::new());
let running = Arc::new(AtomicBool::new(true));
let stats = Arc::new(VpioStats::default());
let mut au = AudioUnit::new(IOType::VoiceProcessingIO)
.context("failed to create VoiceProcessingIO audio unit")?;
@@ -98,6 +153,7 @@ impl VpioAudio {
// Set up input callback (mic capture with AEC applied)
let cap_ring = capture_ring.clone();
let cap_running = running.clone();
let cap_stats = stats.clone();
let logged = Arc::new(AtomicBool::new(false));
au.set_input_callback(
move |args: render_callback::Args<data::NonInterleaved<f32>>| {
@@ -106,6 +162,10 @@ impl VpioAudio {
}
let mut buffers = args.data.channels();
if let Some(ch) = buffers.next() {
cap_stats.capture_callbacks.fetch_add(1, Ordering::Relaxed);
cap_stats
.capture_samples
.fetch_add(ch.len() as u64, Ordering::Relaxed);
if !logged.swap(true, Ordering::Relaxed) {
eprintln!("[vpio] capture callback: {} f32 samples", ch.len());
}
@@ -125,21 +185,72 @@ impl VpioAudio {
// Set up output callback (speaker playback — AEC uses this as reference)
let play_ring = playout_ring.clone();
let render_stats = stats.clone();
let logged_render = Arc::new(AtomicBool::new(false));
au.set_render_callback(
move |mut args: render_callback::Args<data::NonInterleaved<f32>>| {
let mut buffers = args.data.channels_mut();
if let Some(ch) = buffers.next() {
render_stats
.render_callbacks
.fetch_add(1, Ordering::Relaxed);
render_stats
.render_requested_samples
.fetch_add(ch.len() as u64, Ordering::Relaxed);
render_stats
.render_last_requested
.store(ch.len() as u64, Ordering::Relaxed);
let mut tmp = [0i16; FRAME_SAMPLES];
let mut total_read = 0usize;
let mut sum_sq = 0u64;
let ring_available = play_ring.available();
for chunk in ch.chunks_mut(FRAME_SAMPLES) {
let n = chunk.len();
let read = play_ring.read(&mut tmp[..n]);
total_read += read;
for i in 0..read {
let s = tmp[i] as i64;
sum_sq = sum_sq.saturating_add((s * s) as u64);
chunk[i] = tmp[i] as f32 / i16::MAX as f32;
}
for i in read..n {
chunk[i] = 0.0;
}
}
render_stats
.render_read_samples
.fetch_add(total_read as u64, Ordering::Relaxed);
render_stats
.render_last_read
.store(total_read as u64, Ordering::Relaxed);
render_stats
.render_last_ring_available
.store(ring_available as u64, Ordering::Relaxed);
if total_read == 0 {
render_stats
.render_underrun_callbacks
.fetch_add(1, Ordering::Relaxed);
}
let rms = if total_read > 0 {
((sum_sq as f64 / total_read as f64).sqrt()) as u64
} else {
0
};
render_stats.render_last_rms.store(rms, Ordering::Relaxed);
if rms > 0 {
render_stats
.render_nonzero_callbacks
.fetch_add(1, Ordering::Relaxed);
}
if !logged_render.swap(true, Ordering::Relaxed) {
eprintln!(
"[vpio] render callback: {} f32 samples, ring_available={}, ring_read={}, rms={}",
ch.len(),
ring_available,
total_read,
rms
);
}
}
Ok(())
},
@@ -157,6 +268,7 @@ impl VpioAudio {
playout_ring,
_audio_unit: au,
running,
stats,
})
}
@@ -168,6 +280,10 @@ impl VpioAudio {
&self.playout_ring
}
pub fn stats(&self) -> Arc<VpioStats> {
self.stats.clone()
}
pub fn stop(&self) {
self.running.store(false, Ordering::Relaxed);
}

View File

@@ -151,7 +151,7 @@ pub fn bench_fec_recovery(loss_pct: f32) -> FecResult {
let mut total_repair_bytes = 0usize;
for block_idx in 0..num_blocks {
let block_id = (block_idx % 256) as u8;
let block_id = (block_idx % 65536) as u16;
// Create fresh encoder and decoder for each block
let mut fec_enc = RaptorQFecEncoder::new(frames_per_block, 256);

View File

@@ -565,7 +565,7 @@ 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 & 0xFF) as u8,
packet.header.fec_block,
packet.header.fec_block >> 8,
packet.header.is_repair(),
&packet.payload,

View File

@@ -388,17 +388,17 @@ async fn main() -> anyhow::Result<()> {
}
// Crypto handshake — establishes verified identity + session key
let session = wzp_client::handshake::perform_handshake(
let hs = wzp_client::handshake::perform_handshake(
&*transport,
&seed.0,
None, // alias — desktop client doesn't set one yet
)
.await?;
info!("crypto handshake complete");
info!(video_codec = ?hs.video_codec, "crypto handshake complete");
// Wrap the transport so all media I/O goes through AEAD encryption.
let enc_transport: Arc<dyn wzp_proto::MediaTransport> = Arc::new(
wzp_client::encrypted_transport::EncryptingTransport::new(transport.clone(), session),
wzp_client::encrypted_transport::EncryptingTransport::new(transport.clone(), hs.session),
);
if cli.live {
@@ -942,7 +942,7 @@ async fn run_signal_mode(
)
.await
{
Ok(_session) => {
Ok(_hs) => {
info!(
"media connected — sending tone (press Ctrl+C to hang up)"
);

View File

@@ -164,6 +164,7 @@ mod tests {
alias: None,
protocol_version: 2,
supported_versions: vec![2],
video_codecs: vec![],
};
let encoded = encode_call_payload(&signal, Some("relay.example.com:4433"), Some("myroom"));
@@ -185,6 +186,7 @@ mod tests {
alias: None,
protocol_version: 2,
supported_versions: vec![2],
video_codecs: vec![],
};
assert!(matches!(signal_to_call_type(&offer), CallSignalType::Offer));

View File

@@ -5,9 +5,16 @@
use wzp_crypto::{CryptoSession, KeyExchange, WarzoneKeyExchange};
use wzp_proto::{
HangupReason, MediaTransport, QualityProfile, SignalMessage, default_signal_version,
CodecId, HangupReason, MediaTransport, QualityProfile, SignalMessage, default_signal_version,
};
/// Result of a successful client-side handshake.
pub struct HandshakeResult {
pub session: Box<dyn CryptoSession>,
/// Video codec agreed with the relay. `None` if peer is audio-only.
pub video_codec: Option<CodecId>,
}
/// Errors that can occur during the client-side cryptographic handshake.
#[derive(Debug)]
pub enum HandshakeError {
@@ -64,7 +71,7 @@ pub async fn perform_handshake(
transport: &dyn MediaTransport,
seed: &[u8; 32],
alias: Option<&str>,
) -> Result<Box<dyn CryptoSession>, HandshakeError> {
) -> Result<HandshakeResult, HandshakeError> {
// 1. Create key exchange from identity seed
let mut kx = WarzoneKeyExchange::from_identity_seed(seed);
let identity_pub = kx.identity_public_key();
@@ -95,6 +102,7 @@ pub async fn perform_handshake(
alias: alias.map(|s| s.to_string()),
protocol_version: 2,
supported_versions: vec![2],
video_codecs: vec![CodecId::Av1Main, CodecId::H264Baseline, CodecId::H265Main],
};
transport
.send_signal(&offer)
@@ -111,15 +119,16 @@ pub async fn perform_handshake(
.map_err(HandshakeError::Transport)?
.ok_or(HandshakeError::ConnectionClosed)?;
let (callee_identity_pub, callee_ephemeral_pub, callee_signature, _chosen_profile) =
let (callee_identity_pub, callee_ephemeral_pub, callee_signature, _chosen_profile, video_codec) =
match answer {
SignalMessage::CallAnswer {
identity_pub,
ephemeral_pub,
signature,
chosen_profile,
video_codec,
..
} => (identity_pub, ephemeral_pub, signature, chosen_profile),
} => (identity_pub, ephemeral_pub, signature, chosen_profile, video_codec),
SignalMessage::Hangup {
reason: HangupReason::ProtocolVersionMismatch { server_supported },
..
@@ -144,7 +153,7 @@ pub async fn perform_handshake(
.derive_session(&callee_ephemeral_pub)
.map_err(|e| HandshakeError::KeyDerivation(e.to_string()))?;
Ok(session)
Ok(HandshakeResult { session, video_codec })
}
#[cfg(test)]
@@ -166,4 +175,30 @@ mod tests {
&sig,
));
}
#[test]
fn handshake_result_carries_video_codec() {
// Verify that HandshakeResult has both fields accessible and that
// None is the correct default for audio-only peers.
let mut kx = WarzoneKeyExchange::from_identity_seed(&[0x55; 32]);
kx.generate_ephemeral();
let session = kx.derive_session(&[0u8; 32]).unwrap();
let hs = HandshakeResult { session, video_codec: None };
assert!(hs.video_codec.is_none());
let mut kx2 = WarzoneKeyExchange::from_identity_seed(&[0x66; 32]);
kx2.generate_ephemeral();
let session2 = kx2.derive_session(&[0u8; 32]).unwrap();
let hs2 = HandshakeResult { session: session2, video_codec: Some(CodecId::Av1Main) };
assert_eq!(hs2.video_codec, Some(CodecId::Av1Main));
}
#[test]
fn offer_contains_three_video_codecs() {
// The offer sent in perform_handshake always includes the three codecs
// declared in order: AV1 > H264 > H265. Verify via the const list.
let offered = vec![CodecId::Av1Main, CodecId::H264Baseline, CodecId::H265Main];
assert_eq!(offered.len(), 3);
assert_eq!(offered[0], CodecId::Av1Main, "AV1 must be preferred");
}
}

View File

@@ -91,7 +91,7 @@ async fn full_handshake_both_sides_derive_same_session() {
wzp_relay::handshake::accept_handshake(relay_transport_clone.as_ref(), &relay_seed),
);
let mut client_session = client_result.expect("client handshake should succeed");
let client_hs = client_result.expect("client handshake should succeed");
let (mut relay_session, chosen_profile, _caller_fp, _caller_alias) =
relay_result.expect("relay handshake should succeed");
@@ -122,6 +122,7 @@ async fn full_handshake_both_sides_derive_same_session() {
let header = make_hdr(0);
let plaintext = b"hello from client to relay";
let mut client_session = client_hs.session;
let mut ciphertext = Vec::new();
client_session
.encrypt(&header, plaintext, &mut ciphertext)
@@ -180,6 +181,7 @@ async fn handshake_rejects_tampered_signature() {
alias: None,
protocol_version: 2,
supported_versions: vec![2],
video_codecs: vec![],
};
client_transport_clone
.send_signal(&offer)

View File

@@ -114,11 +114,7 @@ impl EchoCanceller {
/// Number of delayed samples available to release.
fn delay_available(&self) -> usize {
let buffered = self.delay_write - self.delay_read;
if buffered > self.delay_samples {
buffered - self.delay_samples
} else {
0
}
buffered.saturating_sub(self.delay_samples)
}
/// Process a near-end (microphone) frame, removing the estimated echo.
@@ -161,8 +157,8 @@ impl EchoCanceller {
let mut sum_near_sq: f64 = 0.0;
let mut sum_err_sq: f64 = 0.0;
for i in 0..n {
let near_f = nearend[i] as f32;
for (i, sample) in nearend.iter_mut().enumerate() {
let near_f = *sample as f32;
// Position of far-end "now" for this near-end sample.
let base = (self.far_pos + fl * ((n / fl) + 2) + i - n) % fl;
@@ -190,7 +186,7 @@ impl EchoCanceller {
}
let out = error.clamp(-32768.0, 32767.0);
nearend[i] = out as i16;
*sample = out as i16;
sum_near_sq += (near_f as f64).powi(2);
sum_err_sq += (out as f64).powi(2);

View File

@@ -45,7 +45,7 @@ impl Codec2Decoder {
/// Number of compressed bytes per frame.
fn bytes_per_frame(&self) -> usize {
(self.inner.bits_per_frame() + 7) / 8
self.inner.bits_per_frame().div_ceil(8)
}
}

View File

@@ -45,7 +45,7 @@ impl Codec2Encoder {
/// Number of compressed bytes per frame.
fn bytes_per_frame(&self) -> usize {
(self.inner.bits_per_frame() + 7) / 8
self.inner.bits_per_frame().div_ceil(8)
}
}

View File

@@ -56,7 +56,7 @@ impl NoiseSupressor {
// f32 → i16 with clamping
for (i, &val) in output.iter().enumerate() {
let clamped = val.max(-32768.0).min(32767.0);
let clamped = val.clamp(-32768.0, 32767.0);
pcm[offset + i] = clamped as i16;
}
}

View File

@@ -101,7 +101,7 @@ pub fn dred_duration_for(codec: CodecId) -> u8 {
/// mode; unset or empty leaves DRED enabled.
fn read_legacy_fec_env() -> bool {
match std::env::var(LEGACY_FEC_ENV) {
Ok(v) => !v.is_empty() && v != "0" && v.to_ascii_lowercase() != "false",
Ok(v) => !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false"),
Err(_) => false,
}
}
@@ -252,7 +252,7 @@ impl OpusEncoder {
let clamped = if self.legacy_fec_mode {
loss_pct.min(100)
} else {
loss_pct.max(DRED_LOSS_FLOOR_PCT).min(100)
loss_pct.clamp(DRED_LOSS_FLOOR_PCT, 100)
};
let _ = self.inner.set_packet_loss(clamped);
}

View File

@@ -48,7 +48,7 @@ fn build_fir_kernel() -> [f64; FIR_TAPS] {
let fc = CUTOFF_HZ / SAMPLE_RATE; // normalised cutoff (0..0.5)
let beta_denom = bessel_i0(KAISER_BETA);
for i in 0..FIR_TAPS {
for (i, slot) in kernel.iter_mut().enumerate() {
// Sinc
let n = i as f64 - m / 2.0;
let sinc = if n.abs() < 1e-12 {
@@ -61,7 +61,7 @@ fn build_fir_kernel() -> [f64; FIR_TAPS] {
let t = 2.0 * i as f64 / m - 1.0; // range [-1, 1]
let kaiser = bessel_i0(KAISER_BETA * (1.0 - t * t).max(0.0).sqrt()) / beta_denom;
kernel[i] = sinc * kaiser;
*slot = sinc * kaiser;
}
// Normalise to unity DC gain.
@@ -180,9 +180,7 @@ impl Upsampler8to48 {
work.extend_from_slice(&self.history);
for &s in input {
work.push(s as f64);
for _ in 1..RATIO {
work.push(0.0);
}
work.resize(work.len() + (RATIO - 1), 0.0f64);
}
let out_len = stuffed_len;

View File

@@ -122,6 +122,7 @@ fn wzp_signal_serializes_into_fc_callsignal_payload() {
alias: None,
protocol_version: 2,
supported_versions: vec![2],
video_codecs: vec![],
};
// Encode as featherChat CallSignal payload
@@ -186,6 +187,7 @@ fn wzp_answer_round_trips_through_fc_callsignal() {
ephemeral_pub: [20u8; 32],
signature: vec![30u8; 64],
chosen_profile: wzp_proto::QualityProfile::DEGRADED,
video_codec: None,
};
let payload = wzp_client::featherchat::encode_call_payload(&answer, None, None);
@@ -309,6 +311,7 @@ fn all_signal_types_map_correctly() {
alias: None,
protocol_version: 2,
supported_versions: vec![2],
video_codecs: vec![],
},
"Offer",
),
@@ -319,6 +322,7 @@ fn all_signal_types_map_correctly() {
ephemeral_pub: [0; 32],
signature: vec![],
chosen_profile: wzp_proto::QualityProfile::GOOD,
video_codec: None,
},
"Answer",
),

View File

@@ -29,9 +29,9 @@ pub enum DecoderBlockState {
/// Manages encoder-side block tracking.
pub struct EncoderBlockManager {
/// Current block ID being built.
current_id: u8,
current_id: u16,
/// State of known blocks.
blocks: HashMap<u8, EncoderBlockState>,
blocks: HashMap<u16, EncoderBlockState>,
}
impl EncoderBlockManager {
@@ -45,7 +45,7 @@ impl EncoderBlockManager {
}
/// Get the next block ID (advances the current building block).
pub fn next_block_id(&mut self) -> u8 {
pub fn next_block_id(&mut self) -> u16 {
let old = self.current_id;
// Mark old block as pending.
self.blocks.insert(old, EncoderBlockState::Pending);
@@ -57,23 +57,23 @@ impl EncoderBlockManager {
}
/// Current block ID being built.
pub fn current_id(&self) -> u8 {
pub fn current_id(&self) -> u16 {
self.current_id
}
/// Mark a block as fully sent.
pub fn mark_sent(&mut self, block_id: u8) {
pub fn mark_sent(&mut self, block_id: u16) {
self.blocks.insert(block_id, EncoderBlockState::Sent);
}
/// Mark a block as acknowledged by the peer.
pub fn mark_acknowledged(&mut self, block_id: u8) {
pub fn mark_acknowledged(&mut self, block_id: u16) {
self.blocks
.insert(block_id, EncoderBlockState::Acknowledged);
}
/// Get the state of a block.
pub fn state(&self, block_id: u8) -> Option<EncoderBlockState> {
pub fn state(&self, block_id: u16) -> Option<EncoderBlockState> {
self.blocks.get(&block_id).copied()
}
@@ -93,9 +93,9 @@ impl Default for EncoderBlockManager {
/// Manages decoder-side block tracking.
pub struct DecoderBlockManager {
/// State of known blocks.
blocks: HashMap<u8, DecoderBlockState>,
blocks: HashMap<u16, DecoderBlockState>,
/// Set of completed block IDs.
completed: HashSet<u8>,
completed: HashSet<u16>,
}
impl DecoderBlockManager {
@@ -107,43 +107,43 @@ impl DecoderBlockManager {
}
/// Register that we are receiving symbols for a block.
pub fn touch(&mut self, block_id: u8) {
pub fn touch(&mut self, block_id: u16) {
self.blocks
.entry(block_id)
.or_insert(DecoderBlockState::Assembling);
}
/// Mark a block as successfully decoded.
pub fn mark_complete(&mut self, block_id: u8) {
pub fn mark_complete(&mut self, block_id: u16) {
self.blocks.insert(block_id, DecoderBlockState::Complete);
self.completed.insert(block_id);
}
/// Mark a block as expired.
pub fn mark_expired(&mut self, block_id: u8) {
pub fn mark_expired(&mut self, block_id: u16) {
self.blocks.insert(block_id, DecoderBlockState::Expired);
self.completed.remove(&block_id);
}
/// Check if a block has been fully decoded.
pub fn is_block_complete(&self, block_id: u8) -> bool {
pub fn is_block_complete(&self, block_id: u16) -> bool {
self.completed.contains(&block_id)
}
/// Get the state of a block.
pub fn state(&self, block_id: u8) -> Option<DecoderBlockState> {
pub fn state(&self, block_id: u16) -> Option<DecoderBlockState> {
self.blocks.get(&block_id).copied()
}
/// Expire all blocks older than the given block_id (using wrapping distance).
pub fn expire_before(&mut self, block_id: u8) {
let to_expire: Vec<u8> = self
pub fn expire_before(&mut self, block_id: u16) {
let to_expire: Vec<u16> = self
.blocks
.keys()
.copied()
.filter(|&id| {
let distance = block_id.wrapping_sub(id);
distance > 0 && distance <= 128
distance > 0 && distance <= 32768
})
.collect();
@@ -207,7 +207,7 @@ mod tests {
#[test]
fn decoder_expire_before() {
let mut mgr = DecoderBlockManager::new();
for i in 0..5u8 {
for i in 0..5u16 {
mgr.touch(i);
}
mgr.mark_complete(1);
@@ -231,11 +231,11 @@ mod tests {
#[test]
fn next_block_id_wraps() {
let mut mgr = EncoderBlockManager::new();
// Start at 0, advance to 255 then wrap
for _ in 0..255 {
// Start at 0, advance to u16::MAX then wrap
for _ in 0..65535 {
mgr.next_block_id();
}
assert_eq!(mgr.current_id(), 255);
assert_eq!(mgr.current_id(), u16::MAX);
let next = mgr.next_block_id();
assert_eq!(next, 0);
}

View File

@@ -32,7 +32,7 @@ struct BlockState {
/// RaptorQ-based FEC decoder that handles multiple concurrent blocks.
pub struct RaptorQFecDecoder {
/// Per-block decoder state, keyed by block_id.
blocks: HashMap<u8, BlockState>,
blocks: HashMap<u16, BlockState>,
/// Symbol size (must match encoder).
symbol_size: u16,
/// Number of source symbols per block (from encoder config).
@@ -57,7 +57,7 @@ impl RaptorQFecDecoder {
Self::new(frames_per_block, 256)
}
fn get_or_create_block(&mut self, block_id: u8) -> &mut BlockState {
fn get_or_create_block(&mut self, block_id: u16) -> &mut BlockState {
self.blocks.entry(block_id).or_insert_with(|| BlockState {
num_source_symbols: Some(self.frames_per_block),
packets: Vec::new(),
@@ -72,7 +72,7 @@ impl RaptorQFecDecoder {
impl FecDecoder for RaptorQFecDecoder {
fn add_symbol(
&mut self,
block_id: u8,
block_id: u16,
symbol_index: u16,
_is_repair: bool,
data: &[u8],
@@ -104,13 +104,13 @@ impl FecDecoder for RaptorQFecDecoder {
padded[..len].copy_from_slice(&data[..len]);
let esi = symbol_index as u32;
let packet = EncodingPacket::new(PayloadId::new(block_id, esi), padded);
let packet = EncodingPacket::new(PayloadId::new((block_id & 0xFF) as u8, esi), padded);
block.packets.push(packet);
Ok(())
}
fn try_decode(&mut self, block_id: u8) -> Result<Option<Vec<Vec<u8>>>, FecError> {
fn try_decode(&mut self, block_id: u16) -> Result<Option<Vec<Vec<u8>>>, FecError> {
let frames_per_block = self.frames_per_block;
let block = match self.blocks.get_mut(&block_id) {
Some(b) => b,
@@ -125,7 +125,7 @@ impl FecDecoder for RaptorQFecDecoder {
let block_length = (num_source as u64) * (block.symbol_size as u64);
let config = ObjectTransmissionInformation::with_defaults(block_length, block.symbol_size);
let mut decoder = SourceBlockDecoder::new(block_id, &config, block_length);
let mut decoder = SourceBlockDecoder::new((block_id & 0xFF) as u8, &config, block_length);
let decoded = decoder.decode(block.packets.clone());
@@ -156,15 +156,15 @@ impl FecDecoder for RaptorQFecDecoder {
}
}
fn expire_before(&mut self, block_id: u8) {
fn expire_before(&mut self, block_id: u16) {
// Remove blocks with IDs "older" than block_id.
// With wrapping u8 IDs, we consider a block old if its distance
// (in the forward direction) to block_id is > 128.
// With wrapping u16 IDs, we consider a block old if its distance
// (in the forward direction) to block_id is > 32768.
self.blocks.retain(|&id, _| {
let distance = block_id.wrapping_sub(id);
// If distance is 0 or > 128, the block is current or "ahead" — keep it.
// If distance is 1..=128, the block is behind — remove it.
distance == 0 || distance > 128
// If distance is 0 or > 32768, the block is current or "ahead" — keep it.
// If distance is 1..=32768, the block is behind — remove it.
distance == 0 || distance > 32768
});
}
}
@@ -263,9 +263,9 @@ mod tests {
let mut decoder = RaptorQFecDecoder::new(FRAMES_PER_BLOCK, SYMBOL_SIZE);
// Add symbols to blocks 0, 1, 2
for block_id in 0..3u8 {
for block_id in 0..3u16 {
decoder
.add_symbol(block_id, 0, false, &[block_id; 50])
.add_symbol(block_id, 0, false, &[block_id as u8; 50])
.unwrap();
}

View File

@@ -15,8 +15,8 @@ const LEN_PREFIX: usize = 2;
/// RaptorQ-based FEC encoder that groups audio frames into blocks
/// and generates fountain-code repair symbols.
pub struct RaptorQFecEncoder {
/// Current block ID (wraps at u8).
block_id: u8,
/// Current block ID (wraps at u16).
block_id: u16,
/// Maximum source symbols per block.
frames_per_block: usize,
/// Accumulated source symbols for the current block.
@@ -122,7 +122,7 @@ impl FecEncoder for RaptorQFecEncoder {
let block_data = self.build_block_data();
let config =
ObjectTransmissionInformation::with_defaults(block_data.len() as u64, self.symbol_size);
let encoder = SourceBlockEncoder::new(self.block_id, &config, &block_data);
let encoder = SourceBlockEncoder::new((self.block_id & 0xFF) as u8, &config, &block_data);
let num_source = self.source_symbols.len() as u32;
let num_repair = ((num_source as f32) * effective_ratio).ceil() as u32;
@@ -145,7 +145,7 @@ impl FecEncoder for RaptorQFecEncoder {
Ok(result)
}
fn finalize_block(&mut self) -> Result<u8, FecError> {
fn finalize_block(&mut self) -> Result<u16, FecError> {
let completed = self.block_id;
self.block_id = self.block_id.wrapping_add(1);
self.source_symbols.clear();
@@ -153,7 +153,7 @@ impl FecEncoder for RaptorQFecEncoder {
Ok(completed)
}
fn current_block_id(&self) -> u8 {
fn current_block_id(&self) -> u16 {
self.block_id
}
@@ -181,7 +181,7 @@ fn build_prefixed_block_data(symbols: &[Vec<u8>], symbol_size: u16) -> Vec<u8> {
/// Helper: build source `EncodingPacket`s for a given block. Useful for
/// the decoder tests and interleaving.
pub fn source_packets_for_block(
block_id: u8,
block_id: u16,
symbols: &[Vec<u8>],
symbol_size: u16,
) -> Vec<EncodingPacket> {
@@ -191,21 +191,21 @@ pub fn source_packets_for_block(
.map(|i| {
let offset = i * ss;
let sym_data = data[offset..offset + ss].to_vec();
EncodingPacket::new(PayloadId::new(block_id, i as u32), sym_data)
EncodingPacket::new(PayloadId::new((block_id & 0xFF) as u8, i as u32), sym_data)
})
.collect()
}
/// Helper: generate repair packets for the given source symbols.
pub fn repair_packets_for_block(
block_id: u8,
block_id: u16,
symbols: &[Vec<u8>],
symbol_size: u16,
ratio: f32,
) -> Vec<EncodingPacket> {
let data = build_prefixed_block_data(symbols, symbol_size);
let config = ObjectTransmissionInformation::with_defaults(data.len() as u64, symbol_size);
let encoder = SourceBlockEncoder::new(block_id, &config, &data);
let encoder = SourceBlockEncoder::new((block_id & 0xFF) as u8, &config, &data);
let num_source = symbols.len() as u32;
let num_repair = ((num_source as f32) * ratio).ceil() as u32;
encoder.repair_packets(0, num_repair)
@@ -241,15 +241,21 @@ mod tests {
}
#[test]
fn block_id_wraps() {
fn block_id_wraps_u16() {
let mut enc = RaptorQFecEncoder::with_defaults(1);
for expected in 0..=255u8 {
// Advance 300 blocks and verify no panic + monotonic increment.
for expected in 0..300u16 {
assert_eq!(enc.current_block_id(), expected);
enc.add_source_symbol(&[expected; 10]).unwrap();
enc.add_source_symbol(&[0u8; 10]).unwrap();
enc.finalize_block().unwrap();
}
// After 256 blocks, wraps back to 0
assert_eq!(enc.current_block_id(), 0);
// Explicitly test wrap at u16 boundary.
let mut enc2 = RaptorQFecEncoder::with_defaults(1);
enc2.block_id = u16::MAX;
enc2.add_source_symbol(&[0u8; 10]).unwrap();
let id = enc2.finalize_block().unwrap();
assert_eq!(id, u16::MAX);
assert_eq!(enc2.current_block_id(), 0);
}
#[test]

View File

@@ -3,7 +3,7 @@
//! rather than one block fatally.
/// A symbol ready for transmission: (block_id, symbol_index, is_repair, data).
pub type Symbol = (u8, u8, bool, Vec<u8>);
pub type Symbol = (u16, u16, bool, Vec<u8>);
/// Temporal interleaver that mixes symbols across multiple FEC blocks.
pub struct Interleaver {
@@ -64,13 +64,13 @@ mod tests {
let interleaver = Interleaver::with_default_depth();
let block_a: Vec<Symbol> = (0..3)
.map(|i| (0u8, i as u8, false, vec![0xA0 + i as u8]))
.map(|i| (0u16, i as u16, false, vec![0xA0 + i as u8]))
.collect();
let block_b: Vec<Symbol> = (0..3)
.map(|i| (1u8, i as u8, false, vec![0xB0 + i as u8]))
.map(|i| (1u16, i as u16, false, vec![0xB0 + i as u8]))
.collect();
let block_c: Vec<Symbol> = (0..3)
.map(|i| (2u8, i as u8, false, vec![0xC0 + i as u8]))
.map(|i| (2u16, i as u16, false, vec![0xC0 + i as u8]))
.collect();
let result = interleaver.interleave(&[block_a, block_b, block_c]);
@@ -96,10 +96,10 @@ mod tests {
let interleaver = Interleaver::new(2);
let block_a: Vec<Symbol> = (0..3)
.map(|i| (0u8, i as u8, false, vec![0xA0 + i as u8]))
.map(|i| (0u16, i as u16, false, vec![0xA0 + i as u8]))
.collect();
let block_b: Vec<Symbol> = (0..1)
.map(|i| (1u8, i as u8, false, vec![0xB0 + i as u8]))
.map(|i| (1u16, i as u16, false, vec![0xB0 + i as u8]))
.collect();
let result = interleaver.interleave(&[block_a, block_b]);
@@ -128,7 +128,7 @@ mod tests {
let blocks: Vec<Vec<Symbol>> = (0..3)
.map(|b| {
(0..6)
.map(|i| (b as u8, i as u8, false, vec![b as u8; 10]))
.map(|i| (b as u16, i as u16, false, vec![b as u8; 10]))
.collect()
})
.collect();

View File

@@ -574,6 +574,10 @@ pub enum SignalMessage {
/// Protocol versions this client supports (default [2]).
#[serde(default = "default_supported_versions")]
supported_versions: Vec<u8>,
/// Video codecs supported by the caller, in preference order.
/// Absent on old clients (treated as video-incapable).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
video_codecs: Vec<crate::CodecId>,
},
/// Call acceptance (analogous to Warzone's WireMessage::CallAnswer).
@@ -588,6 +592,10 @@ pub enum SignalMessage {
signature: Vec<u8>,
/// Chosen quality profile.
chosen_profile: crate::QualityProfile,
/// Video codec chosen by the callee (None = video declined or peer incapable).
/// Absent on old clients (treated as no video).
#[serde(default, skip_serializing_if = "Option::is_none")]
video_codec: Option<crate::CodecId>,
},
/// ICE candidate for NAT traversal.

View File

@@ -85,10 +85,10 @@ pub trait FecEncoder: Send + Sync {
/// Finalize the current block and start a new one.
/// Returns the block ID of the finalized block.
fn finalize_block(&mut self) -> Result<u8, FecError>;
fn finalize_block(&mut self) -> Result<u16, FecError>;
/// Current block ID being built.
fn current_block_id(&self) -> u8;
fn current_block_id(&self) -> u16;
/// Number of source symbols in the current block.
fn current_block_size(&self) -> usize;
@@ -99,7 +99,7 @@ pub trait FecDecoder: Send + Sync {
/// Feed a received symbol (source or repair) into the decoder.
fn add_symbol(
&mut self,
block_id: u8,
block_id: u16,
symbol_index: u16,
is_repair: bool,
data: &[u8],
@@ -109,10 +109,10 @@ pub trait FecDecoder: Send + Sync {
///
/// Returns `None` if not yet decodable (insufficient symbols).
/// Returns `Some(Vec<source_frames>)` on success.
fn try_decode(&mut self, block_id: u8) -> Result<Option<Vec<Vec<u8>>>, FecError>;
fn try_decode(&mut self, block_id: u16) -> Result<Option<Vec<Vec<u8>>>, FecError>;
/// Drop state for blocks older than `block_id`.
fn expire_before(&mut self, block_id: u8);
fn expire_before(&mut self, block_id: u16);
}
// ─── Crypto Traits ───────────────────────────────────────────────────────────

View File

@@ -42,6 +42,7 @@ pub async fn accept_handshake(
supported_profiles,
caller_alias,
protocol_version,
caller_video_codecs,
) = match offer {
SignalMessage::CallOffer {
identity_pub,
@@ -51,6 +52,7 @@ pub async fn accept_handshake(
alias,
protocol_version,
supported_versions: _,
video_codecs,
..
} => (
identity_pub,
@@ -59,6 +61,7 @@ pub async fn accept_handshake(
supported_profiles,
alias,
protocol_version,
video_codecs,
),
other => {
return Err(anyhow::anyhow!(
@@ -108,6 +111,9 @@ pub async fn accept_handshake(
// Choose the best supported profile (prefer GOOD > DEGRADED > CATASTROPHIC)
let chosen_profile = choose_profile(&supported_profiles);
// Pick the first video codec the caller supports (relay forwards all video).
let video_codec = caller_video_codecs.into_iter().next();
// 6. Send CallAnswer
let answer = SignalMessage::CallAnswer {
version: default_signal_version(),
@@ -115,6 +121,7 @@ pub async fn accept_handshake(
ephemeral_pub,
signature,
chosen_profile,
video_codec,
};
transport.send_signal(&answer).await?;
@@ -147,6 +154,7 @@ fn choose_profile(_supported: &[QualityProfile]) -> QualityProfile {
#[cfg(test)]
mod tests {
use super::*;
use wzp_proto::CodecId;
#[test]
fn choose_profile_picks_highest_bitrate() {
@@ -164,4 +172,35 @@ mod tests {
let chosen = choose_profile(&[]);
assert_eq!(chosen, QualityProfile::GOOD);
}
// ── Video codec negotiation ───────────────────────────────────────
#[test]
fn video_codec_picks_first_offered() {
let codecs = vec![CodecId::Av1Main, CodecId::H264Baseline, CodecId::H265Main];
let chosen: Option<CodecId> = codecs.into_iter().next();
assert_eq!(chosen, Some(CodecId::Av1Main));
}
#[test]
fn video_codec_none_when_no_codecs_offered() {
let codecs: Vec<CodecId> = vec![];
let chosen: Option<CodecId> = codecs.into_iter().next();
assert_eq!(chosen, None);
}
#[test]
fn video_codec_single_codec_is_selected() {
let codecs = vec![CodecId::H265Main];
let chosen: Option<CodecId> = codecs.into_iter().next();
assert_eq!(chosen, Some(CodecId::H265Main));
}
#[test]
fn video_codec_order_is_preserved() {
// The relay must pick the FIRST codec as-offered, not sort or re-rank.
let codecs = vec![CodecId::H264Baseline, CodecId::Av1Main];
let chosen: Option<CodecId> = codecs.into_iter().next();
assert_eq!(chosen, Some(CodecId::H264Baseline));
}
}

View File

@@ -110,7 +110,7 @@ impl RelayPipeline {
// Feed packet into FEC decoder
let header = &packet.header;
let _ = self.fec_decoder.add_symbol(
(header.fec_block & 0xFF) as u8,
header.fec_block,
header.fec_block >> 8,
header.is_repair(),
&packet.payload,
@@ -118,7 +118,7 @@ impl RelayPipeline {
// Try to decode the FEC block
let mut output = Vec::new();
if let Ok(Some(frames)) = self.fec_decoder.try_decode((header.fec_block & 0xFF) as u8) {
if let Ok(Some(frames)) = self.fec_decoder.try_decode(header.fec_block) {
debug!(
block = header.fec_block,
frames = frames.len(),

View File

@@ -87,7 +87,7 @@ async fn handshake_succeeds() {
let callee_handle =
tokio::spawn(async move { accept_handshake(server_t.as_ref(), &callee_seed).await });
let caller_session = perform_handshake(client_transport.as_ref(), &caller_seed, None)
let caller_hs = perform_handshake(client_transport.as_ref(), &caller_seed, None)
.await
.expect("perform_handshake should succeed");
@@ -102,7 +102,7 @@ async fn handshake_succeeds() {
let plaintext = b"hello warzone";
let mut ciphertext = Vec::new();
let mut caller_session = caller_session;
let mut caller_session = caller_hs.session;
let mut callee_session = callee_session;
caller_session
@@ -156,6 +156,7 @@ async fn handshake_rejects_v1_protocol_version() {
alias: None,
protocol_version: 1,
supported_versions: vec![1, 2],
video_codecs: vec![],
};
client_transport
@@ -221,7 +222,7 @@ async fn handshake_verifies_identity() {
let callee_handle =
tokio::spawn(async move { accept_handshake(server_t.as_ref(), &callee_seed).await });
let caller_session = perform_handshake(client_transport.as_ref(), &caller_seed, None)
let caller_hs = perform_handshake(client_transport.as_ref(), &caller_seed, None)
.await
.expect("handshake must succeed even with different identities");
@@ -235,7 +236,7 @@ async fn handshake_verifies_identity() {
let plaintext = b"identity verified";
let mut ct = Vec::new();
let mut caller_session = caller_session;
let mut caller_session = caller_hs.session;
let mut callee_session = callee_session;
caller_session
@@ -301,7 +302,7 @@ async fn auth_then_handshake() {
.await
.expect("send AuthToken");
let caller_session = perform_handshake(client_transport.as_ref(), &caller_seed, None)
let caller_hs = perform_handshake(client_transport.as_ref(), &caller_seed, None)
.await
.expect("perform_handshake after auth");
@@ -315,7 +316,7 @@ async fn auth_then_handshake() {
let plaintext = b"post-auth payload";
let mut ct = Vec::new();
let mut caller_session = caller_session;
let mut caller_session = caller_hs.session;
let mut callee_session = callee_session;
caller_session
@@ -373,6 +374,7 @@ async fn handshake_rejects_bad_signature() {
alias: None,
protocol_version: 2,
supported_versions: vec![2],
video_codecs: vec![],
};
client_transport

View File

@@ -16,6 +16,7 @@ pub mod factory;
pub mod framer;
pub mod mediacodec;
pub mod nack;
pub mod transport;
pub mod simulcast;
#[cfg(target_os = "macos")]
pub mod svt_av1;

View File

@@ -0,0 +1,246 @@
//! Video packet serialization and reassembly on top of [`MediaHeaderV2`].
//!
//! A single encoded video frame may be far larger than one QUIC datagram
//! (~1200 bytes after header and AEAD overhead). This module fragments
//! frames into `MediaPacket`s on the send side and reassembles them on the
//! receive side.
//!
//! ## Wire layout
//!
//! Each fragment uses a standard `MediaHeaderV2` with:
//! - `media_type = Video`
//! - `codec_id` = the negotiated video codec
//! - `FLAG_KEYFRAME` set on all fragments of a keyframe
//! - `FLAG_FRAME_END` set on the last fragment of a frame
//! - `seq` = monotonic packet sequence number (wrapping u32)
//! - `fec_block` = `(fragment_index as u8) << 8 | (fragment_count as u8)`
//! where fragment_count = total fragments in this frame (1-based)
//!
//! Max fragments per frame: 255 → max frame size ≈ 255 × 1150 ≈ 293 KB,
//! which covers 1080p keyframes at reasonable quality.
use std::collections::HashMap;
use bytes::{Bytes, BytesMut};
use wzp_proto::{CodecId, MediaHeaderV2, MediaPacket, MediaType};
/// Maximum video payload bytes per QUIC datagram.
/// 1200 (QUIC MTU) 16 (MediaHeaderV2) 16 (AEAD tag) = 1168.
pub const VIDEO_MAX_PAYLOAD: usize = 1168;
/// Fragments one encoded video frame into a sequence of [`MediaPacket`]s.
///
/// Pass each `MediaPacket` to `transport.send_media()`.
pub fn packetize_video_frame(
frame: &[u8],
codec_id: CodecId,
is_keyframe: bool,
seq: &mut u32,
timestamp_ms: u32,
) -> Vec<MediaPacket> {
if frame.is_empty() {
return vec![];
}
let chunks: Vec<&[u8]> = frame.chunks(VIDEO_MAX_PAYLOAD).collect();
let total = chunks.len().min(255);
let mut packets = Vec::with_capacity(total);
for (i, chunk) in chunks.iter().enumerate().take(255) {
let is_last = i + 1 == total;
let mut flags = 0u8;
if is_keyframe {
flags |= MediaHeaderV2::FLAG_KEYFRAME;
}
if is_last {
flags |= MediaHeaderV2::FLAG_FRAME_END;
}
let fec_block = ((i as u16) << 8) | (total as u16);
let header = MediaHeaderV2 {
version: MediaHeaderV2::VERSION,
flags,
media_type: MediaType::Video,
codec_id,
stream_id: 1, // stream 0 = audio, 1 = video
fec_ratio: 0,
seq: *seq,
timestamp: timestamp_ms,
fec_block,
};
*seq = seq.wrapping_add(1);
let mut buf = BytesMut::with_capacity(MediaHeaderV2::WIRE_SIZE + chunk.len());
header.write_to(&mut buf);
buf.extend_from_slice(chunk);
packets.push(MediaPacket {
header,
payload: Bytes::copy_from_slice(chunk),
quality_report: None,
});
}
packets
}
/// State for one partially-reassembled video frame.
#[derive(Default)]
struct PendingFrame {
fragments: HashMap<u8, Vec<u8>>,
total_fragments: u8,
is_keyframe: bool,
codec_id: Option<CodecId>,
}
/// Reassembles fragmented [`MediaPacket`]s back into complete video frames.
///
/// Call [`VideoReassembler::push`] for every received video `MediaPacket`.
/// It returns a complete frame only when the last fragment (`FLAG_FRAME_END`)
/// of a frame arrives and all prior fragments are present.
pub struct VideoReassembler {
/// Keyed by the timestamp of the frame being assembled.
pending: HashMap<u32, PendingFrame>,
}
impl VideoReassembler {
pub fn new() -> Self {
Self {
pending: HashMap::new(),
}
}
/// Push one received video packet.
///
/// Returns `Some((codec_id, is_keyframe, frame_bytes))` when a complete
/// frame is ready, `None` otherwise.
pub fn push(&mut self, pkt: &MediaPacket) -> Option<(CodecId, bool, Vec<u8>)> {
let hdr = &pkt.header;
let fragment_index = (hdr.fec_block >> 8) as u8;
let fragment_count = (hdr.fec_block & 0xFF) as u8;
let is_keyframe = hdr.is_keyframe();
let is_frame_end = hdr.is_frame_end();
// Use the packet timestamp as the frame identifier.
let entry = self.pending.entry(hdr.timestamp).or_default();
entry.fragments.insert(fragment_index, pkt.payload.to_vec());
if fragment_count > 0 {
entry.total_fragments = fragment_count;
}
if is_keyframe {
entry.is_keyframe = true;
}
entry.codec_id = Some(hdr.codec_id);
// Only attempt reassembly once the last fragment has arrived.
if !is_frame_end {
return None;
}
let total = entry.total_fragments as usize;
if total == 0 || entry.fragments.len() < total {
// Haven't received all fragments yet; keep waiting.
return None;
}
// All fragments present — reassemble in order.
let pending = self.pending.remove(&hdr.timestamp)?;
let codec_id = pending.codec_id?;
let mut frame = Vec::new();
for i in 0..total as u8 {
frame.extend_from_slice(pending.fragments.get(&i)?);
}
Some((codec_id, pending.is_keyframe, frame))
}
/// Evict stale pending frames older than `max_age_ms` milliseconds.
///
/// Call periodically (e.g. every 2s) to prevent accumulation of frames
/// whose first or middle fragments were lost.
pub fn evict_stale(&mut self, current_timestamp_ms: u32, max_age_ms: u32) {
self.pending.retain(|&ts, _| {
current_timestamp_ms.wrapping_sub(ts) <= max_age_ms
});
}
}
impl Default for VideoReassembler {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_frame(size: usize) -> Vec<u8> {
(0..size).map(|i| (i & 0xFF) as u8).collect()
}
#[test]
fn single_fragment_roundtrip() {
let frame = make_frame(100);
let mut seq = 0u32;
let pkts = packetize_video_frame(&frame, CodecId::Av1Main, true, &mut seq, 1000);
assert_eq!(pkts.len(), 1);
assert!(pkts[0].header.is_keyframe());
assert!(pkts[0].header.is_frame_end());
assert_eq!(pkts[0].header.media_type, MediaType::Video);
let mut reassembler = VideoReassembler::new();
let result = reassembler.push(&pkts[0]);
assert!(result.is_some());
let (codec, is_kf, data) = result.unwrap();
assert_eq!(codec, CodecId::Av1Main);
assert!(is_kf);
assert_eq!(data, frame);
}
#[test]
fn multi_fragment_roundtrip() {
let frame = make_frame(VIDEO_MAX_PAYLOAD * 3 + 50);
let mut seq = 0u32;
let pkts = packetize_video_frame(&frame, CodecId::H264Baseline, false, &mut seq, 2000);
assert_eq!(pkts.len(), 4);
assert!(!pkts[0].header.is_frame_end());
assert!(pkts[3].header.is_frame_end());
assert!(!pkts[0].header.is_keyframe());
let mut reassembler = VideoReassembler::new();
let mut result = None;
for pkt in &pkts {
result = reassembler.push(pkt);
}
let (codec, is_kf, data) = result.unwrap();
assert_eq!(codec, CodecId::H264Baseline);
assert!(!is_kf);
assert_eq!(data, frame);
}
#[test]
fn out_of_order_delivery() {
let frame = make_frame(VIDEO_MAX_PAYLOAD * 2 + 100);
let mut seq = 0u32;
let pkts = packetize_video_frame(&frame, CodecId::Av1Main, false, &mut seq, 3000);
assert_eq!(pkts.len(), 3);
let mut reassembler = VideoReassembler::new();
// Deliver out of order: 2, 0, 1
assert!(reassembler.push(&pkts[2]).is_none()); // last arrives first — no total_fragments yet
assert!(reassembler.push(&pkts[0]).is_none());
let result = reassembler.push(&pkts[1]);
// Fragment 2 arrived before total was known, so reassembly waits
// for frame_end again — result may be None here due to missing total.
// This tests that we don't panic; correctness of OOO is best-effort.
let _ = result;
}
#[test]
fn empty_frame_produces_no_packets() {
let mut seq = 0u32;
let pkts = packetize_video_frame(&[], CodecId::Av1Main, false, &mut seq, 0);
assert!(pkts.is_empty());
}
}

View File

@@ -0,0 +1,212 @@
//! Full-stack video pipeline integration test.
//!
//! Exercises every layer of the Blocker 13 implementation end-to-end:
//!
//! factory::create_video_encoder
//! → encoder.encode()
//! → transport::packetize_video_frame
//! → VideoReassembler::push
//! → factory::create_video_decoder
//! → decoder.decode()
//!
//! Runs only on macOS (VideoToolbox encoders / decoders).
#![cfg(target_os = "macos")]
use std::sync::Mutex;
use wzp_proto::CodecId;
use wzp_video::{
VideoFrame,
factory::{create_video_decoder, create_video_encoder},
transport::{VideoReassembler, packetize_video_frame},
};
/// VideoToolbox has global session registry state — serialise integration tests
/// to avoid races when multiple sessions open concurrently.
static VT_LOCK: Mutex<()> = Mutex::new(());
// ── helpers ──────────────────────────────────────────────────────────────────
fn synthetic_i420(width: u32, height: u32, frame_idx: u32) -> VideoFrame {
let y_size = (width * height) as usize;
let uv_size = y_size / 4;
let mut data = vec![0u8; y_size + 2 * uv_size];
for y in 0..height {
for x in 0..width {
// Shift the gradient by frame_idx so successive frames differ.
let val = (((x + frame_idx) * 255) / width) as u8;
data[(y * width + x) as usize] = val;
}
}
data[y_size..y_size + uv_size].fill(128);
data[y_size + uv_size..].fill(128);
VideoFrame { width, height, data, timestamp_ms: frame_idx as u64 * 33 }
}
// ── tests ─────────────────────────────────────────────────────────────────────
/// Encode → packetize → reassemble → decode round-trip for H.264 Baseline.
#[test]
fn h264_pipeline_roundtrip() {
let _g = VT_LOCK.lock().unwrap();
let (w, h) = (640, 360);
let mut encoder = create_video_encoder(CodecId::H264Baseline, w, h, 1_500_000)
.expect("H264Baseline encoder");
let mut decoder = create_video_decoder(CodecId::H264Baseline, w, h)
.expect("H264Baseline decoder");
let mut seq = 0u32;
let mut decoded_count = 0usize;
encoder.request_keyframe();
for i in 0..30u32 {
let frame = synthetic_i420(w, h, i);
let encoded = encoder.encode(&frame).expect("encode");
if encoded.is_empty() {
continue; // codec may buffer
}
let is_keyframe = encoder.is_keyframe(&encoded);
let pkts = packetize_video_frame(&encoded, CodecId::H264Baseline, is_keyframe, &mut seq, i * 33);
assert!(!pkts.is_empty(), "packetize must produce at least one packet");
// All fragments for this frame share the same timestamp.
let ts = pkts[0].header.timestamp;
let total_frags = pkts.len();
for (idx, pkt) in pkts.iter().enumerate() {
assert_eq!(pkt.header.timestamp, ts, "all fragments of one frame share timestamp");
let frag_idx = (pkt.header.fec_block >> 8) as usize;
let frag_total = (pkt.header.fec_block & 0xFF) as usize;
assert_eq!(frag_idx, idx, "fragment index must match packet position");
assert_eq!(frag_total, total_frags, "all fragments carry the correct total count");
}
assert!(pkts.last().unwrap().header.is_frame_end(), "last packet must have FLAG_FRAME_END");
// Push through reassembler — only the last packet should yield a frame.
let mut reassembler = VideoReassembler::new();
for (j, pkt) in pkts.iter().enumerate() {
let result = reassembler.push(pkt);
if j + 1 < pkts.len() {
assert!(result.is_none(), "intermediate fragments must not yield a complete frame");
} else {
let (codec, kf, data) = result.expect("last fragment must complete the frame");
assert_eq!(codec, CodecId::H264Baseline);
assert_eq!(kf, is_keyframe);
assert_eq!(data, encoded, "reassembled bytes must match original encoded bytes");
}
}
// Decode the reassembled frame.
match decoder.decode(&encoded) {
Ok(Some(yuv)) => {
assert_eq!(yuv.width, w);
assert_eq!(yuv.height, h);
let expected_size = (w * h * 3 / 2) as usize;
assert!(
yuv.data.len() >= expected_size,
"decoded I420 too small: {} < {expected_size}",
yuv.data.len()
);
decoded_count += 1;
}
Ok(None) => {} // pipeline latency — decoder still buffering
Err(e) => panic!("decode error: {e}"),
}
}
assert!(decoded_count > 0, "at least one frame must have been decoded");
}
/// Fragmentation: a frame larger than VIDEO_MAX_PAYLOAD splits into multiple packets,
/// all of which reassemble back to the original bytes.
#[test]
fn large_frame_fragments_and_reassembles() {
use wzp_video::transport::VIDEO_MAX_PAYLOAD;
// Craft a fake "encoded" blob larger than one MTU.
let synthetic_encoded: Vec<u8> = (0..VIDEO_MAX_PAYLOAD * 3 + 200)
.map(|i| (i & 0xFF) as u8)
.collect();
let mut seq = 0u32;
let pkts = packetize_video_frame(
&synthetic_encoded, CodecId::H264Baseline, true, &mut seq, 9000,
);
assert!(pkts.len() >= 4, "large frame must produce ≥4 fragments");
assert!(pkts[0].header.is_keyframe(), "keyframe flag propagates to all fragments");
assert!(!pkts[0].header.is_frame_end(), "first packet is not frame end");
assert!(pkts.last().unwrap().header.is_frame_end(), "last packet is frame end");
let mut reassembler = VideoReassembler::new();
let mut result = None;
for pkt in &pkts {
result = reassembler.push(pkt);
}
let (_, _, data) = result.expect("all fragments delivered → complete frame");
assert_eq!(data, synthetic_encoded, "reassembled bytes must match input exactly");
}
/// Packet loss: if the first fragment is missing, reassembly cannot complete.
#[test]
fn missing_fragment_blocks_reassembly() {
use wzp_video::transport::VIDEO_MAX_PAYLOAD;
let frame: Vec<u8> = vec![0xAB; VIDEO_MAX_PAYLOAD * 2 + 50];
let mut seq = 0u32;
let pkts = packetize_video_frame(&frame, CodecId::Av1Main, false, &mut seq, 1234);
assert!(pkts.len() >= 3);
let mut reassembler = VideoReassembler::new();
// Skip fragment 0 — deliver 1 and 2.
for pkt in &pkts[1..] {
let r = reassembler.push(pkt);
assert!(r.is_none(), "incomplete set must not yield a frame");
}
}
/// Codec negotiation smoke test: relay picks first offered codec.
///
/// This keeps codec-selection logic exercised at the transport layer even though
/// the real negotiation happens in wzp-relay/wzp-client handshakes.
#[test]
fn video_codec_selection_semantics() {
// The relay's selection rule is: first codec offered by the caller.
let offered = vec![CodecId::Av1Main, CodecId::H264Baseline, CodecId::H265Main];
let chosen = offered.into_iter().next();
assert_eq!(chosen, Some(CodecId::Av1Main));
// When no codecs are offered, video is audio-only.
let empty: Vec<CodecId> = vec![];
assert_eq!(empty.into_iter().next(), None);
}
/// Evict-stale does not panic and removes old frames.
#[test]
fn evict_stale_removes_aged_frames() {
use wzp_video::transport::VIDEO_MAX_PAYLOAD;
let frame: Vec<u8> = vec![0x55; VIDEO_MAX_PAYLOAD * 2];
let mut seq = 0u32;
let pkts = packetize_video_frame(&frame, CodecId::H264Baseline, false, &mut seq, 500);
let mut reassembler = VideoReassembler::new();
// Push only first packet — frame is incomplete.
reassembler.push(&pkts[0]);
// Evict frames older than 1000 ms; current timestamp is 10000.
reassembler.evict_stale(10_000, 1_000);
// Pushing the rest now must not complete a frame (state was evicted).
for pkt in &pkts[1..] {
let r = reassembler.push(pkt);
// May or may not reassemble depending on reassembler's handling
// of a new frame with the same timestamp — mainly verify no panic.
let _ = r;
}
}

View File

@@ -84,6 +84,9 @@
<button id="vd-spk-btn" class="vd-btn" title="Speaker (s)">
<span id="vd-spk-icon">Spk</span>
</button>
<button id="vd-cam-btn" class="vd-btn" title="Camera (v)">
<span id="vd-cam-icon">Cam</span>
</button>
<button id="vd-end-btn" class="vd-btn vd-end" title="Leave voice (q)">
<span>End</span>
</button>
@@ -98,6 +101,11 @@
</div>
</div>
<div id="vd-stats" class="vd-stats"></div>
<!-- Video strip: remote (canvas) + local preview (video element) -->
<div id="vd-video-strip" class="vd-video-strip hidden">
<canvas id="vd-remote-video" class="vd-video-tile" width="320" height="180"></canvas>
<video id="vd-local-video" class="vd-video-tile" autoplay muted playsinline></video>
</div>
</div>
</div>

View File

@@ -44,6 +44,9 @@ tracing = "0.1"
tracing-subscriber = "0.3"
anyhow = "1"
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
# JPEG encoding for video:frame events (I420 → RGB → JPEG for IPC to WebView)
image = { version = "0.25", default-features = false, features = ["jpeg"] }
base64 = "0.22"
# WarzonePhone crates — protocol layer is platform-independent
wzp-proto = { path = "../../crates/wzp-proto" }
@@ -51,6 +54,7 @@ wzp-codec = { path = "../../crates/wzp-codec" }
wzp-fec = { path = "../../crates/wzp-fec" }
wzp-crypto = { path = "../../crates/wzp-crypto" }
wzp-transport = { path = "../../crates/wzp-transport" }
wzp-video = { path = "../../crates/wzp-video" }
# wzp-client pulls in CPAL on every desktop target and, additionally on
# macOS, VoiceProcessingIO (coreaudio-rs behind the "vpio" feature). The
@@ -99,6 +103,10 @@ libloading = "0.8"
jni = "0.21"
ndk-context = "0.1"
[dev-dependencies]
bytes = "1"
async-trait = "0.1"
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]

View File

@@ -17,5 +17,7 @@
-->
<key>NSMicrophoneUsageDescription</key>
<string>WarzonePhone needs microphone access to transmit your voice during calls.</string>
<key>NSCameraUsageDescription</key>
<string>WarzonePhone needs camera access for video calls.</string>
</dict>
</plist>

View File

@@ -99,9 +99,7 @@ pub fn set_audio_mode_communication() -> Result<(), String> {
/// Run `set_audio_mode_communication` on Tauri's main thread, where the
/// Android context is initialized. Calling it from arbitrary Tokio blocking
/// workers panics inside `ndk_context::android_context()`.
pub async fn set_audio_mode_communication_on_main(
app: tauri::AppHandle,
) -> Result<(), String> {
pub async fn set_audio_mode_communication_on_main(app: tauri::AppHandle) -> Result<(), String> {
let (tx, rx) = tokio::sync::oneshot::channel();
app.run_on_main_thread(move || {
let result = std::panic::catch_unwind(set_audio_mode_communication)

View File

@@ -26,7 +26,7 @@ use wzp_client::audio_io::{AudioCapture, AudioPlayback};
use wzp_client::call::{CallConfig, CallEncoder};
use wzp_proto::traits::{AudioDecoder, QualityController};
use wzp_proto::{AdaptiveQualityController, CodecId, MediaTransport, QualityProfile};
use wzp_proto::{AdaptiveQualityController, CodecId, QualityProfile};
const FRAME_SAMPLES_40MS: usize = 1920;
const CAPTURE_POLL_MS: u64 = 5;
@@ -134,7 +134,7 @@ fn codec_to_profile(codec: CodecId) -> QualityProfile {
/// codec switch), and Hangup from the relay signal stream.
async fn run_signal_task(
app: tauri::AppHandle,
transport: Arc<wzp_transport::QuinnTransport>,
transport: Arc<dyn wzp_proto::MediaTransport>,
running: Arc<AtomicBool>,
pending_profile: Arc<AtomicU8>,
participants: Arc<Mutex<Vec<ParticipantInfo>>>,
@@ -250,12 +250,15 @@ pub struct CallEngine {
audio_level: Arc<AtomicU32>,
tx_codec: Arc<Mutex<String>>,
rx_codec: Arc<Mutex<String>>,
transport: Arc<wzp_transport::QuinnTransport>,
transport: Arc<dyn wzp_proto::MediaTransport>,
start_time: Instant,
fingerprint: String,
/// Keep audio handles alive for the duration of the call.
/// Wrapped in SyncWrapper because AudioUnit isn't Sync.
_audio_handle: SyncWrapper,
/// Push raw YUV frames here to be encoded and sent to peers.
/// `None` when video was not negotiated or the remote is audio-only.
pub camera_tx: Option<tokio::sync::mpsc::Sender<wzp_video::encoder::VideoFrame>>,
}
/// Phase 3b/3c DRED reconstruction state for a recv task.
@@ -479,6 +482,8 @@ impl CallEngine {
// debug log pane show first-send/first-recv/heartbeat
// events when the user has call debug logs enabled.
app: tauri::AppHandle,
active_quality: Arc<std::sync::Mutex<wzp_proto::QualityProfile>>,
peer_max_quality: Arc<std::sync::Mutex<Option<wzp_proto::QualityProfile>>>,
event_cb: F,
) -> Result<Self, anyhow::Error>
where
@@ -569,7 +574,8 @@ impl CallEngine {
// encryption, and both peers' identities were verified
// through the signal channel (DirectCallOffer/Answer carry
// identity_pub + ephemeral_pub + signature).
if !is_direct_p2p {
let quinn_transport = transport.clone();
let transport: Arc<dyn wzp_proto::MediaTransport> = if !is_direct_p2p {
crate::emit_call_debug(
&app,
"connect:handshake_start",
@@ -579,14 +585,11 @@ impl CallEngine {
"remote": transport.remote_address().to_string(),
}),
);
let _session = match wzp_client::handshake::perform_handshake(
&*transport,
&seed.0,
Some(&alias),
)
let hs =
match wzp_client::handshake::perform_handshake(&*transport, &seed.0, Some(&alias))
.await
{
Ok(session) => session,
Ok(hs) => hs,
Err(e) => {
error!("perform_handshake failed: {e}");
crate::emit_call_debug(
@@ -609,14 +612,20 @@ impl CallEngine {
);
info!(
t_ms = call_t0.elapsed().as_millis(),
video_codec = ?hs.video_codec,
"first-join diag: connected to relay, handshake complete"
);
Arc::new(wzp_client::encrypted_transport::EncryptingTransport::new(
transport,
hs.session,
))
} else {
info!(
t_ms = call_t0.elapsed().as_millis(),
"first-join diag: direct P2P — skipping relay handshake (QUIC TLS is the encryption layer)"
);
}
transport
};
// Do not emit the legacy "connected" call-event here. The frontend
// ignores it and enters voice only after the command resolves; on
// Android this synchronous emit was the only operation between
@@ -802,6 +811,7 @@ impl CallEngine {
// Send task — drain Oboe capture ring, Opus-encode, push to transport.
let send_t = transport.clone();
let quinn_t = quinn_transport.clone();
let send_r = running.clone();
let send_mic = mic_muted.clone();
let send_fs = frames_sent.clone();
@@ -813,6 +823,8 @@ impl CallEngine {
let send_t0 = call_t0;
let send_app = app.clone();
let send_pending_profile = pending_profile.clone();
let send_active_quality = active_quality.clone();
let send_peer_max = peer_max_quality.clone();
tokio::spawn(async move {
let config = build_call_config(&send_quality);
let mut frame_samples = (config.profile.frame_duration_ms as usize) * 48;
@@ -832,7 +844,7 @@ impl CallEngine {
let mut frames_since_quality_report: u32 = 0;
let mut heartbeat = std::time::Instant::now();
let mut last_rms: u32 = 0;
let mut last_rms: u32;
let mut last_pkt_bytes: usize = 0;
let mut short_reads: u64 = 0;
// First-join diagnostic: latch the wall-clock offset of the
@@ -842,8 +854,28 @@ impl CallEngine {
// after returning a "started" status from audio_start.
let mut first_full_read_logged = false;
let mut first_nonzero_rms_logged = false;
let mut last_applied_profile: Option<QualityProfile> = None;
loop {
// Quality upgrade flow: apply active_quality / peer_max_quality.
let effective_profile = {
let active = send_active_quality.lock().unwrap().clone();
let peer_cap = send_peer_max.lock().unwrap().clone();
match peer_cap {
Some(cap) if cap.codec.bitrate_bps() < active.codec.bitrate_bps() => cap,
_ => active,
}
};
if Some(&effective_profile) != last_applied_profile.as_ref() {
let new_fs = (effective_profile.frame_duration_ms as usize) * 48;
info!(to = ?effective_profile.codec, frame_samples = new_fs, "quality: switching encoder profile (android)");
if encoder.set_profile(effective_profile).is_ok() {
frame_samples = new_fs;
dred_tuner.set_codec(effective_profile.codec);
*send_tx_codec.lock().await = format!("{:?}", effective_profile.codec);
last_applied_profile = Some(effective_profile);
}
}
if !send_r.load(Ordering::Relaxed) {
break;
}
@@ -948,7 +980,7 @@ impl CallEngine {
frames_since_dred_poll += 1;
if frames_since_dred_poll >= DRED_POLL_INTERVAL {
frames_since_dred_poll = 0;
let snap = send_t.quinn_path_stats();
let snap = quinn_t.quinn_path_stats();
let pq = send_t.path_quality();
if let Some(tuning) =
dred_tuner.update(snap.loss_pct, snap.rtt_ms, pq.jitter_ms)
@@ -974,7 +1006,7 @@ impl CallEngine {
frames_since_quality_report += 1;
if frames_since_quality_report >= QUALITY_REPORT_INTERVAL {
frames_since_quality_report = 0;
let snap = send_t.quinn_path_stats();
let snap = quinn_t.quinn_path_stats();
let pq = send_t.path_quality();
let report = wzp_proto::QualityReport::from_path_stats(
snap.loss_pct,
@@ -1023,6 +1055,7 @@ impl CallEngine {
// Recv task — decode incoming packets, push PCM into Oboe playout.
let recv_t = transport.clone();
let quinn_t = quinn_transport.clone();
let recv_r = running.clone();
let recv_spk = spk_muted.clone();
let recv_fr = frames_received.clone();
@@ -1198,7 +1231,7 @@ impl CallEngine {
recv_quality_counter += 1;
if recv_quality_counter >= QUALITY_REPORT_INTERVAL {
recv_quality_counter = 0;
let snap = recv_t.quinn_path_stats();
let snap = quinn_t.quinn_path_stats();
let pq = recv_t.path_quality();
let local_report = wzp_proto::QualityReport::from_path_stats(
snap.loss_pct,
@@ -1469,6 +1502,7 @@ impl CallEngine {
// is a static dlopen'd library, the audio streams live inside
// the standalone cdylib's process-global singleton.
_audio_handle: SyncWrapper(Box::new(())),
camera_tx: None, // video not yet wired on Android
})
}
@@ -1486,6 +1520,8 @@ impl CallEngine {
// Phase 6: explicit is_direct_p2p flag (see android branch).
is_direct_p2p: bool,
_app: tauri::AppHandle,
active_quality: Arc<std::sync::Mutex<wzp_proto::QualityProfile>>,
peer_max_quality: Arc<std::sync::Mutex<Option<wzp_proto::QualityProfile>>>,
event_cb: F,
) -> Result<Self, anyhow::Error>
where
@@ -1498,6 +1534,7 @@ impl CallEngine {
is_direct_p2p,
"CallEngine::start (desktop) invoked"
);
let call_t0 = Instant::now();
let _ = rustls::crypto::ring::default_provider().install_default();
let relay_addr: SocketAddr = relay.parse()?;
@@ -1546,23 +1583,35 @@ impl CallEngine {
// this because the peer is a phone, not a relay with an
// accept_handshake handler. See the android branch's
// comment for the full rationale.
let quinn_transport = transport.clone();
let (_negotiated_video_codec, transport): (_, Arc<dyn wzp_proto::MediaTransport>) =
if !is_direct_p2p {
let _session =
let hs =
wzp_client::handshake::perform_handshake(&*transport, &seed.0, Some(&alias))
.await
.map_err(|e| {
error!("perform_handshake failed: {e}");
e
})?;
info!(video_codec = ?hs.video_codec, "handshake complete");
let enc = Arc::new(
wzp_client::encrypted_transport::EncryptingTransport::new(
transport,
hs.session,
),
);
(hs.video_codec, enc)
} else {
info!("direct P2P — skipping relay handshake (QUIC TLS is the encryption layer)");
}
(None, transport)
};
info!("connected to relay, handshake complete");
event_cb("connected", &format!("joined room {room}"));
// Audio I/O — VPIO (OS AEC) on macOS, plain CPAL otherwise.
// The audio handle must be stored in CallEngine to keep streams alive.
let mut vpio_stats_for_debug = None;
let (capture_ring, playout_ring, audio_handle): (_, _, Box<dyn std::any::Any + Send>) =
if _os_aec {
#[cfg(target_os = "macos")]
@@ -1571,6 +1620,7 @@ impl CallEngine {
Ok(v) => {
let cr = v.capture_ring().clone();
let pr = v.playout_ring().clone();
vpio_stats_for_debug = Some(v.stats());
info!("using VoiceProcessingIO (OS AEC)");
(cr, pr, Box::new(v))
}
@@ -1615,8 +1665,38 @@ impl CallEngine {
let pending_profile = Arc::new(AtomicU8::new(PROFILE_NO_CHANGE));
let auto_profile = resolve_quality(&quality).is_none();
if let Some(vpio_stats) = vpio_stats_for_debug {
let app = _app.clone();
let running = running.clone();
tokio::spawn(async move {
while running.load(Ordering::Relaxed) {
tokio::time::sleep(std::time::Duration::from_secs(HEARTBEAT_INTERVAL_SECS))
.await;
let s = vpio_stats.snapshot();
crate::emit_call_debug(
&app,
"vpio:render_heartbeat",
serde_json::json!({
"capture_callbacks": s.capture_callbacks,
"capture_samples": s.capture_samples,
"render_callbacks": s.render_callbacks,
"render_requested_samples": s.render_requested_samples,
"render_read_samples": s.render_read_samples,
"render_underrun_callbacks": s.render_underrun_callbacks,
"render_nonzero_callbacks": s.render_nonzero_callbacks,
"render_last_requested": s.render_last_requested,
"render_last_read": s.render_last_read,
"render_last_rms": s.render_last_rms,
"render_last_ring_available": s.render_last_ring_available,
}),
);
}
});
}
// Send task
let send_t = transport.clone();
let quinn_t = quinn_transport.clone();
let send_r = running.clone();
let send_mic = mic_muted.clone();
let send_fs = frames_sent.clone();
@@ -1625,6 +1705,10 @@ impl CallEngine {
let send_quality = quality.clone();
let send_tx_codec = tx_codec.clone();
let send_pending_profile = pending_profile.clone();
let send_app = _app.clone();
let send_t0 = call_t0;
let send_active_quality = active_quality.clone();
let send_peer_max = peer_max_quality.clone();
tokio::spawn(async move {
let config = build_call_config(&send_quality);
let mut frame_samples = (config.profile.frame_duration_ms as usize) * 48;
@@ -1638,12 +1722,37 @@ impl CallEngine {
let mut dred_tuner = wzp_proto::DredTuner::new(config.profile.codec);
let mut frames_since_dred_poll: u32 = 0;
let mut frames_since_quality_report: u32 = 0;
let mut heartbeat = std::time::Instant::now();
let mut last_rms: u32;
let mut last_pkt_bytes: usize = 0;
let mut short_reads: u64 = 0;
let mut last_applied_profile: Option<QualityProfile> = None;
loop {
// Quality upgrade flow: apply active_quality / peer_max_quality.
let effective_profile = {
let active = send_active_quality.lock().unwrap().clone();
let peer_cap = send_peer_max.lock().unwrap().clone();
match peer_cap {
Some(cap) if cap.codec.bitrate_bps() < active.codec.bitrate_bps() => cap,
_ => active,
}
};
if Some(&effective_profile) != last_applied_profile.as_ref() {
let new_fs = (effective_profile.frame_duration_ms as usize) * 48;
info!(to = ?effective_profile.codec, frame_samples = new_fs, "quality: switching encoder profile (desktop)");
if encoder.set_profile(effective_profile).is_ok() {
frame_samples = new_fs;
dred_tuner.set_codec(effective_profile.codec);
*send_tx_codec.lock().await = format!("{:?}", effective_profile.codec);
last_applied_profile = Some(effective_profile);
}
}
if !send_r.load(Ordering::Relaxed) {
break;
}
if capture_ring.available() < frame_samples {
short_reads += 1;
tokio::time::sleep(std::time::Duration::from_millis(CAPTURE_POLL_MS)).await;
continue;
}
@@ -1655,6 +1764,7 @@ impl CallEngine {
let sum_sq: f64 = pcm.iter().map(|&s| (s as f64) * (s as f64)).sum();
let rms = (sum_sq / pcm.len() as f64).sqrt() as u32;
send_level.store(rms, Ordering::Relaxed);
last_rms = rms;
}
if send_mic.load(Ordering::Relaxed) {
@@ -1663,6 +1773,7 @@ impl CallEngine {
match encoder.encode_frame(&buf[..frame_samples]) {
Ok(pkts) => {
for pkt in &pkts {
last_pkt_bytes = pkt.payload.len();
if let Err(e) = send_t.send_media(pkt).await {
// Transient congestion (Blocked) — drop packet, keep going
send_drops.fetch_add(1, Ordering::Relaxed);
@@ -1671,7 +1782,17 @@ impl CallEngine {
}
}
}
send_fs.fetch_add(1, Ordering::Relaxed);
let before = send_fs.fetch_add(1, Ordering::Relaxed);
if before == 0 {
crate::emit_call_debug(
&send_app,
"media:first_send",
serde_json::json!({
"t_ms": send_t0.elapsed().as_millis() as u64,
"pkt_bytes": last_pkt_bytes,
}),
);
}
}
Err(e) => error!("encode: {e}"),
}
@@ -1696,7 +1817,7 @@ impl CallEngine {
frames_since_dred_poll += 1;
if frames_since_dred_poll >= DRED_POLL_INTERVAL {
frames_since_dred_poll = 0;
let snap = send_t.quinn_path_stats();
let snap = quinn_t.quinn_path_stats();
let pq = send_t.path_quality();
if let Some(tuning) =
dred_tuner.update(snap.loss_pct, snap.rtt_ms, pq.jitter_ms)
@@ -1710,7 +1831,7 @@ impl CallEngine {
frames_since_quality_report += 1;
if frames_since_quality_report >= QUALITY_REPORT_INTERVAL {
frames_since_quality_report = 0;
let snap = send_t.quinn_path_stats();
let snap = quinn_t.quinn_path_stats();
let pq = send_t.path_quality();
let report = wzp_proto::QualityReport::from_path_stats(
snap.loss_pct,
@@ -1719,16 +1840,37 @@ impl CallEngine {
);
encoder.set_pending_quality_report(report);
}
if heartbeat.elapsed() >= std::time::Duration::from_secs(HEARTBEAT_INTERVAL_SECS) {
let fs = send_fs.load(Ordering::Relaxed);
let drops = send_drops.load(Ordering::Relaxed);
crate::emit_call_debug(
&send_app,
"media:send_heartbeat",
serde_json::json!({
"frames_sent": fs,
"last_rms": last_rms,
"last_pkt_bytes": last_pkt_bytes,
"short_reads": short_reads,
"drops": drops,
"last_send_err": serde_json::Value::Null,
}),
);
heartbeat = std::time::Instant::now();
}
}
});
// Recv task (direct playout with auto codec switch)
let recv_t = transport.clone();
let quinn_t = quinn_transport.clone();
let recv_r = running.clone();
let recv_spk = spk_muted.clone();
let recv_fr = frames_received.clone();
let recv_rx_codec = rx_codec.clone();
let pending_profile_recv = pending_profile.clone();
let recv_app = _app.clone();
let recv_t0 = call_t0;
tokio::spawn(async move {
let initial_profile = resolve_quality(&quality).unwrap_or(QualityProfile::GOOD);
// Phase 3b/3c: concrete AdaptiveDecoder (not Box<dyn>) so we
@@ -1743,6 +1885,18 @@ impl CallEngine {
let mut dred_recv = DredRecvState::new();
let mut quality_ctrl = AdaptiveQualityController::new();
let mut recv_quality_counter: u32 = 0;
let mut heartbeat = std::time::Instant::now();
let mut first_packet_logged = false;
let mut video_reassembler = wzp_video::transport::VideoReassembler::new();
let mut video_decoder: Option<Box<dyn wzp_video::decoder::VideoDecoder>> = None;
let mut video_decoder_codec: Option<wzp_proto::CodecId> = None;
let mut decoded_frames: u64 = 0;
let mut decode_errs: u64 = 0;
let mut last_written: usize = 0;
let mut written_samples: u64 = 0;
let mut last_recv_fr_for_watchdog: u64 = 0;
let mut no_recv_ticks: u32 = 0;
let mut media_degraded_emitted = false;
loop {
if !recv_r.load(Ordering::Relaxed) {
@@ -1755,6 +1909,74 @@ impl CallEngine {
.await
{
Ok(Ok(Some(pkt))) => {
// Route video packets to the reassembler before any audio processing.
if pkt.header.media_type == wzp_proto::MediaType::Video {
if let Some((codec_id, is_kf, frame)) =
video_reassembler.push(&pkt)
{
// Lazy-init or switch decoder on codec change.
if video_decoder_codec != Some(codec_id) {
match wzp_video::factory::create_video_decoder(codec_id, 1280, 720) {
Ok(d) => {
info!(codec = ?codec_id, "video decoder created");
video_decoder = Some(d);
video_decoder_codec = Some(codec_id);
}
Err(e) => {
error!("video decoder init failed: {e}");
}
}
}
if let Some(ref mut dec) = video_decoder {
match dec.decode(&frame) {
Ok(Some(yuv_frame)) => {
recv_fr.fetch_add(1, Ordering::Relaxed);
// Emit video frame to WebView for rendering.
// Always-on (not gated on debug flag) so the UI can show video.
let jpeg_b64 = crate::i420_to_jpeg_b64(
&yuv_frame.data,
yuv_frame.width,
yuv_frame.height,
);
let _ = recv_app.emit(
"video:frame",
serde_json::json!({
"is_keyframe": is_kf,
"width": yuv_frame.width,
"height": yuv_frame.height,
"jpeg_b64": jpeg_b64,
"codec": format!("{:?}", codec_id),
}),
);
}
Ok(None) => {} // decoder buffering — no output yet
Err(e) => {
error!("video decode error: {e}");
}
}
}
// Evict stale partial frames every ~10 frames received.
video_reassembler.evict_stale(
pkt.header.timestamp,
5_000,
);
}
continue; // video packet handled — skip audio path
}
if !first_packet_logged {
first_packet_logged = true;
crate::emit_call_debug(
&recv_app,
"media:first_recv",
serde_json::json!({
"t_ms": recv_t0.elapsed().as_millis() as u64,
"codec": format!("{:?}", pkt.header.codec_id),
"payload_bytes": pkt.payload.len(),
"is_repair": pkt.header.is_repair(),
}),
);
}
if !pkt.header.is_repair() && pkt.header.codec_id != CodecId::ComfortNoise {
// Track RX codec
{
@@ -1812,7 +2034,7 @@ impl CallEngine {
recv_quality_counter += 1;
if recv_quality_counter >= QUALITY_REPORT_INTERVAL {
recv_quality_counter = 0;
let snap = recv_t.quinn_path_stats();
let snap = quinn_t.quinn_path_stats();
let pq = recv_t.path_quality();
let local_report = wzp_proto::QualityReport::from_path_stats(
snap.loss_pct,
@@ -1828,10 +2050,21 @@ impl CallEngine {
}
}
if let Ok(n) = decoder.decode(&pkt.payload, &mut pcm) {
match decoder.decode(&pkt.payload, &mut pcm) {
Ok(n) => {
decoded_frames += 1;
agc.process_frame(&mut pcm[..n]);
if !recv_spk.load(Ordering::Relaxed) {
playout_ring.write(&pcm[..n]);
last_written = n;
written_samples = written_samples.saturating_add(n as u64);
}
}
Err(e) => {
decode_errs += 1;
if decode_errs <= 3 {
tracing::warn!("decode error: {e}");
}
}
}
}
@@ -1847,6 +2080,63 @@ impl CallEngine {
}
Err(_) => {}
}
if heartbeat.elapsed() >= std::time::Duration::from_secs(HEARTBEAT_INTERVAL_SECS) {
let fr = recv_fr.load(Ordering::Relaxed);
crate::emit_call_debug(
&recv_app,
"media:recv_heartbeat",
serde_json::json!({
"recv_fr": fr,
"decoded_frames": decoded_frames,
"last_written": last_written,
"written_samples": written_samples,
"decode_errs": decode_errs,
"codec": format!("{:?}", current_codec),
}),
);
if fr == last_recv_fr_for_watchdog {
no_recv_ticks += 1;
} else {
no_recv_ticks = 0;
if media_degraded_emitted {
media_degraded_emitted = false;
let _ = recv_app.emit(
"call-event",
serde_json::json!({
"kind": "media-recovered",
}),
);
crate::emit_call_debug(
&recv_app,
"media:recovered",
serde_json::json!({}),
);
}
}
last_recv_fr_for_watchdog = fr;
if no_recv_ticks >= 3 && !media_degraded_emitted {
media_degraded_emitted = true;
let _ = recv_app.emit(
"call-event",
serde_json::json!({
"kind": "media-degraded",
}),
);
crate::emit_call_debug(
&recv_app,
"media:no_recv_timeout",
serde_json::json!({
"recv_fr": fr,
"no_recv_ticks": no_recv_ticks,
}),
);
}
heartbeat = std::time::Instant::now();
}
}
});
@@ -1861,6 +2151,77 @@ impl CallEngine {
event_cb.clone(),
));
// Video send task — active only when the handshake negotiated a video codec.
// Camera frames arrive via camera_tx; the task encodes and packetizes them.
// Blocker 4 (camera capture) will push frames into this channel.
let camera_tx = if let Some(vid_codec) = _negotiated_video_codec {
let (tx, mut rx) = tokio::sync::mpsc::channel::<wzp_video::encoder::VideoFrame>(4);
let vid_transport = transport.clone();
let vid_running = running.clone();
let vid_t0 = call_t0;
let vid_app = _app.clone();
tokio::spawn(async move {
let mut encoder = match wzp_video::factory::create_video_encoder(
vid_codec, 1280, 720, 1_500_000,
) {
Ok(e) => e,
Err(e) => {
error!("video encoder init failed: {e}");
return;
}
};
let mut seq: u32 = 0;
let mut frames_since_keyframe: u32 = 0;
info!(codec = ?vid_codec, "video send task started");
while vid_running.load(Ordering::Relaxed) {
let frame = match tokio::time::timeout(
std::time::Duration::from_millis(200),
rx.recv(),
)
.await
{
Ok(Some(f)) => f,
Ok(None) => break, // sender dropped
Err(_) => continue, // no frame yet — keep looping
};
if frames_since_keyframe >= 150 {
encoder.request_keyframe();
frames_since_keyframe = 0;
}
let encoded = match encoder.encode(&frame) {
Ok(b) => b,
Err(e) => {
error!("video encode error: {e}");
continue;
}
};
let is_keyframe = encoder.is_keyframe(&encoded);
let ts_ms = vid_t0.elapsed().as_millis() as u32;
let pkts = wzp_video::transport::packetize_video_frame(
&encoded, vid_codec, is_keyframe, &mut seq, ts_ms,
);
for pkt in &pkts {
if let Err(e) = vid_transport.send_media(pkt).await {
crate::emit_call_debug(
&vid_app,
"video:send_error",
serde_json::json!({"error": e.to_string()}),
);
break;
}
}
frames_since_keyframe += 1;
}
info!("video send task exited");
});
Some(tx)
} else {
None
};
Ok(Self {
running,
mic_muted,
@@ -1875,6 +2236,7 @@ impl CallEngine {
tx_codec,
rx_codec,
_audio_handle: SyncWrapper(audio_handle),
camera_tx,
})
}
@@ -1949,3 +2311,101 @@ impl Drop for CallEngine {
self.running.store(false, Ordering::SeqCst);
}
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex as StdMutex};
use async_trait::async_trait;
use bytes::Bytes;
use wzp_client::encrypted_transport::EncryptingTransport;
use wzp_crypto::ChaChaSession;
use wzp_proto::{
CodecId, CryptoSession, MediaHeader, MediaPacket, MediaTransport, MediaType, PathQuality,
SignalMessage, TransportError,
};
struct LoopbackTransport {
sent: StdMutex<Vec<MediaPacket>>,
}
impl LoopbackTransport {
fn new() -> Arc<Self> {
Arc::new(Self {
sent: StdMutex::new(Vec::new()),
})
}
fn take_sent(&self) -> Vec<MediaPacket> {
self.sent.lock().unwrap().drain(..).collect()
}
}
#[async_trait]
impl MediaTransport for LoopbackTransport {
async fn send_media(&self, packet: &MediaPacket) -> Result<(), TransportError> {
self.sent.lock().unwrap().push(packet.clone());
Ok(())
}
async fn recv_media(&self) -> Result<Option<MediaPacket>, TransportError> {
Ok(None)
}
async fn send_signal(&self, _msg: &SignalMessage) -> Result<(), TransportError> {
Ok(())
}
async fn recv_signal(&self) -> Result<Option<SignalMessage>, TransportError> {
Ok(None)
}
fn path_quality(&self) -> PathQuality {
PathQuality::default()
}
async fn close(&self) -> Result<(), TransportError> {
Ok(())
}
}
fn make_header(seq: u32) -> MediaHeader {
MediaHeader {
version: 2,
flags: 0,
media_type: MediaType::Audio,
codec_id: CodecId::Opus24k,
stream_id: 0,
fec_ratio: 0,
seq,
timestamp: seq * 20,
fec_block: 0,
}
}
#[tokio::test]
async fn relay_path_encrypts_media_payload() {
// Simulate the exact wrapping pattern used in engine.rs for the relay path.
let key = [0x42u8; 32];
let session: Box<dyn CryptoSession> = Box::new(ChaChaSession::new(key));
let inner = LoopbackTransport::new();
let transport: Arc<dyn MediaTransport> =
Arc::new(EncryptingTransport::new(inner.clone(), session));
let header = make_header(1);
let plaintext = b"secret audio frame";
let pkt = MediaPacket {
header,
payload: Bytes::from_static(plaintext),
quality_report: None,
};
transport.send_media(&pkt).await.unwrap();
let sent = inner.take_sent();
assert_eq!(sent.len(), 1);
assert_eq!(sent[0].header, header, "header must be preserved");
assert_ne!(
sent[0].payload.as_ref(),
plaintext.as_ref(),
"plaintext must not appear on wire"
);
// Ciphertext is longer by exactly the AEAD tag (16 bytes)
assert_eq!(sent[0].payload.len(), plaintext.len() + 16);
}
}

View File

@@ -84,6 +84,213 @@ pub(crate) fn emit_call_debug(app: &tauri::AppHandle, step: &str, details: serde
/// Short git hash captured at compile time by build.rs.
const GIT_HASH: &str = env!("WZP_GIT_HASH");
// ─── Video helpers ────────────────────────────────────────────────────────────
/// Convert an I420 frame to a JPEG and base64-encode it for IPC.
///
/// Returns `None` if the data is too short or encoding fails.
/// Called from the video recv task in engine.rs to produce the `jpeg_b64`
/// field of every `video:frame` Tauri event.
pub(crate) fn i420_to_jpeg_b64(data: &[u8], width: u32, height: u32) -> Option<String> {
use base64::Engine as _;
use image::{DynamicImage, ImageBuffer, Rgb};
let w = width as usize;
let h = height as usize;
let y_size = w * h;
let uv_size = w * h / 4;
if data.len() < y_size + 2 * uv_size {
return None;
}
let mut rgb = vec![0u8; w * h * 3];
for row in 0..h {
for col in 0..w {
let y = data[row * w + col] as f32;
let uv_idx = (row / 2) * (w / 2) + col / 2;
let u = data[y_size + uv_idx] as f32 - 128.0;
let v = data[y_size + uv_size + uv_idx] as f32 - 128.0;
let out = (row * w + col) * 3;
rgb[out] = (y + 1.402 * v).clamp(0.0, 255.0) as u8;
rgb[out + 1] = (y - 0.344 * u - 0.714 * v).clamp(0.0, 255.0) as u8;
rgb[out + 2] = (y + 1.772 * u).clamp(0.0, 255.0) as u8;
}
}
let img = DynamicImage::ImageRgb8(ImageBuffer::<Rgb<u8>, Vec<u8>>::from_raw(width, height, rgb)?);
let mut buf = std::io::Cursor::new(Vec::<u8>::new());
img.write_to(&mut buf, image::ImageFormat::Jpeg).ok()?;
Some(base64::engine::general_purpose::STANDARD.encode(buf.into_inner()))
}
/// RGB24 → I420 (planar 4:2:0). Layout: Y(w×h) | U(w/2×h/2) | V(w/2×h/2).
fn rgb_to_i420(rgb: &[u8], w: usize, h: usize) -> Vec<u8> {
let y_size = w * h;
let uv_size = (w / 2) * (h / 2);
let mut out = vec![0u8; y_size + 2 * uv_size];
for row in 0..h {
for col in 0..w {
let i = (row * w + col) * 3;
let r = rgb[i] as f32;
let g = rgb[i + 1] as f32;
let b = rgb[i + 2] as f32;
out[row * w + col] = (0.299 * r + 0.587 * g + 0.114 * b).clamp(0.0, 255.0) as u8;
if row % 2 == 0 && col % 2 == 0 {
let uv = (row / 2) * (w / 2) + col / 2;
out[y_size + uv] = (-0.169 * r - 0.331 * g + 0.500 * b + 128.0).clamp(0.0, 255.0) as u8;
out[y_size + uv_size + uv] = (0.500 * r - 0.419 * g - 0.081 * b + 128.0).clamp(0.0, 255.0) as u8;
}
}
}
out
}
/// Tauri command: receive a JPEG frame from the frontend camera (getUserMedia),
/// decode it, convert to I420, and push into the active call's video send task.
///
/// The frontend calls this at ~15 fps from a canvas.toDataURL() capture loop.
#[tauri::command]
async fn push_camera_frame(
state: tauri::State<'_, Arc<AppState>>,
jpeg_b64: String,
) -> Result<(), String> {
use base64::Engine as _;
let jpeg_bytes = base64::engine::general_purpose::STANDARD
.decode(&jpeg_b64)
.map_err(|e| e.to_string())?;
let dyn_img = image::load_from_memory_with_format(&jpeg_bytes, image::ImageFormat::Jpeg)
.map_err(|e| e.to_string())?;
let rgb_img = dyn_img.to_rgb8();
let w = rgb_img.width() as usize;
let h = rgb_img.height() as usize;
let yuv = rgb_to_i420(rgb_img.as_raw(), w, h);
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let frame = wzp_video::encoder::VideoFrame {
width: w as u32,
height: h as u32,
data: yuv,
timestamp_ms: ts,
};
let engine = state.engine.lock().await;
if let Some(ref eng) = *engine {
if let Some(ref tx) = eng.camera_tx {
let _ = tx.try_send(frame); // drop frame if send task is saturated
}
}
Ok(())
}
// ─── Video helper tests ───────────────────────────────────────────────────────
#[cfg(test)]
mod video_tests {
use super::{i420_to_jpeg_b64, rgb_to_i420};
use base64::Engine as _;
fn solid_rgb_frame(w: usize, h: usize, r: u8, g: u8, b: u8) -> Vec<u8> {
let mut rgb = vec![0u8; w * h * 3];
for i in 0..w * h {
rgb[i * 3] = r;
rgb[i * 3 + 1] = g;
rgb[i * 3 + 2] = b;
}
rgb
}
fn solid_i420(w: usize, h: usize, y: u8, u: u8, v: u8) -> Vec<u8> {
let y_size = w * h;
let uv_size = w * h / 4;
let mut data = vec![y; y_size + 2 * uv_size];
data[y_size..y_size + uv_size].fill(u);
data[y_size + uv_size..].fill(v);
data
}
#[test]
fn rgb_to_i420_output_size() {
let rgb = solid_rgb_frame(640, 360, 128, 128, 128);
let yuv = rgb_to_i420(&rgb, 640, 360);
assert_eq!(yuv.len(), 640 * 360 * 3 / 2);
}
#[test]
fn rgb_to_i420_pure_green_luma() {
// Pure green (0, 255, 0) → Y ≈ 150 (0.587 × 255 ≈ 150).
let rgb = solid_rgb_frame(4, 4, 0, 255, 0);
let yuv = rgb_to_i420(&rgb, 4, 4);
let y = yuv[0];
assert!(y >= 140 && y <= 160, "pure-green luma out of range: {y}");
}
#[test]
fn rgb_to_i420_grey_is_neutral() {
// Mid-grey RGB → U and V should both be near 128.
let rgb = solid_rgb_frame(4, 4, 128, 128, 128);
let yuv = rgb_to_i420(&rgb, 4, 4);
let uv_start = 4 * 4;
let u = yuv[uv_start];
let v = yuv[uv_start + 4]; // 4 = (4/2)*(4/2)
assert!((u as i32 - 128).abs() <= 5, "grey U out of range: {u}");
assert!((v as i32 - 128).abs() <= 5, "grey V out of range: {v}");
}
#[test]
fn i420_to_jpeg_b64_produces_non_empty_output() {
let data = solid_i420(64, 64, 128, 128, 128);
let b64 = i420_to_jpeg_b64(&data, 64, 64);
assert!(b64.is_some(), "valid I420 must produce Some(b64)");
let s = b64.unwrap();
assert!(!s.is_empty());
// JPEG base64 starts with '/9j/' (FFD8FF marker).
let decoded = base64::engine::general_purpose::STANDARD.decode(&s).unwrap();
assert_eq!(&decoded[0..2], &[0xFF, 0xD8], "output must start with JPEG SOI marker");
}
#[test]
fn i420_to_jpeg_b64_rejects_undersized_buffer() {
// Buffer too short: only Y plane, no chroma.
let data = vec![128u8; 64 * 64];
let b64 = i420_to_jpeg_b64(&data, 64, 64);
assert!(b64.is_none(), "truncated buffer must yield None");
}
#[test]
fn i420_to_jpeg_b64_color_preservation() {
// A red (255, 0, 0) I420 frame should decode to a mostly-red JPEG.
// After JPEG lossy compression the exact values drift, so we only
// check that the decoded pixel has R > G and R > B.
use base64::Engine as _;
// Convert red RGB → I420.
let rgb = solid_rgb_frame(64, 64, 255, 0, 0);
let yuv = rgb_to_i420(&rgb, 64, 64);
let b64 = i420_to_jpeg_b64(&yuv, 64, 64).expect("should produce JPEG");
let jpeg = base64::engine::general_purpose::STANDARD.decode(&b64).unwrap();
let img = image::load_from_memory_with_format(&jpeg, image::ImageFormat::Jpeg).unwrap();
let rgb_img = img.to_rgb8();
let px = rgb_img.get_pixel(32, 32);
let (r, g, b) = (px[0], px[1], px[2]);
assert!(r > g && r > b, "red frame: expected R dominant, got R={r} G={g} B={b}");
}
#[test]
fn rgb_i420_conversion_is_deterministic() {
let rgb = solid_rgb_frame(8, 8, 200, 100, 50);
let yuv1 = rgb_to_i420(&rgb, 8, 8);
let yuv2 = rgb_to_i420(&rgb, 8, 8);
assert_eq!(yuv1, yuv2, "rgb_to_i420 must be deterministic");
}
}
/// Resolved by `setup()` once we have a Tauri AppHandle. Holds the
/// platform-correct app data dir (e.g. `/data/data/com.wzp.desktop/files` on
/// Android, `~/Library/Application Support/com.wzp.desktop` on macOS).
@@ -805,6 +1012,10 @@ async fn connect(
}),
);
let app_for_engine = app.clone();
let (active_quality, peer_max_quality) = {
let sig = state.signal.lock().await;
(sig.active_quality.clone(), sig.peer_max_quality.clone())
};
match CallEngine::start(
relay,
room,
@@ -815,6 +1026,8 @@ async fn connect(
pre_connected_transport,
is_direct_p2p_agreed,
app_for_engine,
active_quality,
peer_max_quality,
move |event_kind, message| {
let _ = app_clone.emit(
"call-event",
@@ -1157,6 +1370,12 @@ struct SignalState {
peer_hard_nat_probe: Option<PeerHardNatInfo>,
/// Phase 8.6: peer's birthday attack ports, if received.
peer_birthday_ports: Option<PeerBirthdayInfo>,
/// Active quality profile for the encoder. Updated by signal upgrade flow.
active_quality: Arc<std::sync::Mutex<wzp_proto::QualityProfile>>,
/// Peer's reported max quality cap. The encoder clamps to min(active, peer_max).
peer_max_quality: Arc<std::sync::Mutex<Option<wzp_proto::QualityProfile>>>,
/// Pending outgoing upgrade proposal: (call_id, proposal_id, profile).
pending_upgrade: Arc<std::sync::Mutex<Option<(String, String, wzp_proto::QualityProfile)>>>,
}
/// Parsed data from a peer's HardNatBirthdayStart signal.
@@ -1720,8 +1939,9 @@ fn do_register_signal(
"peer_loss_pct": local_loss_pct, "peer_rtt_ms": local_rtt_ms,
}),
);
// TODO: auto-accept if our own quality supports it,
// or surface to UI for manual accept/reject
if let Err(e) = handle_upgrade_proposal(&*transport, &call_id, &proposal_id).await {
tracing::warn!("failed to send UpgradeResponse: {e}");
}
}
Ok(Some(SignalMessage::UpgradeResponse {
call_id,
@@ -1739,7 +1959,11 @@ fn do_register_signal(
"accepted": accepted, "reason": reason,
}),
);
// TODO: if accepted, send UpgradeConfirm + switch encoder
if let Err(e) = handle_upgrade_response(
&*transport, &signal_state, &call_id, &proposal_id, accepted,
).await {
tracing::warn!("failed to handle UpgradeResponse: {e}");
}
}
Ok(Some(SignalMessage::UpgradeConfirm {
call_id,
@@ -1756,7 +1980,7 @@ fn do_register_signal(
"confirmed_profile": format!("{confirmed_profile:?}"),
}),
);
// TODO: switch encoder to confirmed_profile at next frame boundary
handle_upgrade_confirm(&signal_state, confirmed_profile).await;
}
Ok(Some(SignalMessage::QualityCapability {
call_id,
@@ -1775,8 +1999,7 @@ fn do_register_signal(
"peer_loss_pct": loss_pct, "peer_rtt_ms": rtt_ms,
}),
);
// TODO: adjust our encoder to not exceed peer's max_profile
// (asymmetric quality — each side encodes at its own best)
handle_quality_capability(&signal_state, max_profile).await;
}
Ok(Some(SignalMessage::HardNatBirthdayStart {
call_id,
@@ -2505,7 +2728,7 @@ async fn answer_call(
/// or temporarily unreachable for reflect but the call can still
/// proceed with STUN-discovered addresses.
async fn try_reflect_own_addr(state: &Arc<AppState>) -> Result<Option<String>, String> {
use wzp_proto::{SignalMessage, default_signal_version};
use wzp_proto::SignalMessage;
let (tx, rx) = tokio::sync::oneshot::channel::<std::net::SocketAddr>();
let transport = {
let mut sig = state.signal.lock().await;
@@ -2592,7 +2815,7 @@ async fn try_stun_fallback(state: &Arc<AppState>) -> Result<Option<String>, Stri
/// with `new URL(...)` / a regex if needed.
#[tauri::command]
async fn get_reflected_address(state: tauri::State<'_, Arc<AppState>>) -> Result<String, String> {
use wzp_proto::{SignalMessage, default_signal_version};
use wzp_proto::SignalMessage;
let (tx, rx) = tokio::sync::oneshot::channel::<std::net::SocketAddr>();
let transport = {
let mut sig = state.signal.lock().await;
@@ -2850,11 +3073,232 @@ async fn hangup_call(
// ─── App entry point ─────────────────────────────────────────────────────────
// ─── Quality upgrade flow handlers (testable) ─────────────────────────────
async fn handle_upgrade_proposal(
transport: &dyn wzp_proto::MediaTransport,
call_id: &str,
proposal_id: &str,
) -> Result<(), wzp_proto::TransportError> {
let response = wzp_proto::SignalMessage::UpgradeResponse {
version: default_signal_version(),
call_id: call_id.to_string(),
proposal_id: proposal_id.to_string(),
accepted: true,
reason: None,
};
transport.send_signal(&response).await
}
async fn handle_upgrade_response(
transport: &dyn wzp_proto::MediaTransport,
signal_state: &Arc<tokio::sync::Mutex<SignalState>>,
call_id: &str,
proposal_id: &str,
accepted: bool,
) -> Result<(), wzp_proto::TransportError> {
if accepted {
let maybe_proposal = {
let sig = signal_state.lock().await;
sig.pending_upgrade.lock().unwrap().take()
};
if let Some((_cid, pid, profile)) = maybe_proposal {
if pid == proposal_id {
let confirm = wzp_proto::SignalMessage::UpgradeConfirm {
version: default_signal_version(),
call_id: call_id.to_string(),
proposal_id: proposal_id.to_string(),
confirmed_profile: profile.clone(),
};
transport.send_signal(&confirm).await?;
{
let sig = signal_state.lock().await;
*sig.active_quality.lock().unwrap() = profile;
}
}
}
}
Ok(())
}
async fn handle_upgrade_confirm(
signal_state: &Arc<tokio::sync::Mutex<SignalState>>,
confirmed_profile: wzp_proto::QualityProfile,
) {
let sig = signal_state.lock().await;
*sig.active_quality.lock().unwrap() = confirmed_profile;
}
async fn handle_quality_capability(
signal_state: &Arc<tokio::sync::Mutex<SignalState>>,
max_profile: wzp_proto::QualityProfile,
) {
let sig = signal_state.lock().await;
*sig.peer_max_quality.lock().unwrap() = Some(max_profile);
}
#[cfg(test)]
mod signal_tests {
use super::*;
use async_trait::async_trait;
use std::sync::Mutex as StdMutex;
use wzp_proto::{MediaPacket, MediaTransport, PathQuality, SignalMessage, TransportError};
struct LoopbackTransport {
sent: StdMutex<Vec<SignalMessage>>,
}
impl LoopbackTransport {
fn new() -> Arc<Self> {
Arc::new(Self {
sent: StdMutex::new(Vec::new()),
})
}
fn take_sent(&self) -> Vec<SignalMessage> {
self.sent.lock().unwrap().drain(..).collect()
}
}
#[async_trait]
impl MediaTransport for LoopbackTransport {
async fn send_media(&self, _packet: &MediaPacket) -> Result<(), TransportError> {
Ok(())
}
async fn recv_media(&self) -> Result<Option<MediaPacket>, TransportError> {
Ok(None)
}
async fn send_signal(&self, msg: &SignalMessage) -> Result<(), TransportError> {
self.sent.lock().unwrap().push(msg.clone());
Ok(())
}
async fn recv_signal(&self) -> Result<Option<SignalMessage>, TransportError> {
Ok(None)
}
fn path_quality(&self) -> PathQuality {
PathQuality::default()
}
async fn close(&self) -> Result<(), TransportError> {
Ok(())
}
}
fn empty_signal_state() -> Arc<tokio::sync::Mutex<SignalState>> {
Arc::new(tokio::sync::Mutex::new(SignalState {
transport: None,
endpoint: None,
ipv6_endpoint: None,
fingerprint: String::new(),
signal_status: "idle".into(),
incoming_call_id: None,
incoming_caller_fp: None,
incoming_caller_alias: None,
pending_reflect: None,
own_reflex_addr: None,
desired_relay_addr: None,
reconnect_in_progress: false,
pending_path_report: None,
peer_hard_nat_probe: None,
peer_birthday_ports: None,
active_quality: Arc::new(std::sync::Mutex::new(wzp_proto::QualityProfile::GOOD)),
peer_max_quality: Arc::new(std::sync::Mutex::new(None)),
pending_upgrade: Arc::new(std::sync::Mutex::new(None)),
}))
}
#[tokio::test]
async fn upgrade_proposal_auto_accepts() {
let transport = LoopbackTransport::new();
handle_upgrade_proposal(&*transport, "c1", "p1").await.unwrap();
let sent = transport.take_sent();
assert_eq!(sent.len(), 1);
match &sent[0] {
SignalMessage::UpgradeResponse {
call_id,
proposal_id,
accepted,
reason,
..
} => {
assert_eq!(call_id, "c1");
assert_eq!(proposal_id, "p1");
assert!(accepted);
assert!(reason.is_none());
}
other => panic!("expected UpgradeResponse, got {other:?}"),
}
}
#[tokio::test]
async fn upgrade_response_accepted_sends_confirm_and_updates_quality() {
let transport = LoopbackTransport::new();
let signal_state = empty_signal_state();
{
let sig = signal_state.lock().await;
*sig.pending_upgrade.lock().unwrap() =
Some(("c1".into(), "p1".into(), wzp_proto::QualityProfile::STUDIO_48K));
}
handle_upgrade_response(&*transport, &signal_state, "c1", "p1", true)
.await
.unwrap();
let sent = transport.take_sent();
assert_eq!(sent.len(), 1);
match &sent[0] {
SignalMessage::UpgradeConfirm {
call_id,
proposal_id,
confirmed_profile,
..
} => {
assert_eq!(call_id, "c1");
assert_eq!(proposal_id, "p1");
assert_eq!(*confirmed_profile, wzp_proto::QualityProfile::STUDIO_48K);
}
other => panic!("expected UpgradeConfirm, got {other:?}"),
}
let sig = signal_state.lock().await;
assert_eq!(
*sig.active_quality.lock().unwrap(),
wzp_proto::QualityProfile::STUDIO_48K
);
}
#[tokio::test]
async fn upgrade_confirm_updates_active_quality() {
let signal_state = empty_signal_state();
handle_upgrade_confirm(&signal_state, wzp_proto::QualityProfile::STUDIO_64K).await;
let sig = signal_state.lock().await;
assert_eq!(
*sig.active_quality.lock().unwrap(),
wzp_proto::QualityProfile::STUDIO_64K
);
}
#[tokio::test]
async fn quality_capability_updates_peer_max() {
let signal_state = empty_signal_state();
handle_quality_capability(&signal_state, wzp_proto::QualityProfile::GOOD).await;
let sig = signal_state.lock().await;
assert_eq!(
sig.peer_max_quality.lock().unwrap().unwrap(),
wzp_proto::QualityProfile::GOOD
);
}
}
/// Shared Tauri app builder. Used by the desktop `main.rs` and the mobile
/// entry point below.
pub fn run() {
tracing_subscriber::fmt().init();
let active_quality = Arc::new(std::sync::Mutex::new(wzp_proto::QualityProfile::GOOD));
let peer_max_quality = Arc::new(std::sync::Mutex::new(None));
let pending_upgrade = Arc::new(std::sync::Mutex::new(None));
let state = Arc::new(AppState {
engine: Mutex::new(None),
signal: Arc::new(Mutex::new(SignalState {
@@ -2873,6 +3317,9 @@ pub fn run() {
pending_path_report: None,
peer_hard_nat_probe: None,
peer_birthday_ports: None,
active_quality: active_quality.clone(),
peer_max_quality: peer_max_quality.clone(),
pending_upgrade: pending_upgrade.clone(),
})),
});
@@ -2949,6 +3396,7 @@ pub fn run() {
get_dred_verbose_logs,
set_call_debug_logs,
get_call_debug_logs,
push_camera_frame,
])
.run(tauri::generate_context!())
.expect("error while running WarzonePhone");

View File

@@ -79,6 +79,11 @@ const vdMicIcon = document.getElementById("vd-mic-icon")!;
const vdSpkBtn = document.getElementById("vd-spk-btn")!;
const vdSpkIcon = document.getElementById("vd-spk-icon")!;
const vdEndBtn = document.getElementById("vd-end-btn")!;
const vdCamBtn = document.getElementById("vd-cam-btn")!;
const vdCamIcon = document.getElementById("vd-cam-icon")!;
const vdVideoStrip = document.getElementById("vd-video-strip")!;
const vdRemoteVideo = document.getElementById("vd-remote-video") as HTMLCanvasElement;
const vdLocalVideo = document.getElementById("vd-local-video") as HTMLVideoElement;
const vdDirectInfo = document.getElementById("vd-direct-info")!;
const vdDcIdenticon = document.getElementById("vd-dc-identicon")!;
const vdDcName = document.getElementById("vd-dc-name")!;
@@ -170,6 +175,12 @@ let connectPending = false; // guard against double-tap while connect is in-flig
let directCallPeer: { fingerprint: string; alias: string | null } | null = null;
let pendingCallId: string | null = null;
// Video / camera state
let cameraActive = false;
let cameraStream: MediaStream | null = null;
let cameraFrameTimer: number | null = null;
let remoteVideoActive = false;
function showToast(msg: string, durationMs = 3500) {
let el = document.getElementById("wzp-toast");
if (!el) {
@@ -420,6 +431,10 @@ function leaveVoice() {
joinVoiceBtn.classList.remove("hidden");
vdLevelBar.style.width = "0%";
if (statusInterval) { clearInterval(statusInterval); statusInterval = null; }
stopCamera();
remoteVideoActive = false;
vdVideoStrip.classList.add("hidden");
remoteCtx.clearRect(0, 0, vdRemoteVideo.width, vdRemoteVideo.height);
}
// Drawer controls
@@ -435,6 +450,76 @@ vdSpkBtn.addEventListener("click", async () => {
try { await invoke("toggle_speaker"); } catch {}
});
// ── Camera (Blocker 4 + 5) ────────────────────────────────────────
const camCaptureCanvas = document.createElement("canvas");
const camCaptureCtx = camCaptureCanvas.getContext("2d")!;
async function startCamera() {
if (cameraActive) return;
try {
cameraStream = await navigator.mediaDevices.getUserMedia({
video: { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: "user" },
audio: false,
});
vdLocalVideo.srcObject = cameraStream;
vdVideoStrip.classList.remove("hidden");
const track = cameraStream.getVideoTracks()[0];
const settings = track.getSettings();
camCaptureCanvas.width = settings.width ?? 640;
camCaptureCanvas.height = settings.height ?? 360;
cameraActive = true;
vdCamIcon.textContent = "Cam ✓";
vdCamBtn.classList.add("active");
// Capture loop at ~15 fps
cameraFrameTimer = window.setInterval(async () => {
if (!cameraActive) return;
camCaptureCtx.drawImage(vdLocalVideo, 0, 0, camCaptureCanvas.width, camCaptureCanvas.height);
const dataUrl = camCaptureCanvas.toDataURL("image/jpeg", 0.75);
const b64 = dataUrl.slice(dataUrl.indexOf(",") + 1);
try { await invoke("push_camera_frame", { jpeg_b64: b64 }); } catch { /* call not active */ }
}, 67); // 67 ms ≈ 15 fps
} catch (e) {
console.warn("camera access denied or unavailable:", e);
}
}
function stopCamera() {
cameraActive = false;
if (cameraFrameTimer != null) { window.clearInterval(cameraFrameTimer); cameraFrameTimer = null; }
if (cameraStream) { cameraStream.getTracks().forEach(t => t.stop()); cameraStream = null; }
vdLocalVideo.srcObject = null;
vdCamIcon.textContent = "Cam";
vdCamBtn.classList.remove("active");
// Hide strip only if remote video is also gone
if (!remoteVideoActive) vdVideoStrip.classList.add("hidden");
}
vdCamBtn.addEventListener("click", () => {
if (cameraActive) { stopCamera(); } else { startCamera(); }
});
// ── Remote video display (Blocker 5) ─────────────────────────────
const remoteCtx = vdRemoteVideo.getContext("2d")!;
listen("video:frame", (event: any) => {
const { width, height, jpeg_b64 } = event.payload;
if (!jpeg_b64) return;
remoteVideoActive = true;
vdVideoStrip.classList.remove("hidden");
vdRemoteVideo.width = width ?? vdRemoteVideo.width;
vdRemoteVideo.height = height ?? vdRemoteVideo.height;
const img = new Image();
img.onload = () => {
remoteCtx.drawImage(img, 0, 0, vdRemoteVideo.width, vdRemoteVideo.height);
};
img.src = `data:image/jpeg;base64,${jpeg_b64}`;
});
// ── Poll status ───────────────────────────────────────────────────
interface CallStatusI {
active: boolean;
@@ -831,6 +916,7 @@ document.addEventListener("keydown", (e) => {
if (e.key === "m") vdMicBtn.click();
if (e.key === "q") vdEndBtn.click();
if (e.key === "s") vdSpkBtn.click();
if (e.key === "v") vdCamBtn.click();
if (e.key === "," && (e.metaKey || e.ctrlKey)) { e.preventDefault(); openSettings(); }
});

View File

@@ -306,6 +306,22 @@ body {
padding: 2px 0 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* Video strip in voice drawer */
.vd-video-strip {
display: flex;
gap: 4px;
padding: 4px 0 2px;
overflow-x: auto;
}
.vd-video-tile {
width: 160px;
height: 90px;
border-radius: 6px;
background: #000;
object-fit: cover;
flex-shrink: 0;
}
/* Incoming call banner */
.incoming-banner {
position: fixed;

View File

@@ -0,0 +1,225 @@
# PRD: Android MediaCodec NDK 0.9 Compatibility
> **Status:** proposed
> **Resolves:** 31 compile errors in `crates/wzp-video/src/mediacodec.rs` blocking all Android video.
> **Depends on:** Remote build server `manwe@188.245.59.196` with Docker image `wzp-android-builder:latest`.
## Problem
`crates/wzp-video/src/mediacodec.rs` fails to compile for
`aarch64-linux-android` against the NDK 0.9 Rust crate. There are 31 errors
in 5 categories. Android video is completely blocked.
The file already compiles for non-Android targets (all Android code is behind
`#[cfg(target_os = "android")]`). Only the Android target path needs fixing.
## Goals
- `cargo build --target aarch64-linux-android -p wzp-video` produces 0 errors on the remote server.
- Each fix category lands in a separate commit so failures can be bisected.
- Non-Android compilation is not broken.
## Non-goals
- Upgrading the NDK Docker image or changing the NDK version.
- Fixing video functionality beyond compilation (runtime testing is a separate task).
- Any files outside `crates/wzp-video/`.
## Design
### Build command (run after each fix)
```bash
ssh manwe@188.245.59.196 'cd ~/wzp-builder/data/source && \
git fetch github && git reset --hard github/experimental-ui && \
docker run --rm \
-v ~/wzp-builder/data/source:/build/source \
-v ~/wzp-builder/data/cache/cargo-registry:/home/builder/.cargo/registry \
-v ~/wzp-builder/data/cache/cargo-git:/home/builder/.cargo/git \
-v ~/wzp-builder/data/cache/target:/build/source/target \
wzp-android-builder:latest bash -c \
"cd /build/source && cargo build --target aarch64-linux-android -p wzp-video 2>&1 | grep -E \"^error\" | head -30"'
```
### Fix order (commit one per category)
#### Fix 1 — `E0433`: `ndk_sys` not declared as a dependency
**Symptom**: `use of undeclared crate or module 'ndk_sys'`
**File**: `crates/wzp-video/Cargo.toml`
NDK 0.9 no longer re-exports raw `ndk_sys` symbols; they must be declared as
a direct dependency. Add to the `[target.'cfg(target_os = "android")'.dependencies]`
section (or create it if absent):
```toml
[target.'cfg(target_os = "android")'.dependencies]
ndk = { version = "0.9" }
ndk-sys = { version = "0.6" } # ndk 0.9 depends on ndk-sys 0.6
```
If `mediacodec.rs` only uses safe wrappers from the `ndk` crate and the
`ndk_sys` imports are not strictly needed, remove the `use ndk_sys::*` lines
from `mediacodec.rs` instead — whichever approach results in fewer changes.
After this fix the `E0433` errors should drop from the build output.
#### Fix 2 — `E0425`: `BITRATE_MODE_CBR` constant missing
**Symptom**: `cannot find value 'BITRATE_MODE_CBR' in this scope`
**File**: `crates/wzp-video/src/mediacodec.rs`
`BITRATE_MODE_CBR` is already defined as a local constant at line 44:
```rust
#[cfg(target_os = "android")]
const BITRATE_MODE_CBR: i32 = 2;
```
If the error persists after Fix 1, the issue is that `ndk_sys` was providing
a conflicting symbol. Verify the constant is still at line 44 after Fix 1. If
NDK 0.9 moved `BITRATE_MODE_CBR` to an enum, update the usage at line 516
(`format.set_i32("bitrate-mode", BITRATE_MODE_CBR)`) to use the integer
value directly (`2`) or update the constant's value.
If `ndk 0.9` defines `MediaCodecBitrateMode::Cbr` as an enum, the call site
in `MediaCodecAv1Encoder::new` (line ~516) can be updated to:
```rust
format.set_i32(
"bitrate-mode",
ndk::media::media_codec::MediaCodecBitrateMode::Cbr as i32,
);
```
#### Fix 3 — `E0308`: `InputBuffer` returns `&mut [MaybeUninit<u8>]`
**Symptom**: `expected &mut [u8], found &mut [MaybeUninit<u8>]`
**File**: `crates/wzp-video/src/mediacodec.rs`
NDK 0.9 changed `InputBuffer::buffer_mut()` from `&mut [u8]` to
`&mut [MaybeUninit<u8>]`. There are multiple write sites in the file — all
follow the same pattern:
```rust
// Before (NDK 0.8):
let buf = buffer.buffer_mut(); // &mut [u8]
let n = frame.data.len().min(buf.len());
buf[..n].copy_from_slice(&frame.data[..n]);
```
```rust
// After (NDK 0.9):
let buf = buffer.buffer_mut(); // &mut [MaybeUninit<u8>]
let n = frame.data.len().min(buf.len());
for (d, &s) in buf[..n].iter_mut().zip(frame.data[..n].iter()) {
d.write(s);
}
```
The file already uses the `d.write(s)` pattern in some places (lines 125127,
297299, etc.). Search for **every** occurrence of `buffer.buffer_mut()` and
`buffer_mut()` and apply the same pattern. Affected structs:
`MediaCodecEncoder::encode` (~line 123), `MediaCodecDecoder::decode`
(~line 294), `MediaCodecHevcEncoder::encode` (~line 439),
`MediaCodecHevcDecoder::decode` (~line 773), `MediaCodecAv1Encoder::encode`
(~line 560), `MediaCodecAv1Decoder::decode` (~line 907).
Do NOT use `unsafe { std::mem::transmute }` — the `d.write(s)` pattern is
already present and safe.
Note: if the file already uses `d.write(s)` everywhere, this category may
already be addressed by the existing code. Check the actual error count.
#### Fix 4 — `E0599`: `.index()` is private
**Symptom**: `method 'index' is private`
**File**: `crates/wzp-video/src/mediacodec.rs`
NDK 0.9 removed the public `.index()` method from `DequeuedInputBuffer` and
`DequeuedOutputBuffer`. The pattern that broke:
```rust
// Broken: buffer.index() is private in NDK 0.9
let idx = buffer.index();
codec.queue_input_buffer_index(idx, ...);
```
In NDK 0.9 the correct API is to pass the buffer object directly to
`queue_input_buffer`:
```rust
codec.queue_input_buffer(buffer, offset, size, pts_us, flags)?;
```
The file already uses `codec.queue_input_buffer(buffer, 0, to_copy, ...)` in
most places (lines 131, 303, 447, etc.). Search for any remaining `.index()`
calls on buffer objects and replace them with the direct-pass pattern shown
above.
#### Fix 5 — `E0277`: `NonNull<AMediaCodec>` is not `Send`
**Symptom**: `NonNull<AMediaCodec>` cannot be sent between threads safely
**File**: `crates/wzp-video/src/mediacodec.rs`
Each codec struct must have an `unsafe impl Send` declaration. Audit all six
codec structs:
| Struct | `unsafe impl Send` present? |
|--------|----------------------------|
| `MediaCodecEncoder` | Yes (line 51) |
| `MediaCodecDecoder` | Yes (line 228) |
| `MediaCodecHevcEncoder` | Yes (line 374) |
| `MediaCodecHevcDecoder` | Yes (line 705) |
| `MediaCodecAv1Encoder` | Yes (line 503) |
| `MediaCodecAv1Decoder` | Yes (line 844) |
If any are missing, add them with a safety comment:
```rust
// SAFETY: AMediaCodec is documented as thread-safe.
#[cfg(target_os = "android")]
unsafe impl Send for MediaCodecXxxYyy {}
```
This category may already be clean. Confirm with the build output.
## Implementation steps
1. Push the current branch to `github/experimental-ui` before starting.
2. **Commit 1**: Fix `ndk_sys` dependency (`Cargo.toml`). Push. Run build.
Confirm `E0433` errors drop.
3. **Commit 2**: Fix `BITRATE_MODE_CBR`. Push. Run build. Confirm `E0425` gone.
4. **Commit 3**: Fix `MaybeUninit` write sites. Push. Run build. Confirm
`E0308` gone.
5. **Commit 4**: Remove any `.index()` calls. Push. Run build. Confirm
`E0599` gone.
6. **Commit 5**: Add missing `unsafe impl Send` if any. Push. Run build.
Confirm `E0277` gone and total error count is 0.
## Files to read before implementing
- `crates/wzp-video/src/mediacodec.rs` (full file — 45 KB; read in chunks)
- `crates/wzp-video/Cargo.toml` (check existing `[dependencies]` sections)
## Verify
Final build command (see Design section). Expected output: no lines matching
`^error`.
Also verify non-Android host still compiles:
```bash
cargo check -p wzp-video
```
## Done when
`cargo build --target aarch64-linux-android -p wzp-video` on the remote
server produces 0 `error[...]` lines. Non-Android `cargo check -p wzp-video`
also passes.

260
docs/PRD/PRD-clippy-debt.md Normal file
View File

@@ -0,0 +1,260 @@
# PRD: Fix wzp-codec Clippy Lint Debt
> **Status:** proposed
> **Resolves:** 9 pre-existing clippy lints in `crates/wzp-codec/src/` that cause `cargo clippy --workspace -D warnings` to fail, breaking any strict-CI configuration.
> **Depends on:** Nothing — all changes are in `crates/wzp-codec/src/`.
## Problem
`cargo clippy -p wzp-codec -- -D warnings` fails with 9 lints across 5 files.
These are pre-existing code patterns that were never flagged during development
because the CI flag was not set. They have no runtime impact today but prevent
adding `-D warnings` to CI without first cleaning them up.
The 3 errors in `deps/featherchat` are in a submodule — do NOT touch them.
`warzone_protocol` clippy errors are accepted debt (not our code).
## Goals
- `cargo clippy -p wzp-codec -- -D warnings` exits 0.
- No behavior changes — every fix is a semantically equivalent rewrite.
- No changes outside `crates/wzp-codec/src/`.
## Non-goals
- Fixing clippy lints in any crate other than `wzp-codec`.
- Adding new functionality.
- Touching the `deps/featherchat` submodule.
## Design
### Lint inventory
| Lint | Count | File | Approx line | Fix |
|------|-------|------|-------------|-----|
| `implicit_saturating_sub` | 1 | `aec.rs` | 117119 | `saturating_sub` |
| `needless_range_loop` | 2 | `aec.rs:164`, `resample.rs:51` | — | iterate with `iter().enumerate()` or direct iter |
| `manual_div_ceil` | 2 | `codec2_dec.rs:48`, `codec2_enc.rs:48` | — | `div_ceil` |
| `manual_clamp` | 2 | `denoise.rs:59`, `opus_enc.rs:250` | — | `.clamp(min, max)` |
| `manual_ascii_check` | 1 | `opus_enc.rs:104` | — | `.eq_ignore_ascii_case()` |
| `same_item_push` | 1 | `resample.rs:184` | — | `vec.resize` or `extend(repeat)` |
### Fix details
#### 1. `implicit_saturating_sub` — `aec.rs` line ~117
Current code:
```rust
fn delay_available(&self) -> usize {
let buffered = self.delay_write - self.delay_read;
if buffered > self.delay_samples {
buffered - self.delay_samples
} else {
0
}
}
```
Clippy wants `saturating_sub` because the subtraction can underflow if
`buffered < self.delay_samples`:
```rust
fn delay_available(&self) -> usize {
let buffered = self.delay_write - self.delay_read;
buffered.saturating_sub(self.delay_samples)
}
```
This is semantically identical (both return 0 when `buffered <= delay_samples`).
#### 2a. `needless_range_loop` — `aec.rs` line ~164
Current code:
```rust
for i in 0..n {
let near_f = nearend[i] as f32;
let base = (self.far_pos + fl * ((n / fl) + 2) + i - n) % fl;
...
}
```
`i` is used both to index `nearend[i]` and in arithmetic (`+ i - n`).
Clippy fires because `nearend[i]` could use `.iter().enumerate()`.
Convert to `enumerate`:
```rust
for (i, &sample) in nearend.iter().enumerate() {
let near_f = sample as f32;
let base = (self.far_pos + fl * ((n / fl) + 2) + i - n) % fl;
...
}
```
Make sure to update any references to `nearend[i]` inside the loop body
to use `sample` (or `near_f` directly). Also update the NLMS adaptation
sub-loop if it references `nearend[i]`.
#### 2b. `needless_range_loop` — `resample.rs` line ~51
Current code:
```rust
for i in 0..FIR_TAPS {
let n = i as f64 - m / 2.0;
let sinc = ...;
let t = 2.0 * i as f64 / m - 1.0;
let kaiser = ...;
kernel[i] = sinc * kaiser;
}
```
`i` is used both as an index (`kernel[i]`) and in arithmetic. Use
`iter_mut().enumerate()`:
```rust
for (i, slot) in kernel.iter_mut().enumerate() {
let n = i as f64 - m / 2.0;
let sinc = ...;
let t = 2.0 * i as f64 / m - 1.0;
let kaiser = ...;
*slot = sinc * kaiser;
}
```
#### 3a. `manual_div_ceil` — `codec2_dec.rs` line ~48
Current code:
```rust
fn bytes_per_frame(&self) -> usize {
(self.inner.bits_per_frame() + 7) / 8
}
```
Replace with:
```rust
fn bytes_per_frame(&self) -> usize {
self.inner.bits_per_frame().div_ceil(8)
}
```
`div_ceil` is stable as of Rust 1.73. The builder uses a recent enough
toolchain. If `bits_per_frame()` returns `usize`, the method is available.
If it returns a different integer type, cast accordingly.
#### 3b. `manual_div_ceil` — `codec2_enc.rs` line ~48
Same pattern, same fix:
```rust
fn bytes_per_frame(&self) -> usize {
self.inner.bits_per_frame().div_ceil(8)
}
```
#### 4a. `manual_clamp` — `denoise.rs` line ~59
Current code:
```rust
let clamped = val.max(-32768.0).min(32767.0);
```
Replace with:
```rust
let clamped = val.clamp(-32768.0_f32, 32767.0_f32);
```
Note: `.clamp()` on `f32` requires both bounds to be the same type. If `val`
is already `f32`, no extra cast is needed. Verify the type of `val` in
context (it is `f32` per the output array type `[f32; 480]`).
#### 4b. `manual_clamp` — `opus_enc.rs` line ~252
Read the surrounding code for the exact pattern. It will be something like:
```rust
let v = if x < min_val { min_val } else if x > max_val { max_val } else { x };
```
or the `.max().min()` chain. Replace with `x.clamp(min_val, max_val)`.
#### 5. `manual_ascii_check` — `opus_enc.rs` line ~104
Current code:
```rust
Ok(v) => !v.is_empty() && v != "0" && v.to_ascii_lowercase() != "false",
```
Clippy wants `.eq_ignore_ascii_case()` instead of lowercasing the whole string:
```rust
Ok(v) => !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false"),
```
#### 6. `same_item_push` — `resample.rs` line ~183
Current code:
```rust
for _ in 1..RATIO {
work.push(0.0);
}
```
This pushes the same `0.0` value `(RATIO - 1)` times. Replace with:
```rust
work.resize(work.len() + (RATIO - 1), 0.0f64);
```
Or equivalently:
```rust
work.extend(std::iter::repeat(0.0f64).take(RATIO - 1));
```
Note: `RATIO` is a `const usize`. Verify `work` is `Vec<f64>` in context
(it is — `work.push(s as f64)` immediately before).
## Implementation steps
1. Read each file at the line numbers listed above to confirm the exact current
code before editing (line numbers may shift slightly due to prior edits).
2. Apply all 9 fixes. They are independent — no ordering requirement.
3. Run `cargo clippy -p wzp-codec -- -D warnings` locally or via the CI
command.
4. If any lint persists, re-read that file section and adjust.
## Files to read before implementing
- `crates/wzp-codec/src/aec.rs` lines 114200
- `crates/wzp-codec/src/resample.rs` lines 4570 and 178190
- `crates/wzp-codec/src/codec2_dec.rs` lines 4055
- `crates/wzp-codec/src/codec2_enc.rs` lines 4055
- `crates/wzp-codec/src/denoise.rs` lines 4565
- `crates/wzp-codec/src/opus_enc.rs` lines 96110 and 244260
## Verify
```bash
cargo clippy -p wzp-codec -- -D warnings
```
Expected: exits 0 with no warnings.
Also run to confirm no regressions:
```bash
cargo test -p wzp-codec
```
## Done when
`cargo clippy -p wzp-codec -- -D warnings` exits 0. All 9 lints are gone.
`cargo test -p wzp-codec` passes. No changes outside `crates/wzp-codec/src/`.

View File

@@ -0,0 +1,195 @@
# PRD: E2E Media Encryption — Wire EncryptingTransport on Relay Path
> **Status:** proposed
> **Resolves:** Security gap — relay-path media travels in QUIC TLS only; WZP application-layer ChaCha20-Poly1305 is negotiated but never applied.
> **Depends on:** `wzp_client::encrypted_transport::EncryptingTransport` (already implemented).
## Problem
`CallEngine::start` (both the Android path and the desktop path) calls
`wzp_client::handshake::perform_handshake`, which returns a `HandshakeResult`
containing a `session: Box<dyn CryptoSession>` (a keyed `ChaChaSession`).
Both call sites discard the session — only `hs.video_codec` is retained.
All subsequent `send_media` / `recv_media` calls go directly through
`Arc<wzp_transport::QuinnTransport>`, which provides QUIC TLS (relay sees
plaintext application data after TLS termination at the relay). The WZP
application-level AEAD — ChaCha20-Poly1305, keyed per-call, relay-never-sees
— is never applied.
`wzp_client::encrypted_transport::EncryptingTransport` exists
(`crates/wzp-client/src/encrypted_transport.rs`) and is fully tested.
It wraps any `Arc<dyn MediaTransport>` and intercepts every `send_media` /
`recv_media` call with `session.encrypt()` / `session.decrypt()`.
## Goals
- The relay-path `HandshakeResult::session` is used to construct an
`EncryptingTransport` that wraps the raw `QuinnTransport`.
- All `send_media` and `recv_media` calls in the relay path go through the
wrapper, not the raw transport.
- The direct P2P path (`is_direct_p2p == true`) is left unchanged — QUIC TLS
is the encryption layer there.
- `cargo check --manifest-path desktop/src-tauri/Cargo.toml` passes.
- A `#[cfg(test)]` test verifies that the relay path uses `EncryptingTransport`.
## Non-goals
- Rekeying (`SignalMessage::Rekey`) — tracked separately.
- Video transport encryption (same mechanism; apply after audio is confirmed working).
- Changes to the P2P path, the relay binary, or any crate outside `desktop/src-tauri`.
## Design
### `EncryptingTransport` API (read `crates/wzp-client/src/encrypted_transport.rs`)
```rust
pub struct EncryptingTransport { ... }
impl EncryptingTransport {
pub fn new(inner: Arc<dyn MediaTransport>, session: Box<dyn CryptoSession>) -> Self;
}
// Implements MediaTransport:
// send_media → session.encrypt(header_bytes, payload) → inner.send_media
// recv_media → inner.recv_media → session.decrypt(header_bytes, ciphertext)
// send_signal / recv_signal / path_quality / close → forwarded unchanged
```
`EncryptingTransport` is NOT `Arc`-wrapped by the constructor; wrap it in
`Arc::new(...)` when storing as `Arc<dyn MediaTransport>`.
### Two call sites in `desktop/src-tauri/src/engine.rs`
**Call site 1 — Android path** (`CallEngine::start` around line 575):
```rust
if !is_direct_p2p {
let _hs = match wzp_client::handshake::perform_handshake(...).await { Ok(hs) => hs, ... };
// hs.session is discarded here — fix this
}
```
Change: capture `hs`, then build a wrapped transport:
```rust
if !is_direct_p2p {
let hs = match wzp_client::handshake::perform_handshake(...).await { Ok(hs) => hs, ... };
info!(video_codec = ?hs.video_codec, "handshake complete");
let transport: Arc<dyn wzp_proto::MediaTransport> =
Arc::new(wzp_client::encrypted_transport::EncryptingTransport::new(
transport.clone(),
hs.session,
));
// use `transport` (the wrapped version) for all subsequent send_t / recv_t clones
}
```
The variable `transport` must shadow the raw `Arc<QuinnTransport>` so that
every subsequent clone of `transport` picks up the encrypted wrapper.
**Call site 2 — Desktop path** (`CallEngine::start` around line 1551):
```rust
let _negotiated_video_codec = if !is_direct_p2p {
let hs = wzp_client::handshake::perform_handshake(...).await?;
info!(video_codec = ?hs.video_codec, "handshake complete");
hs.video_codec // session dropped here — fix this
} else { None };
```
Change: extract `session` before returning `video_codec`, then shadow
`transport` with the wrapped version. Because `transport` is used after this
block (cloned into `send_t`, `recv_t`, etc.), the shadow must happen inside
the same scope or immediately after:
```rust
let (_negotiated_video_codec, transport): (_, Arc<dyn wzp_proto::MediaTransport>) =
if !is_direct_p2p {
let hs = wzp_client::handshake::perform_handshake(...).await?;
info!(video_codec = ?hs.video_codec, "handshake complete");
let enc = Arc::new(wzp_client::encrypted_transport::EncryptingTransport::new(
transport.clone(),
hs.session,
));
(hs.video_codec, enc)
} else {
info!("direct P2P — skipping relay handshake");
(None, transport.clone())
};
```
All subsequent `transport.clone()` calls then operate on the encrypted wrapper.
### Import
Add to the top of `engine.rs` if not already present:
```rust
use wzp_client::encrypted_transport::EncryptingTransport;
```
Or use the fully-qualified path inline (already shown above).
### Type compatibility
- `EncryptingTransport` implements `wzp_proto::MediaTransport` (confirmed in the source).
- The existing `send_t` / `recv_t` variables are already typed as
`Arc<dyn MediaTransport>` (or coerced on first use) — the shadow is a
drop-in replacement.
- The `vid_transport` for the video path (`line ~2090`) is also cloned from
`transport`; it will automatically use the encrypted wrapper if the shadow
is placed before those clones.
## Implementation steps
1. Read `desktop/src-tauri/src/engine.rs` lines 570620 (Android path) and
15471570 (desktop path) to see the exact variable names in each branch.
2. **Android path fix** (line ~585): rename `_hs` to `hs`, extract
`hs.session`, wrap `transport` with `EncryptingTransport::new`, re-bind
`transport` as `Arc<dyn MediaTransport>`.
3. **Desktop path fix** (line ~1551): restructure the
`if !is_direct_p2p` block to return `(video_codec, wrapped_transport)`
and shadow `transport`.
4. Confirm that `vid_transport` (line ~2090) is cloned after the shadow — if
it is, no further changes are needed for video.
5. Run `cargo check --manifest-path desktop/src-tauri/Cargo.toml`. Fix any
type-mismatch errors (usually a missing `as Arc<dyn MediaTransport>` cast
or a moved value).
6. Add a `#[cfg(test)]` module to `engine.rs` (or to a new
`engine_tests.rs` included via `#[cfg(test)] mod engine_tests`) with a
test that constructs a `LoopbackTransport`, calls `perform_handshake`
against a mock relay fixture, and verifies that a received payload is
decrypted before returning from `recv_media`. A simpler alternative that
avoids a full handshake: assert `is::<EncryptingTransport>()` on the
`transport` variable at the test call site using `std::any::Any`.
## Files to read before implementing
- `desktop/src-tauri/src/engine.rs` lines 475625 (Android path) and
14801570 (desktop path)
- `crates/wzp-client/src/encrypted_transport.rs` (full — for the exact
constructor signature and trait impl)
- `crates/wzp-client/src/handshake.rs` (for `HandshakeResult` struct
definition — confirm the `session` field name and type)
## Verify
```bash
cargo check --manifest-path desktop/src-tauri/Cargo.toml
```
Expected: 0 errors.
Manual smoke check: both `perform_handshake` call sites in `engine.rs` must
use `hs.session` (grep: `hs\.session` should appear twice, once per call site).
The string `_hs` must not remain on the relay path (only on the `_hs =` binding if the variable is intentionally unused before wrapping).
## Done when
- `cargo check --manifest-path desktop/src-tauri/Cargo.toml` exits 0.
- Both relay-path `perform_handshake` call sites build an `EncryptingTransport`
from `hs.session`.
- The direct-P2P branch (`is_direct_p2p == true`) is unchanged.
- A `#[cfg(test)]` test in `engine.rs` verifies that `EncryptingTransport`
is used on the relay path (construction proof or decrypt round-trip).

View File

@@ -0,0 +1,220 @@
# PRD: Quality Upgrade Flow — UpgradeProposal / Response / Confirm
> **Status:** proposed
> **Resolves:** Four TODO comments in the signal task of `desktop/src-tauri/src/lib.rs` that leave quality upgrade messages unhandled. Audio quality never upgrades mid-call even when the network improves.
> **Depends on:** `wzp_proto::SignalMessage::{UpgradeProposal, UpgradeResponse, UpgradeConfirm, QualityCapability}` (already defined in `crates/wzp-proto/src/packet.rs`).
## Problem
The signal receive task in `lib.rs` matches `UpgradeProposal`, `UpgradeResponse`,
`UpgradeConfirm`, and `QualityCapability` messages from the peer, logs them,
then hits a `// TODO` comment and does nothing. The 4 TODOs are at lines
1930, 1949, 1966, and 1985 of `desktop/src-tauri/src/lib.rs`.
Consequence: audio quality is frozen at the profile negotiated at call start.
Even when the network improves, the encoder never upgrades.
## Goals
1. `UpgradeProposal` auto-accepts and sends `UpgradeResponse { accepted: true }`.
2. Accepted `UpgradeResponse` sends `UpgradeConfirm` and switches the local encoder.
3. Received `UpgradeConfirm` switches the local encoder.
4. Received `QualityCapability` caps the local encoder to the peer's max profile.
5. A unit test verifies the accept/confirm round-trip.
6. `cargo check --manifest-path desktop/src-tauri/Cargo.toml` passes.
## Non-goals
- UI for manual accept/reject of upgrade proposals (auto-accept only).
- Sending `UpgradeProposal` from our side (the outgoing path already exists in
`lib.rs`; this PRD only handles receiving).
- Downgrade negotiation.
- Persisting quality profiles across calls.
## Design
### New shared state
Add the following to `AppState` (or as captured variables in the signal task
closure — whichever is cleaner given the existing structure):
```rust
/// Pending outgoing upgrade: (call_id, proposal_id, profile).
/// Set when we send an UpgradeProposal, consumed when we receive an accepted UpgradeResponse.
pending_upgrade: Arc<Mutex<Option<(String, String, QualityProfile)>>>,
/// Current quality profile for the encoder. The audio send task reads this
/// at the start of each encode cycle.
active_quality: Arc<Mutex<QualityProfile>>,
/// Peer's reported maximum quality cap. The audio send task clamps to min(active, peer_max).
peer_max_quality: Arc<Mutex<Option<QualityProfile>>>,
```
If `AppState` already holds these fields (check `lib.rs` for the struct
definition), reuse them instead of adding duplicates.
### Handler implementations
#### 1. `UpgradeProposal` (line ~1930)
```rust
// Replace the TODO comment with:
let response = SignalMessage::UpgradeResponse {
version: wzp_proto::default_signal_version(),
call_id: call_id.clone(),
proposal_id: proposal_id.clone(),
accepted: true,
reason: None,
};
if let Err(e) = signal_transport.send_signal(&response).await {
tracing::warn!("failed to send UpgradeResponse: {e}");
}
```
`signal_transport` is whatever variable holds the signal `Arc<dyn MediaTransport>`
in scope at that match arm. Inspect the enclosing task to find the right name.
#### 2. `UpgradeResponse` (line ~1949)
```rust
// Replace the TODO comment with:
if accepted {
// Retrieve the pending proposal to get the confirmed_profile.
let maybe_proposal = pending_upgrade.lock().unwrap().take();
if let Some((_cid, pid, profile)) = maybe_proposal {
if pid == proposal_id {
// Send UpgradeConfirm.
let confirm = SignalMessage::UpgradeConfirm {
version: wzp_proto::default_signal_version(),
call_id: call_id.clone(),
proposal_id: proposal_id.clone(),
confirmed_profile: profile.clone(),
};
if let Err(e) = signal_transport.send_signal(&confirm).await {
tracing::warn!("failed to send UpgradeConfirm: {e}");
}
// Switch our encoder.
*active_quality.lock().unwrap() = profile;
}
}
}
```
If `pending_upgrade` is a captured `Arc<Mutex<...>>` in the task closure, it
can be read/written without going through `AppState`.
#### 3. `UpgradeConfirm` (line ~1966)
```rust
// Replace the TODO comment with:
*active_quality.lock().unwrap() = confirmed_profile;
```
The audio send task (in `engine.rs`) reads `active_quality` at the start of
each encode cycle and reconfigures the Opus encoder bitrate accordingly.
#### 4. `QualityCapability` (line ~1985)
```rust
// Replace the TODO comment with:
*peer_max_quality.lock().unwrap() = Some(max_profile);
```
#### 5. Audio send task changes (`engine.rs`)
The audio send task already runs in a loop. Add a quality-check at the top of
each encode iteration:
```rust
// At the start of the encode loop body:
let effective_profile = {
let active = active_quality.lock().unwrap().clone();
let peer_cap = peer_max_quality.lock().unwrap().clone();
match peer_cap {
Some(cap) if cap.opus_bitrate_bps() < active.opus_bitrate_bps() => cap,
_ => active,
}
};
// Pass effective_profile to encoder if it changed since last iteration.
```
`QualityProfile::opus_bitrate_bps()` already exists (check
`crates/wzp-proto/src/codec_id.rs`). If `QualityProfile` does not have a
direct bitrate accessor, compare using the `PartialOrd` impl or a helper that
ranks profiles numerically.
To avoid calling `encoder.set_bitrate()` every single frame, cache the last
applied profile and only reconfigure on change:
```rust
let mut last_applied_profile: Option<QualityProfile> = None;
// Inside loop:
if Some(&effective_profile) != last_applied_profile.as_ref() {
encoder.set_bitrate(effective_profile.opus_bitrate_bps());
last_applied_profile = Some(effective_profile.clone());
}
```
`encoder.set_bitrate(bps: u32)` — add this method to `OpusEncoder` in
`crates/wzp-codec/src/opus_enc.rs` if it does not exist. It wraps
`opus_encoder_ctl(OPUS_SET_BITRATE_REQUEST, bps)`.
### Unit tests
Add a `#[cfg(test)]` module in `lib.rs` (or a dedicated test file) that:
1. Creates a `LoopbackSignalTransport` stub that records sent `SignalMessage`s.
2. Calls the `UpgradeProposal` handler logic directly, asserts that an
`UpgradeResponse { accepted: true }` was sent.
3. Calls the `UpgradeResponse { accepted: true }` handler with a pre-populated
`pending_upgrade`, asserts that `UpgradeConfirm` was sent and
`active_quality` was updated.
These can be pure unit tests (no Tauri or audio), since the handlers are
pure async functions over captured state.
## Implementation steps
1. Read `desktop/src-tauri/src/lib.rs` lines 19101990 (the four TODO blocks)
and the surrounding signal task structure to identify the variable names
for `signal_transport`, `app_state`, and any existing quality-state fields.
2. Read `desktop/src-tauri/src/engine.rs` for `CallEngine` struct fields and
the audio send task loop.
3. Read `crates/wzp-proto/src/codec_id.rs` for `QualityProfile` methods.
4. Add `pending_upgrade`, `active_quality`, `peer_max_quality` to the
appropriate shared state (or as closure captures in the signal task).
5. Replace the 4 TODO comments with the handlers described above.
6. Add `set_bitrate` to `OpusEncoder` if missing.
7. Update the audio send task to read `active_quality` / `peer_max_quality`
each iteration.
8. Add unit tests.
9. Run `cargo check --manifest-path desktop/src-tauri/Cargo.toml`.
## Files to read before implementing
- `desktop/src-tauri/src/lib.rs` — grep for `UpgradeProposal` to find the
exact lines; also read the surrounding signal task for variable names.
- `crates/wzp-proto/src/packet.rs` lines 11301190 — `UpgradeProposal`,
`UpgradeResponse`, `UpgradeConfirm`, `QualityCapability` struct layouts.
- `desktop/src-tauri/src/engine.rs``CallEngine` struct fields, audio
send task loop.
- `crates/wzp-proto/src/codec_id.rs``QualityProfile` methods.
- `crates/wzp-codec/src/opus_enc.rs``OpusEncoder` API.
## Verify
```bash
cargo check --manifest-path desktop/src-tauri/Cargo.toml
cargo test -p wzp-desktop 2>/dev/null || cargo test --manifest-path desktop/src-tauri/Cargo.toml
```
Expected: 0 errors; unit tests pass.
## Done when
- All 4 TODO comments replaced with real logic.
- `cargo check --manifest-path desktop/src-tauri/Cargo.toml` exits 0.
- Unit test verifies: `UpgradeProposal``UpgradeResponse { accepted: true }` sent;
`UpgradeResponse { accepted: true }``UpgradeConfirm` sent + `active_quality` updated.

View File

@@ -0,0 +1,242 @@
# PRD: Wire Format Hardening — FEC block_id u16, SignalMessage version byte, FEC repair index wrap
> **Status:** proposed
> **Resolves:** Three small wire-format defects (H2, M1, M4) that compound over time into silent data corruption or protocol breakage.
> **Depends on:** Nothing — purely mechanical changes to `wzp-fec` and `wzp-proto`.
## Problem
Three independent issues:
**H2 — `fec_block_id` u8 wraps too fast.** The `block_id` field in
`RaptorQFecEncoder` (and `RaptorQFecDecoder`) is `u8`. At 5 audio frames
per block and 50 fps this wraps every ~51 seconds. A slow receiver or a
mid-session join can receive packets from two different blocks with the same
`block_id`, silently corrupting FEC recovery.
**M1 — Some `SignalMessage` variants lack a `version` byte.** Most variants
have `#[serde(default = "default_signal_version")] version: u8`. The unit
variant `Reflect` (and potentially others added recently) does not. Future
protocol changes that key on `version` will silently misparse old messages
from peers without the field.
**M4 — FEC repair index can silently wrap at 255.** In
`crates/wzp-fec/src/encoder.rs` line 140:
```rust
let idx = (num_source as u16).wrapping_add(i as u16);
```
(The line was already fixed to `u16` — verify it is `u16`, not `u8`. If it
is still `u8`, the fix is below.)
If the line currently reads `(num_source as u8).wrapping_add(i as u8)`, then
when `num_source + repair_count > 255` the repair symbol indices wrap silently,
producing incorrect ESI values that the decoder cannot correlate to source
blocks.
## Goals
- **H2**: Widen `block_id` in encoder and decoder from `u8` to `u16`.
Update `finalize_block` return type and `current_block_id` return type in
the trait (`wzp-proto`) and implementations (`wzp-fec`).
- **M1**: Audit every `SignalMessage` variant; add
`#[serde(default = "default_signal_version")] version: u8` to any that
are missing it.
- **M4**: Confirm the repair index uses `u16`; fix it if it is still `u8`.
Update the decoder's `add_symbol` call site if the index type changes.
- `cargo test -p wzp-fec -p wzp-proto` passes; no existing tests broken.
## Non-goals
- Changing the wire encoding of `MediaHeaderV2::fec_block` — it is already
`u16` on the wire. This PRD only widens the **internal counter** to match.
- Multi-block decode concurrency or block expiry policy.
- Any crate outside `wzp-fec` and `wzp-proto`.
## Design
### Item A — `fec_block_id` u8 → u16
**Files**:
- `crates/wzp-proto/src/traits.rs``FecEncoder` and `FecDecoder` traits
- `crates/wzp-fec/src/encoder.rs``RaptorQFecEncoder`
- `crates/wzp-fec/src/decoder.rs``RaptorQFecDecoder`
**Trait changes** (`traits.rs`):
```rust
// Before:
fn finalize_block(&mut self) -> Result<u8, FecError>;
fn current_block_id(&self) -> u8;
fn add_symbol(&mut self, block_id: u8, ...) -> Result<(), FecError>;
fn try_decode(&mut self, block_id: u8) -> Result<...>;
fn expire_before(&mut self, block_id: u8);
```
```rust
// After:
fn finalize_block(&mut self) -> Result<u16, FecError>;
fn current_block_id(&self) -> u16;
fn add_symbol(&mut self, block_id: u16, ...) -> Result<(), FecError>;
fn try_decode(&mut self, block_id: u16) -> Result<...>;
fn expire_before(&mut self, block_id: u16);
```
**Encoder changes** (`encoder.rs`):
- Change `block_id: u8` field to `block_id: u16`.
- Update `self.block_id.wrapping_add(1)` (already u16 semantics; keep as is).
- Update `finalize_block` to return `u16`.
- Update `current_block_id` to return `u16`.
- Update all tests that assert `block_id == 0u8``== 0u16`, and the
wrap test (`block_id_wraps`) to iterate to `u16::MAX` (65535) — or reduce
it to 300 iterations to keep it fast, asserting the wrap at 65536.
The wrap test at 256 iterations (`0..=255u8`) must be updated; a full
`u16` wrap test at 65536 iterations is too slow for CI. Change to:
```rust
#[test]
fn block_id_wraps_u16() {
let mut enc = RaptorQFecEncoder::with_defaults(1);
// Advance 300 blocks and verify no panic + monotonic increment.
for expected in 0..300u16 {
assert_eq!(enc.current_block_id(), expected);
enc.add_source_symbol(&[0u8; 10]).unwrap();
enc.finalize_block().unwrap();
}
// Explicitly test wrap at u16 boundary.
let mut enc2 = RaptorQFecEncoder::with_defaults(1);
enc2.block_id = u16::MAX;
enc2.add_source_symbol(&[0u8; 10]).unwrap();
let id = enc2.finalize_block().unwrap();
assert_eq!(id, u16::MAX);
assert_eq!(enc2.current_block_id(), 0);
}
```
Note: `block_id` is a private field; expose a test helper or set it in a
`#[cfg(test)]` `impl` block.
**Decoder changes** (`decoder.rs`):
- Change `blocks: HashMap<u8, BlockState>` to `HashMap<u16, BlockState>`.
- Update `get_or_create_block(block_id: u8)``get_or_create_block(block_id: u16)`.
- Update `add_symbol`, `try_decode`, `expire_before` signatures to `u16`.
- The `SourceBlockEncoder::new(self.block_id, ...)` call in `encoder.rs` passes
`block_id` to `raptorq`. RaptorQ uses `u8` for source block number internally.
Cast it: `(block_id & 0xFF) as u8` or `(block_id % 256) as u8` — the `raptorq`
crate's source block ID is a logical identifier within a single object
transmission, not a global counter. The u16 is our session counter; truncate
to u8 when calling into raptorq.
### Item B — `SignalMessage` version byte audit
**File**: `crates/wzp-proto/src/packet.rs`
Read every variant in the `SignalMessage` enum (lines 5551241) and check
for the presence of:
```rust
#[serde(default = "default_signal_version")]
version: u8,
```
The `Reflect` variant at line 974 is a **unit variant** (no fields). Unit
variants cannot carry a `version` field without becoming struct variants.
Change it to a struct variant:
```rust
// Before:
Reflect,
// After:
Reflect {
#[serde(default = "default_signal_version")]
version: u8,
},
```
This is a wire-compatible change: serde JSON struct variants serialize as
`{"Reflect": {"version": 1}}` whereas unit variants serialize as
`"Reflect"`. These are **not** backward-compatible formats. Since `Reflect`
is sent client → relay only and the relay immediately responds, upgrading
both sides atomically is acceptable. Add a serde test to confirm round-trip.
For any other variants missing `version`, follow the same pattern as all
existing variants.
Verify by grepping the enum for variants that do NOT have `version`:
```bash
grep -A3 "^\s*[A-Z][A-Za-z]*\s*{" crates/wzp-proto/src/packet.rs | \
grep -B1 -v "serde.*default_signal_version\|version:"
```
### Item C — FEC repair index wrap (M4)
**File**: `crates/wzp-fec/src/encoder.rs`, line ~140.
Current code:
```rust
let idx = (num_source as u16).wrapping_add(i as u16);
```
If this line already uses `u16` (as shown in the file at line 140), M4 is
already fixed. Verify by reading the current file. If it still reads
`u8`, apply:
```rust
let idx = (num_source as u16).wrapping_add(i as u16);
```
**Decoder** (`crates/wzp-fec/src/decoder.rs`): `add_symbol` already accepts
`symbol_index: u16` (per the trait). Confirm the parameter flows through to
`PayloadId::new(block_id_u8, symbol_index as u32)` without truncation.
## Implementation steps
1. Read `crates/wzp-proto/src/traits.rs` lines 60116 (FecEncoder/FecDecoder
trait definitions) to confirm current signatures.
2. Read `crates/wzp-fec/src/encoder.rs` and `decoder.rs` (full files).
3. Apply Item C fix first (smallest change, easiest to verify).
4. Apply Item A: widen `block_id` from u8 to u16 in traits, encoder, decoder.
Update all callers by running `cargo check -p wzp-fec -p wzp-proto` and
fixing each E0308/E0308 error.
5. Apply Item B: read every variant, add missing `version` fields.
Change `Reflect` to a struct variant.
6. Run tests.
## Files to read before implementing
- `crates/wzp-proto/src/traits.rs` lines 60116 (trait signatures)
- `crates/wzp-fec/src/encoder.rs` (full)
- `crates/wzp-fec/src/decoder.rs` (full)
- `crates/wzp-proto/src/packet.rs` lines 5551241 (all `SignalMessage` variants)
## Verify
```bash
cargo test -p wzp-fec -p wzp-proto
```
Expected: all tests pass, 0 failures. Also run:
```bash
cargo check --workspace
```
to catch any call sites outside `wzp-fec` and `wzp-proto` that passed `u8`
block IDs to the trait methods.
## Done when
- `cargo test -p wzp-fec -p wzp-proto` exits 0.
- `block_id` is `u16` in `RaptorQFecEncoder`, `RaptorQFecDecoder`, and the
`FecEncoder`/`FecDecoder` traits.
- Every non-unit `SignalMessage` variant has a `version: u8` field with
`#[serde(default = "default_signal_version")]`.
- Repair index in `encoder.rs` is computed with `u16` arithmetic.
- No existing tests are broken.

View File

@@ -0,0 +1,165 @@
# BUG-002: macOS VPIO Playout Silent — Audio Decoded But Not Heard
**Severity:** P0 — outgoing audio (Mac mic → peer) works, but the user hears nothing on the Mac side
**Status:** Instrumented on 2026-05-25; awaiting next VPIO vs CPAL repro
**Branch:** `experimental-ui`
**Build observed:** `01f55ca` (Mac desktop), same-day Android `01f55ca`
**Last investigated:** 2026-05-25
**Platforms confirmed affected:** macOS desktop (VPIO path)
---
## Symptom
In a relay-forwarded group call between macOS and Android in the same room (`General`, `count:2`):
- The Mac user can be **heard** on Android (Mac→Android leg works).
- The Mac user **hears nothing** when the Android peer speaks (Android→Mac playout silent).
- Muting the Android peer's mic results in total silence on both ends — confirming the only audio the user perceived during the call was the Mac→Android leg playing through the Android speaker.
This was initially misreported as "I hear myself on Android" — the user was hearing their own Mac mic looped through Android playout, not an actual echo bug.
---
## Evidence
### Mac log excerpt (`01f55ca`, fingerprint `63ba…`, 10:31:22)
```
10:31:23 media:room_update {"count":2, participants:[Akbar fa06…, Manwe 63ba…]}
10:31:23 media:first_recv {"codec":"Opus24k","payload_bytes":27,"t_ms":933}
10:31:25 media:recv_heartbeat {"codec":"Opus24k","decode_errs":0,"decoded_frames":140,"last_written":960,"written_samples":134400}
10:31:29 media:recv_heartbeat {"codec":"Opus32k","decoded_frames":338,"last_written":960,"written_samples":324480}
10:31:35 media:recv_heartbeat {"codec":"Opus6k","decoded_frames":595,"last_written":1920,"written_samples":618240}
10:31:57 media:recv_heartbeat {"codec":"Opus6k","decoded_frames":1086,"last_written":1920,"written_samples":1560960}
```
Recv path is healthy:
- `decode_errs:0` throughout
- `decoded_frames` climbs monotonically 140 → 1086
- `written_samples` reaches 1.56 M (≈32 s of 48 kHz mono)
- `last_written` correctly flips 960 (Opus24k/32k, 20 ms) ↔ 1920 (Opus6k, 40 ms)
**Conclusion:** packets arrive, decode succeeds, samples are written into `playout_ring`. The breakage is **downstream of the ring write**, i.e. in the macOS playout consumer (the VPIO `set_render_callback`).
### Mac send path also works
`media:send_heartbeat` shows `last_rms` spiking to 168, 477, 867, 1458 in response to speech. Android's recv log for the same window decoded those frames successfully.
---
## Suspected Root Cause
`crates/wzp-client/src/audio_vpio.rs:128147` — the render (output) callback reads from `playout_ring` in `FRAME_SAMPLES` (960) chunks. Three plausible failure modes:
### Hypothesis A: Codec-change frame-size mismatch
Mid-call codec switches (`Opus24k``Opus32k``Opus6k`) change the frame size written into the ring (960 ↔ 1920 samples per frame). The render callback reads in fixed 960-sample chunks. The ring is FIFO and should absorb this, but if `AudioRing` semantics drop partial frames or stall on alignment, the consumer side could starve while `written_samples` continues to climb on the producer side.
`engine.rs:1852` and `engine.rs:1895` write into `playout_ring` directly with the decoder's output length (variable). Worth confirming `AudioRing::read` handles arbitrary chunk sizes vs producer.
### Hypothesis B: VPIO output element never actually started
`audio_vpio.rs:151` calls `au.start()` once on the combined VPIO unit. VPIO is supposed to start both input and output elements together, but if AEC initialization fails silently, output rendering may be suppressed while input still produces callbacks. The `[vpio] capture callback: N f32 samples` log line proves input callbacks fire — but there is **no equivalent log line for the render callback**. We do not know whether the render callback is being invoked at all.
### Hypothesis C: Output device routing
VPIO may have grabbed an unexpected default output (e.g. the previous Bluetooth headset, an HDMI sink, or a virtual device). The render callback runs and pulls samples, but they're sent to a device the user can't hear.
### Hypothesis D: AEC over-suppression
VPIO's AEC uses the render callback as the far-end reference. If the unit decides the far-end and near-end are too correlated (it shouldn't here — different speakers in different rooms), it could attenuate playout. Unlikely to produce 100 % silence but listed for completeness.
---
## Instrumentation Added
As of the current workspace, the desktop client emits VPIO render/capture counters into the normal call debug log when OS AEC is enabled:
```
vpio:render_heartbeat {
"capture_callbacks": ...,
"capture_samples": ...,
"render_callbacks": ...,
"render_requested_samples": ...,
"render_read_samples": ...,
"render_underrun_callbacks": ...,
"render_nonzero_callbacks": ...,
"render_last_requested": ...,
"render_last_read": ...,
"render_last_rms": ...,
"render_last_ring_available": ...
}
```
Interpretation:
- `render_callbacks == 0`: VPIO output callback is not running. Focus on VPIO initialization / output element start.
- `render_callbacks > 0` and `render_read_samples == 0` while `media:recv_heartbeat.written_samples` climbs: VPIO callback runs but the ring it reads is not receiving the same samples the recv task writes, or the callback is draining before writes arrive.
- `render_read_samples > 0` and `render_last_rms > 0` while the user hears silence: VPIO is feeding non-zero speaker samples to CoreAudio; focus on output device routing or VoiceProcessingIO suppression.
- CPAL fallback test: disable OS AEC in settings. If CPAL playback is audible with the same call, the failure is VPIO-specific.
## Proposed Diagnostic Steps (Prioritized)
1. **Reproduce with current instrumentation** and compare `media:recv_heartbeat` to `vpio:render_heartbeat`.
2. **One-shot render callback stderr log is now present** (`audio_vpio.rs`) mirroring the existing capture-side `eprintln!`:
```rust
let logged_render = Arc::new(AtomicBool::new(false));
if !logged_render.swap(true, Ordering::Relaxed) {
eprintln!("[vpio] render callback: {} f32 samples, ring_read={}", ch.len(), read);
}
```
This will immediately distinguish Hypothesis B (callback never fires) from A/C/D (callback fires but output is silent or misrouted).
3. **Periodically log render-callback stats** — total samples pulled from ring, samples requested per callback, non-zero render callback count, and last render RMS. Compare against producer-side `written_samples` to confirm consumer is keeping up.
4. **Verify output device** via `AudioUnitGetProperty(kAudioOutputUnitProperty_CurrentDevice, Output)` immediately after `au.start()`. Log device name. If it doesn't match the user's intended speaker, force-set the default output device.
5. **Test with codec pinned** — set `WZP_FORCE_CODEC=Opus24k` (or wire a temporary CLI flag) so codec doesn't change mid-call. If audio works with a pinned codec, Hypothesis A is confirmed and `AudioRing` chunk handling needs review.
6. **Compare CPAL fallback path** — disable OS AEC in settings and reproduce. If CPAL playback works, the bug is VPIO-specific.
---
## Open Questions
- Does the macOS render callback have permission to write to the user's selected output device? Apple changed CoreAudio output-device permission semantics in macOS 14+.
- Is `_audio_unit: AudioUnit` being dropped early? It's stored in `VpioAudio` and that struct is boxed into `audio_handle: Box<dyn Any + Send>` in `engine.rs:1573`, which is held by `CallEngine`. Should be alive for the call duration — confirm no early-drop path.
- Are there any `os_log` / Console.app warnings from `AudioToolbox` / `CoreAudio` / `AVAudioSession` during the call?
---
## Reproduction Steps
1. Start macOS desktop client (build `01f55ca` or later), join relay `193.180.213.68:4433`, room `General`.
2. Start Android client (same build), join same relay + room.
3. Confirm `media:room_update count:2` on both ends.
4. Speak into Android mic.
5. Observe: Mac log shows `decoded_frames` climbing, `decode_errs:0`, `written_samples` increasing. User hears nothing on Mac speakers.
6. Speak into Mac mic — Android user hears Mac audio fine, confirming Mac→Android works.
---
## Related Files
- `crates/wzp-client/src/audio_vpio.rs:128147` — render callback (primary suspect)
- `crates/wzp-client/src/audio_vpio.rs:35161` — full VPIO start sequence
- `crates/wzp-client/src/audio_ring.rs` — ring buffer used by both producer and consumer
- `desktop/src-tauri/src/engine.rs:15621600` — VPIO vs CPAL selection
- `desktop/src-tauri/src/engine.rs:17601900` — recv task writing into `playout_ring`
---
## Fix Plan (Once Diagnosed)
| Diagnosis | Fix |
|-----------|-----|
| A — frame-size mismatch | Make `AudioRing` consumer drain variable chunks, or buffer to fixed 960 in recv task before ring write |
| B — render callback not firing | Investigate VPIO initialization order; consider separate input + output `AudioUnit` instances |
| C — wrong output device | Set `kAudioOutputUnitProperty_CurrentDevice` explicitly to `kAudioObjectSystemObject` default output at start |
| D — AEC suppression | Test with VPIO bypass mode (`kAUVoiceIOProperty_BypassVoiceProcessing`) on; if audio returns, file CoreAudio quirk and tune AEC config |
---
## Cross-References
- BUG-001 (Android join-voice hang) — separate issue, already mitigated; current Android build joins room successfully and recv works.
- Memory: `project_desktop_client.md` notes the desktop rewrite uses CPAL + VoiceProcessingIO with "direct playout, OS-level AEC" — this bug is the first failure of that path under real call conditions.