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::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	pub mid: Mid,
31	pub pt: Pt,
32	pub time: MediaTime,
33	pub payload: Bytes,
34}
35
36/// Holds the broadcast + catalog and spawns per-rendition pump tasks.
37pub struct EgressSource {
38	broadcast: moq_net::BroadcastConsumer,
39	/// Snapshot of the catalog at session start. Sufficient for v1: SDP
40	/// negotiation happens once and the codec list is fixed for the
41	/// lifetime of the session.
42	catalog: Catalog,
43	writes_tx: mpsc::Sender<WriteRequest>,
44	writes_rx: Option<mpsc::Receiver<WriteRequest>>,
45}
46
47impl EgressSource {
48	/// Subscribe to the broadcast's catalog and wait for the first snapshot.
49	///
50	/// The session loop drives the pumps via the returned channel; the
51	/// caller hands `EgressSource` to [`Session::egress`](crate::session::Session::egress)
52	/// which takes the receiver via [`Self::take_writes`].
53	pub async fn new(broadcast: moq_net::BroadcastConsumer) -> Result<Self> {
54		let catalog_track = broadcast.subscribe_track(&moq_net::Track::new(hang::Catalog::DEFAULT_NAME))?;
55		let mut consumer = moq_mux::catalog::hang::Consumer::new(catalog_track);
56		let catalog = consumer
57			.next()
58			.await
59			.map_err(|err| Error::Other(anyhow::anyhow!("catalog subscribe: {err}")))?
60			.ok_or_else(|| Error::Other(anyhow::anyhow!("catalog closed before first snapshot")))?;
61
62		let (tx, rx) = mpsc::channel(64);
63		Ok(Self {
64			broadcast,
65			catalog,
66			writes_tx: tx,
67			writes_rx: Some(rx),
68		})
69	}
70
71	/// One-shot extractor for the write-request receiver. The session loop
72	/// awaits on this to forward frames into str0m.
73	pub fn take_writes(&mut self) -> mpsc::Receiver<WriteRequest> {
74		self.writes_rx.take().expect("EgressSource writes_rx already taken")
75	}
76
77	/// Spawn a pump task for a newly added (sendonly) media line.
78	///
79	/// `mid` and `pt` come from str0m's negotiated state; `clock_rate` is
80	/// the codec's negotiated RTP clock. The pump subscribes to a matching
81	/// catalog rendition and forwards every frame as a [`WriteRequest`].
82	pub fn on_track(&mut self, mid: Mid, codec: Codec, pt: Pt, clock_rate: Frequency) -> Result<()> {
83		// the `subscribe` call blocks on SUBSCRIBE_OK, so pick + subscribe inside
84		// the pump task to keep this str0m callback non-blocking.
85		let tx = self.writes_tx.clone();
86		let broadcast = self.broadcast.clone();
87		let catalog = self.catalog.clone();
88		tokio::spawn(async move {
89			let track = match pick_track(&broadcast, &catalog, codec).await {
90				Ok(Some(t)) => t,
91				Ok(None) => {
92					tracing::warn!(?codec, "no matching catalog rendition; egress track ignored");
93					return;
94				}
95				Err(err) => {
96					tracing::warn!(?codec, %err, "egress track subscribe failed");
97					return;
98				}
99			};
100			pump(mid, pt, clock_rate, track, tx).await;
101		});
102		Ok(())
103	}
104
105	/// Codecs present in the catalog, used by the SDP-offer side
106	/// (`client publish`) to declare what we have. For v1: the union of
107	/// audio + video codecs across all renditions.
108	pub fn catalog_codecs(&self) -> Vec<Codec> {
109		let mut out = Vec::new();
110		if self
111			.catalog
112			.audio
113			.renditions
114			.values()
115			.any(|r| matches!(r.codec, AudioCodec::Opus))
116		{
117			out.push(Codec::Opus);
118		}
119		for rendition in self.catalog.video.renditions.values() {
120			if let Some(c) = video_codec(&rendition.codec)
121				&& !out.contains(&c)
122			{
123				out.push(c);
124			}
125		}
126		out
127	}
128}
129
130/// Map a hang catalog video codec to the str0m codec we can egress, if any.
131fn video_codec(codec: &VideoCodec) -> Option<Codec> {
132	match codec {
133		VideoCodec::H264(_) => Some(Codec::H264),
134		VideoCodec::H265(_) => Some(Codec::H265),
135		VideoCodec::VP8 => Some(Codec::Vp8),
136		VideoCodec::VP9(_) => Some(Codec::Vp9),
137		VideoCodec::AV1(_) => Some(Codec::Av1),
138		_ => None,
139	}
140}
141
142/// Find the first catalog rendition for the given codec and build a
143/// [`codec::Track`] subscribed to it. Returns `None` if no rendition matches.
144async fn pick_track(
145	broadcast: &moq_net::BroadcastConsumer,
146	catalog: &Catalog,
147	codec: Codec,
148) -> Result<Option<codec::Track>> {
149	match codec {
150		Codec::Opus => {
151			let Some((name, _config)) = catalog
152				.audio
153				.renditions
154				.iter()
155				.find(|(_, c)| matches!(c.codec, AudioCodec::Opus))
156			else {
157				return Ok(None);
158			};
159			Ok(Some(codec::Track::opus(broadcast, name).await?))
160		}
161		Codec::H264 | Codec::H265 | Codec::Vp8 | Codec::Vp9 | Codec::Av1 => {
162			let Some((name, config)) = catalog
163				.video
164				.renditions
165				.iter()
166				.find(|(_, c)| video_codec(&c.codec) == Some(codec))
167			else {
168				return Ok(None);
169			};
170			Ok(Some(codec::Track::video(broadcast, name, config).await?))
171		}
172		other => Err(Error::UnsupportedCodec(format!("{other:?}"))),
173	}
174}
175
176/// Per-rendition pump task. Reads frames, converts the timestamp into the
177/// codec's clock domain, and forwards as a [`WriteRequest`].
178async fn pump(mid: Mid, pt: Pt, clock_rate: Frequency, mut track: codec::Track, tx: mpsc::Sender<WriteRequest>) {
179	loop {
180		let frame = match track.next().await {
181			Ok(Some(f)) => f,
182			Ok(None) => {
183				tracing::debug!(?mid, "egress track ended");
184				return;
185			}
186			Err(err) => {
187				tracing::warn!(?mid, %err, "egress track error");
188				return;
189			}
190		};
191		let ticks = us_to_ticks(frame.timestamp_us, clock_rate);
192		let time = MediaTime::new(ticks, clock_rate);
193		let req = WriteRequest {
194			mid,
195			pt,
196			time,
197			payload: frame.payload,
198		};
199		if tx.send(req).await.is_err() {
200			// Session closed; drop the pump.
201			return;
202		}
203	}
204}
205
206/// Convert a microsecond timestamp to a tick count at the given clock rate.
207/// Uses u128 internally to avoid overflow at high tick rates.
208fn us_to_ticks(timestamp_us: u64, clock_rate: Frequency) -> u64 {
209	let rate = clock_rate.get() as u128;
210	((timestamp_us as u128 * rate) / 1_000_000) as u64
211}
212
213/// Write one `WriteRequest` into str0m.
214///
215/// Lives here (not in session.rs) so the egress data shape is colocated
216/// with the channel definition. Logs and swallows non-fatal errors; an
217/// `UnknownPt` error after renegotiation isn't worth tearing down the
218/// session over.
219pub fn dispatch(rtc: &mut str0m::Rtc, request: WriteRequest, wallclock: Instant) {
220	let Some(writer) = rtc.writer(request.mid) else {
221		tracing::debug!(?request.mid, "egress write before media available");
222		return;
223	};
224	let WriteRequest {
225		pt,
226		time,
227		payload,
228		mid: _,
229	} = request;
230	if let Err(err) = writer.write(pt, wallclock, time, payload.to_vec()) {
231		tracing::warn!(%err, "egress write rejected by str0m");
232	}
233}