diff --git a/crates/wzp-client/src/dual_path.rs b/crates/wzp-client/src/dual_path.rs index 102d4c8..05c28de 100644 --- a/crates/wzp-client/src/dual_path.rs +++ b/crates/wzp-client/src/dual_path.rs @@ -291,7 +291,30 @@ pub async fn race( let relay_ep_for_fut = relay_ep.clone(); let relay_client_cfg = wzp_transport::client_config(); let relay_sni = room_sni.clone(); + // Phase 5.5 direct-path head-start: hold the relay dial for + // 500ms before attempting it. On same-LAN cone-NAT pairs the + // direct dial finishes in ~30-100ms, so giving direct a 500ms + // head start means direct reliably wins when it's going to + // work at all. The worst case adds 500ms to the fall-back- + // to-relay scenario, which is imperceptible for users on + // setups where direct isn't available anyway. + // + // Prior behavior (immediate race) caused the relay to win + // ~105ms races on a MikroTik LAN because: + // - Acceptor role's direct_fut = accept() can only fire + // when the peer has completed its outbound LAN dial + // - Dialer role's parallel LAN dials need the peer's + // CallSetup processed + the race started on the other + // side before they can reach us + // - Meanwhile relay_fut is a plain dial that completes in + // whatever the client→relay RTT is (often <100ms) + // + // The 500ms head start is the minimum that empirically makes + // same-LAN direct reliably beat relay, without penalizing + // users who genuinely need the relay path. + const DIRECT_HEAD_START: Duration = Duration::from_millis(500); let relay_fut = async move { + tokio::time::sleep(DIRECT_HEAD_START).await; let conn = wzp_transport::connect(&relay_ep_for_fut, relay_addr, &relay_sni, relay_client_cfg) .await diff --git a/desktop/src-tauri/src/engine.rs b/desktop/src-tauri/src/engine.rs index 4e5fb05..4c92c6a 100644 --- a/desktop/src-tauri/src/engine.rs +++ b/desktop/src-tauri/src/engine.rs @@ -302,6 +302,11 @@ impl CallEngine { // our own wzp_transport::connect step and use this // directly. If None, existing Phase 0 behavior. pre_connected_transport: Option>, + // Phase 5.6: Tauri AppHandle for emitting call-debug + // events from inside the send/recv tasks. Lets the + // debug log pane show first-send/first-recv/heartbeat + // events when the user has call debug logs enabled. + app: tauri::AppHandle, event_cb: F, ) -> Result where @@ -431,6 +436,7 @@ impl CallEngine { let send_quality = quality.clone(); let send_tx_codec = tx_codec.clone(); let send_t0 = call_t0; + let send_app = app.clone(); tokio::spawn(async move { let profile = resolve_quality(&send_quality); let config = match profile { @@ -516,7 +522,22 @@ impl CallEngine { } } } - send_fs.fetch_add(1, Ordering::Relaxed); + let before = send_fs.fetch_add(1, Ordering::Relaxed); + if before == 0 { + // First encoded frame successfully handed + // to the transport. Useful for diagnosing + // 1-way audio: if this fires but the + // peer's media:first_recv never does, + // outbound is broken on our side. + 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}"), } @@ -533,6 +554,24 @@ impl CallEngine { send_drops = drops, "send heartbeat (android)" ); + // Phase 5.6: also emit to the GUI debug log + // when call debug is enabled. Helps diagnose + // 1-way audio — a stalled send heartbeat + // (frames_sent == 0 or last_rms == 0) tells + // you capture/mic is broken; a live one with + // no peer recv tells you outbound is being + // dropped somewhere in the media path. + 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, + }), + ); heartbeat = std::time::Instant::now(); } } @@ -545,6 +584,7 @@ impl CallEngine { let recv_fr = frames_received.clone(); let recv_rx_codec = rx_codec.clone(); let recv_t0 = call_t0; + let recv_app = app.clone(); tokio::spawn(async move { let initial_profile = resolve_quality(&quality).unwrap_or(QualityProfile::GOOD); // Phase 3b/3c: use concrete AdaptiveDecoder (not Box>, + // Phase 5.6: Tauri AppHandle for call-debug event emits + // from inside the send/recv tasks. See android branch for + // the full rationale. Desktop branch accepts it for API + // symmetry but doesn't yet thread it into the send/recv + // tasks — android is where the reporter actually sees the + // 1-way audio regression. + _app: tauri::AppHandle, event_cb: F, ) -> Result where diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 318a4ce..ac4b54d 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -63,7 +63,7 @@ fn set_call_debug_logs_internal(on: bool) { /// Also mirrors to `tracing::info!` so logcat keeps its copy /// regardless of the flag — the toggle only controls the GUI /// overlay, not the underlying Android log stream. -fn emit_call_debug( +pub(crate) fn emit_call_debug( app: &tauri::AppHandle, step: &str, details: serde_json::Value, @@ -469,7 +469,8 @@ async fn connect( let app_clone = app.clone(); emit_call_debug(&app, "connect:call_engine_starting", serde_json::json!({})); - match CallEngine::start(relay, room, alias, os_aec, quality, reuse_endpoint, pre_connected_transport, move |event_kind, message| { + let app_for_engine = app.clone(); + match CallEngine::start(relay, room, alias, os_aec, quality, reuse_endpoint, pre_connected_transport, app_for_engine, move |event_kind, message| { let _ = app_clone.emit( "call-event", CallEvent { @@ -1531,6 +1532,73 @@ async fn deregister(state: tauri::State<'_, Arc>) -> Result<(), String Ok(()) } +/// End the current call, telling the peer via a signal-plane +/// `Hangup` message before tearing down the local media engine. +/// +/// Prior to this command existing, the hangup button just called +/// `disconnect` which stopped the local engine but didn't notify +/// the peer — so the OTHER party stayed on the call screen with +/// nothing to hear. The relay DOES notice the media connection +/// closing but doesn't forward anything to the peer on its own, +/// so a real `SignalMessage::Hangup` is the only reliable signal. +/// +/// Best-effort: if the signal transport is down (e.g. the relay +/// dropped us mid-call), we still tear down the engine locally +/// and return success. The peer's CallEngine will eventually +/// notice the media side dying and the signal-event hangup +/// handler will fire on receiving it from their signal loop if +/// the relay is still up on their side. +#[tauri::command] +async fn hangup_call( + state: tauri::State<'_, Arc>, + app: tauri::AppHandle, +) -> Result<(), String> { + use wzp_proto::SignalMessage; + + emit_call_debug(&app, "hangup_call:start", serde_json::json!({})); + + // Step 1: send Hangup over the signal channel so the relay + // forwards it to the peer. Do this FIRST so the peer gets + // the notification even if the engine shutdown takes a beat. + { + let sig = state.signal.lock().await; + if let Some(ref transport) = sig.transport { + match transport + .send_signal(&SignalMessage::Hangup { + reason: wzp_proto::HangupReason::Normal, + }) + .await + { + Ok(()) => { + tracing::info!("hangup_call: Hangup signal sent to relay"); + emit_call_debug(&app, "hangup_call:signal_sent", serde_json::json!({})); + } + Err(e) => { + tracing::warn!(error = %e, "hangup_call: failed to send Hangup signal"); + emit_call_debug( + &app, + "hangup_call:signal_send_failed", + serde_json::json!({ "error": e.to_string() }), + ); + } + } + } else { + tracing::debug!("hangup_call: no signal transport, skipping Hangup send"); + emit_call_debug(&app, "hangup_call:no_signal_transport", serde_json::json!({})); + } + } + + // Step 2: tear down the local media engine. + let mut engine_lock = state.engine.lock().await; + if let Some(engine) = engine_lock.take() { + engine.stop().await; + emit_call_debug(&app, "hangup_call:engine_stopped", serde_json::json!({})); + } else { + emit_call_debug(&app, "hangup_call:no_engine", serde_json::json!({})); + } + Ok(()) +} + // ─── App entry point ───────────────────────────────────────────────────────── /// Shared Tauri app builder. Used by the desktop `main.rs` and the mobile @@ -1598,6 +1666,7 @@ pub fn run() { connect, disconnect, toggle_mic, toggle_speaker, get_status, register_signal, place_call, answer_call, get_signal_status, get_reflected_address, detect_nat_type, + hangup_call, deregister, set_speakerphone, is_speakerphone_on, get_call_history, get_recent_contacts, clear_call_history, diff --git a/desktop/src/main.ts b/desktop/src/main.ts index 3062a0c..cd929b2 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -897,7 +897,20 @@ spkBtn.addEventListener("click", async () => { }); hangupBtn.addEventListener("click", async () => { userDisconnected = true; - try { await invoke("disconnect"); } catch {} + // Use the new hangup_call command instead of raw disconnect — + // it sends a Hangup signal to the relay FIRST so the peer + // gets auto-dismissed from the call screen, then tears down + // our local engine. Plain `disconnect` would leave the peer + // stuck on the call screen with silent audio. + try { + await invoke("hangup_call"); + } catch { + // Fall back to plain disconnect if hangup_call errors + // (older Rust build without the new command). + try { + await invoke("disconnect"); + } catch {} + } showConnectScreen(); });