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:
@@ -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"]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,27 +585,24 @@ impl CallEngine {
|
||||
"remote": transport.remote_address().to_string(),
|
||||
}),
|
||||
);
|
||||
let _session = match wzp_client::handshake::perform_handshake(
|
||||
&*transport,
|
||||
&seed.0,
|
||||
Some(&alias),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(session) => session,
|
||||
Err(e) => {
|
||||
error!("perform_handshake failed: {e}");
|
||||
crate::emit_call_debug(
|
||||
&app,
|
||||
"connect:handshake_failed",
|
||||
serde_json::json!({
|
||||
"t_ms": call_t0.elapsed().as_millis(),
|
||||
"error": e.to_string(),
|
||||
}),
|
||||
);
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
let hs =
|
||||
match wzp_client::handshake::perform_handshake(&*transport, &seed.0, Some(&alias))
|
||||
.await
|
||||
{
|
||||
Ok(hs) => hs,
|
||||
Err(e) => {
|
||||
error!("perform_handshake failed: {e}");
|
||||
crate::emit_call_debug(
|
||||
&app,
|
||||
"connect:handshake_failed",
|
||||
serde_json::json!({
|
||||
"t_ms": call_t0.elapsed().as_millis(),
|
||||
"error": e.to_string(),
|
||||
}),
|
||||
);
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
crate::emit_call_debug(
|
||||
&app,
|
||||
"connect:handshake_done",
|
||||
@@ -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.
|
||||
if !is_direct_p2p {
|
||||
let _session =
|
||||
wzp_client::handshake::perform_handshake(&*transport, &seed.0, Some(&alias))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("perform_handshake failed: {e}");
|
||||
e
|
||||
})?;
|
||||
} else {
|
||||
info!("direct P2P — skipping relay handshake (QUIC TLS is the encryption layer)");
|
||||
}
|
||||
let quinn_transport = transport.clone();
|
||||
let (_negotiated_video_codec, transport): (_, Arc<dyn wzp_proto::MediaTransport>) =
|
||||
if !is_direct_p2p {
|
||||
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) {
|
||||
agc.process_frame(&mut pcm[..n]);
|
||||
if !recv_spk.load(Ordering::Relaxed) {
|
||||
playout_ring.write(&pcm[..n]);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user