use std::sync::Arc;
use serde_json::json;
use webrtc::api::interceptor_registry::register_default_interceptors;
use webrtc::api::media_engine::{
MIME_TYPE_H264, MIME_TYPE_OPUS, MIME_TYPE_VP8, MIME_TYPE_VP9, MediaEngine,
};
use webrtc::api::{API, APIBuilder};
use webrtc::ice_transport::ice_server::RTCIceServer;
use webrtc::interceptor::registry::Registry;
use webrtc::peer_connection::RTCPeerConnection;
use webrtc::peer_connection::configuration::RTCConfiguration;
use webrtc::rtp_transceiver::RTCPFeedback;
use webrtc::rtp_transceiver::rtp_codec::{
RTCRtpCodecCapability, RTCRtpCodecParameters, RTCRtpHeaderExtensionCapability, RTPCodecType,
};
use webrtc::rtp_transceiver::rtp_transceiver_direction::RTCRtpTransceiverDirection;
use webrtc::sdp::extmap::{
AUDIO_LEVEL_URI, SDES_MID_URI, SDES_REPAIR_RTP_STREAM_ID_URI, SDES_RTP_STREAM_ID_URI,
};
use super::coordinator::IceServer;
use super::error::Result;
use super::publish_options::H264_FMTP;
use super::tracer::Tracer;
const OPUS_PAYLOAD_TYPE: u8 = 111;
const VP8_PAYLOAD_TYPE: u8 = 96;
const VP9_PAYLOAD_TYPE: u8 = 98;
const H264_PAYLOAD_TYPE: u8 = 125;
fn register_supported_codecs(media_engine: &mut MediaEngine) -> Result<()> {
media_engine.register_codec(
RTCRtpCodecParameters {
capability: RTCRtpCodecCapability {
mime_type: MIME_TYPE_OPUS.to_owned(),
clock_rate: 48_000,
channels: 2,
sdp_fmtp_line: "minptime=10;useinbandfec=1".to_owned(),
rtcp_feedback: vec![],
},
payload_type: OPUS_PAYLOAD_TYPE,
..Default::default()
},
RTPCodecType::Audio,
)?;
let video_feedback = vec![
RTCPFeedback {
typ: "goog-remb".to_owned(),
parameter: String::new(),
},
RTCPFeedback {
typ: "ccm".to_owned(),
parameter: "fir".to_owned(),
},
RTCPFeedback {
typ: "nack".to_owned(),
parameter: String::new(),
},
RTCPFeedback {
typ: "nack".to_owned(),
parameter: "pli".to_owned(),
},
];
for (mime_type, payload_type, fmtp) in [
(MIME_TYPE_VP8, VP8_PAYLOAD_TYPE, ""),
(MIME_TYPE_VP9, VP9_PAYLOAD_TYPE, "profile-id=0"),
(MIME_TYPE_H264, H264_PAYLOAD_TYPE, H264_FMTP),
] {
media_engine.register_codec(
RTCRtpCodecParameters {
capability: RTCRtpCodecCapability {
mime_type: mime_type.to_owned(),
clock_rate: 90_000,
channels: 0,
sdp_fmtp_line: fmtp.to_owned(),
rtcp_feedback: video_feedback.clone(),
},
payload_type,
..Default::default()
},
RTPCodecType::Video,
)?;
}
Ok(())
}
fn build_api() -> Result<API> {
let mut media_engine = MediaEngine::default();
register_supported_codecs(&mut media_engine)?;
let registry = register_default_interceptors(Registry::new(), &mut media_engine)?;
media_engine.register_header_extension(
RTCRtpHeaderExtensionCapability {
uri: AUDIO_LEVEL_URI.to_owned(),
},
RTPCodecType::Audio,
Some(RTCRtpTransceiverDirection::Sendonly),
)?;
for uri in [
SDES_MID_URI,
SDES_RTP_STREAM_ID_URI,
SDES_REPAIR_RTP_STREAM_ID_URI,
] {
media_engine.register_header_extension(
RTCRtpHeaderExtensionCapability {
uri: uri.to_owned(),
},
RTPCodecType::Video,
Some(RTCRtpTransceiverDirection::Sendonly),
)?;
}
Ok(APIBuilder::new()
.with_media_engine(media_engine)
.with_interceptor_registry(registry)
.build())
}
pub fn to_rtc_ice_servers(servers: &[IceServer]) -> Vec<RTCIceServer> {
servers
.iter()
.filter(|s| !s.urls.is_empty())
.map(|s| RTCIceServer {
urls: s.urls.clone(),
username: s.username.clone(),
credential: s.password.clone(),
})
.collect()
}
pub async fn new_peer_connection(ice: &[IceServer]) -> Result<Arc<RTCPeerConnection>> {
let api = build_api()?;
let config = RTCConfiguration {
ice_servers: to_rtc_ice_servers(ice),
..Default::default()
};
let pc = api.new_peer_connection(config).await?;
Ok(Arc::new(pc))
}
pub fn trace_peer_events(pc: &Arc<RTCPeerConnection>, tracer: Arc<Tracer>) {
let t = tracer.clone();
pc.on_signaling_state_change(Box::new(move |state| {
let t = t.clone();
Box::pin(async move {
t.trace("signalingstatechange", json!(state.to_string()));
})
}));
let t = tracer.clone();
pc.on_ice_gathering_state_change(Box::new(move |state| {
let t = t.clone();
Box::pin(async move {
t.trace("icegatheringstatechange", json!(state.to_string()));
})
}));
let t = tracer.clone();
pc.on_ice_connection_state_change(Box::new(move |state| {
let t = t.clone();
Box::pin(async move {
t.trace("iceconnectionstatechange", json!(state.to_string()));
})
}));
let t = tracer.clone();
pc.on_negotiation_needed(Box::new(move || {
let t = t.clone();
Box::pin(async move {
t.trace("negotiationneeded", serde_json::Value::Null);
})
}));
let t = tracer;
pc.on_data_channel(Box::new(move |channel| {
let t = t.clone();
Box::pin(async move {
t.trace("datachannel", json!([channel.id(), channel.label()]));
})
}));
}
pub async fn generic_sdp(direction: RTCRtpTransceiverDirection) -> Result<String> {
let api = build_api()?;
let pc = api.new_peer_connection(RTCConfiguration::default()).await?;
let result = build_generic_offer(&pc, direction).await;
let _ = pc.close().await;
result
}
async fn build_generic_offer(
pc: &RTCPeerConnection,
direction: RTCRtpTransceiverDirection,
) -> Result<String> {
use webrtc::rtp_transceiver::RTCRtpTransceiverInit;
pc.add_transceiver_from_kind(
RTPCodecType::Video,
Some(RTCRtpTransceiverInit {
direction,
send_encodings: vec![],
}),
)
.await?;
pc.add_transceiver_from_kind(
RTPCodecType::Audio,
Some(RTCRtpTransceiverInit {
direction,
send_encodings: vec![],
}),
)
.await?;
let offer = pc.create_offer(None).await?;
Ok(offer.sdp)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ice_servers_filter_empty_and_map_credential() {
let servers = vec![
IceServer {
urls: vec!["stun:stun.l.google.com:19302".into()],
username: String::new(),
password: String::new(),
},
IceServer {
urls: vec!["turn:turn.example.com:3478".into()],
username: "user".into(),
password: "pass".into(),
},
IceServer::default(), ];
let mapped = to_rtc_ice_servers(&servers);
assert_eq!(mapped.len(), 2);
assert_eq!(mapped[1].username, "user");
assert_eq!(mapped[1].credential, "pass");
}
#[tokio::test]
async fn generic_sdp_has_audio_and_video_mlines() {
let sdp = generic_sdp(RTCRtpTransceiverDirection::Recvonly)
.await
.expect("generic sdp");
assert!(sdp.contains("m=audio"), "expected audio m-line");
assert!(sdp.contains("m=video"), "expected video m-line");
}
#[tokio::test]
async fn generic_sdp_advertises_only_decodable_codecs() {
let sdp = generic_sdp(RTCRtpTransceiverDirection::Recvonly)
.await
.expect("generic sdp");
let audio = media_section(&sdp, "audio");
let video = media_section(&sdp, "video");
assert!(audio.contains("opus/48000/2"), "missing Opus:\n{audio}");
for unsupported in ["PCMU/8000", "PCMA/8000", "G722/8000"] {
assert!(
!audio.contains(unsupported),
"advertised unsupported audio codec {unsupported}:\n{audio}"
);
}
for supported in ["VP8/90000", "VP9/90000", "H264/90000"] {
assert!(
video.contains(supported),
"missing supported video codec {supported}:\n{video}"
);
}
for unsupported in ["AV1/90000", "H265/90000"] {
assert!(
!video.contains(unsupported),
"advertised unsupported video codec {unsupported}:\n{video}"
);
}
assert!(
video.contains("profile-id=0"),
"missing VP9 profile 0:\n{video}"
);
assert!(
!video.contains("profile-id=1"),
"advertised unsupported VP9 profile 1:\n{video}"
);
}
fn media_section<'a>(sdp: &'a str, kind: &str) -> &'a str {
let start = sdp
.find(&format!("m={kind}"))
.unwrap_or_else(|| panic!("no m={kind} section in:\n{sdp}"));
let rest = &sdp[start..];
match rest[1..].find("\r\nm=") {
Some(end) => &rest[..end + 1],
None => rest,
}
}
#[tokio::test]
async fn sendonly_sdp_offers_audio_level_on_audio_only() {
let sdp = generic_sdp(RTCRtpTransceiverDirection::Sendonly)
.await
.expect("generic sdp");
let audio = media_section(&sdp, "audio");
let video = media_section(&sdp, "video");
let extmap = audio
.lines()
.map(str::trim_end)
.find(|l| l.starts_with("a=extmap:") && l.ends_with(AUDIO_LEVEL_URI));
assert!(
extmap.is_some(),
"publisher audio m-line must offer a=extmap:<n> {AUDIO_LEVEL_URI}:\n{audio}"
);
assert!(
!video.contains(AUDIO_LEVEL_URI),
"audio-level extmap must not appear on the video m-line:\n{video}"
);
}
#[tokio::test]
async fn recvonly_sdp_omits_audio_level() {
let sdp = generic_sdp(RTCRtpTransceiverDirection::Recvonly)
.await
.expect("generic sdp");
assert!(
!sdp.contains(AUDIO_LEVEL_URI),
"recvonly SDP must not offer {AUDIO_LEVEL_URI}:\n{sdp}"
);
}
}