reactor_webrtc/media.rs
1//! Media tracks and decoded frames.
2//!
3//! A [`Track`] wraps a native media track (local or remote, audio or video).
4//! Local tracks created via [`crate::PeerConnectionFactory::create_video_track`]
5//! / [`create_audio_track`](crate::PeerConnectionFactory::create_audio_track)
6//! can push frames; remote tracks delivered to
7//! [`PeerConnectionObserver::on_track`](crate::PeerConnectionObserver) can have
8//! a frame sink attached. Audio send is via the factory ADM
9//! ([`crate::PeerConnectionFactory::push_audio_frame`]).
10
11use crate::{Error, FactoryHandle, Result};
12use std::collections::{HashMap, VecDeque};
13use std::ffi::c_void;
14use std::os::raw::c_int;
15use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
16use std::sync::{Arc, Mutex};
17
18const SENDER_META_CAP: usize = 300;
19
20/// Audio vs. video.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum MediaKind {
23 Audio,
24 Video,
25 Unknown,
26}
27
28impl MediaKind {
29 pub(crate) fn from_raw(kind: c_int) -> Self {
30 match kind {
31 0 => MediaKind::Audio,
32 1 => MediaKind::Video,
33 _ => MediaKind::Unknown,
34 }
35 }
36}
37
38/// A decoded video frame, borrowed for the duration of the callback. The buffer
39/// is BGRA (`width*height*4`, B-G-R-A); copy it if you need to keep it.
40pub struct VideoFrame<'a> {
41 pub bgra: &'a [u8],
42 pub width: u32,
43 pub height: u32,
44 /// Metadata attached by the sender via
45 /// [`Track::push_video_frame_with_metadata`], decoded from the packet trailer.
46 ///
47 /// `None` when no trailer was present — which includes every frame from a peer
48 /// that did not negotiate the capability, and every frame on a connection built
49 /// with [`RtcConfiguration::frame_metadata`](crate::RtcConfiguration) off.
50 pub metadata: Option<crate::metadata::FrameMetadata>,
51}
52
53/// A chunk of decoded audio, borrowed for the duration of the callback:
54/// interleaved signed 16-bit PCM (`frames * channels` samples).
55pub struct AudioFrame<'a> {
56 pub pcm: &'a [i16],
57 pub sample_rate: u32,
58 pub channels: u32,
59 pub frames: u32,
60}
61
62/// Where an audio track's samples come from
63/// ([`create_audio_track_with_options`](crate::PeerConnectionFactory::create_audio_track_with_options)).
64#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
65#[non_exhaustive]
66pub enum AudioTrackSource {
67 /// The factory's ADM: the platform microphone + speaker (real devices on
68 /// desktop) or the shared synthetic pipe fed by
69 /// [`PeerConnectionFactory::push_audio_frame`](crate::PeerConnectionFactory::push_audio_frame).
70 /// Every ADM-sourced track carries the **same** signal.
71 ///
72 /// There is exactly one ADM per factory — per-track choice is "tap it or
73 /// bypass it", not "which ADM".
74 ///
75 /// On the **platform** device, the render path is fully automatic: every
76 /// inbound audio track decodes to the speaker by default — you cannot
77 /// route one remote track to the speaker and another elsewhere through
78 /// plumbing. `on_frame` taps them, it doesn't divert. Nothing plays
79 /// automatically through the synthetic device.
80 #[default]
81 Adm,
82 /// A per-track push source, fully independent of the ADM: feed it with
83 /// [`Track::push_pcm`]. The way to send per-track audio — e.g. a music
84 /// track next to the mic — or to route different audio to different
85 /// peers from one factory.
86 LocalPush,
87}
88
89/// Options for
90/// [`PeerConnectionFactory::create_audio_track_with_options`].
91///
92/// All fields optional; `Default::default()` produces exactly what
93/// [`PeerConnectionFactory::create_audio_track`] produces. The struct is
94/// `#[non_exhaustive]` — construct via `Default` and assign fields.
95///
96/// The processing flags are **constraints refining what this track asks of
97/// the factory's APM chain** (the one
98/// [`PeerConnectionFactoryBuilder::with_apm`] built — default: none):
99/// `None` inherits the chain's state, `Some(v)` forces the stage on/off for
100/// this track's source. They act **one level above the flags list** — with
101/// libwebrtc semantics this crate honors: a flag set per constraint only
102/// takes effect by engaging part of an APM the factory already has.
103/// **On a factory without an APM (the default) they are inert** — there is
104/// nothing in the capture pipeline for them to toggle; that is not a bug,
105/// the DSP chain genuinely doesn't exist. They constrain the capture/send
106/// side only — received audio is never processed.
107///
108/// A [`LocalPush`](AudioTrackSource::LocalPush) source bypasses the ADM
109/// device path entirely, so these flags only ever matter on
110/// [`AudioTrackSource::Adm`] — pushed audio has no echo to cancel.
111///
112/// ```rust,ignore
113/// let mic = factory.create_audio_track_with_options("mic", {
114/// let mut o = AudioTrackOptions::default();
115/// o.echo_cancellation = Some(true); // target the APM chain
116/// o.noise_suppression = Some(true);
117/// o
118/// })?;
119/// let music = factory.create_audio_track_with_options("music", {
120/// let mut o = AudioTrackOptions::default();
121/// o.source = AudioTrackSource::LocalPush;
122/// o
123/// })?;
124/// ```
125#[derive(Default)]
126#[non_exhaustive]
127pub struct AudioTrackOptions {
128 /// Where this track's samples come from. Default: [`AudioTrackSource::Adm`].
129 pub source: AudioTrackSource,
130 /// AEC3 echo cancellation for this track's source.
131 pub echo_cancellation: Option<bool>,
132 /// Noise suppression for this track's source.
133 pub noise_suppression: Option<bool>,
134 /// Automatic gain control for this track's source.
135 pub auto_gain_control: Option<bool>,
136 /// High-pass filter for this track's source.
137 pub high_pass_filter: Option<bool>,
138}
139
140type VideoSinkCb = Box<dyn for<'a> FnMut(VideoFrame<'a>, std::time::Duration) + Send>;
141type AudioSinkCb = Box<dyn for<'a> FnMut(AudioFrame<'a>) + Send>;
142
143// sender: HashMap keyed by capture_time_ms (no-erase for simulcast), evicted
144// FIFO when the 300-entry cap is reached.
145type SenderMetaMap = Mutex<(HashMap<i64, crate::metadata::FrameMetadata>, VecDeque<i64>)>;
146
147/// A [`Track`]'s outgoing metadata, keyed by the frame's capture timestamp.
148///
149/// Registered in [`crate::sender_meta`] under the native track's identity so that
150/// `set_remote_description` can find it when it installs the sender transform.
151#[derive(Default)]
152pub(crate) struct CaptureTimeMeta(SenderMetaMap);
153
154impl crate::sender_meta::SenderMetaSource for CaptureTimeMeta {
155 fn take(&self, frame: &crate::encoded::EncodedFrame) -> Option<crate::metadata::FrameMetadata> {
156 // Look up rather than remove, and only with a real timestamp: simulcast
157 // layers of one frame share capture_time_ms and each must find the entry.
158 if frame.capture_time_ms <= 0 {
159 return None;
160 }
161 self.0
162 .lock()
163 .ok()
164 .and_then(|g| g.0.get(&frame.capture_time_ms).cloned())
165 }
166}
167
168// receiver: queue written by the strip transform and drained by video_sink_tramp,
169// which pairs by SSRC and RTP timestamp so a frame dropped before render can't
170// shift it.
171use crate::sender_meta::ReceiverMetaQueue;
172
173/// How long a remote video track's sink took to hand its decoded frames on,
174/// added up since the sink was attached ([`VideoTrack::sink_timings`]).
175#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
176pub struct VideoSinkTimings {
177 /// From libwebrtc handing a decoded frame to the sink, before its conversion
178 /// to BGRA, until the frame reaches the `on_frame` closure.
179 pub convert: std::time::Duration,
180 /// The frames the closure received.
181 pub frames: u64,
182}
183
184/// A sink's totals, updated and read as one pair so a reading's time and count
185/// describe the same frames.
186#[derive(Default)]
187struct SinkTimingTotals(Mutex<VideoSinkTimings>);
188
189// Heap-pinned sink state behind the C userdata pointer.
190struct VideoSinkState {
191 cb: Mutex<VideoSinkCb>,
192 // Shared with Track::video_sink_timings, which sink_timings reads.
193 timings: Arc<SinkTimingTotals>,
194 // The track's inbound metadata queue, always present. It is simply empty when
195 // no strip transform is installed or the sender attached no trailer, so there
196 // is no state to switch on and no back-fill ordering to get right.
197 receiver_meta: Arc<ReceiverMetaQueue>,
198}
199struct AudioSinkState {
200 cb: Mutex<AudioSinkCb>,
201}
202
203extern "C" fn video_sink_tramp(
204 ud: *mut c_void,
205 bgra: *const u8,
206 width: c_int,
207 height: c_int,
208 ssrc: u32,
209 rtp_timestamp: u32,
210 convert_ns: i64,
211) {
212 // The native sink timed its own share; this one adds the work here, until
213 // the callback, on a clock as fine.
214 let entered = std::time::Instant::now();
215 let st = unsafe { &*(ud as *const VideoSinkState) };
216 let len = (width as usize) * (height as usize) * 4;
217 let slice = unsafe { std::slice::from_raw_parts(bgra, len) };
218 let metadata = crate::sender_meta::take_receiver_meta(&st.receiver_meta, ssrc, rtp_timestamp);
219 if let Ok(mut cb) = st.cb.lock() {
220 let here_ns = u64::try_from(entered.elapsed().as_nanos()).unwrap_or(u64::MAX);
221 let convert = (convert_ns.max(0) as u64).saturating_add(here_ns);
222 if let Ok(mut totals) = st.timings.0.lock() {
223 totals.convert = totals
224 .convert
225 .saturating_add(std::time::Duration::from_nanos(convert));
226 totals.frames += 1;
227 }
228 cb(
229 VideoFrame {
230 bgra: slice,
231 width: width as u32,
232 height: height as u32,
233 metadata,
234 },
235 std::time::Duration::from_nanos(convert),
236 );
237 }
238}
239
240extern "C" fn audio_sink_tramp(
241 ud: *mut c_void,
242 pcm: *const i16,
243 sample_rate: c_int,
244 channels: c_int,
245 frames: c_int,
246) {
247 let st = unsafe { &*(ud as *const AudioSinkState) };
248 let len = (frames as usize) * (channels as usize);
249 let slice = unsafe { std::slice::from_raw_parts(pcm, len) };
250 if let Ok(mut cb) = st.cb.lock() {
251 cb(AudioFrame {
252 pcm: slice,
253 sample_rate: sample_rate as u32,
254 channels: channels as u32,
255 frames: frames as u32,
256 });
257 }
258}
259
260/// A media track (local or remote). Dropping it detaches any sink and releases
261/// the native track.
262pub struct Track {
263 raw: *mut reactor_webrtc_sys::MediaStreamTrack,
264 kind: MediaKind,
265 // Mutex, not a plain field, so on_video_frame/on_audio_frame can take &self:
266 // Transceiver.set_track needs to share a Track behind an Arc (for the Python
267 // binding's spawn_blocking future), which rules out ever taking &mut self.
268 video_sink: Mutex<Option<Box<VideoSinkState>>>,
269 // The totals of the video sink attached last, apart from `video_sink`:
270 // attaching holds that lock while it waits on the worker thread, which runs
271 // the frame callbacks, so a callback reading the totals through it could
272 // deadlock. This lock is only ever held to swap or clone the Arc.
273 video_sink_timings: Mutex<Arc<SinkTimingTotals>>,
274 audio_sink: Mutex<Option<Box<AudioSinkState>>>,
275 // Always present: push_video_frame_with_metadata fills it, and the sender
276 // transform the peer connection installs after negotiation reads it. There is
277 // nothing to switch on — an empty map simply yields no metadata.
278 sender_meta: Arc<CaptureTimeMeta>,
279 // Identity of the native track, which is how `sender_meta` is registered and
280 // therefore how a transceiver finds it. Cached because Drop needs it.
281 native_id: usize,
282 // Always present, shared with VideoSinkState and with the strip transform the
283 // peer connection installs after negotiation.
284 receiver_meta: Arc<ReceiverMetaQueue>,
285 // Monotonic per-track frame counter; wraps from u64::MAX to 1 (0 = unset).
286 frame_counter: AtomicU64,
287 // Last capture_ms allocated by alloc_send_capture_us; ensures strictly-
288 // increasing keys in the sender map even when two pushes land in the
289 // same millisecond.
290 last_send_ms: AtomicI64,
291 // Keeps the factory's signaling/worker/network threads alive for as long
292 // as this track exists — destroying a track dispatches onto them, whether
293 // the track is a local one this factory produced or a remote one the
294 // observer delivered over a connection this factory created.
295 _factory: Arc<FactoryHandle>,
296 // Tracks delivered by the observer are inbound-only: pushing frames onto
297 // them is an error (surfaced by the media wrappers).
298 is_remote: bool,
299 // Per-track frame-metadata switch: off tracks never get a trailer writer
300 // installed and drop `user_data` on pushes (the frame itself still flows).
301 // Defaults from the factory kill switch; overridable per track via
302 // `VideoTrackOptions::frame_metadata`.
303 metadata_enabled: bool,
304 // The factory's encoder registry for local tracks — dropped slots must be
305 // retracted when the track dies ([`EncoderRegistry::retract`]). None for
306 // remote tracks: their slots never existed.
307 registry: Option<Arc<crate::encoded::EncoderRegistry>>,
308}
309
310// SAFETY: the native track is internally thread-safe; sink callbacks and sink
311// (re)attachment are both guarded by a Mutex, and the other `&self` methods
312// (frame push) go through WebRTC's internally-locked broadcaster, so a
313// `&Track` may be shared and used concurrently across threads.
314unsafe impl Send for Track {}
315unsafe impl Sync for Track {}
316
317impl Track {
318 pub(crate) fn from_raw(
319 raw: *mut reactor_webrtc_sys::MediaStreamTrack,
320 kind: MediaKind,
321 factory: Arc<FactoryHandle>,
322 is_remote: bool,
323 metadata_enabled: bool,
324 ) -> Self {
325 Self::from_raw_with_registry(raw, kind, factory, is_remote, metadata_enabled, None)
326 }
327
328 pub(crate) fn from_raw_with_registry(
329 raw: *mut reactor_webrtc_sys::MediaStreamTrack,
330 kind: MediaKind,
331 factory: Arc<FactoryHandle>,
332 is_remote: bool,
333 metadata_enabled: bool,
334 registry: Option<Arc<crate::encoded::EncoderRegistry>>,
335 ) -> Self {
336 let sender_meta = Arc::new(CaptureTimeMeta::default());
337 let receiver_meta: Arc<ReceiverMetaQueue> = Arc::new(ReceiverMetaQueue::default());
338 let native_id = unsafe { reactor_webrtc_sys::reactor_webrtc_media_stream_track_id(raw) };
339 // Video only: audio carries no metadata, so registering it would just put
340 // an entry in the way of nothing.
341 if kind == MediaKind::Video {
342 let source: Arc<dyn crate::sender_meta::SenderMetaSource> = sender_meta.clone();
343 crate::sender_meta::register(native_id, &source);
344 crate::sender_meta::register_receiver(native_id, &receiver_meta);
345 crate::sender_meta::register_allowed(native_id, metadata_enabled);
346 }
347 Self {
348 raw,
349 kind,
350 video_sink: Mutex::new(None),
351 video_sink_timings: Mutex::default(),
352 audio_sink: Mutex::new(None),
353 sender_meta,
354 native_id,
355 receiver_meta,
356 frame_counter: AtomicU64::new(0),
357 last_send_ms: AtomicI64::new(0),
358 _factory: factory,
359 is_remote,
360 metadata_enabled,
361 registry,
362 }
363 }
364
365 /// Whether this track came from the remote side of a peer connection.
366 /// Remote tracks only receive — pushing frames onto one is an error.
367 pub fn is_remote(&self) -> bool {
368 self.is_remote
369 }
370
371 /// Whether this track carries a frame-metadata trailer (`user_data`
372 /// survives the push and reaches the receiver).
373 pub(crate) fn metadata_enabled(&self) -> bool {
374 self.metadata_enabled
375 }
376
377 pub(crate) fn raw(&self) -> *mut reactor_webrtc_sys::MediaStreamTrack {
378 self.raw
379 }
380
381 /// Identity of the native track, comparable with
382 /// `reactor_webrtc_rtp_transceiver_sender_track_id`.
383 pub(crate) fn native_id(&self) -> usize {
384 self.native_id
385 }
386
387 pub fn kind(&self) -> MediaKind {
388 self.kind
389 }
390
391 /// Push a BGRA frame captured *now* — the untimestamped native path.
392 /// Called by [`VideoTrack::push_frame`]. No-op for non-video tracks.
393 pub(crate) fn push_video_frame_now(&self, bgra: &[u8], width: u32, height: u32) {
394 if self.kind != MediaKind::Video {
395 return;
396 }
397 unsafe {
398 reactor_webrtc_sys::reactor_webrtc_video_track_push_frame(
399 self.raw,
400 bgra.as_ptr(),
401 width as c_int,
402 height as c_int,
403 );
404 }
405 }
406
407 /// Shared body of [`VideoTrack::push_frame_with_metadata` and `_at`]:
408 /// `capture_time_us` is the caller's declared time, or `None` for the clock.
409 ///
410 /// `capture_time_us` does two jobs, and they want different things from it.
411 /// The trailer carries it **exactly**, so the receiver reads the number the
412 /// caller put on the frame — that is the caller's own timeline, and rounding
413 /// it would be this library editing a value it does not own. Playout
414 /// synchronisation gets it too, so a frame the model annotated still lines up
415 /// with the audio produced alongside it: the trailer must not cost the caller
416 /// its timestamp.
417 ///
418 /// Synchronisation is where the rounding lives. The trailer is matched to its
419 /// frame by capture millisecond, so two frames stamped inside the same
420 /// millisecond would collide; the second is nudged to the following one —
421 /// far below the resolution any synchronisation cares about, and only
422 /// reachable above 1000 fps. Neither the truncation nor the nudge reaches the
423 /// trailer, so the value delivered is the value passed, and several tracks
424 /// pushed with one stamp deliver that one stamp.
425 pub(crate) fn push_metadata_frame(
426 &self,
427 bgra: &[u8],
428 width: u32,
429 height: u32,
430 user_data: &[u8],
431 capture_time_us: Option<i64>,
432 ) {
433 if self.kind != MediaKind::Video {
434 return;
435 }
436 // One reading, two uses. The trailer carries it exactly as it is, because
437 // that is the caller's own timeline and rounding it would be this library
438 // editing a number it does not own. The join key and the capture
439 // timestamp libwebrtc gets are derived from the same value, and the
440 // millisecond truncation and uniqueness nudge they need stop there.
441 let declared = capture_time_us.unwrap_or_else(crate::time_micros);
442 let capture_us = self.alloc_send_capture_us(Some(declared));
443 let meta = crate::metadata::FrameMetadata {
444 frame_id: self.next_frame_id(),
445 // The wire field is unsigned and 0 is its "unset", so a negative
446 // reading has nowhere to land and says nothing instead of a wrapped
447 // number that would read as 584 thousand years from now.
448 capture_time_us: declared.max(0) as u64,
449 user_data: user_data.to_vec(),
450 };
451 self.insert_sender_meta(capture_us / 1000, meta);
452 self.push_video_frame_ts(bgra, width, height, capture_us);
453 }
454
455 /// Return a strictly-increasing capture timestamp in microseconds for use
456 /// as the sender-map key and `push_video_frame_ts` argument.
457 ///
458 /// `requested` is the caller's own capture time, or `None` to read the
459 /// clock. Either way two frames in the same millisecond would produce the
460 /// same `capture_ms` key and share one metadata entry, so the counter
461 /// advances by 1 ms when the source hasn't moved, guaranteeing unique keys
462 /// regardless of call frequency.
463 pub(crate) fn alloc_send_capture_us(&self, requested: Option<i64>) -> i64 {
464 let raw_ms = match requested {
465 Some(us) => us / 1000,
466 None => crate::time_micros() / 1000,
467 };
468 next_unique_capture_ms(&self.last_send_ms, raw_ms) * 1000
469 }
470
471 /// Advance the per-track frame counter and return the new value (1-based,
472 /// wraps from `u64::MAX` back to 1; 0 is reserved to mean "unset").
473 pub(crate) fn next_frame_id(&self) -> u64 {
474 let raw = self.frame_counter.fetch_add(1, Ordering::Relaxed);
475 if raw == u64::MAX {
476 1
477 } else {
478 raw + 1
479 }
480 }
481
482 /// Insert a metadata entry keyed by `capture_ms` into the sender map.
483 ///
484 /// Always accepted, whether or not a sender transform is installed and whether
485 /// or not the peer declared support. The map is capacity-bounded and evicts in
486 /// insertion order, so entries nobody reads are reclaimed rather than
487 /// accumulated.
488 pub(crate) fn insert_sender_meta(&self, capture_ms: i64, meta: crate::metadata::FrameMetadata) {
489 if let Ok(mut guard) = self.sender_meta.0.lock() {
490 let (ref mut hmap, ref mut order) = *guard;
491 if order.len() >= SENDER_META_CAP {
492 if let Some(old_key) = order.pop_front() {
493 hmap.remove(&old_key);
494 }
495 }
496 // No-erase on insert: simulcast layers sharing the same
497 // capture_time_ms must all find the same entry.
498 hmap.entry(capture_ms).or_insert(meta);
499 order.push_back(capture_ms);
500 }
501 }
502
503 /// Push a BGRA frame with an explicit WebRTC capture timestamp (µs).
504 /// Used internally to correlate metadata with the sender FrameTransform.
505 pub(crate) fn push_video_frame_ts(
506 &self,
507 bgra: &[u8],
508 width: u32,
509 height: u32,
510 capture_us: i64,
511 ) {
512 unsafe {
513 reactor_webrtc_sys::reactor_webrtc_video_track_push_frame_ts(
514 self.raw,
515 bgra.as_ptr(),
516 width as c_int,
517 height as c_int,
518 capture_us,
519 );
520 }
521 }
522
523 /// Register a decoded-frame sink, replacing any previous one. Called by
524 /// [`VideoTrack::on_frame`].
525 ///
526 /// [`VideoFrame::metadata`] is populated whenever the sender included a
527 /// metadata trailer and the peer connection installed the strip transform —
528 /// which it does automatically, on both peers, once the capability has been
529 /// negotiated. Nothing here has to be arranged for it.
530 pub(crate) fn attach_video_sink(
531 &self,
532 cb: impl for<'a> FnMut(VideoFrame<'a>, std::time::Duration) + Send + 'static,
533 ) {
534 let timings = Arc::new(SinkTimingTotals::default());
535 let state = Box::new(VideoSinkState {
536 cb: Mutex::new(Box::new(cb)),
537 timings: Arc::clone(&timings),
538 receiver_meta: self.receiver_meta.clone(),
539 });
540 let ud = &*state as *const VideoSinkState as *mut c_void;
541 // Held across both the native registration and the Rust-side store: two
542 // concurrent callers must not interleave their FFI call with the other's
543 // store, or the native side can end up pointing at whichever Box the
544 // *other* caller's store just dropped — freed memory the callback thread
545 // then reads. Holding the lock over both serializes each caller's pair of
546 // steps, so the last one to run leaves a native pointer and a stored Box
547 // that always agree with each other.
548 let mut guard = self.video_sink.lock().expect("video_sink mutex poisoned");
549 unsafe {
550 reactor_webrtc_sys::reactor_webrtc_video_track_add_sink(self.raw, ud, video_sink_tramp);
551 }
552 // Drops the previous sink Box, if any, same as an `Option` field replace.
553 *guard = Some(state);
554 *self
555 .video_sink_timings
556 .lock()
557 .expect("video_sink_timings mutex poisoned") = timings;
558 }
559
560 /// Shared body of [`AudioTrack::push_frame`] / [`push_frame_at`]:
561 /// `capture_time_ms = 0` means "unknown / stamp at arrival".
562 pub(crate) fn push_pcm_inner(
563 &self,
564 pcm: &[i16],
565 sample_rate: u32,
566 channels: u32,
567 capture_time_ms: i64,
568 ) -> Result<()> {
569 if channels == 0 {
570 return Err(Error::Webrtc("channels must be at least 1".to_owned()));
571 }
572 if !pcm.len().is_multiple_of(channels as usize) {
573 return Err(Error::Webrtc(format!(
574 "pcm length ({}) is not a multiple of channels ({})",
575 pcm.len(),
576 channels
577 )));
578 }
579 let channels = channels as c_int;
580 let samples_per_channel = (pcm.len() / channels as usize) as c_int;
581 unsafe {
582 reactor_webrtc_sys::reactor_webrtc_audio_track_push_pcm_ts(
583 self.raw,
584 pcm.as_ptr(),
585 samples_per_channel,
586 sample_rate as c_int,
587 channels,
588 capture_time_ms,
589 );
590 }
591 Ok(())
592 }
593
594 /// Register a decoded-PCM sink, replacing any previous one. Called by
595 /// [`AudioTrack::on_frame`].
596 pub(crate) fn attach_audio_sink(
597 &self,
598 cb: impl for<'a> FnMut(AudioFrame<'a>) + Send + 'static,
599 ) {
600 let state = Box::new(AudioSinkState {
601 cb: Mutex::new(Box::new(cb)),
602 });
603 let ud = &*state as *const AudioSinkState as *mut c_void;
604 // Same reasoning as `on_video_frame`: the lock spans both steps so the
605 // two can never interleave across callers.
606 let mut guard = self.audio_sink.lock().expect("audio_sink mutex poisoned");
607 unsafe {
608 reactor_webrtc_sys::reactor_webrtc_audio_track_add_sink(self.raw, ud, audio_sink_tramp);
609 }
610 // Drops the previous sink Box, if any, same as an `Option` field replace.
611 *guard = Some(state);
612 }
613}
614
615impl Drop for Track {
616 fn drop(&mut self) {
617 // Guarded on identity inside deregister: an EncodedVideoTrack wrapping this
618 // track will have replaced the entry with its own FIFO source, and that one
619 // must survive this drop.
620 if self.kind == MediaKind::Video {
621 let source: Arc<dyn crate::sender_meta::SenderMetaSource> = self.sender_meta.clone();
622 crate::sender_meta::deregister(self.native_id, &source);
623 crate::sender_meta::deregister_receiver(self.native_id, &self.receiver_meta);
624 crate::sender_meta::deregister_allowed(self.native_id);
625 crate::encoded::deregister_feedback_binding(self.native_id);
626 }
627 // Video-only: this track may have reserved encoder slots — reclaim
628 // them so nothing else can be routed through the dead lane.
629 if let Some(registry) = &self.registry {
630 if self.kind == MediaKind::Video {
631 registry.retract(self.native_id());
632 }
633 }
634 // Detaches the C++ sink before the sink-state boxes are freed.
635 unsafe { reactor_webrtc_sys::reactor_webrtc_media_stream_track_destroy(self.raw) }
636 }
637}
638
639// ── Media-typed wrappers ─────────────────────────────────────────────────────
640//
641// `Track` is the untyped core: the native handle, lifetime, metadata plumbing
642// and the transceiver/FFI surface. The public frame API lives on these
643// wrappers instead — pushing a video frame onto an audio track is a compile
644// error, not a runtime no-op. Everything derefs to `&Track`, so
645// `transceiver.set_track(&wrapper)` just works.
646
647use std::ops::Deref;
648
649/// A video [`Track`] — local (raw) or remote. `on_frame` receives decoded
650/// video; `push_frame` family feeds a local track (`Err` on a remote track).
651pub struct VideoTrack(Track);
652
653/// An audio [`Track`] — local or remote. `on_frame` receives decoded PCM;
654/// `push_frame` family feeds a local [`AudioTrackSource::LocalPush`] track
655/// (`Err` on a remote track; no-op semantics on ADM-backed locals match the
656/// native ADM).
657pub struct AudioTrack(Track);
658
659impl VideoTrack {
660 pub(crate) fn wrap(track: Track) -> Self {
661 debug_assert_eq!(track.kind(), MediaKind::Video);
662 Self(track)
663 }
664
665 /// Subscribe to decoded frames. Replaces any previous sink. The closure
666 /// runs on a WebRTC thread.
667 pub fn on_frame(&self, mut cb: impl for<'a> FnMut(VideoFrame<'a>) + Send + 'static) {
668 self.0.attach_video_sink(move |frame, _| cb(frame));
669 }
670
671 /// [`on_frame`](Self::on_frame), with each frame's share of
672 /// [`VideoSinkTimings::convert`]: how long it took from libwebrtc handing it
673 /// to the sink until the closure. For a caller adding up its own totals
674 /// alongside that time, so they all count the same frames.
675 pub fn on_frame_timed(
676 &self,
677 cb: impl for<'a> FnMut(VideoFrame<'a>, std::time::Duration) + Send + 'static,
678 ) {
679 self.0.attach_video_sink(cb);
680 }
681
682 /// How long the sink took to hand decoded frames to the [`on_frame`](Self::on_frame)
683 /// closure, added up since it was attached. Zero before any sink is attached.
684 /// It can be called from the closure.
685 pub fn sink_timings(&self) -> VideoSinkTimings {
686 let timings = Arc::clone(
687 &self
688 .0
689 .video_sink_timings
690 .lock()
691 .expect("video_sink_timings mutex poisoned"),
692 );
693 let totals = *timings.0.lock().expect("sink timings mutex poisoned");
694 totals
695 }
696
697 /// Push a BGRA frame (`width*height*4` bytes), captured now — on a local
698 /// track only (a remote track returns `Err`). What a
699 /// [`TrackVideoEncoder::Inline`](crate::TrackVideoEncoder) callback sees on
700 /// its slot comes straight from these pushes.
701 pub fn push_frame(&self, frame: VideoFrame<'_>) -> Result<()> {
702 self.guard_remote()?;
703 self.0
704 .push_video_frame_now(frame.bgra, frame.width, frame.height);
705 Ok(())
706 }
707
708 /// Push a BGRA frame captured at `capture_time_us` ([`time_micros`]'s
709 /// epoch) rather than whenever it reaches the encoder.
710 ///
711 /// A track's RTP timestamp otherwise counts only the samples handed to it,
712 /// which says how much media exists but not when it was captured.
713 /// Supplying the same timestamp used for the audio/video produced
714 /// alongside it is what lets the receiver play them together.
715 pub fn push_frame_at(&self, frame: VideoFrame<'_>, capture_time_us: i64) -> Result<()> {
716 self.guard_remote()?;
717 self.0
718 .push_video_frame_ts(frame.bgra, frame.width, frame.height, capture_time_us);
719 Ok(())
720 }
721
722 /// Push a BGRA frame with arbitrary `user_data` embedded as a protobuf
723 /// trailer, captured now. `frame_id` (monotonic) and `capture_time_us`
724 /// are filled in internally.
725 pub fn push_frame_with_metadata(&self, frame: VideoFrame<'_>, user_data: &[u8]) -> Result<()> {
726 self.guard_remote()?;
727 if self.0.metadata_enabled() {
728 self.0
729 .push_metadata_frame(frame.bgra, frame.width, frame.height, user_data, None);
730 } else {
731 // user_data dropped by contract; the frame itself still flows.
732 self.0
733 .push_video_frame_now(frame.bgra, frame.width, frame.height);
734 }
735 Ok(())
736 }
737
738 /// Same as [`push_frame_with_metadata`](Self::push_frame_with_metadata)
739 /// with the capture time declared by the caller. The trailer carries the
740 /// value **exactly**; the millisecond-uniqueness nudge lives elsewhere and
741 /// never reaches the wire value.
742 pub fn push_frame_with_metadata_at(
743 &self,
744 frame: VideoFrame<'_>,
745 user_data: &[u8],
746 capture_time_us: i64,
747 ) -> Result<()> {
748 self.guard_remote()?;
749 if self.0.metadata_enabled() {
750 self.0.push_metadata_frame(
751 frame.bgra,
752 frame.width,
753 frame.height,
754 user_data,
755 Some(capture_time_us),
756 );
757 } else {
758 // user_data dropped by contract; the frame still flows, stamped.
759 self.0
760 .push_video_frame_ts(frame.bgra, frame.width, frame.height, capture_time_us);
761 }
762 Ok(())
763 }
764
765 fn guard_remote(&self) -> Result<()> {
766 if self.0.is_remote() {
767 return Err(Error::Webrtc(
768 "cannot push frames onto a remote track".to_owned(),
769 ));
770 }
771 Ok(())
772 }
773
774 /// Listen for encoder feedback
775 /// ([`EncoderFeedback::RateUpdate`](crate::EncoderFeedback::RateUpdate)) on
776 /// a track created with [`TrackVideoEncoder::Inline`]. Inline tracks see
777 /// keyframe demands inside the [`RawVideoFrame`] their callback receives
778 /// (`request_key_frame`); the other feedback — rate control from the
779 /// congestion controller — surfaces here. Latest registration wins.
780 ///
781 /// Only meaningful on inline-encoder tracks: returns an error on raw
782 /// (builtin-encoder) tracks, where BWE adaptation is internal to
783 /// libwebrtc, and on remote tracks.
784 pub fn on_encoder_feedback(
785 &self,
786 cb: impl FnMut(crate::encoded::EncoderFeedback) + Send + 'static,
787 ) -> Result<()> {
788 self.guard_remote()?;
789 let Some(listeners) = crate::encoded::feedback_binding(self.0.native_id()) else {
790 return Err(Error::Webrtc(
791 "on_encoder_feedback is only valid on tracks created with TrackVideoEncoder::Inline — builtin-encoder tracks adapt internally"
792 .to_owned(),
793 ));
794 };
795 listeners.set(Box::new(cb));
796 Ok(())
797 }
798}
799
800impl AudioTrack {
801 pub(crate) fn wrap(track: Track) -> Self {
802 debug_assert_eq!(track.kind(), MediaKind::Audio);
803 Self(track)
804 }
805
806 /// Subscribe to decoded PCM. Replaces any previous sink. The closure runs
807 /// on a WebRTC thread.
808 pub fn on_frame(&self, cb: impl for<'a> FnMut(AudioFrame<'a>) + Send + 'static) {
809 self.0.attach_audio_sink(cb);
810 }
811
812 /// Push interleaved i16 PCM, timestamped on arrival. Only meaningful on a
813 /// local [`AudioTrackSource::LocalPush`] track — it's a no-op on
814 /// ADM-backed locals and an `Err` on remote tracks. A remote→relay use
815 /// case passes the received [`AudioFrame`] straight back in — same struct
816 /// both ways.
817 ///
818 /// `pcm.len()` must be `frames * channels`; a partial trailing frame is an
819 /// error. Only one thread should call this at a time for a given track —
820 /// concurrent callers produce interleaved, garbage audio.
821 pub fn push_frame(&self, frame: AudioFrame<'_>) -> Result<()> {
822 self.guard_remote()?;
823 self.0
824 .push_pcm_inner(frame.pcm, frame.sample_rate, frame.channels, 0)
825 }
826
827 /// Push PCM captured at `capture_time_us` ([`time_micros`]'s epoch) rather
828 /// than whenever it reaches the encoder — see
829 /// [`VideoTrack::push_frame_at`] for why the shared stamp matters.
830 pub fn push_frame_at(&self, frame: AudioFrame<'_>, capture_time_us: i64) -> Result<()> {
831 self.guard_remote()?;
832 self.0.push_pcm_inner(
833 frame.pcm,
834 frame.sample_rate,
835 frame.channels,
836 capture_time_us / 1000,
837 )
838 }
839
840 fn guard_remote(&self) -> Result<()> {
841 if self.0.is_remote() {
842 return Err(Error::Webrtc(
843 "cannot push frames onto a remote track".to_owned(),
844 ));
845 }
846 Ok(())
847 }
848}
849
850impl Deref for VideoTrack {
851 type Target = Track;
852 fn deref(&self) -> &Track {
853 &self.0
854 }
855}
856impl Deref for AudioTrack {
857 type Target = Track;
858 fn deref(&self) -> &Track {
859 &self.0
860 }
861}
862
863// SAFETY: wraps a Track; its own fields add no state.
864unsafe impl Send for VideoTrack {}
865unsafe impl Sync for VideoTrack {}
866unsafe impl Send for AudioTrack {}
867unsafe impl Sync for AudioTrack {}
868
869/// A track delivered by [`PeerConnectionObserver::on_track`] — media-typed,
870/// so `RemoteTrack::Video(v).on_frame(...)` and `RemoteTrack::Audio(a)
871/// .on_frame(...)` each take the matching frame type and nothing else.
872pub enum RemoteTrack {
873 /// A remote video track.
874 Video(VideoTrack),
875 /// A remote audio track.
876 Audio(AudioTrack),
877}
878
879impl RemoteTrack {
880 /// Audio vs video.
881 pub fn kind(&self) -> MediaKind {
882 match self {
883 Self::Video(_) => MediaKind::Video,
884 Self::Audio(_) => MediaKind::Audio,
885 }
886 }
887
888 /// `Some` when this is a video track.
889 pub fn as_video(&self) -> Option<&VideoTrack> {
890 match self {
891 Self::Video(t) => Some(t),
892 Self::Audio(_) => None,
893 }
894 }
895
896 /// `Some` when this is an audio track.
897 pub fn as_audio(&self) -> Option<&AudioTrack> {
898 match self {
899 Self::Audio(t) => Some(t),
900 Self::Video(_) => None,
901 }
902 }
903
904 /// Unwrap a video track (panics on audio tracks).
905 pub fn into_video(self) -> VideoTrack {
906 match self {
907 Self::Video(t) => t,
908 Self::Audio(_) => panic!("into_video on an audio track"),
909 }
910 }
911
912 /// Unwrap an audio track (panics on video tracks).
913 pub fn into_audio(self) -> AudioTrack {
914 match self {
915 Self::Audio(t) => t,
916 Self::Video(_) => panic!("into_audio on a video track"),
917 }
918 }
919}
920
921impl VideoFrame<'_> {
922 /// A push-side frame: BGRA pixels, no metadata (outgoing trailers go
923 /// through [`VideoTrack::push_frame_with_metadata(_at)`] with `user_data`).
924 /// The same struct `on_frame` delivers — relay use cases repack nothing.
925 /// `metadata` is populated only on receive.
926 pub const fn new(bgra: &'_ [u8], width: u32, height: u32) -> VideoFrame<'_> {
927 VideoFrame {
928 bgra,
929 width,
930 height,
931 metadata: None,
932 }
933 }
934}
935
936impl AudioFrame<'_> {
937 /// A push-side frame: interleaved i16 PCM + format. `frames` is derived
938 /// (`pcm.len() / channels`) — the push validates it against `pcm.len()`.
939 /// The same struct `on_frame` delivers.
940 pub fn new(pcm: &'_ [i16], sample_rate: u32, channels: u32) -> AudioFrame<'_> {
941 AudioFrame {
942 pcm,
943 sample_rate,
944 channels,
945 frames: if channels == 0 {
946 0
947 } else {
948 (pcm.len() / channels as usize) as u32
949 },
950 }
951 }
952}
953
954/// Claim `raw_ms` as a capture millisecond, advancing past `last` when the
955/// source has not moved.
956///
957/// The sender metadata map is keyed by capture millisecond, so two frames
958/// claiming the same one would share a trailer. Nudging the second forward
959/// keeps keys unique whether the millisecond came from the clock or from a
960/// caller stamping its own capture time.
961fn next_unique_capture_ms(last: &AtomicI64, raw_ms: i64) -> i64 {
962 let mut prev = last.load(Ordering::Relaxed);
963 loop {
964 let next = raw_ms.max(prev + 1);
965 match last.compare_exchange_weak(prev, next, Ordering::SeqCst, Ordering::Relaxed) {
966 Ok(_) => return next,
967 Err(actual) => prev = actual,
968 }
969 }
970}
971
972#[cfg(test)]
973mod tests {
974 use super::*;
975
976 #[test]
977 fn a_caller_timestamp_is_used_as_given() {
978 let last = AtomicI64::new(0);
979 assert_eq!(next_unique_capture_ms(&last, 1_700), 1_700);
980 }
981
982 #[test]
983 fn a_second_frame_in_the_same_millisecond_is_nudged_forward() {
984 let last = AtomicI64::new(0);
985 assert_eq!(next_unique_capture_ms(&last, 1_700), 1_700);
986 assert_eq!(next_unique_capture_ms(&last, 1_700), 1_701);
987 assert_eq!(next_unique_capture_ms(&last, 1_700), 1_702);
988 }
989
990 #[test]
991 fn a_timestamp_past_the_nudge_wins() {
992 let last = AtomicI64::new(0);
993 next_unique_capture_ms(&last, 1_700);
994 next_unique_capture_ms(&last, 1_700);
995 // 40 ms later — a real frame interval — lands where the caller asked.
996 assert_eq!(next_unique_capture_ms(&last, 1_740), 1_740);
997 }
998
999 #[test]
1000 fn keys_stay_unique_across_threads() {
1001 let last = Arc::new(AtomicI64::new(0));
1002 let seen = Arc::new(Mutex::new(Vec::new()));
1003 let mut handles = Vec::new();
1004 for _ in 0..8 {
1005 let last = Arc::clone(&last);
1006 let seen = Arc::clone(&seen);
1007 handles.push(std::thread::spawn(move || {
1008 for _ in 0..100 {
1009 let ms = next_unique_capture_ms(&last, 5_000);
1010 seen.lock().unwrap().push(ms);
1011 }
1012 }));
1013 }
1014 for h in handles {
1015 h.join().unwrap();
1016 }
1017 let mut all = seen.lock().unwrap().clone();
1018 all.sort_unstable();
1019 let before = all.len();
1020 all.dedup();
1021 assert_eq!(all.len(), before, "capture milliseconds collided");
1022 }
1023}