From bb23976076288b5a58aa819c196e36db3beca103 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Tue, 14 Apr 2026 17:25:34 +0400 Subject: [PATCH] feat(quality): upgrade negotiation + asymmetric quality signals (#28, #29, #30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New SignalMessage variants for P2P quality coordination: UpgradeProposal/UpgradeResponse/UpgradeConfirm (#28): - Consensual quality upgrade flow — proposer sends desired profile, peer accepts/rejects based on own conditions, confirm commits both - All carry call_id for relay routing QualityCapability (#30): - Peer reports its max sustainable profile — enables asymmetric encoding where each side uses its own best quality instead of forcing everyone to the weakest link Relay forwards all 4 signals to the call peer (same pattern as MediaPathReport, CandidateUpdate, HardNatProbe). Desktop signal recv loop handles all 4 with debug logging. Encoder switching TODOs noted for wiring into CallEngine. 4 new serde roundtrip tests. 603 total, 0 regressions. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/wzp-client/src/featherchat.rs | 4 + crates/wzp-proto/src/packet.rs | 129 +++++++++++++++++++++++++++ crates/wzp-relay/src/main.rs | 6 +- desktop/src-tauri/src/lib.rs | 36 ++++++++ 4 files changed, 174 insertions(+), 1 deletion(-) diff --git a/crates/wzp-client/src/featherchat.rs b/crates/wzp-client/src/featherchat.rs index db3bede..8297c0e 100644 --- a/crates/wzp-client/src/featherchat.rs +++ b/crates/wzp-client/src/featherchat.rs @@ -134,6 +134,10 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType { SignalMessage::CandidateUpdate { .. } => CallSignalType::IceCandidate, // mid-call re-gather SignalMessage::HardNatProbe { .. } => CallSignalType::IceCandidate, // hard NAT coordination SignalMessage::HardNatBirthdayStart { .. } => CallSignalType::IceCandidate, // birthday attack + SignalMessage::UpgradeProposal { .. } + | SignalMessage::UpgradeResponse { .. } + | SignalMessage::UpgradeConfirm { .. } + | SignalMessage::QualityCapability { .. } => CallSignalType::Offer, // quality negotiation SignalMessage::QualityDirective { .. } => CallSignalType::Offer, // relay-initiated } } diff --git a/crates/wzp-proto/src/packet.rs b/crates/wzp-proto/src/packet.rs index dc8e301..4f254e0 100644 --- a/crates/wzp-proto/src/packet.rs +++ b/crates/wzp-proto/src/packet.rs @@ -1019,6 +1019,61 @@ pub enum SignalMessage { #[serde(default, skip_serializing_if = "Option::is_none")] reason: Option, }, + + // ── Quality upgrade negotiation (#28, #29) ────────────────── + + /// Peer proposes upgrading to a higher quality profile. + /// The other side can accept or reject based on its own network + /// conditions. Used for consensual upgrades that require both + /// sides to agree (e.g., switching from Opus24k to Studio48k). + UpgradeProposal { + call_id: String, + /// Unique ID for this proposal (to match response). + proposal_id: String, + /// The profile being proposed. + proposed_profile: crate::QualityProfile, + /// Current local network quality to justify the upgrade. + #[serde(default, skip_serializing_if = "Option::is_none")] + local_loss_pct: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + local_rtt_ms: Option, + }, + + /// Response to an UpgradeProposal. + UpgradeResponse { + call_id: String, + proposal_id: String, + /// true = accepted, both sides switch. false = rejected. + accepted: bool, + /// Reason for rejection (if any). + #[serde(default, skip_serializing_if = "Option::is_none")] + reason: Option, + }, + + /// Confirmation that the upgrade is committed — both sides + /// should switch encoder at the next frame boundary. + UpgradeConfirm { + call_id: String, + proposal_id: String, + confirmed_profile: crate::QualityProfile, + }, + + // ── Per-participant quality (#30) ─────────────────────────── + + /// Peer reports its own quality capability — allows asymmetric + /// encoding where each side uses the best quality its connection + /// supports, rather than forcing all to the weakest link. + QualityCapability { + call_id: String, + /// The best profile this peer can sustain based on its + /// current network conditions. + max_profile: crate::QualityProfile, + /// Current loss/RTT for context. + #[serde(default, skip_serializing_if = "Option::is_none")] + loss_pct: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + rtt_ms: Option, + }, } /// How the callee responds to a direct call. @@ -1851,6 +1906,80 @@ mod tests { } } + // ── Quality negotiation roundtrip tests (#28, #29, #30) ───── + + #[test] + fn upgrade_proposal_roundtrip() { + let msg = SignalMessage::UpgradeProposal { + call_id: "c1".into(), + proposal_id: "p1".into(), + proposed_profile: crate::QualityProfile::STUDIO_48K, + local_loss_pct: Some(0.5), + local_rtt_ms: Some(25), + }; + let json = serde_json::to_string(&msg).unwrap(); + let decoded: SignalMessage = serde_json::from_str(&json).unwrap(); + match decoded { + SignalMessage::UpgradeProposal { proposal_id, proposed_profile, .. } => { + assert_eq!(proposal_id, "p1"); + assert_eq!(proposed_profile, crate::QualityProfile::STUDIO_48K); + } + _ => panic!("wrong variant"), + } + } + + #[test] + fn upgrade_response_roundtrip() { + let msg = SignalMessage::UpgradeResponse { + call_id: "c1".into(), + proposal_id: "p1".into(), + accepted: true, + reason: None, + }; + let json = serde_json::to_string(&msg).unwrap(); + let decoded: SignalMessage = serde_json::from_str(&json).unwrap(); + match decoded { + SignalMessage::UpgradeResponse { accepted, .. } => assert!(accepted), + _ => panic!("wrong variant"), + } + } + + #[test] + fn upgrade_confirm_roundtrip() { + let msg = SignalMessage::UpgradeConfirm { + call_id: "c1".into(), + proposal_id: "p1".into(), + confirmed_profile: crate::QualityProfile::STUDIO_64K, + }; + let json = serde_json::to_string(&msg).unwrap(); + let decoded: SignalMessage = serde_json::from_str(&json).unwrap(); + match decoded { + SignalMessage::UpgradeConfirm { confirmed_profile, .. } => { + assert_eq!(confirmed_profile, crate::QualityProfile::STUDIO_64K); + } + _ => panic!("wrong variant"), + } + } + + #[test] + fn quality_capability_roundtrip() { + let msg = SignalMessage::QualityCapability { + call_id: "c1".into(), + max_profile: crate::QualityProfile::GOOD, + loss_pct: Some(2.5), + rtt_ms: Some(80), + }; + let json = serde_json::to_string(&msg).unwrap(); + let decoded: SignalMessage = serde_json::from_str(&json).unwrap(); + match decoded { + SignalMessage::QualityCapability { max_profile, loss_pct, .. } => { + assert_eq!(max_profile, crate::QualityProfile::GOOD); + assert!((loss_pct.unwrap() - 2.5).abs() < 0.01); + } + _ => panic!("wrong variant"), + } + } + // ── Phase 8: Tailscale-inspired signal roundtrip tests ────── #[test] diff --git a/crates/wzp-relay/src/main.rs b/crates/wzp-relay/src/main.rs index 539ebec..be54506 100644 --- a/crates/wzp-relay/src/main.rs +++ b/crates/wzp-relay/src/main.rs @@ -1446,7 +1446,11 @@ async fn main() -> anyhow::Result<()> { // Hard NAT: forward HardNatProbe + HardNatBirthdayStart // to call peer (same pattern as CandidateUpdate). SignalMessage::HardNatBirthdayStart { ref call_id, .. } | - SignalMessage::HardNatProbe { ref call_id, .. } => { + SignalMessage::HardNatProbe { ref call_id, .. } | + SignalMessage::UpgradeProposal { ref call_id, .. } | + SignalMessage::UpgradeResponse { ref call_id, .. } | + SignalMessage::UpgradeConfirm { ref call_id, .. } | + SignalMessage::QualityCapability { ref call_id, .. } => { let (peer_fp, peer_relay_fp) = { let reg = call_registry.lock().await; match reg.get(call_id) { diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 9d9a96e..6ec0c73 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1451,6 +1451,42 @@ fn do_register_signal( }); } } + Ok(Some(SignalMessage::UpgradeProposal { call_id, proposal_id, proposed_profile, local_loss_pct, local_rtt_ms })) => { + tracing::info!(%call_id, %proposal_id, ?proposed_profile, "signal: UpgradeProposal from peer"); + emit_call_debug(&app_clone, "recv:UpgradeProposal", serde_json::json!({ + "call_id": call_id, "proposal_id": proposal_id, + "proposed_profile": format!("{proposed_profile:?}"), + "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 + } + Ok(Some(SignalMessage::UpgradeResponse { call_id, proposal_id, accepted, reason })) => { + tracing::info!(%call_id, %proposal_id, accepted, ?reason, "signal: UpgradeResponse from peer"); + emit_call_debug(&app_clone, "recv:UpgradeResponse", serde_json::json!({ + "call_id": call_id, "proposal_id": proposal_id, + "accepted": accepted, "reason": reason, + })); + // TODO: if accepted, send UpgradeConfirm + switch encoder + } + Ok(Some(SignalMessage::UpgradeConfirm { call_id, proposal_id, confirmed_profile })) => { + tracing::info!(%call_id, %proposal_id, ?confirmed_profile, "signal: UpgradeConfirm"); + emit_call_debug(&app_clone, "recv:UpgradeConfirm", serde_json::json!({ + "call_id": call_id, "proposal_id": proposal_id, + "confirmed_profile": format!("{confirmed_profile:?}"), + })); + // TODO: switch encoder to confirmed_profile at next frame boundary + } + Ok(Some(SignalMessage::QualityCapability { call_id, max_profile, loss_pct, rtt_ms })) => { + tracing::info!(%call_id, ?max_profile, "signal: QualityCapability from peer"); + emit_call_debug(&app_clone, "recv:QualityCapability", serde_json::json!({ + "call_id": call_id, + "peer_max_profile": format!("{max_profile:?}"), + "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) + } Ok(Some(SignalMessage::HardNatBirthdayStart { call_id, acceptor_port_count, acceptor_ports, external_ip })) => { tracing::info!( %call_id,