diff --git a/CHANGELOG.md b/CHANGELOG.md index 2022695c8..014a2d61b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Unreleased + * Support NACK retransmission without RTX, resending in-band on the main SSRC + * Add `PayloadParams::set_resend` to configure or disable RTX * Fix `Simulcast::add_recv_layer` to push to recv instead of send #968 * Accept any non-empty SDP session name (`s=`), not just `s=-` (RFC 8866 section 5.3) diff --git a/src/change/sdp.rs b/src/change/sdp.rs index 3374bc3fd..d861c989d 100644 --- a/src/change/sdp.rs +++ b/src/change/sdp.rs @@ -1385,9 +1385,13 @@ fn update_media( .find(|r| r.repairs == Some(i.ssrc)) .map(|r| r.ssrc); - // If remote communicated a main a=ssrc, but no RTX, we will not send nacks. + // NACK gated on negotiated `nack` feedback; RTX is just the transport (as in libwebrtc). + let fb_nack = config + .params() + .iter() + .any(|p| media.remote_pts().contains(&p.pt) && p.fb_nack()); let midrid = MidRid(media.mid(), None); - let suppress_nack = repair_ssrc.is_none(); + let suppress_nack = !fb_nack; streams.expect_stream_rx(i.ssrc, repair_ssrc, midrid, suppress_nack); } } diff --git a/src/format/payload_params.rs b/src/format/payload_params.rs index 8f907fc4e..0f93d37da 100644 --- a/src/format/payload_params.rs +++ b/src/format/payload_params.rs @@ -208,6 +208,12 @@ impl PayloadParams { self.resend } + /// Sets the RTX resend payload type. Use `None` to disable RTX, in which + /// case retransmissions (if NACK is enabled) are sent in-band on the main SSRC. + pub fn set_resend(&mut self, resend: Option) { + self.resend = resend; + } + /// The codec with settings for this group of parameters. pub fn spec(&self) -> CodecSpec { self.spec @@ -578,6 +584,14 @@ impl PayloadParams { let mut remote_pt = first.pt; let mut remote_rtx = first.resend; + // The RTX payload type (RFC 4588 §8.6) is a media format, and for unicast + // the answerer selects formats (RFC 3264 §6.1, Unicast Streams) "from + // amongst those listed in the offer". So if we didn't offer RTX for this + // codec, don't adopt the remote's RTX PT. + if self.resend.is_none() { + remote_rtx = None; + } + if self.locked { // This can happen if the incoming PTs are suggestions (send-direction) rather than demanded // (receive-direction). We only want to warn if we get receive direction changes. @@ -646,6 +660,13 @@ impl PayloadParams { claimed.assert_claim_once(rtx); } + // The answerer removes rtcp-fb attributes it does not support, and per + // RFC 4585 §4.2 "both offerer and answerer MUST only use feedback + // mechanisms negotiated in this way". So intersect our local `fb_nack` + // with what the remote signaled (`a=rtcp-fb nack`). Runs once at + // lock time, when `self.fb_nack` still holds the local desired value. + self.fb_nack = self.fb_nack && first.fb_nack; + // This is now locked. self.locked = true; } diff --git a/src/streams/mod.rs b/src/streams/mod.rs index e7e868d75..71eee9665 100644 --- a/src/streams/mod.rs +++ b/src/streams/mod.rs @@ -298,8 +298,8 @@ impl Streams { } } - // If we don't have an RTX PT configured, we don't want NACK. - let suppress_nack = payload.resend.is_none(); + // NACK gated on negotiated `nack` feedback; RTX is just the transport (as in libwebrtc). + let suppress_nack = !payload.fb_nack; // If stream already exists, this might only "fill in" the RTX. self.expect_stream_rx(ssrc_main, rtx, midrid, suppress_nack); diff --git a/src/streams/send.rs b/src/streams/send.rs index d5347a5a9..410662a03 100644 --- a/src/streams/send.rs +++ b/src/streams/send.rs @@ -505,12 +505,10 @@ impl StreamTx { // since the above loop figuring out param needs to be correct also // for the NextPacketKind::Blank case. set_pt_for_padding = Some(pt_main); - } else { - // If the PT we're sending on doesn't have a corresponding RTX PT, - // the packet is de-facto not nackable. - // - // This blocks incoming NACK requests and thus ensures there are no - // entries in self.retries without a RTX PT. + } else if !param.fb_nack() { + // No RTX PT and no `fb_nack`: the packet cannot be resent (neither + // via RTX nor in-band), so it is de-facto not nackable. This ensures + // there are no entries in the rtx cache we can't resend. next.pkt.nackable = false; } @@ -527,6 +525,13 @@ impl StreamTx { header_ref.clone() } + NextPacketKind::ResendInband => { + // In-band resend on the main SSRC: reuse the original PT and sequence + // number so SRTP reproduces the original ROC/IV and the encrypted + // payload decrypts at the receiver. + header_ref.ext_vals.rid_repair = None; + header_ref.clone() + } NextPacketKind::Resend(_) | NextPacketKind::Blank(_) => { // * For the Resend case, we will not have accepted/cached the packet unless // we have a RTX PT (see logic setting next.pkt.nackable above). @@ -587,7 +592,7 @@ impl StreamTx { let pkt = &next.pkt; let body_len = match next.kind { - NextPacketKind::Regular | NextPacketKind::Resend(_) => { + NextPacketKind::Regular | NextPacketKind::Resend(_) | NextPacketKind::ResendInband => { let body_len = pkt.payload.len(); if let Some(patch) = pkt.vp8_patch.as_ref() { patch.copy_to(pkt.payload.as_ref(), &mut body_out[..body_len]); @@ -753,6 +758,15 @@ impl StreamTx { h.push(now, len); } + if self.rtx.is_none() { + let seq_no = pkt.seq_no; + return Some(NextPacket { + kind: NextPacketKind::ResendInband, + seq_no, + pkt, + }); + } + let seq_no = self.seq_no_rtx.inc(); let orig_seq_no = pkt.seq_no; @@ -1203,6 +1217,7 @@ struct NextPacket<'a> { enum NextPacketKind { Regular, Resend(SeqNo), + ResendInband, Blank(u8), } diff --git a/tests/nack.rs b/tests/nack.rs index 836a9d48a..e3938e3a1 100644 --- a/tests/nack.rs +++ b/tests/nack.rs @@ -1,15 +1,18 @@ use std::collections::VecDeque; +use std::net::Ipv4Addr; use std::time::{Duration, Instant}; use netem::{NetemConfig, Probability, RandomLoss}; -use str0m::RtcError; use str0m::format::Codec; -use str0m::media::MediaKind; +use str0m::media::Pt; +use str0m::media::{Direction, MediaKind}; use str0m::rtp::rtcp::Rtcp; use str0m::rtp::{ExtensionValues, RawPacket, RtpWrite, SeqNo, Ssrc}; +use str0m::{Rtc, RtcError}; mod common; -use common::{connect_l_r, init_crypto_default, init_log, progress}; +use common::{Peer, TestRtc, connect_l_r, connect_l_r_with_rtc}; +use common::{init_crypto_default, init_log, progress}; #[test] pub fn loss_recovery() -> Result<(), RtcError> { @@ -167,6 +170,368 @@ pub fn loss_recovery() -> Result<(), RtcError> { Ok(()) } +const INBAND_FIRST_SEQ: u16 = 47_000; + +fn loss_5pct() -> NetemConfig { + NetemConfig::new() + .loss(RandomLoss::new(Probability::new(0.05))) + .seed(42) +} + +/// Send `num_packets` packets on `ssrc`/`pt` from L to R, starting at +/// `INBAND_FIRST_SEQ`, `time_step` RTP units apart. R drops 5%, except the +/// first/last 10 packets, then lets the streams settle so resends complete. +fn send_with_loss( + l: &mut TestRtc, + r: &mut TestRtc, + ssrc: Ssrc, + pt: Pt, + num_packets: usize, + time_step: u32, + payload_for: impl Fn(u16) -> [u8; 4], +) -> Result<(), RtcError> { + let loss_range = 10..=(num_packets - 10); + + for index in 0..num_packets { + let wallclock = l.start + l.duration(); + let seq = INBAND_FIRST_SEQ + index as u16; + + let mut direct = l.direct_api(); + let stream = direct.stream_tx(&ssrc).unwrap(); + + let time = index as u32 * time_step + 47_000_000; + stream.write_rtp( + RtpWrite::new(pt, (seq as u64).into(), time, wallclock, payload_for(seq)) + .nackable(true), + ); + + if !loss_range.contains(&index) { + r.set_netem(NetemConfig::new()); + } + + progress(l, r)?; + + if index == 9 { + r.set_netem(loss_5pct()); + } + } + + let settle_time = l.duration() + Duration::from_secs(10); + loop { + progress(l, r)?; + if l.duration() > settle_time { + break; + } + } + + Ok(()) +} + +/// Assert R sent NACKs, L resent packets in-band (same main-SSRC sequence number +/// sent more than once), and R received the full contiguous range on the main PT. +fn assert_full_inband_recovery(l: &TestRtc, r: &TestRtc, ssrc: Ssrc, pt: Pt, num_packets: usize) { + let nacks_tx = r + .events + .iter() + .filter(|(_, e)| matches!(e.as_raw_packet(), Some(RawPacket::RtcpTx(Rtcp::Nack(_))))) + .count(); + assert!(nacks_tx > 0, "R should have sent NACKs"); + + let mut tx_seqs: Vec = l + .events + .iter() + .filter_map(|(_, e)| match e.as_raw_packet() { + Some(RawPacket::RtpTx(h, _)) if h.ssrc == ssrc && h.payload_type == pt => { + Some(h.sequence_number) + } + _ => None, + }) + .collect(); + tx_seqs.sort(); + let resent = tx_seqs.windows(2).filter(|w| w[0] == w[1]).count(); + assert!( + resent > 0, + "L should have resent packets in-band on the main SSRC" + ); + + let mut packets_rx = r + .events + .iter() + .filter_map(|(_, e)| match e.as_raw_packet() { + Some(RawPacket::RtpRx(p, _)) => { + assert_eq!(p.payload_type, pt, "all RX packets are on the main PT"); + Some(p.sequence_number) + } + _ => None, + }) + .collect::>(); + packets_rx.sort(); + packets_rx.dedup(); + + let discontinuities = packets_rx.windows(2).filter(|w| w[0] + 1 != w[1]).count(); + + assert_eq!(*packets_rx.first().unwrap(), INBAND_FIRST_SEQ); + assert_eq!( + *packets_rx.last().unwrap(), + INBAND_FIRST_SEQ + num_packets as u16 - 1 + ); + assert_eq!(discontinuities, 0); + assert_eq!(packets_rx.len(), num_packets); +} + +#[test] +pub fn loss_recovery_inband_video() -> Result<(), RtcError> { + init_log(); + init_crypto_default(); + + // L sends, R receives. + let (mut l, mut r) = connect_l_r(); + + let mid = "vid".into(); + let ssrc_tx: Ssrc = 42.into(); + + // Note: no RTX SSRC declared on either side. + l.direct_api().declare_media(mid, MediaKind::Video); + l.direct_api().declare_stream_tx(ssrc_tx, None, mid, None); + + r.direct_api().declare_media(mid, MediaKind::Video); + r.direct_api().expect_stream_rx(ssrc_tx, None, mid, None); + + let max = l.last.max(r.last); + l.last = max; + r.last = max; + + let params = l.params_vp8(); + let ssrc = l.direct_api().stream_tx_by_mid(mid, None).unwrap().ssrc(); + assert_eq!(params.spec().codec, Codec::Vp8); + // VP8 has an RTX resend PT, but no RTX SSRC was declared above, so resends + // go in-band on the main SSRC. + assert!(params.resend().is_some()); + assert!(params.fb_nack()); + let pt = params.pt(); + + let num_packets = 1000; + send_with_loss(&mut l, &mut r, ssrc, pt, num_packets, 1000, |_| { + [0x1, 0x2, 0x3, 0x4] + })?; + + assert_full_inband_recovery(&l, &r, ssrc, pt, num_packets); + + Ok(()) +} + +#[test] +pub fn loss_recovery_inband_audio() -> Result<(), RtcError> { + init_log(); + init_crypto_default(); + + let rtc_with_opus_nack = || { + let mut builder = Rtc::builder() + .set_rtp_mode(true) + .enable_raw_packets(true) + .clear_codecs(); + let config = builder.codec_config(); + config.enable_opus(true); + for params in config.iter_mut() { + params.set_fb_nack(true); + } + + builder.build(Instant::now()) + }; + + // L sends, R receives. + let (mut l, mut r) = connect_l_r_with_rtc(rtc_with_opus_nack(), rtc_with_opus_nack()); + + let mid = "aud".into(); + let ssrc_tx: Ssrc = 42.into(); + + l.direct_api().declare_media(mid, MediaKind::Audio); + l.direct_api().declare_stream_tx(ssrc_tx, None, mid, None); + + r.direct_api().declare_media(mid, MediaKind::Audio); + r.direct_api().expect_stream_rx(ssrc_tx, None, mid, None); + + let max = l.last.max(r.last); + l.last = max; + r.last = max; + + let params = l.params_opus(); + let ssrc = l.direct_api().stream_tx_by_mid(mid, None).unwrap().ssrc(); + assert_eq!(params.spec().codec, Codec::Opus); + assert!(params.resend().is_none()); + assert!(params.fb_nack()); + let pt = params.pt(); + + let num_packets = 1000; + send_with_loss(&mut l, &mut r, ssrc, pt, num_packets, 960, |_| { + [0x1, 0x2, 0x3, 0x4] + })?; + + assert_full_inband_recovery(&l, &r, ssrc, pt, num_packets); + + Ok(()) +} + +/// An in-band resend must reproduce the original packet exactly: same SSRC/PT/seq +/// and identical payload (this is what makes it SRTP-safe). +#[test] +pub fn inband_resend_resends_same_packet() -> Result<(), RtcError> { + use std::collections::HashMap; + + init_log(); + init_crypto_default(); + + // L sends, R receives. + let (mut l, mut r) = connect_l_r(); + + let mid = "vid".into(); + let ssrc_tx: Ssrc = 42.into(); + + // No RTX SSRC: resends go in-band on the main SSRC. + l.direct_api().declare_media(mid, MediaKind::Video); + l.direct_api().declare_stream_tx(ssrc_tx, None, mid, None); + + r.direct_api().declare_media(mid, MediaKind::Video); + r.direct_api().expect_stream_rx(ssrc_tx, None, mid, None); + + let max = l.last.max(r.last); + l.last = max; + r.last = max; + + let params = l.params_vp8(); + let ssrc = l.direct_api().stream_tx_by_mid(mid, None).unwrap().ssrc(); + let pt = params.pt(); + + // Encode the seq in the payload so a resend's content can be verified. + let payload_for = |seq: u16| -> [u8; 4] { (seq as u32).to_be_bytes() }; + + let num_packets = 500; + send_with_loss(&mut l, &mut r, ssrc, pt, num_packets, 1000, payload_for)?; + + // Per seq, collect the payload of every transmission L made on the main SSRC. + let mut tx_by_seq: HashMap>> = HashMap::new(); + for (_, e) in &l.events { + if let Some(RawPacket::RtpTx(h, buf)) = e.as_raw_packet() { + if h.ssrc == ssrc && h.payload_type == pt { + let payload = buf[h.header_len..].to_vec(); + tx_by_seq + .entry(h.sequence_number) + .or_default() + .push(payload); + } + } + } + + // At least one packet must have been resent (transmitted more than once). + let resent: Vec = tx_by_seq + .iter() + .filter(|(_, txs)| txs.len() > 1) + .map(|(seq, _)| *seq) + .collect(); + assert!(!resent.is_empty(), "expected at least one in-band resend"); + + // Every resend must be byte-identical to the original transmission. + for seq in &resent { + let txs = &tx_by_seq[seq]; + let original = &txs[0]; + for resend in &txs[1..] { + assert_eq!( + resend, original, + "resend of seq {} differs from original", + seq + ); + } + } + + // R must receive the exact original payload for every seq, recovered or not. + for (_, e) in &r.events { + if let Some(RawPacket::RtpRx(h, buf)) = e.as_raw_packet() { + if h.ssrc == ssrc { + assert_eq!(buf.as_slice(), payload_for(h.sequence_number).as_slice()); + } + } + } + + Ok(()) +} + +/// Drives the in-band path through full SDP negotiation. An audio m-line gets no +/// RTX, so the answerer's `update_media` sees `repair_ssrc == None` and enables +/// NACK purely via the negotiated `fb_nack` — the branch under test. +#[test] +pub fn loss_recovery_inband_sdp_negotiated() -> Result<(), RtcError> { + init_log(); + init_crypto_default(); + + let rtc_with_opus_nack = || { + let mut builder = Rtc::builder().enable_raw_packets(true); + let config = builder.codec_config(); + for params in config.iter_mut() { + if params.spec().codec == Codec::Opus { + params.set_fb_nack(true); + } + } + + builder.build(Instant::now()) + }; + + // L sends, R receives. + let mut l = TestRtc::new_with_rtc(Peer::Left.span(), rtc_with_opus_nack()); + let mut r = TestRtc::new_with_rtc(Peer::Right.span(), rtc_with_opus_nack()); + + l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into()); + r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into()); + + let mut change = l.sdp_api(); + let mid = change.add_media(MediaKind::Audio, Direction::SendOnly, None, None, None); + let (offer, pending) = change.apply().unwrap(); + + // Offer announces the main SSRC but no RTX FID group. + let offer_sdp = offer.to_sdp_string(); + assert!( + offer_sdp.contains("a=ssrc:"), + "offer should carry a=ssrc:\n{offer_sdp}" + ); + assert!( + !offer_sdp.contains("a=ssrc-group:FID"), + "offer must not carry an RTX FID group:\n{offer_sdp}" + ); + assert!( + offer_sdp.contains("nack"), + "offer should negotiate nack feedback:\n{offer_sdp}" + ); + + let answer = r.rtc.sdp_api().accept_offer(offer)?; + l.rtc.sdp_api().accept_answer(pending, answer)?; + + loop { + if l.is_connected() || r.is_connected() { + break; + } + progress(&mut l, &mut r)?; + } + + let max = l.last.max(r.last); + l.last = max; + r.last = max; + + let params = l.params_opus(); + assert_eq!(params.spec().codec, Codec::Opus); + assert!(params.fb_nack()); + assert!(params.resend().is_none()); + let pt = params.pt(); + let ssrc = l.direct_api().stream_tx_by_mid(mid, None).unwrap().ssrc(); + + let num_packets = 1000; + send_with_loss(&mut l, &mut r, ssrc, pt, num_packets, 960, |_| { + [0x1, 0x2, 0x3, 0x4] + })?; + + assert_full_inband_recovery(&l, &r, ssrc, pt, num_packets); + + Ok(()) +} + #[test] pub fn nack_delay() -> Result<(), RtcError> { init_log(); diff --git a/tests/sdp-negotiation.rs b/tests/sdp-negotiation.rs index 7a3445548..f900171eb 100644 --- a/tests/sdp-negotiation.rs +++ b/tests/sdp-negotiation.rs @@ -11,6 +11,7 @@ use common::{extract_sctp_init, remove_sctp_init, replace_sctp_init}; use str0m::Rtc; use str0m::change::SdpOffer; use str0m::format::Codec; +use str0m::format::CodecConfig; use str0m::format::CodecSpec; use str0m::format::FormatParams; use str0m::format::PayloadParams; @@ -186,6 +187,142 @@ pub fn answer_no_match() { assert_eq!(r.media(mid).unwrap().remote_pts(), &[]); } +// Offerer offers VP8 with RTX, answerer has RTX disabled -> answer has no RTX. +#[test] +fn rtx_disabled_when_answerer_has_no_rtx() { + init_log(); + init_crypto_default(); + + let mut l = build_params(info_span!("L"), &[vp8_rtx(100, 101)]); + let mut r = build_params(info_span!("R"), &[vp8(96)]); + + let (offer, _pending) = l.span.in_scope(|| { + let mut change = l.rtc.sdp_api(); + change.add_media(MediaKind::Video, Direction::SendRecv, None, None, None); + change.apply().unwrap() + }); + + let answer = r + .span + .in_scope(|| r.rtc.sdp_api().accept_offer(offer).unwrap()); + + // Answerer kept its "no RTX" config despite the offer carrying RTX. + let r_vp8 = r + .codec_config() + .iter() + .find(|p| p.spec().codec == Codec::Vp8) + .unwrap(); + assert_eq!(r_vp8.resend(), None); + + let sdp = answer.to_sdp_string(); + assert!( + !sdp.contains("rtx/90000"), + "answer must not offer RTX:\n{sdp}" + ); + assert!( + !sdp.contains("apt="), + "answer must not contain apt=:\n{sdp}" + ); +} + +// When both sides offer RTX, the answerer adopts the offerer's RTX PT. +#[test] +fn rtx_preserved_when_both_have_rtx() { + init_log(); + init_crypto_default(); + + let mut l = build_params(info_span!("L"), &[vp8_rtx(100, 101)]); + let mut r = build_params(info_span!("R"), &[vp8_rtx(96, 97)]); + + let (offer, _pending) = l.span.in_scope(|| { + let mut change = l.rtc.sdp_api(); + change.add_media(MediaKind::Video, Direction::SendRecv, None, None, None); + change.apply().unwrap() + }); + + let answer = r + .span + .in_scope(|| r.rtc.sdp_api().accept_offer(offer).unwrap()); + + let r_vp8 = r + .codec_config() + .iter() + .find(|p| p.spec().codec == Codec::Vp8) + .unwrap(); + // Answerer adopted the offerer's RTX PT. + assert_eq!(r_vp8.resend(), Some(101.into())); + + let sdp = answer.to_sdp_string(); + assert!(sdp.contains("rtx/90000"), "answer must offer RTX:\n{sdp}"); + assert!(sdp.contains("apt="), "answer must contain apt=:\n{sdp}"); +} + +// Answerer disables RTX and enables Opus NACK -> answer carries no RTX and +// `a=rtcp-fb: nack`. +#[test] +fn answer_disables_rtx_and_keeps_opus_nack() { + init_log(); + init_crypto_default(); + + // Offerer offers Opus with NACK (and the default video codecs with RTX). + let mut l = build_default(info_span!("L"), |cc| { + for p in cc.iter_mut() { + if p.spec().codec == Codec::Opus { + p.set_fb_nack(true); + } + } + }); + + // Answerer disables RTX for all codecs and enables Opus NACK. + let mut r = build_default(info_span!("R"), |cc| { + for p in cc.iter_mut() { + p.set_resend(None); + if p.spec().codec == Codec::Opus { + p.set_fb_nack(true); + } + } + }); + + let (offer, _pending) = l.span.in_scope(|| { + let mut change = l.rtc.sdp_api(); + change.add_media(MediaKind::Audio, Direction::SendRecv, None, None, None); + change.add_media(MediaKind::Video, Direction::SendRecv, None, None, None); + change.apply().unwrap() + }); + + let answer = r + .span + .in_scope(|| r.rtc.sdp_api().accept_offer(offer).unwrap()); + let sdp = answer.to_sdp_string(); + + // No RTX anywhere in the answer. + assert!( + !sdp.contains("rtx/90000"), + "answer must not offer RTX:\n{sdp}" + ); + assert!( + !sdp.contains("apt="), + "answer must not contain apt=:\n{sdp}" + ); + + // Opus carries NACK feedback. + let opus_pt = sdp + .lines() + .find_map(|line| { + let rest = line.trim_end().strip_prefix("a=rtpmap:")?; + let (pt, codec) = rest.split_once(' ')?; + codec + .to_lowercase() + .starts_with("opus") + .then(|| pt.to_string()) + }) + .expect("opus in answer"); + assert!( + sdp.contains(&format!("a=rtcp-fb:{opus_pt} nack")), + "answer must contain opus NACK feedback:\n{sdp}" + ); +} + #[test] pub fn stop_media() { init_log(); @@ -1286,6 +1423,13 @@ fn build_exts(span: Span, exts: ExtensionMap) -> TestRtc { TestRtc::new_with_rtc(span, rtc) } +// Full default codec set, then tweak. +fn build_default(span: Span, configure: impl FnOnce(&mut CodecConfig)) -> TestRtc { + let mut b = Rtc::builder(); + configure(b.codec_config()); + TestRtc::new_with_rtc(span, b.build(Instant::now())) +} + fn opus(pt: u8) -> PayloadParams { PayloadParams::new( pt.into(), @@ -1312,6 +1456,19 @@ fn vp8(pt: u8) -> PayloadParams { ) } +fn vp8_rtx(pt: u8, rtx: u8) -> PayloadParams { + PayloadParams::new( + pt.into(), + Some(rtx.into()), + CodecSpec { + codec: Codec::Vp8, + channels: None, + clock_rate: Frequency::NINETY_KHZ, + format: FormatParams::default(), + }, + ) +} + fn h264(pt: u8) -> PayloadParams { PayloadParams::new( pt.into(),