Skip to content

Commit e222d66

Browse files
committed
Add RFC 2198 RED (redundant audio) support for Opus
A default-off `RtcConfig::enable_red` negotiates RFC 2198 RED for Opus, modeled on RTX: a `Codec::Red` marker and a `PayloadParams.red` linkage folded onto the primary codec in SDP (`red/48000/2` and `fmtp <opus>/<opus>`), kept only when both peers offer it. In frame mode RED is transparent. Outgoing Opus is wrapped with one level of redundancy (the previous frame); incoming RED is unwrapped back to Opus and a single lost packet is recovered from the next packet's redundancy. In rtp mode the RED packets pass through unchanged, and `RedEncoder`/`RedDecoder` are exposed for callers that want to build or parse RED themselves. A self-contained `packet::red` codec does the byte-level RFC 2198 work and is fuzzed; recovered packets reuse the existing depayload path and the jitter buffer's de-duplication.
1 parent 2abd209 commit e222d66

21 files changed

Lines changed: 1378 additions & 34 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Unreleased
22

3+
* Add RFC 2198 RED (redundant audio) for Opus via `RtcConfig::enable_red`: transparent send/recv
4+
with single-loss recovery in frame mode, pass-through in rtp mode, and public `RedEncoder`/`RedDecoder` #982
35
* Add H266/VVC RTP packetizer and depacketizer #971
46
* Negotiate SCTP max message size for data channels #852
57
* Expose the negotiated DTLS protocol version #979

crates/fuzz/Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,9 @@ name = "receive_register"
6262
path = "fuzz_targets/receive_register.rs"
6363
test = false
6464
doc = false
65+
66+
[[bin]]
67+
name = "red_decode"
68+
path = "fuzz_targets/red_decode.rs"
69+
test = false
70+
doc = false
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
#![no_main]
2+
3+
use libfuzzer_sys::fuzz_target;
4+
use str0m::rtp::RedDecoder;
5+
6+
fuzz_target!(|data: &[u8]| {
7+
// RFC 2198 RED payloads come from untrusted peers; decoding must never panic.
8+
let _ = RedDecoder::decode(data);
9+
});

src/change/sdp.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1521,6 +1521,9 @@ impl AsSdpMediaLine for Media {
15211521
if let Some(rtx) = p.resend() {
15221522
pts.push(rtx);
15231523
}
1524+
if let Some(red) = p.red() {
1525+
pts.push(red);
1526+
}
15241527
}
15251528

15261529
if let Some(s) = self.simulcast() {

src/config.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,19 @@ impl RtcConfig {
229229
self
230230
}
231231

232+
/// Enable RFC 2198 RED (redundant Opus audio). Off by default. Requires Opus.
233+
///
234+
/// RED carries a redundant copy of the previous Opus frame in each packet so the
235+
/// receiver can recover single packet losses without retransmission, at the cost of
236+
/// roughly doubling audio payload size.
237+
///
238+
/// Call this after [`Self::enable_opus`]: RED folds onto the enabled Opus payload, so
239+
/// enabling it while no Opus codec is configured is a no-op (and is logged).
240+
pub fn enable_red(mut self, enabled: bool) -> Self {
241+
self.codec_config.enable_red(enabled);
242+
self
243+
}
244+
232245
/// Enable PCM μ-law audio codec.
233246
///
234247
/// This is 14-bit audio compressed to 8-bit as specified by G.711

src/format/codec.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ pub enum Codec {
4646
/// in `a=rtpmap` lines.
4747
#[doc(hidden)]
4848
Rtx,
49+
/// RFC 2198 redundant encoding (RED). Like `Rtx`, technically not a codec;
50+
/// it appears in `a=rtpmap` lines and wraps a primary audio codec.
51+
#[doc(hidden)]
52+
Red,
4953
/// For RTP mode. No codec.
5054
#[doc(hidden)]
5155
Null,
@@ -93,6 +97,7 @@ impl<'a> From<&'a str> for Codec {
9397
"vp9" => Codec::Vp9,
9498
"av1" => Codec::Av1,
9599
"rtx" => Codec::Rtx, // resends
100+
"red" => Codec::Red, // RFC 2198 redundancy
96101
_ => Codec::Unknown,
97102
}
98103
}
@@ -111,8 +116,22 @@ impl fmt::Display for Codec {
111116
Codec::Vp9 => write!(f, "VP9"),
112117
Codec::Av1 => write!(f, "AV1"),
113118
Codec::Rtx => write!(f, "rtx"),
119+
Codec::Red => write!(f, "red"),
114120
Codec::Null => write!(f, "null"),
115121
Codec::Unknown => write!(f, "unknown"),
116122
}
117123
}
118124
}
125+
126+
#[cfg(test)]
127+
mod test {
128+
use super::*;
129+
130+
#[test]
131+
fn red_codec_string_roundtrip() {
132+
assert_eq!(Codec::from("red"), Codec::Red);
133+
assert_eq!(Codec::Red.to_string(), "red");
134+
assert!(!Codec::Red.is_audio());
135+
assert!(!Codec::Red.is_video());
136+
}
137+
}

src/format/codec_config.rs

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ pub(crate) const PT_H266_RTX: Pt = Pt::new_with_value(105);
4949
/// Default payload type for Opus.
5050
pub(crate) const PT_OPUS: Pt = Pt::new_with_value(111);
5151

