Skip to main content

moq_rtc/
session.rs

1//! str0m session driver shared by every HTTP role / media direction.
2//!
3//! str0m is sans-IO, so we drive the [`str0m::Rtc`] instance from a tokio
4//! task that owns a UDP socket. [`Session::run`] alternates between
5//! [`Rtc::poll_output`] (drain pending transmits / events) and
6//! [`Rtc::handle_input`] (feed UDP packets or timeouts).
7//!
8//! The session itself doesn't care whether the [`Rtc`] was populated by
9//! accepting an SDP offer (server side) or by minting one and posting it
10//! to a remote URL (client side), or whether the media flow is RTP-in
11//! ([`MediaSink`]) or RTP-out ([`crate::egress::EgressSource`]).
12
13use std::collections::HashMap;
14use std::net::SocketAddr;
15use std::sync::Arc;
16use std::time::{Duration, Instant};
17
18use str0m::{Event, IceConnectionState, Input, Output, Rtc, net::Receive};
19use tokio::net::UdpSocket;
20use tokio::sync::mpsc;
21
22use crate::egress::{EgressSource, WriteRequest};
23use crate::{Error, Result, codec};
24
25/// One inbound UDP datagram plus its source address, the unit fed to a session.
26/// The [`server`](crate::server) paths get these from the shared-socket demux
27/// (`crate::server::mux`); the client paths get them from a 1:1 reader
28/// ([`spawn_socket_reader`]).
29pub(crate) type Packet = (Vec<u8>, SocketAddr);
30
31/// Bound on a session's inbound datagram queue, sized like a socket buffer:
32/// past this, datagrams are dropped rather than buffered (WebRTC tolerates loss
33/// and a stalled session must not grow memory without limit).
34pub(crate) const SESSION_INBOX: usize = 256;
35
36/// str0m's outbound video buffer depth (packets), which also backs NACK resends.
37/// Raised above the str0m default (1000) so a late-joining peer can recover a
38/// large keyframe and the rest of the current group via NACK; see
39/// [`rtc_config_with_codecs`].
40const EGRESS_SEND_BUFFER_VIDEO: usize = 3000;
41
42/// Backstop deadline for a session to reach a connected ICE state, covering the one
43/// case str0m's ICE agent deliberately never times out: a peer that answers the SDP
44/// but provides NO remote candidates and sends nothing (an abandoned WHIP/WHEP
45/// offer, or a probe that only exercises signalling). str0m DOES end a connection
46/// whose candidate pairs were tried and exhausted -- the agent goes to
47/// `IceConnectionState::Disconnected` (handled in `handle_event`) after
48/// ~`StunTiming::timeout()` (~21s at the defaults). But when `remote_candidates`
49/// stays empty the agent treats the session as "still possible" forever (trickle
50/// ICE: more candidates could arrive), so it sits in `Checking` indefinitely,
51/// pinning this task, its broadcast announcement, and its mux registration. Nothing
52/// upstream ends it, so we do. Set ABOVE str0m's ~21s pair-exhaustion so a
53/// connection that actually started checks is ended by str0m's native path (and a
54/// slow-but-real TURN/lossy peer isn't clipped); this only fires for the
55/// never-any-candidate case.
56const ICE_ESTABLISH_TIMEOUT: Duration = Duration::from_secs(30);
57
58/// Receives `MediaData` events from str0m and dispatches to the right codec
59/// [`Bridge`](codec::Bridge). Used as the per-session sink in [`Session::run`]
60/// for any flow where RTP arrives from the peer (`server publish` / WHIP
61/// server, `client subscribe` / WHEP client).
62pub trait MediaSink: Send {
63	/// Called once str0m has confirmed which codec is on which `mid`.
64	fn on_track(
65		&mut self,
66		mid: str0m::media::Mid,
67		kind: str0m::media::MediaKind,
68		codec: str0m::format::Codec,
69		audio_params: Option<(u32, u32)>,
70	) -> Result<()>;
71
72	/// Called on each [`MediaData`](str0m::media::MediaData) event. The session
73	/// loop has already converted the timestamp to microseconds.
74	fn on_frame(&mut self, mid: str0m::media::Mid, frame: codec::Frame) -> Result<()>;
75}
76
77/// What the session does with the negotiated media stream.
78#[non_exhaustive]
79pub enum MediaRole {
80	/// RTP-in: dispatch peer frames into a [`MediaSink`].
81	Ingest(Box<dyn MediaSink>),
82	/// RTP-out: pull frames from a [`crate::egress::EgressSource`] and forward to the peer.
83	Egress(Box<EgressSource>),
84}
85
86/// Drives a [`Rtc`] instance until it ends.
87///
88/// The caller pre-populates the `Rtc` with whatever SDP exchange they need.
89/// Sends go out the (possibly shared) `socket`; inbound datagrams arrive on
90/// `inbound` rather than being read off the socket directly, so several
91/// sessions can share one socket behind the `crate::server::mux`.
92pub struct Session {
93	rtc: Rtc,
94	/// Send side. Shared across sessions on the server (the mux socket); owned
95	/// 1:1 on the client. Receiving happens via `inbound`, not this socket.
96	socket: Arc<UdpSocket>,
97	/// The local ICE candidates we advertised. Each inbound datagram is tagged
98	/// (for str0m) with the candidate whose address family matches the packet's
99	/// source, so a dual-stack peer reaching us over IPv6 isn't told the packet
100	/// arrived on an IPv4 host candidate. MUST be the advertised candidates, not
101	/// the socket's bind address: str0m drops a STUN binding request whose
102	/// destination doesn't match a host candidate ("unknown interface"), and the
103	/// shared mux socket binds a wildcard (`0.0.0.0`) while advertising concrete
104	/// IPs. Never empty (falls back to the bound address).
105	locals: Vec<SocketAddr>,
106	/// Inbound datagrams routed to this session (demux on the server, a 1:1
107	/// reader on the client). `None` from `recv` means every sender dropped, so
108	/// the session is done.
109	inbound: mpsc::Receiver<Packet>,
110	role: MediaRole,
111	/// Egress write requests. `Some` only for [`MediaRole::Egress`]
112	/// sessions; pumps send frames here, the main loop forwards them into
113	/// str0m's [`Writer`](str0m::media::Writer).
114	writes_rx: Option<mpsc::Receiver<WriteRequest>>,
115	/// Rebases each ingested track's raw RTP timestamps onto one session
116	/// timeline so audio and video stay in sync. Unused by egress sessions.
117	clock: IngestClock,
118}
119
120impl Session {
121	/// Convenience for the ingest case (WHIP server, WHEP client). `locals` are the
122	/// advertised ICE candidates (see the field docs), not the socket bind.
123	pub fn ingest(
124		rtc: Rtc,
125		socket: Arc<UdpSocket>,
126		locals: Vec<SocketAddr>,
127		inbound: mpsc::Receiver<Packet>,
128		sink: Box<dyn MediaSink>,
129	) -> Self {
130		Self {
131			rtc,
132			socket,
133			locals,
134			inbound,
135			role: MediaRole::Ingest(sink),
136			writes_rx: None,
137			clock: IngestClock::default(),
138		}
139	}
140
141	/// Convenience for the egress case (WHEP server, WHIP client). `locals` are the
142	/// advertised ICE candidates (see the field docs), not the socket bind.
143	pub fn egress(
144		rtc: Rtc,
145		socket: Arc<UdpSocket>,
146		locals: Vec<SocketAddr>,
147		inbound: mpsc::Receiver<Packet>,
148		mut source: EgressSource,
149	) -> Self {
150		let writes_rx = source.take_writes();
151		Self {
152			rtc,
153			socket,
154			locals,
155			inbound,
156			role: MediaRole::Egress(Box::new(source)),
157			writes_rx: Some(writes_rx),
158			clock: IngestClock::default(),
159		}
160	}
161
162	pub async fn run(mut self) -> Result<()> {
163		let started = Instant::now();
164		let mut connected = false;
165		loop {
166			// A dead Rtc (DTLS/SDP failure, explicit disconnect) makes poll_output
167			// return a never-firing timeout instead of erroring, which would hang
168			// this task forever holding the broadcast announcement + mux
169			// registration. Bail so those release.
170			if !self.rtc.is_alive() {
171				return Err(Error::SessionClosed);
172			}
173
174			// Abort a session that never finishes connecting (see
175			// ICE_ESTABLISH_TIMEOUT); once connected, str0m's own timeouts take over.
176			if !connected && started.elapsed() >= ICE_ESTABLISH_TIMEOUT {
177				return Err(Error::IceTimeout);
178			}
179
180			let timeout = match self.rtc.poll_output().map_err(Error::Rtc)? {
181				Output::Timeout(t) => t,
182				Output::Transmit(t) => {
183					if let Err(err) = self.socket.send_to(&t.contents, t.destination).await {
184						tracing::warn!(%err, dst = %t.destination, "send failed");
185					}
186					continue;
187				}
188				Output::Event(event) => {
189					if let Event::IceConnectionStateChange(state) = &event {
190						connected |= state.is_connected();
191					}
192					self.handle_event(event)?;
193					continue;
194				}
195			};
196
197			let now = Instant::now();
198			let mut duration = timeout.saturating_duration_since(now);
199			// While still connecting, never sleep past the establishment deadline, so
200			// the check above fires on time even if str0m scheduled a far-off timeout.
201			if !connected {
202				duration = duration.min(ICE_ESTABLISH_TIMEOUT.saturating_sub(started.elapsed()));
203			}
204			if duration.is_zero() {
205				self.rtc.handle_input(Input::Timeout(now)).map_err(Error::Rtc)?;
206				continue;
207			}
208
209			// Wait for the earliest of: an inbound UDP packet, an egress
210			// write request (if egress), or the str0m-requested timeout.
211			tokio::select! {
212				biased;
213
214				// Egress writes get drained promptly. Without `biased` an
215				// idle socket select could starve them.
216				Some(req) = async {
217					match self.writes_rx.as_mut() {
218						Some(rx) => rx.recv().await,
219						None => std::future::pending::<Option<WriteRequest>>().await,
220					}
221				} => {
222					crate::egress::dispatch(&mut self.rtc, req, Instant::now());
223				}
224
225				packet = self.inbound.recv() => {
226					match packet {
227						Some((data, src)) => {
228							let now = Instant::now();
229							// Tag the packet with the advertised candidate matching its
230							// address family, not the socket bind (see the `locals` docs).
231							let local = pick_local(&self.locals, src);
232							let recv = Receive::new(str0m::net::Protocol::Udp, src, local, &data)
233								.map_err(Error::RtcInput)?;
234							self.rtc.handle_input(Input::Receive(now, recv)).map_err(Error::Rtc)?;
235						}
236						// Every sender dropped: the demux unregistered us (or the
237						// 1:1 reader stopped). Nothing more will arrive, so end.
238						None => return Err(Error::SessionClosed),
239					}
240				}
241
242				_ = tokio::time::sleep(duration) => {
243					self.rtc
244						.handle_input(Input::Timeout(Instant::now()))
245						.map_err(Error::Rtc)?;
246				}
247			}
248		}
249	}
250
251	fn handle_event(&mut self, event: Event) -> Result<()> {
252		match event {
253			Event::IceConnectionStateChange(state) => {
254				tracing::debug!(?state, "ice state");
255				if state == IceConnectionState::Disconnected {
256					return Err(Error::SessionClosed);
257				}
258			}
259			Event::MediaAdded(added) => self.handle_media_added(added)?,
260			Event::MediaData(data) => {
261				// `clock` and `role` are disjoint fields, so the borrow checker lets
262				// us rebase the (random, per-track) RTP base and feed the sink in one
263				// block; egress sessions never get here so the clock stays untouched.
264				if let MediaRole::Ingest(sink) = &mut self.role {
265					let media_us = media_time_to_micros(&data.time);
266					let timestamp_us = self.clock.normalize(data.mid, data.network_time, media_us);
267					sink.on_frame(
268						data.mid,
269						codec::Frame {
270							timestamp_us,
271							payload: bytes::Bytes::from_owner(data.data),
272						},
273					)?;
274				}
275			}
276			Event::KeyframeRequest(req) => {
277				// PLI / FIR from the egress peer. For v1 we just log and
278				// rely on the next natural keyframe from the MoQ source.
279				tracing::debug!(?req, "keyframe request from peer");
280			}
281			_ => {}
282		}
283		Ok(())
284	}
285
286	fn handle_media_added(&mut self, added: str0m::media::MediaAdded) -> Result<()> {
287		// str0m's CodecConfig is the negotiated set; pick the first
288		// codec advertised for this `mid`.
289		let pt = self.rtc.media(added.mid).and_then(|m| m.remote_pts().first().copied());
290		let params = pt.and_then(|pt| self.rtc.codec_config().params().iter().find(|p| p.pt() == pt).copied());
291		let params = match params {
292			Some(p) => p,
293			None => {
294				tracing::warn!(?added.mid, "no codec params for media; ignoring");
295				return Ok(());
296			}
297		};
298		let spec = params.spec();
299		let codec = spec.codec;
300
301		match &mut self.role {
302			MediaRole::Ingest(sink) => {
303				let audio_params = if codec.is_audio() {
304					Some((spec.clock_rate.get(), spec.channels.unwrap_or(1) as u32))
305				} else {
306					None
307				};
308				sink.on_track(added.mid, added.kind, codec, audio_params)?;
309			}
310			MediaRole::Egress(source) => {
311				source.on_track(added.mid, codec, params.pt(), spec.clock_rate)?;
312			}
313		}
314		Ok(())
315	}
316}
317
318/// Per-session clock that rebases each ingested track's raw RTP timestamps onto
319/// one timeline so audio and video stay in sync.
320///
321/// str0m hands us the RTP header timestamp verbatim
322/// ([`MediaData::time`](str0m::media::MediaData::time)). Per RFC 3550 that base
323/// is random and independent for each track, and str0m applies no RTCP
324/// sender-report correlation, so publishing the values as-is would desync audio
325/// from video (their bases differ by hours) and start the broadcast at an
326/// arbitrary offset. We anchor each track on its first frame to that packet's
327/// arrival time (relative to the first frame seen in the whole session), then
328/// advance within the track by the RTP delta (str0m extends the 32-bit RTP
329/// timestamp with a roll-over counter, so the delta is wrap-safe). The first
330/// frame of the session maps to 0.
331#[derive(Default)]
332pub(crate) struct IngestClock {
333	/// Arrival time of the first frame seen in the session; the timeline origin.
334	epoch: Option<Instant>,
335	/// Per-track additive offset (microseconds) applied to the raw RTP time.
336	offsets: HashMap<str0m::media::Mid, i64>,
337}
338
339impl IngestClock {
340	/// Map a raw RTP-derived microsecond timestamp onto the session timeline.
341	/// `arrival` is the packet's network time
342	/// ([`MediaData::network_time`](str0m::media::MediaData::network_time)).
343	fn normalize(&mut self, mid: str0m::media::Mid, arrival: Instant, media_us: u64) -> u64 {
344		let epoch = *self.epoch.get_or_insert(arrival);
345		let offset = *self.offsets.entry(mid).or_insert_with(|| {
346			// Signed wall delta from the epoch: a track whose first frame we dequeue
347			// after the epoch frame may have actually arrived *before* it, and that
348			// lead must pull its timeline earlier (not clamp to the epoch via an
349			// unsigned subtraction) so it stays in sync.
350			let wall_us = if arrival >= epoch {
351				arrival.duration_since(epoch).as_micros() as i64
352			} else {
353				-(epoch.duration_since(arrival).as_micros() as i64)
354			};
355			wall_us - media_us as i64
356		});
357		(media_us as i64 + offset).max(0) as u64
358	}
359}
360
361/// Log a finished session at the right level: an ordinary peer disconnect
362/// ([`Error::SessionClosed`]) is debug, a genuine failure is a warning. Keeps
363/// normal WebRTC churn out of the warning stream. `role` labels the path
364/// (e.g. `"whip server"`).
365pub(crate) fn log_session_end(role: &str, result: &Result<()>) {
366	match result {
367		Ok(()) | Err(Error::SessionClosed) => tracing::debug!(role, "session ended"),
368		// An abandoned offer (peer answered but never connected) is normal churn, not
369		// a failure: keep it out of the warning stream.
370		Err(Error::IceTimeout) => tracing::debug!(role, "session ended: ICE never connected"),
371		Err(err) => tracing::warn!(%err, role, "session ended"),
372	}
373}
374
375/// Pick the advertised local candidate to tag an inbound packet with: the first
376/// one whose address family matches `src`, falling back to the first candidate
377/// (the list is never empty). Keeps a dual-stack peer's packets tagged with a
378/// same-family host candidate so str0m's ICE pairing stays consistent.
379fn pick_local(locals: &[SocketAddr], src: SocketAddr) -> SocketAddr {
380	locals
381		.iter()
382		.find(|l| l.is_ipv4() == src.is_ipv4())
383		.copied()
384		.unwrap_or(locals[0])
385}
386
387/// Convert a str0m [`MediaTime`](str0m::media::MediaTime) to microseconds.
388fn media_time_to_micros(time: &str0m::media::MediaTime) -> u64 {
389	// MediaTime stores `numer / denom` seconds; cast through i128 so the
390	// product doesn't overflow at 90 kHz video timestamps.
391	let numer = time.numer() as i128;
392	let denom = time.denom() as i128;
393	if denom == 0 {
394		return 0;
395	}
396	let micros = (numer.saturating_mul(1_000_000)) / denom;
397	micros.max(0) as u64
398}
399
400/// Type-erased map of `Mid` -> codec bridge, populated as `MediaAdded`
401/// events arrive on the ingest side.
402pub(crate) struct Bridges {
403	inner: HashMap<str0m::media::Mid, Box<dyn codec::Bridge>>,
404}
405
406impl Bridges {
407	pub fn new() -> Self {
408		Self { inner: HashMap::new() }
409	}
410
411	pub fn insert(&mut self, mid: str0m::media::Mid, bridge: Box<dyn codec::Bridge>) {
412		self.inner.insert(mid, bridge);
413	}
414
415	pub fn push(&mut self, mid: str0m::media::Mid, frame: codec::Frame) -> Result<()> {
416		if let Some(bridge) = self.inner.get_mut(&mid) {
417			bridge.push(frame)?;
418		}
419		Ok(())
420	}
421}
422
423/// Build a [`Rtc`] with `CodecConfig` restricted to the supplied codecs.
424///
425/// Used by the two egress paths so we don't advertise codecs we have no
426/// source for in the catalog (WHIP client) or accept incoming codecs we
427/// can't fulfil (WHEP server). For both, the negotiated SDP intersects with
428/// what we can actually deliver, so `MediaAdded` only fires for codecs that
429/// [`crate::egress::EgressSource`] can match to a rendition.
430pub fn rtc_config_with_codecs(codecs: &[str0m::format::Codec]) -> str0m::RtcConfig {
431	use str0m::format::Codec;
432	// str0m fulfils NACK resends from the video send buffer (default 1000
433	// packets). MoQ has no PLI path back to the publisher, so a late joiner's
434	// recovery is whatever the peer can NACK out of this buffer while the current
435	// group is still in flight. Widen it so a large keyframe plus the rest of the
436	// group stays recoverable instead of aging out after ~1000 packets.
437	let mut config = str0m::RtcConfig::new()
438		.clear_codecs()
439		.set_send_buffer_video(EGRESS_SEND_BUFFER_VIDEO);
440	for c in codecs {
441		config = match c {
442			Codec::Opus => config.enable_opus(true),
443			Codec::H264 => config.enable_h264(true),
444			Codec::H265 => config.enable_h265(true),
445			Codec::Vp8 => config.enable_vp8(true),
446			Codec::Vp9 => config.enable_vp9(true),
447			Codec::Av1 => config.enable_av1(true),
448			// Any other codec str0m grows is one we have no egress source for.
449			_ => config,
450		};
451	}
452	config
453}
454
455/// Build a codec-restricted [`Rtc`] for the client egress path (which lets
456/// str0m mint its own ICE credentials). The server egress path uses
457/// [`rtc_config_with_codecs`] directly so it can inject the mux's known
458/// credentials before building.
459pub fn rtc_with_codecs(codecs: &[str0m::format::Codec]) -> Rtc {
460	rtc_config_with_codecs(codecs).build(std::time::Instant::now())
461}
462
463/// Bind an ephemeral UDP socket for a single client session and return it
464/// (shared with its [reader task](spawn_socket_reader)) plus the ICE candidates
465/// to advertise.
466///
467/// The client paths are 1:1 (one socket per dialed session, no demux); the
468/// server paths share one socket via `crate::server::mux` instead. `advertise`
469/// IPs are used verbatim (reusing the bound port); empty falls back to whatever
470/// address the OS picked (loopback only).
471pub async fn bind_udp(advertise: &[SocketAddr]) -> Result<(Arc<UdpSocket>, Vec<SocketAddr>)> {
472	let socket = UdpSocket::bind(("0.0.0.0", 0)).await?;
473	let local = socket.local_addr()?;
474	let candidates = if advertise.is_empty() {
475		vec![local]
476	} else {
477		// Reuse the bound port across each advertised IP, since str0m's ICE
478		// agent picks the destination port from the candidate it's pairing
479		// against.
480		advertise
481			.iter()
482			.map(|addr| SocketAddr::new(addr.ip(), local.port()))
483			.collect()
484	};
485	Ok((Arc::new(socket), candidates))
486}
487
488/// Spawn a 1:1 reader pumping every datagram from `socket` into a channel, for
489/// the client paths (one socket per session, so no demux is needed). Mirrors the
490/// inbound side of `crate::server::mux` for a single session.
491pub fn spawn_socket_reader(socket: Arc<UdpSocket>) -> mpsc::Receiver<Packet> {
492	let (tx, rx) = mpsc::channel(SESSION_INBOX);
493	tokio::spawn(async move {
494		let mut buf = vec![0u8; 65_535];
495		loop {
496			match socket.recv_from(&mut buf).await {
497				// Bounded like a socket buffer: drop on full, stop once the
498				// session's receiver is gone.
499				Ok((len, src)) => {
500					if let Err(mpsc::error::TrySendError::Closed(_)) = tx.try_send((buf[..len].to_vec(), src)) {
501						break;
502					}
503				}
504				Err(err) => {
505					tracing::warn!(%err, "webrtc client socket recv failed");
506					break;
507				}
508			}
509		}
510	});
511	rx
512}
513
514#[cfg(test)]
515mod tests {
516	use std::time::Duration;
517
518	use str0m::media::Mid;
519
520	use super::*;
521
522	#[test]
523	fn pick_local_matches_address_family() {
524		let v4: SocketAddr = "1.2.3.4:5000".parse().unwrap();
525		let v6: SocketAddr = "[2001:db8::1]:5000".parse().unwrap();
526		let locals = vec![v4, v6];
527		let src_v4: SocketAddr = "9.9.9.9:1".parse().unwrap();
528		let src_v6: SocketAddr = "[2001:db8::2]:1".parse().unwrap();
529		assert_eq!(pick_local(&locals, src_v4), v4);
530		assert_eq!(pick_local(&locals, src_v6), v6);
531		// No same-family candidate falls back to the first.
532		assert_eq!(pick_local(&[v4], src_v6), v4);
533	}
534
535	#[test]
536	fn ingest_clock_rebases_first_frame_to_zero() {
537		let mut clock = IngestClock::default();
538		let mid = Mid::from("0");
539		let t0 = Instant::now();
540		// Raw RTP base is a large random value; the first frame must map to 0.
541		assert_eq!(clock.normalize(mid, t0, 5_000_000_000), 0);
542	}
543
544	#[test]
545	fn ingest_clock_tracks_rtp_delta_within_track() {
546		let mut clock = IngestClock::default();
547		let mid = Mid::from("0");
548		let t0 = Instant::now();
549		assert_eq!(clock.normalize(mid, t0, 5_000_000_000), 0);
550		// A later frame advances by the RTP delta, not by arrival jitter.
551		let arrival = t0 + Duration::from_millis(17); // jittered arrival, ignored after anchor
552		assert_eq!(clock.normalize(mid, arrival, 5_000_020_000), 20_000);
553	}
554
555	#[test]
556	fn ingest_clock_keeps_tracks_in_sync_via_arrival() {
557		let mut clock = IngestClock::default();
558		let audio = Mid::from("0");
559		let video = Mid::from("1");
560		let t0 = Instant::now();
561		// Audio anchors the session at 0 with its own random RTP base.
562		assert_eq!(clock.normalize(audio, t0, 1_000_000_000), 0);
563		// Video's first frame arrives 5 ms later with an unrelated RTP base; it
564		// must land at +5 ms on the shared timeline, not at video's raw base.
565		let video_arrival = t0 + Duration::from_millis(5);
566		assert_eq!(clock.normalize(video, video_arrival, 8_000_000_000), 5_000);
567		// And then track its own RTP delta.
568		assert_eq!(
569			clock.normalize(video, video_arrival + Duration::from_millis(33), 8_000_033_000),
570			38_000
571		);
572	}
573
574	#[test]
575	fn ingest_clock_handles_track_arriving_before_epoch() {
576		let mut clock = IngestClock::default();
577		let audio = Mid::from("0");
578		let video = Mid::from("1");
579		let t0 = Instant::now();
580		// Audio's MediaData is dequeued first and sets the epoch at t0.
581		assert_eq!(clock.normalize(audio, t0, 1_000_000), 0);
582		// Video's first frame actually arrived 5 ms *before* the epoch. Its lead
583		// pulls the start below zero (clamped to 0), and a frame 33 ms into video
584		// lands 28 ms onto the shared timeline (33 ms - the 5 ms head start).
585		let video_arrival = t0 - Duration::from_millis(5);
586		assert_eq!(clock.normalize(video, video_arrival, 8_000_000), 0);
587		assert_eq!(
588			clock.normalize(video, video_arrival + Duration::from_millis(33), 8_033_000),
589			28_000
590		);
591	}
592}