feat(quality): upgrade negotiation + asymmetric quality signals (#28, #29, #30)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 31s
Build Release Binaries / build-amd64 (push) Failing after 3m33s

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) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-04-14 17:25:34 +04:00
parent 18e5e75f33
commit bb23976076
4 changed files with 174 additions and 1 deletions

View File

@@ -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
}
}

View File

@@ -1019,6 +1019,61 @@ pub enum SignalMessage {
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<String>,
},
// ── 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<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
local_rtt_ms: Option<u32>,
},
/// 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<String>,
},
/// 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<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
rtt_ms: Option<u32>,
},
}
/// 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]

View File

@@ -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) {

View File

@@ -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,