52+
/// Default payload type for RFC 2198 RED (redundant Opus).
53+
pub(crate) const PT_RED: Pt = Pt::new_with_value(63);
54+
5255
/// Session config for all codecs.
5356
#[derive(Debug, Clone, Default)]
5457
pub struct CodecConfig {
@@ -119,6 +122,7 @@ impl CodecConfig {
119122
format,
120123
},
121124
resend,
125+
red: None,
122126
fb_transport_cc,
123127
fb_fir,
124128
fb_nack,
@@ -232,6 +236,21 @@ impl CodecConfig {
232236
)
233237
}
234238

239+
/// Enable RFC 2198 RED (redundant Opus) for the audio m-line. Off by default.
240+
///
241+
/// RED roughly doubles audio payload size, so it is opt-in. This is a no-op when
242+
/// Opus is not enabled.
243+
pub fn enable_red(&mut self, enabled: bool) {
244+
if enabled && !self.params.iter().any(|p| p.spec.codec == Codec::Opus) {
245+
warn!("enable_red(true) ignored: Opus is not enabled");
246+
}
247+
for p in &mut self.params {
248+
if p.spec.codec == Codec::Opus {
249+
p.red = if enabled { Some(PT_RED) } else { None };
250+
}
251+
}
252+
}
253+
235254
/// Add a default VP8 payload type.
236255
pub fn enable_vp8(&mut self, enabled: bool) {
237256
self.params.retain(|c| c.spec.codec != Codec::Vp8);
@@ -444,14 +463,18 @@ impl CodecConfig {
444463
if let Some(rtx) = p.resend {
445464
claimed.assert_claim_once(rtx);
446465
}
466+
467+
if let Some(red) = p.red {
468+
claimed.assert_claim_once(red);
469+
}
447470
}
448471

449472
// Collect all currently unlocked PTs since we will need to avoid them when reassigning.
450473
let mut unlocked = self
451474
.params
452475
.iter()
453476
.filter(|p| !p.locked)
454-
.flat_map(|p| [Some(p.pt()), p.resend()])
477+
.flat_map(|p| [Some(p.pt()), p.resend(), p.red()])
455478
.flatten()
456479
.collect::<HashSet<_>>();
457480

@@ -491,6 +514,25 @@ impl CodecConfig {
491514
unlocked.remove(&pt);
492515
}
493516

517+
// RED rides its own PT (like RTX); reassign it if it now collides.
518+
if let Some(red) = p.red {
519+
if claimed.is_claimed(red) {
520+
match claimed.find_unclaimed(PREFERED_RANGES, &unlocked) {
521+
Some(new_red) => {
522+
debug!("Reassigned RED PT {:?} => {:?}", p.red, new_red);
523+
p.red = Some(new_red);
524+
claimed.assert_claim_once(new_red);
525+
unlocked.remove(&new_red);
526+
}
527+
// RED is opt-in; drop it rather than panic if no PT is free.
528+
None => {
529+
debug!("No free PT for RED; dropping it");
530+
p.red = None;
531+
}
532+
}
533+
}
534+
}
535+
494536
let Some(rtx) = p.resend else {
495537
continue;
496538
};
@@ -554,6 +596,62 @@ mod test {
554596
use super::*;
555597
use crate::format::{CodecSpec, FormatParams};
556598

599+
#[test]
600+
fn enable_red_links_opus() {
601+
let mut c = CodecConfig::empty();
602+
c.enable_opus(true);
603+
c.enable_red(true);
604+
let opus = c
605+
.params()
606+
.iter()
607+
.find(|p| p.spec().codec == Codec::Opus)
608+
.unwrap();
609+
assert_eq!(opus.red(), Some(PT_RED));
610+
}
611+
612+
#[test]
613+
fn red_kept_when_both_sides_enable() {
614+
let mut local = CodecConfig::empty();
615+
local.enable_opus(true);
616+
local.enable_red(true);
617+
618+
let mut remote = CodecConfig::empty();
619+
remote.enable_opus(true);
620+
remote.enable_red(true);
621+
622+
local.update_params(remote.params(), Direction::SendRecv);
623+
624+
let opus = local
625+
.params()
626+
.iter()
627+
.find(|p| p.spec().codec == Codec::Opus)
628+
.unwrap();
629+
assert_eq!(opus.red(), Some(PT_RED));
630+
}
631+
632+
#[test]
633+
fn red_dropped_when_remote_lacks_red() {
634+
let mut local = CodecConfig::empty();
635+
local.enable_opus(true);
636+
local.enable_red(true);
637+
638+
let mut remote = CodecConfig::empty();
639+
remote.enable_opus(true); // remote does not offer RED
640+
641+
local.update_params(remote.params(), Direction::SendRecv);
642+
643+
let opus = local
644+
.params()
645+
.iter()
646+
.find(|p| p.spec().codec == Codec::Opus)
647+
.unwrap();
648+
assert_eq!(
649+
opus.red(),
650+
None,
651+
"RED must be dropped when remote doesn't offer it"
652+
);
653+
}
654+
557655
#[test]
558656
fn test_pt_conflict_different_directions() {
559657
// Simulates:

src/format/format_params.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ impl FormatParams {
157157
H266ProfileTierLevel(v) => self.h266_profile_tier_level = Some(*v),
158158
SpropMaxDonDiff(v) => self.sprop_max_don_diff = Some(*v),
159159
Apt(_) => {}
160+
Red(_) => {}
160161
Unknown => {}
161162
}
162163
}

0 commit comments

Comments
 (0)