Skip to main content

moq_rtc/
egress.rs

1//! Per-broadcast egress source for the RTP-out paths.
2//!
3//! Counterpart to [`crate::ingest::IngestSink`]. Holds a
4//! [`moq_net::BroadcastConsumer`] and a cached catalog snapshot; on each
5//! `MediaAdded` event the session loop calls [`EgressSource::on_track`]
6//! which picks a matching rendition, subscribes to it, and spawns a pump
7//! task that feeds RTP-ready frames back to the session loop via an mpsc
8//! channel.
9//!
10//! Used by `server subscribe` (WHEP server) and `client publish` (WHIP
11//! client). SDP negotiation lives in the matching modules; this file is
12//! transport-agnostic.
13
14use std::time::{Duration, Instant};
15
16use bytes::Bytes;
17use hang::catalog::{AudioCodec, VideoCodec};
18use moq_mux::catalog::hang::Catalog;
19use str0m::format::Codec;
20use str0m::media::{Frequency, MediaTime, Mid, Pt};
21use tokio::sync::mpsc;
22
23use crate::{Error, Result, codec};
24
25/// One frame waiting to be written into str0m's [`Writer`](str0m::media::Writer).
26///
27/// Pump tasks build these and send them down the channel; the session loop
28/// receives them and calls `rtc.writer(mid).write(pt, wallclock, time, payload)`.
29pub struct WriteRequest {
30	/// Negotiated media line to write to.
31	pub mid: Mid,
32	/// Negotiated RTP payload type.
33	pub pt: Pt,
34	/// Presentation timestamp in the negotiated RTP clock domain.
35	pub time: MediaTime,
36	/// Complete encoded media frame.
37	pub payload: Bytes,
38}
39
40/// Maps the shared MoQ presentation timeline to str0m's wallclock.
41///
42/// The first observed frame supplies an initial epoch. Later frames can prove
43/// that epoch too recent when buffered media arrives faster than real time. In
44/// that case the anchor moves earlier so no observed frame maps into the future.
45/// Arrival delays never move it later, so dequeue jitter cannot become a
46/// permanent difference between audio and video sender reports.
47#[derive(Default)]
48pub(crate) struct EgressClock {
49	anchor: Option<(Duration, Instant)>,
50}
51
52impl EgressClock {
53	/// Return the production wallclock corresponding to a presentation timestamp.
54	pub(crate) fn wallclock(&mut self, time: MediaTime, now: Instant) -> Instant {
55		let presentation = Duration::from(time);
56		let Some((anchor_presentation, anchor_wallclock)) = self.anchor else {
57			self.anchor = Some((presentation, now));
58			return now;
59		};
60
61		if presentation >= anchor_presentation {
62			let delta = presentation - anchor_presentation;
63			let Some(mapped) = anchor_wallclock.checked_add(delta) else {
64				self.anchor = Some((presentation, now));
65				return now;
66			};
67			if mapped > now {
68				// A catch-up burst revealed that the previous anchor was too recent.
69				// Tighten it to the newest constraint. This anchor is shared by every
70				// track, so equal presentation times map to equal wallclocks.
71				self.anchor = Some((presentation, now));
72				now
73			} else {
74				mapped
75			}
76		} else {
77			anchor_wallclock
78				.checked_sub(anchor_presentation - presentation)
79				.unwrap_or(now)
80		}
81	}
82}
83
84/// Holds the broadcast + catalog and spawns per-rendition pump tasks.
85pub struct EgressSource {
86	broadcast: moq_net::BroadcastConsumer,
87	/// Snapshot of the catalog at session start. Sufficient for v1: SDP
88	/// negotiation happens once and the codec list is fixed for the
89	/// lifetime of the session.
90	catalog: Catalog,
91	writes_tx: mpsc::Sender<WriteRequest>,
92	writes_rx: Option<mpsc::Receiver<WriteRequest>>,
93}
94
95impl EgressSource {
96	/// Subscribe to the broadcast's catalog and wait for the first snapshot.
97	///
98	/// The session loop drives the pumps via the returned channel; the
99	/// caller hands `EgressSource` to [`Session::egress`](crate::session::Session::egress)
100	/// which takes the receiver via [`Self::take_writes`].
101	pub async fn new(broadcast: moq_net::BroadcastConsumer) -> Result<Self> {
102		let catalog_track = broadcast.subscribe_track(&moq_net::Track::new(hang::Catalog::DEFAULT_NAME))?;
103		let mut consumer = moq_mux::catalog::hang::Consumer::new(catalog_track);
104		let catalog = consumer
105			.next()
106			.await
107			.map_err(|err| Error::Other(anyhow::anyhow!("catalog subscribe: {err}")))?
108			.ok_or_else(|| Error::Other(anyhow::anyhow!("catalog closed before first snapshot")))?;
109
110		let (tx, rx) = mpsc::channel(64);
111		Ok(Self {
112			broadcast,
113			catalog,
114			writes_tx: tx,
115			writes_rx: Some(rx),
116		})
117	}
118
119	/// One-shot extractor for the write-request receiver. The session loop
120	/// awaits on this to forward frames into str0m.
121	pub fn take_writes(&mut self) -> mpsc::Receiver<WriteRequest> {
122		self.writes_rx.take().expect("EgressSource writes_rx already taken")
123	}
124
125	/// Spawn a pump task for a newly added (sendonly) media line.
126	///
127	/// `mid` and `pt` come from str0m's negotiated state; `clock_rate` is
128	/// the codec's negotiated RTP clock. The pump subscribes to a matching
129	/// catalog rendition and forwards every frame as a [`WriteRequest`].
130	pub fn on_track(&mut self, mid: Mid, codec: Codec, pt: Pt, clock_rate: Frequency) -> Result<()> {
131		// the `subscribe` call blocks on SUBSCRIBE_OK, so pick + subscribe inside
132		// the pump task to keep this str0m callback non-blocking.
133		let tx = self.writes_tx.clone();
134		let broadcast = self.broadcast.clone();
135		let catalog = self.catalog.clone();
136		tokio::spawn(async move {
137			let track = match pick_track(&broadcast, &catalog, codec).await {
138				Ok(Some(t)) => t,
139				Ok(None) => {
140					tracing::warn!(?codec, "no matching catalog rendition; egress track ignored");
141					return;
142				}
143				Err(err) => {
144					tracing::warn!(?codec, %err, "egress track subscribe failed");
145					return;
146				}
147			};
148			pump(mid, pt, clock_rate, track, tx).await;
149		});
150		Ok(())
151	}
152
153	/// Codecs present in the catalog, used by the SDP-offer side
154	/// (`client publish`) to declare what we have. For v1: the union of
155	/// audio + video codecs across all renditions.
156	pub fn catalog_codecs(&self) -> Vec<Codec> {
157		let mut out = Vec::new();
158		if self
159			.catalog
160			.audio
161			.renditions
162			.values()
163			.any(|r| matches!(r.codec, AudioCodec::Opus))
164		{
165			out.push(Codec::Opus);
166		}
167		for rendition in self.catalog.video.renditions.values() {
168			if let Some(c) = video_codec(&rendition.codec)
169				&& !out.contains(&c)
170			{
171				out.push(c);
172			}
173		}
174		out
175	}
176}
177
178/// Map a hang catalog video codec to the str0m codec we can egress, if any.
179fn video_codec(codec: &VideoCodec) -> Option<Codec> {
180	match codec {
181		VideoCodec::H264(_) => Some(Codec::H264),
182		VideoCodec::H265(_) => Some(Codec::H265),
183		VideoCodec::VP8 => Some(Codec::Vp8),
184		VideoCodec::VP9(_) => Some(Codec::Vp9),
185		VideoCodec::AV1(_) => Some(Codec::Av1),
186		_ => None,
187	}
188}
189
190/// Find the first catalog rendition for the given codec and build a
191/// [`codec::Track`] subscribed to it. Returns `None` if no rendition matches.
192async fn pick_track(
193	broadcast: &moq_net::BroadcastConsumer,
194	catalog: &Catalog,
195	codec: Codec,
196) -> Result<Option<codec::Track>> {
197	match codec {
198		Codec::Opus => {
199			let Some((name, _config)) = catalog
200				.audio
201				.renditions
202				.iter()
203				.find(|(_, c)| matches!(c.codec, AudioCodec::Opus))
204			else {
205				return Ok(None);
206			};
207			Ok(Some(codec::Track::opus(broadcast, name).await?))
208		}
209		Codec::H264 | Codec::H265 | Codec::Vp8 | Codec::Vp9 | Codec::Av1 => {
210			let Some((name, config)) = catalog
211				.video
212				.renditions
213				.iter()
214				.find(|(_, c)| video_codec(&c.codec) == Some(codec))
215			else {
216				return Ok(None);
217			};
218			Ok(Some(codec::Track::video(broadcast, name, config).await?))
219		}
220		other => Err(Error::UnsupportedCodec(format!("{other:?}"))),
221	}
222}
223
224/// Per-rendition pump task. Reads frames, converts the timestamp into the
225/// codec's clock domain, and forwards as a [`WriteRequest`].
226async fn pump(mid: Mid, pt: Pt, clock_rate: Frequency, mut track: codec::Track, tx: mpsc::Sender<WriteRequest>) {
227	loop {
228		let frame = match track.next().await {
229			Ok(Some(f)) => f,
230			Ok(None) => {
231				tracing::debug!(?mid, "egress track ended");
232				return;
233			}
234			Err(err) => {
235				tracing::warn!(?mid, %err, "egress track error");
236				return;
237			}
238		};
239		let ticks = us_to_ticks(frame.timestamp_us, clock_rate);
240		let time = MediaTime::new(ticks, clock_rate);
241		let req = WriteRequest {
242			mid,
243			pt,
244			time,
245			payload: frame.payload,
246		};
247		if tx.send(req).await.is_err() {
248			// Session closed; drop the pump.
249			return;
250		}
251	}
252}
253
254/// Convert a microsecond timestamp to a tick count at the given clock rate.
255/// Uses u128 internally to avoid overflow at high tick rates.
256fn us_to_ticks(timestamp_us: u64, clock_rate: Frequency) -> u64 {
257	let rate = clock_rate.get() as u128;
258	((timestamp_us as u128 * rate) / 1_000_000) as u64
259}
260
261/// Write one `WriteRequest` into str0m.
262///
263/// Lives here (not in session.rs) so the egress data shape is colocated
264/// with the channel definition. Logs and swallows non-fatal errors; an
265/// `UnknownPt` error after renegotiation isn't worth tearing down the
266/// session over.
267pub fn dispatch(rtc: &mut str0m::Rtc, request: WriteRequest, wallclock: Instant) {
268	let Some(writer) = rtc.writer(request.mid) else {
269		tracing::debug!(?request.mid, "egress write before media available");
270		return;
271	};
272	let WriteRequest {
273		pt,
274		time,
275		payload,
276		mid: _,
277	} = request;
278	if let Err(err) = writer.write(pt, wallclock, time, payload.to_vec()) {
279		tracing::warn!(%err, "egress write rejected by str0m");
280	}
281}
282
283#[cfg(test)]
284mod tests {
285	use super::*;
286
287	#[test]
288	fn egress_clock_ignores_cross_track_dequeue_jitter() {
289		let mut clock = EgressClock::default();
290		let t0 = Instant::now();
291
292		assert_eq!(clock.wallclock(MediaTime::from_millis(1_000), t0), t0);
293		assert_eq!(
294			clock.wallclock(MediaTime::from_millis(1_100), t0 + Duration::from_millis(100)),
295			t0 + Duration::from_millis(100)
296		);
297
298		// Two tracks dequeue the same presentation time 50 ms apart. Their
299		// sender-report wallclocks must still agree.
300		let audio = clock.wallclock(MediaTime::from_millis(1_200), t0 + Duration::from_millis(250));
301		let video = clock.wallclock(MediaTime::from_millis(1_200), t0 + Duration::from_millis(300));
302		assert_eq!(audio, t0 + Duration::from_millis(200));
303		assert_eq!(video, audio);
304	}
305
306	#[test]
307	fn egress_clock_moves_epoch_earlier_for_catch_up_bursts() {
308		let mut clock = EgressClock::default();
309		let t0 = Instant::now();
310
311		assert_eq!(clock.wallclock(MediaTime::from_millis(1_000), t0), t0);
312		// The next 100 ms of media was already buffered and arrives immediately.
313		// Re-anchor it at now instead of handing str0m a future wallclock.
314		assert_eq!(clock.wallclock(MediaTime::from_millis(1_100), t0), t0);
315
316		// Once the live edge is known, another track uses the same mapping even
317		// when its frames dequeue later.
318		assert_eq!(
319			clock.wallclock(MediaTime::from_millis(1_100), t0 + Duration::from_millis(50)),
320			t0
321		);
322	}
323}