Skip to main content

reactor_webrtc/
lib.rs

1//! `reactor-webrtc` — a safe, idiomatic Rust API over an owned build of Google's
2//! WebRTC engine (see the `reactor-webrtc-sys` crate and `../../webrtc-build`).
3//!
4//! ## Shape
5//!
6//! [`PeerConnectionFactory`] → [`PeerConnection`] (with a closure-based
7//! [`PeerConnectionObserver`]) → SDP offer/answer, ICE, [`Track`]s (audio +
8//! video send/receive) and [`DataChannel`]s. RAII throughout: dropping a handle
9//! releases the native object.
10//!
11//! ICE credentials are libwebrtc's to generate, and it exposes no setter. When an
12//! application needs to *choose* its ufrag — so that a fronting layer can route on
13//! it — [`SessionDescription::with_ice_credentials`] substitutes them in the
14//! description before it is set locally, which is where libwebrtc reads the
15//! transport's ICE parameters from.
16//!
17//! Per-frame metadata ([`metadata`]) rides in a trailer appended to the encoded
18//! payload, which only works if the peer strips it again. That is negotiated for
19//! you: every [`PeerConnection::create_offer`] advertises the capability as a
20//! session-level `a=x-reactor-frame-metadata`,
21//! [`PeerConnection::create_answer`] mirrors an offer that asked for
22//! it, and [`PeerConnection::set_remote_description`] arms a
23//! [`FrameMetadataGate`] from what the remote declared, and installs the metadata
24//! steps on the video transceivers. Callers pass `user_data` when it is meaningful
25//! and never have to ask what the far end supports — a trailer reaches the wire only
26//! when the peer said it strips them. Set
27//! [`RtcConfiguration::frame_metadata`] to `false` to keep the whole thing out of a
28//! connection.
29//!
30//! A [`FrameTransform`] of your own composes with that rather than displacing it:
31//! the crate owns libwebrtc's single transformer slot per sender/receiver and runs
32//! both, so encoded-frame access and per-frame metadata work on one transceiver.
33//!
34//! Building a real binary or test requires a native `libwebrtc`; set
35//! `REACTOR_WEBRTC_LIB_DIR` or `REACTOR_WEBRTC_PREBUILT_URL`. `cargo check`
36//! works without one.
37
38mod builder;
39mod chunking_times;
40mod config;
41mod dc_chunking;
42mod encoded;
43mod media;
44pub mod metadata;
45mod observer;
46mod peer_connection;
47pub mod platform;
48mod playout;
49mod sender_meta;
50
51use std::ffi::CString;
52use std::os::raw::c_int;
53use std::sync::Arc;
54
55pub use builder::PeerConnectionFactoryBuilder;
56pub use chunking_times::ChunkingStats;
57pub use config::{
58    BundlePolicy, ContinualGatheringPolicy, IceServer, IceTransportsType, RtcConfiguration,
59    TcpCandidatePolicy,
60};
61pub use dc_chunking::DcChunking;
62pub use encoded::{
63    EncodedFrame, EncodedVideoFrame, EncodedVideoTrack, EncoderFeedback, FrameAction,
64    FrameDirection, FrameTransform, H264Backend, InlineEncoderCallback, LocalVideoTrack,
65    PreEncodedOptions, RawVideoFrame, TrackVideoEncoder, VideoCodec, VideoTrackOptions,
66};
67/// What a peer declares in `a=x-reactor-dc-chunking` (see
68/// [`SessionDescription::with_dc_chunking`]).
69pub use reactor_webrtc_dc_chunking::Params as DcChunkingParams;
70/// Why a chunked data channel refused a message ([`Error::DataChannel`]).
71pub use reactor_webrtc_dc_chunking::SendError as DcSendError;
72
73/// Whether this build targets Apple (H.264 VideoToolbox backend exists).
74pub(crate) const HAVE_VIDEO_TOOLBOX: bool = cfg!(target_vendor = "apple");
75pub use media::{
76    AudioFrame, AudioTrack, AudioTrackOptions, AudioTrackSource, MediaKind, RemoteTrack, Track,
77    VideoFrame, VideoSinkTimings, VideoTrack,
78};
79pub use metadata::{
80    FrameMetadata, FrameMetadataGate, FRAME_METADATA_ATTRIBUTE, FRAME_METADATA_VERSION,
81};
82pub use observer::PeerConnectionObserver;
83pub use peer_connection::{
84    DataChannel, DataChannelState, DataChannelStats, IceCandidate, IceCandidatePairState,
85    IceCandidatePairStats, IceCandidateType, IceGatheringState, InboundRtpStats, OutboundRtpStats,
86    PeerConnection, PeerConnectionState, RelayProtocol, SctpStats, SdpType, SessionDescription,
87    StatsReport, StreamKind, TimingFrameInfo, TimingFrameReceiverTimestamps,
88    TimingFrameSenderTimestamps, Transceiver, TransceiverDirection,
89};
90pub use playout::PlayoutDelay;
91/// Runtime download/verification/caching of Cisco's OpenH264 shared library,
92/// and the required attribution string — registered with
93/// [`PeerConnectionFactoryBuilder::with_openh264`].
94#[cfg(feature = "openh264")]
95pub use reactor_webrtc_sys::openh264;
96
97/// The ABI version of the linked native build. Used to assert that the safe
98/// crate and the prebuilt `libwebrtc` agree.
99pub fn native_abi_version() -> u32 {
100    // Safe: a pure version getter with no arguments.
101    unsafe { reactor_webrtc_sys::reactor_webrtc_abi_version() }
102}
103
104/// The engine's monotonic clock, in microseconds.
105///
106/// The epoch [`Track::push_video_frame_at`], [`Track::push_video_frame_with_metadata_at`],
107/// and [`Track::push_pcm_at`] read their capture timestamps in. Read it once per
108/// unit of produced media and stamp every track with that one value: audio and
109/// video are synchronised by sharing a capture time, not by reaching the encoder
110/// at the same moment.
111pub fn time_micros() -> i64 {
112    // Safe: a pure clock read with no arguments.
113    unsafe { reactor_webrtc_sys::reactor_webrtc_time_micros() }
114}
115
116/// Errors surfaced by the WebRTC engine.
117#[derive(Debug, Clone)]
118pub enum Error {
119    /// A WebRTC operation failed (SDP, ICE, transport, …).
120    Webrtc(String),
121    /// The requested track/transceiver/data-channel was not found.
122    NotFound(String),
123    /// A chunked data channel refused a message: it is larger than the
124    /// effective max message size, or the send queue is full. Nothing of it
125    /// was sent, and the channel is still usable.
126    DataChannel(DcSendError),
127}
128
129impl std::fmt::Display for Error {
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        match self {
132            Error::Webrtc(m) => write!(f, "webrtc error: {m}"),
133            Error::NotFound(m) => write!(f, "not found: {m}"),
134            Error::DataChannel(e) => write!(f, "data channel: {e}"),
135        }
136    }
137}
138
139impl std::error::Error for Error {}
140
141pub type Result<T> = std::result::Result<T, Error>;
142
143/// Which audio device module a [`PeerConnectionFactory`] uses.
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
145pub enum AdmMode {
146    /// Headless, no audio hardware: the app pushes mic PCM via
147    /// [`PeerConnectionFactory::push_audio_frame`] and receives decoded audio
148    /// through the track sinks ([`Track::on_audio_frame`]). Right for servers /
149    /// app-driven media.
150    #[default]
151    Synthetic,
152    /// The platform audio device (CoreAudio / ALSA / WASAPI): real mic capture +
153    /// speaker playout. Right for desktop client apps.
154    Platform,
155}
156
157/// Audio Processing Module configuration passed to [`PeerConnectionFactory`].
158///
159/// All fields default to `false` (no processing). Enable selectively:
160/// ```
161/// use reactor_webrtc::ApmConfig;
162///
163/// let apm = ApmConfig { echo_canceller: true, noise_suppression: true, ..Default::default() };
164/// ```
165#[derive(Debug, Clone, Copy, Default)]
166pub struct ApmConfig {
167    /// AEC3 acoustic echo cancellation.
168    pub echo_canceller: bool,
169    /// Noise suppression (level = kHigh when enabled).
170    pub noise_suppression: bool,
171    /// Automatic gain control (gain_controller1).
172    pub agc: bool,
173    /// High-pass filter.
174    pub high_pass_filter: bool,
175}
176
177impl ApmConfig {
178    pub(crate) fn to_flags(self) -> c_int {
179        let mut f: c_int = 0;
180        if self.echo_canceller {
181            f |= 0x01;
182        }
183        if self.noise_suppression {
184            f |= 0x02;
185        }
186        if self.agc {
187            f |= 0x04;
188        }
189        if self.high_pass_filter {
190            f |= 0x08;
191        }
192        f
193    }
194}
195
196/// The native factory pointer, refcounted so every object the factory produces
197/// (a [`PeerConnection`], a [`Track`](crate::media::Track)) can hold a clone and
198/// keep the factory's signaling/worker/network threads alive for as long as it
199/// lives — including past the point where the [`PeerConnectionFactory`] value
200/// itself is dropped. Without this, the factory's `Drop` tears those threads
201/// down as soon as its own handle goes out of scope, and any object it produced
202/// that outlives it is left holding a pointer into what those threads used to
203/// back.
204pub(crate) struct FactoryHandle(*mut reactor_webrtc_sys::PeerConnectionFactory);
205
206// SAFETY: the native factory is internally thread-safe (it owns the WebRTC
207// signaling/worker/network threads).
208unsafe impl Send for FactoryHandle {}
209unsafe impl Sync for FactoryHandle {}
210
211impl FactoryHandle {
212    pub(crate) fn raw(&self) -> *mut reactor_webrtc_sys::PeerConnectionFactory {
213        self.0
214    }
215}
216
217impl Drop for FactoryHandle {
218    fn drop(&mut self) {
219        unsafe { reactor_webrtc_sys::reactor_webrtc_factory_destroy(self.0) }
220    }
221}
222
223/// Entry point: creates peer connections and tracks, and owns the audio device
224/// module (synthetic by default, or the platform device). Construct it with
225/// [`PeerConnectionFactory::builder`].
226pub struct PeerConnectionFactory {
227    handle: Arc<FactoryHandle>,
228    /// Factory-level kill switch for per-frame metadata: when `false`, every
229    /// [`PeerConnection`] from this factory behaves like one created with
230    /// `RtcConfiguration::frame_metadata` off, whatever each config says.
231    metadata_enabled: bool,
232    /// [`PeerConnectionFactoryBuilder::with_dc_chunking`]: the settings every
233    /// connection from this factory takes part with, unless its
234    /// `RtcConfiguration::dc_chunking` opts out. `None` = never offered.
235    dc_chunking: Option<DcChunking>,
236    /// Per-track encoder slots (pre-encoded / inline), wired into the native
237    /// factory at creation. Every factory has one; tracks register slots via
238    /// [`PeerConnectionFactory::create_video_track_with_options`].
239    registry: Arc<crate::encoded::EncoderRegistry>,
240    /// Whether an OpenH264 library path was registered at build
241    /// ([`PeerConnectionFactoryBuilder::with_openh264`]). Gates the explicit
242    /// [`H264Backend::OpenH264`] per-track selection. (Whether the library
243    /// itself loaded is a native-side concern — a failed load degrades to
244    /// "no OpenH264 backend", it never fails the factory.)
245    #[cfg_attr(not(feature = "openh264"), allow(dead_code))]
246    openh264_registered: bool,
247}
248
249impl PeerConnectionFactory {
250    /// Clone the refcounted native handle, so a [`PeerConnection`] or
251    /// [`Track`] created from this factory can keep its threads alive past
252    /// this value's own lifetime.
253    pub(crate) fn handle(&self) -> Arc<FactoryHandle> {
254        Arc::clone(&self.handle)
255    }
256
257    /// Start composing a factory — the replacement for the old one-shot
258    /// constructors. Chain the knobs you need, then [`build`](PeerConnectionFactoryBuilder::build):
259    ///
260    /// ```rust,ignore
261    /// let factory = PeerConnectionFactory::builder()
262    ///     .with_platform_adm()
263    ///     .with_metadata(false)
264    ///     .build()?;
265    /// ```
266    pub fn builder() -> PeerConnectionFactoryBuilder {
267        PeerConnectionFactoryBuilder::new()
268    }
269
270    /// Shared create path: every constructor shapes a
271    /// [`reactor_webrtc_sys::ReactorFactoryOptions`] and lands here. The glue
272    /// writes a reason into `err` when it returns null; a silent null gets the
273    /// generic message.
274    pub(crate) fn create_from_options(
275        opts: &reactor_webrtc_sys::ReactorFactoryOptions,
276        metadata_enabled: bool,
277        dc_chunking: Option<DcChunking>,
278        registry: Arc<crate::encoded::EncoderRegistry>,
279        openh264_registered: bool,
280    ) -> Result<Self> {
281        let mut err = [0 as std::os::raw::c_char; 256];
282        let raw = unsafe {
283            reactor_webrtc_sys::reactor_webrtc_factory_create(
284                opts,
285                err.as_mut_ptr(),
286                err.len() as c_int,
287            )
288        };
289        if raw.is_null() {
290            let reason = unsafe { std::ffi::CStr::from_ptr(err.as_ptr()) }
291                .to_string_lossy()
292                .into_owned();
293            return Err(Error::Webrtc(if reason.is_empty() {
294                "factory creation returned null".into()
295            } else {
296                format!("factory creation failed: {reason}")
297            }));
298        }
299        Ok(Self {
300            handle: Arc::new(FactoryHandle(raw)),
301            metadata_enabled,
302            dc_chunking,
303            registry,
304            openh264_registered,
305        })
306    }
307
308    /// Create a peer connection with the given configuration and observer.
309    pub fn create_peer_connection(
310        &self,
311        config: &RtcConfiguration,
312        observer: PeerConnectionObserver,
313    ) -> Result<PeerConnection> {
314        let dc_negotiation = dc_chunking::DcNegotiation::new(
315            self.dc_chunking.clone().filter(|_| config.dc_chunking),
316        );
317        let state = observer.into_state(self.handle(), Arc::clone(&dc_negotiation));
318        let callbacks = state.callbacks();
319        let native = config.to_native()?;
320        // libwebrtc reports why it rejected the configuration (an empty TURN
321        // credential, for example); the glue copies that reason in here.
322        let mut err = [0 as std::os::raw::c_char; 256];
323        let raw = unsafe {
324            reactor_webrtc_sys::reactor_webrtc_peer_connection_create(
325                self.handle.raw(),
326                &native.config(),
327                &callbacks,
328                err.as_mut_ptr(),
329                err.len() as c_int,
330            )
331        };
332        if raw.is_null() {
333            let reason = unsafe { std::ffi::CStr::from_ptr(err.as_ptr()) }
334                .to_string_lossy()
335                .into_owned();
336            return Err(Error::Webrtc(if reason.is_empty() {
337                "peer connection creation returned null".into()
338            } else {
339                format!("peer connection creation failed: {reason}")
340            }));
341        }
342        Ok(PeerConnection::new(
343            raw,
344            state,
345            self.handle(),
346            config.frame_metadata && self.metadata_enabled,
347            dc_negotiation,
348        ))
349    }
350
351    /// Create a local video track backed by a push-able source
352    /// ([`Track::push_video_frame`]); libwebrtc's builtin encoder pipeline
353    /// encodes it. For encoder plumbing (pre-encoded / inline) use
354    /// [`create_video_track_with_options`](Self::create_video_track_with_options).
355    ///
356    /// Slot assignment between encoder instances and tracks is **positional**:
357    /// create tracks before (or in the same order as) the transceivers that
358    /// carry them. Slots are reserved only after native creation succeeds,
359    /// so a failed creation (bad id, native error) never leaves an orphan
360    /// slot misbinding the next track's encoder.
361    pub fn create_video_track(&self, id: &str) -> Result<VideoTrack> {
362        let track = self.create_video_track_no_slot(id, self.metadata_enabled)?;
363        self.registry.add_raw_slot(track.native_id());
364        Ok(VideoTrack::wrap(track))
365    }
366
367    /// Create a local video track with per-track [`VideoTrackOptions`] —
368    /// encoder plumbing for this track alone, alongside any number of other
369    /// raw or encoded tracks on the same factory.
370    ///
371    /// ```rust,ignore
372    /// // Pre-encoded: push already-encoded bytes whenever you produce them.
373    /// let screen = factory.create_video_track_with_options("screen", {
374    ///     let mut o = VideoTrackOptions::default();
375    ///     o.encoder = Some(TrackVideoEncoder::PreEncoded(PreEncodedOptions::new(1920, 1080)));
376    ///     o
377    /// })?;
378    /// if let LocalVideoTrack::Encoded(enc) = screen {
379    ///     enc.push_encoded_frame(frame);
380    /// }
381    /// ```
382    ///
383    /// The same positional slot-assignment rule as
384    /// [`create_video_track`](Self::create_video_track) applies, and slots are
385    /// likewise reserved only after a successful native creation.
386    ///
387    /// **One pre-encoded / inline track serves exactly one peer connection.**
388    /// Each PeerConnection layers on its own encoder instance, and the
389    /// registry binds them positionally by reservation — a second PC wired to
390    /// the same track finds no reservation and falls back to the builtin
391    /// encoder, which encodes the track's raw pushes as ordinary video
392    /// (grey/dropped output, not a copy of your bitstream). Create one
393    /// encoder-carrying track per PeerConnection instead of sharing.
394    pub fn create_video_track_with_options(
395        &self,
396        id: &str,
397        options: VideoTrackOptions,
398    ) -> Result<LocalVideoTrack> {
399        if options.encoder.is_some() && options.h264_backend.is_some() {
400            return Err(Error::Webrtc(
401                "h264_backend with a custom encoder: the track's bytes come \
402                 from your own pipeline — there is no backend to route to"
403                    .into(),
404            ));
405        }
406        match options.encoder {
407            None => {
408                let pref = self.h264_backend_pref(options.h264_backend)?;
409                let track = VideoTrack::wrap(self.create_video_track_no_slot(
410                    id,
411                    self.track_metadata_enabled(options.frame_metadata),
412                )?);
413                self.registry
414                    .add_raw_slot_with_backend(track.native_id(), pref);
415                Ok(LocalVideoTrack::Raw(track))
416            }
417            Some(TrackVideoEncoder::PreEncoded(o)) => {
418                let track = VideoTrack::wrap(self.create_video_track_no_slot(
419                    id,
420                    self.track_metadata_enabled(options.frame_metadata),
421                )?);
422                let (queue, feedback) = self.registry.add_encoded_slot(track.native_id());
423                Ok(LocalVideoTrack::Encoded(EncodedVideoTrack::new(
424                    track, queue, feedback, o.width, o.height,
425                )))
426            }
427            Some(TrackVideoEncoder::Inline(cb)) => {
428                let track = VideoTrack::wrap(self.create_video_track_no_slot(
429                    id,
430                    self.track_metadata_enabled(options.frame_metadata),
431                )?);
432                let feedback = self.registry.add_inline_slot(track.native_id(), cb);
433                crate::encoded::register_feedback_binding(track.native_id(), &feedback);
434                Ok(LocalVideoTrack::Raw(track))
435            }
436        }
437    }
438
439    /// Validate an explicit [`H264Backend`] choice against what this build /
440    /// this factory can actually serve, and map it to the slot preference.
441    fn h264_backend_pref(
442        &self,
443        backend: Option<H264Backend>,
444    ) -> Result<crate::encoded::H264BackendPref> {
445        use crate::encoded::H264BackendPref as Pref;
446        match backend {
447            None => Ok(Pref::Auto),
448            Some(H264Backend::VideoToolbox) if !HAVE_VIDEO_TOOLBOX => Err(Error::Webrtc(
449                "H264Backend::VideoToolbox is only available on Apple platforms".into(),
450            )),
451            Some(H264Backend::VideoToolbox) => Ok(Pref::VideoToolbox),
452            #[cfg(feature = "openh264")]
453            Some(H264Backend::OpenH264) if !self.openh264_registered => Err(Error::Webrtc(
454                "H264Backend::OpenH264 requires registering the library first \
455                 (PeerConnectionFactory::builder().with_openh264(path))"
456                    .into(),
457            )),
458            #[cfg(feature = "openh264")]
459            Some(H264Backend::OpenH264) => Ok(Pref::OpenH264),
460        }
461    }
462
463    /// Effective frame-metadata on a track: the factory kill switch is a
464    /// process-wide **off** that a per-track `Some(true)` can never
465    /// re-enable — `None` simply defers to the factory's setting.
466    fn track_metadata_enabled(&self, track_flag: Option<bool>) -> bool {
467        self.metadata_enabled && track_flag.unwrap_or(true)
468    }
469
470    /// Shared native side of [`create_video_track`](Self::create_video_track):
471    /// the strict FFI call, **without** touching the registry — callers
472    /// reserve their slot *after* this succeeds (so a failed create never
473    /// leaves an orphan positional slot behind).
474    fn create_video_track_no_slot(&self, id: &str, metadata_enabled: bool) -> Result<Track> {
475        let cid = CString::new(id).map_err(|_| Error::Webrtc("id contains a NUL byte".into()))?;
476        let raw = unsafe {
477            reactor_webrtc_sys::reactor_webrtc_video_track_create(self.handle.raw(), cid.as_ptr())
478        };
479        if raw.is_null() {
480            return Err(Error::Webrtc("video track creation returned null".into()));
481        }
482        Ok(Track::from_raw_with_registry(
483            raw,
484            MediaKind::Video,
485            self.handle(),
486            false,
487            metadata_enabled,
488            Some(Arc::clone(&self.registry)),
489        ))
490    }
491
492    /// Create a local audio track. Its samples come from this factory's ADM —
493    /// feed it with [`PeerConnectionFactory::push_audio_frame`]. For per-track
494    /// sources and processing constraints use
495    /// [`create_audio_track_with_options`](Self::create_audio_track_with_options).
496    pub fn create_audio_track(&self, id: &str) -> Result<AudioTrack> {
497        self.create_audio_track_with_options(id, AudioTrackOptions::default())
498    }
499
500    /// Create a local audio track with a per-track audio source, independent of
501    /// the factory ADM. Feed samples with [`Track::push_pcm`].
502    ///
503    /// **Deprecated:** retained for 0.12 source compatibility; use
504    /// [`create_audio_track_with_options`](Self::create_audio_track_with_options)
505    /// with [`AudioTrackSource::LocalPush`] instead. Accepting that the type
506    /// is now `AudioTrack` — push helpers didn't change names, though; calls
507    /// typed against [`Track`] adapt trivially (`let track: AudioTrack`).
508    #[deprecated(note = "use create_audio_track_with_options with AudioTrackSource::LocalPush")]
509    pub fn create_audio_track_with_local_source(&self, id: &str) -> Result<AudioTrack> {
510        self.create_audio_track_with_options(
511            id,
512            AudioTrackOptions {
513                source: AudioTrackSource::LocalPush,
514                ..Default::default()
515            },
516        )
517    }
518
519    /// Create a local audio track with per-track [`AudioTrackOptions`] —
520    /// choose the source (factory ADM vs independent push source) and the
521    /// per-source processing constraints (AEC / noise suppression / AGC /
522    /// high-pass), alongside any number of other audio tracks on the same
523    /// factory — the mic + music scenario, or different audio per peer.
524    ///
525    /// For [`AudioTrackSource::LocalPush`] tracks, feed samples with
526    /// [`Track::push_pcm`]; the returned [`Track`] handles both cases (push
527    /// methods are documented no-ops where they don't apply).
528    pub fn create_audio_track_with_options(
529        &self,
530        id: &str,
531        options: AudioTrackOptions,
532    ) -> Result<AudioTrack> {
533        let cid = CString::new(id).map_err(|_| Error::Webrtc("id contains a NUL byte".into()))?;
534        let tri = |v: Option<bool>| v.map_or(-1, |b| b as c_int);
535        let sys_opts = reactor_webrtc_sys::ReactorAudioTrackOptions {
536            source: match options.source {
537                AudioTrackSource::Adm => 0,
538                AudioTrackSource::LocalPush => 1,
539            },
540            echo_cancellation: tri(options.echo_cancellation),
541            noise_suppression: tri(options.noise_suppression),
542            auto_gain_control: tri(options.auto_gain_control),
543            high_pass_filter: tri(options.high_pass_filter),
544            ..Default::default()
545        };
546        let raw = unsafe {
547            reactor_webrtc_sys::reactor_webrtc_audio_track_create(
548                self.handle.raw(),
549                cid.as_ptr(),
550                &sys_opts,
551            )
552        };
553        if raw.is_null() {
554            return Err(Error::Webrtc("audio track creation returned null".into()));
555        }
556        Ok(AudioTrack::wrap(Track::from_raw(
557            raw,
558            MediaKind::Audio,
559            self.handle(),
560            false,
561            true, // metadata is a video feature; audio stays neutral
562        )))
563    }
564
565    /// Feed interleaved i16 PCM to the (synthetic) ADM, shared by all local
566    /// audio tracks. Typically called with ~10ms blocks (e.g. 480 frames @
567    /// 48kHz). No-op with the platform ADM.
568    pub fn push_audio_frame(&self, pcm: &[i16], sample_rate: u32, channels: u32) {
569        let channels = channels.max(1);
570        let samples_per_channel = (pcm.len() / channels as usize) as c_int;
571        unsafe {
572            reactor_webrtc_sys::reactor_webrtc_factory_push_audio_frame(
573                self.handle.raw(),
574                pcm.as_ptr(),
575                samples_per_channel,
576                sample_rate as c_int,
577                channels as c_int,
578            );
579        }
580    }
581
582    /// Enable/disable the synthetic ADM's playout pump (no-op for the platform
583    /// ADM). Disable to stay silent in send-only / headless scenarios.
584    pub fn set_adm_playout_enabled(&self, enabled: bool) {
585        unsafe {
586            reactor_webrtc_sys::reactor_webrtc_factory_set_adm_playout_enabled(
587                self.handle.raw(),
588                enabled as c_int,
589            )
590        }
591    }
592}