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::broadcast::Consumer`] 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, VideoCodecKind};
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 export source and catalog, and spawns per-rendition pump tasks.
85pub struct EgressSource {
86	/// The catalog broadcast plus optional origin context, so a rendition referencing a
87	/// sibling broadcast (its catalog `broadcast` field) resolves against the origin.
88	source: moq_mux::Source,
89	/// Snapshot of the catalog at session start. Sufficient for v1: SDP
90	/// negotiation happens once and the codec list is fixed for the
91	/// lifetime of the session.
92	catalog: Catalog,
93	writes_tx: mpsc::Sender<WriteRequest>,
94	writes_rx: Option<mpsc::Receiver<WriteRequest>>,
95}
96
97impl EgressSource {
98	/// Subscribe to the broadcast's catalog and wait for the first snapshot.
99	///
100	/// The [`moq_mux::Source`] carries the origin and catalog-broadcast path, so a
101	/// rendition whose catalog `broadcast` field references a sibling broadcast is
102	/// resolved against the same origin.
103	///
104	/// The session loop drives the pumps via the returned channel; the
105	/// caller hands `EgressSource` to [`Session::egress`](crate::session::Session::egress)
106	/// which takes the receiver via [`Self::take_writes`].
107	pub async fn new(source: moq_mux::Source) -> Result<Self> {
108		let catalog_track = source
109			.broadcast()
110			.await?
111			.track(hang::Catalog::DEFAULT_NAME)?
112			.subscribe(hang::Catalog::default_subscription())
113			.await?;
114		let mut consumer = moq_mux::catalog::hang::Consumer::new(catalog_track);
115		let catalog = consumer
116			.next()
117			.await
118			.map_err(|err| Error::Other(anyhow::anyhow!("catalog subscribe: {err}")))?
119			.ok_or_else(|| Error::Other(anyhow::anyhow!("catalog closed before first snapshot")))?;
120
121		let (tx, rx) = mpsc::channel(64);
122		Ok(Self {
123			source,
124			catalog,
125			writes_tx: tx,
126			writes_rx: Some(rx),
127		})
128	}
129
130	/// One-shot extractor for the write-request receiver. The session loop
131	/// awaits on this to forward frames into str0m.
132	pub fn take_writes(&mut self) -> mpsc::Receiver<WriteRequest> {
133		self.writes_rx.take().expect("EgressSource writes_rx already taken")
134	}
135
136	/// Spawn a pump task for a newly added (sendonly) media line.
137	///
138	/// `mid` and `pt` come from str0m's negotiated state; `clock_rate` is
139	/// the codec's negotiated RTP clock. The pump subscribes to a matching
140	/// catalog rendition and forwards every frame as a [`WriteRequest`].
141	pub fn on_track(&mut self, mid: Mid, codec: Codec, pt: Pt, clock_rate: Frequency) -> Result<()> {
142		// the `subscribe` call blocks on SUBSCRIBE_OK, so pick + subscribe inside
143		// the pump task to keep this str0m callback non-blocking.
144		let tx = self.writes_tx.clone();
145		let source = self.source.clone();
146		let catalog = self.catalog.clone();
147		tokio::spawn(async move {
148			let track = match pick_track(&source, &catalog, codec).await {
149				Ok(Some(t)) => t,
150				Ok(None) => {
151					tracing::warn!(?codec, "no matching catalog rendition; egress track ignored");
152					return;
153				}
154				Err(err) => {
155					tracing::warn!(?codec, %err, "egress track subscribe failed");
156					return;
157				}
158			};
159			pump(mid, pt, clock_rate, track, tx).await;
160		});
161		Ok(())
162	}
163
164	/// Codecs present in the catalog, used by the SDP-offer side
165	/// (`client publish`) to declare what we have. For v1: the union of
166	/// audio + video codecs across all renditions.
167	pub fn catalog_codecs(&self) -> Vec<Codec> {
168		let mut out = Vec::new();
169		if self
170			.catalog
171			.audio
172			.renditions
173			.values()
174			.any(|r| matches!(r.codec, AudioCodec::Opus) && valid_reference(&self.source, r.broadcast.as_ref()))
175		{
176			out.push(Codec::Opus);
177		}
178		for rendition in self.catalog.video.renditions.values() {
179			if !valid_reference(&self.source, rendition.broadcast.as_ref()) {
180				continue;
181			}
182			let codec = match rendition.codec.kind() {
183				VideoCodecKind::H264 => Some(Codec::H264),
184				VideoCodecKind::H265 => Some(Codec::H265),
185				VideoCodecKind::VP8 => Some(Codec::Vp8),
186				VideoCodecKind::VP9 => Some(Codec::Vp9),
187				VideoCodecKind::AV1 => Some(Codec::Av1),
188				_ => None,
189			};
190			if let Some(c) = codec
191				&& !out.contains(&c)
192			{
193				out.push(c);
194			}
195		}
196		out
197	}
198}
199
200fn valid_reference(source: &moq_mux::Source, broadcast: Option<&moq_net::PathRelative<'_>>) -> bool {
201	source.resolve_reference(broadcast).is_some()
202}
203
204/// Find the first catalog rendition for the given codec and build a
205/// [`codec::Track`] subscribed to it, honoring an optional cross-broadcast
206/// reference (the rendition's catalog `broadcast` field). Returns `None` if no
207/// rendition matches.
208async fn pick_track(source: &moq_mux::Source, catalog: &Catalog, codec: Codec) -> Result<Option<codec::Track>> {
209	match codec {
210		Codec::Opus => {
211			let Some((name, config)) =
212				catalog.audio.renditions.iter().find(|(_, c)| {
213					matches!(c.codec, AudioCodec::Opus) && valid_reference(source, c.broadcast.as_ref())
214				})
215			else {
216				return Ok(None);
217			};
218			let track = source.subscribe_track(config.broadcast.as_ref(), name).await?;
219			Ok(Some(codec::Track::opus(track)))
220		}
221		Codec::H264 | Codec::H265 | Codec::Vp8 | Codec::Vp9 | Codec::Av1 => {
222			let target = match codec {
223				Codec::H264 => VideoCodecKind::H264,
224				Codec::H265 => VideoCodecKind::H265,
225				Codec::Vp8 => VideoCodecKind::VP8,
226				Codec::Vp9 => VideoCodecKind::VP9,
227				Codec::Av1 => VideoCodecKind::AV1,
228				_ => unreachable!(),
229			};
230			let Some((name, config)) = catalog
231				.video
232				.renditions
233				.iter()
234				.find(|(_, c)| c.codec.kind() == target && valid_reference(source, c.broadcast.as_ref()))
235			else {
236				return Ok(None);
237			};
238			let track = source.subscribe_track(config.broadcast.as_ref(), name).await?;
239			Ok(Some(codec::Track::video(track, config)?))
240		}
241		other => Err(Error::UnsupportedCodec(format!("{other:?}"))),
242	}
243}
244
245/// Per-rendition pump task. Reads frames, converts the timestamp into the
246/// codec's clock domain, and forwards as a [`WriteRequest`].
247async fn pump(mid: Mid, pt: Pt, clock_rate: Frequency, mut track: codec::Track, tx: mpsc::Sender<WriteRequest>) {
248	loop {
249		let frame = match track.next().await {
250			Ok(Some(f)) => f,
251			Ok(None) => {
252				tracing::debug!(?mid, "egress track ended");
253				return;
254			}
255			Err(err) => {
256				tracing::warn!(?mid, %err, "egress track error");
257				return;
258			}
259		};
260		let ticks = us_to_ticks(frame.timestamp_us, clock_rate);
261		let time = MediaTime::new(ticks, clock_rate);
262		let req = WriteRequest {
263			mid,
264			pt,
265			time,
266			payload: frame.payload,
267		};
268		if tx.send(req).await.is_err() {
269			// Session closed; drop the pump.
270			return;
271		}
272	}
273}
274
275/// Convert a microsecond timestamp to a tick count at the given clock rate.
276/// Uses u128 internally to avoid overflow at high tick rates.
277fn us_to_ticks(timestamp_us: u64, clock_rate: Frequency) -> u64 {
278	let rate = clock_rate.get() as u128;
279	((timestamp_us as u128 * rate) / 1_000_000) as u64
280}
281
282/// Write one `WriteRequest` into str0m.
283///
284/// Lives here (not in session.rs) so the egress data shape is colocated
285/// with the channel definition. Logs and swallows non-fatal errors; an
286/// `UnknownPt` error after renegotiation isn't worth tearing down the
287/// session over.
288pub fn dispatch(rtc: &mut str0m::Rtc, request: WriteRequest, wallclock: Instant) {
289	let Some(writer) = rtc.writer(request.mid) else {
290		tracing::debug!(?request.mid, "egress write before media available");
291		return;
292	};
293	let WriteRequest {
294		pt,
295		time,
296		payload,
297		mid: _,
298	} = request;
299	if let Err(err) = writer.write(pt, wallclock, time, payload.to_vec()) {
300		tracing::warn!(%err, "egress write rejected by str0m");
301	}
302}
303
304#[cfg(test)]
305mod tests {
306	use super::*;
307	use hang::catalog::{AudioConfig, H264, VideoCodec, VideoConfig};
308	use moq_net::{Origin, PathRelative};
309
310	#[test]
311	fn catalog_codecs_ignores_codecs_available_only_via_escaping_references() {
312		let origin = Origin::random().produce();
313		let source = moq_mux::Source::new(origin.consume(), "a/pub");
314		let mut catalog = Catalog::default();
315
316		let mut escaped_audio = AudioConfig::new(AudioCodec::Opus, 48_000, 2);
317		escaped_audio.broadcast = Some(PathRelative::new("../../source").to_owned());
318		catalog.audio.renditions.insert("opus".to_string(), escaped_audio);
319
320		let mut escaped_video = VideoConfig::new(H264 {
321			profile: 0x42,
322			constraints: 0,
323			level: 0x1e,
324			inline: false,
325		});
326		escaped_video.broadcast = Some(PathRelative::new("../../source").to_owned());
327		catalog.video.renditions.insert("h264".to_string(), escaped_video);
328
329		let mut valid_video = VideoConfig::new(VideoCodec::VP8);
330		valid_video.broadcast = Some(PathRelative::new("./source").to_owned());
331		catalog.video.renditions.insert("vp8".to_string(), valid_video);
332
333		let (writes_tx, writes_rx) = mpsc::channel(1);
334		let egress = EgressSource {
335			source,
336			catalog,
337			writes_tx,
338			writes_rx: Some(writes_rx),
339		};
340
341		assert_eq!(egress.catalog_codecs(), vec![Codec::Vp8]);
342	}
343
344	#[test]
345	fn egress_clock_ignores_cross_track_dequeue_jitter() {
346		let mut clock = EgressClock::default();
347		let t0 = Instant::now();
348
349		assert_eq!(clock.wallclock(MediaTime::from_millis(1_000), t0), t0);
350		assert_eq!(
351			clock.wallclock(MediaTime::from_millis(1_100), t0 + Duration::from_millis(100)),
352			t0 + Duration::from_millis(100)
353		);
354
355		// Two tracks dequeue the same presentation time 50 ms apart. Their
356		// sender-report wallclocks must still agree.
357		let audio = clock.wallclock(MediaTime::from_millis(1_200), t0 + Duration::from_millis(250));
358		let video = clock.wallclock(MediaTime::from_millis(1_200), t0 + Duration::from_millis(300));
359		assert_eq!(audio, t0 + Duration::from_millis(200));
360		assert_eq!(video, audio);
361	}
362
363	#[test]
364	fn egress_clock_moves_epoch_earlier_for_catch_up_bursts() {
365		let mut clock = EgressClock::default();
366		let t0 = Instant::now();
367
368		assert_eq!(clock.wallclock(MediaTime::from_millis(1_000), t0), t0);
369		// The next 100 ms of media was already buffered and arrives immediately.
370		// Re-anchor it at now instead of handing str0m a future wallclock.
371		assert_eq!(clock.wallclock(MediaTime::from_millis(1_100), t0), t0);
372
373		// Once the live edge is known, another track uses the same mapping even
374		// when its frames dequeue later.
375		assert_eq!(
376			clock.wallclock(MediaTime::from_millis(1_100), t0 + Duration::from_millis(50)),
377			t0
378		);
379	}
380}