-
Notifications
You must be signed in to change notification settings - Fork 2
Interop ABI
The contract between WebRtcInterop.dll and WebRTCme.Bindings.Maui.Windows.
This is the layer Android and iOS get for free. Their bindings translate a Java or Objective-C SDK that Google already wrote; Windows has no such SDK, so it has to be built here. See Platform layers for why.
Two repositories, one contract:
WebRTCnative WebRTCme
webrtc.dll (Google's tree, clang + libc++)
WebRtcInterop.dll ── flat C exports ──► P/Invoke ──► Bindings.Maui.Windows ──► WebRTCme.Api
Changing this ABI means changing both sides together, so it is worth getting the conventions right before writing the second function.
webrtc.dll is compiled with clang against libc++ — the component build ships libc++.dll
beside it. Anything MSVC compiles has a different std::string, a different allocator and a
different exception model, so no C++ type and no C++ object may cross the boundary. That rules out
C++/CLI, and it rules out linking WebRTC's C++ headers from a Visual Studio project.
The consequence: the shim must be compiled by the same clang toolchain, inside the GN build. It
is grafted into the checkout at src/WebRtcInterop and built by
WebRtcNativeInteropWindows. It cannot live in WebRTCme.
Only these cross the boundary: int32_t, int64_t, double, pointers, const char* holding
UTF-8, and function pointers.
rtc_<object>_<verb>, lower snake case. Creation is rtc_<object>_create, disposal is
rtc_<object>_release. Everything is extern "C", exported with RTC_API and declared RTC_CALL (__cdecl on
Windows, so x86 works too).
Every object crosses as an opaque, forward-declared pointer — never void*, so the compiler still
catches mismatches:
typedef struct rtc_factory rtc_factory;
typedef struct rtc_peer_connection rtc_peer_connection;
typedef struct rtc_media_track rtc_media_track;On the C# side these are IntPtr wrapped in a SafeHandle per type.
Every handle you receive — through an out-parameter or through a callback — is yours, and must be passed to exactly one matching
_release.
Nothing else transfers ownership. No handle is freed as a side effect of another call, and
_release on a handle you did not receive is undefined.
The callback half of that rule exists for on_track, which delivers a rtc_media_track* the
receiver must release. Handles are the one thing a callback hands over rather than lends; strings
passed to a callback stay borrowed.
This rule exists because the 2023 attempt died without one: CreateBuiltinAudioEncoderFactory
returned the address of a stack scoped_refptr, and CallCreatePeerConnectionFactory was
abandoned because there was no answer to who owned the result. Internally the shim keeps WebRTC's
scoped_refptr alive in a heap struct; the handle is a pointer to that struct, and _release
destroys it. Reference counting stays entirely on the C++ side.
Every function returns a status. Results come back through out-parameters.
typedef int32_t rtc_status;
#define RTC_OK 0
#define RTC_ERR_INVALID_ARG -1
#define RTC_ERR_INVALID_STATE -2
#define RTC_ERR_NOT_FOUND -3
#define RTC_ERR_UNSUPPORTED -4
#define RTC_ERR_INTERNAL -5The shim is written exception-free, not exception-guarded. WebRTC compiles with
-fno-exceptions, and so does anything grafted into its build — a try block is a compile error:
error: cannot use 'try' with exceptions disabled
That is not a restriction to work around. With exceptions disabled nothing in the process can
throw, so there is nothing to catch: an exception cannot unwind into P/Invoke because one cannot
arise. Allocation that may fail uses new (std::nothrow) and is checked, returning
RTC_ERR_INTERNAL.
An earlier draft of this page prescribed wrapping every entry point in try/catch(...). That was
written before the first build and is wrong.
UTF-8, always. Strings into the shim are borrowed for the duration of the call — the shim copies what it needs. Strings out of the shim are heap-allocated and owned by the caller:
void rtc_string_free(char* s);SDP blobs are the reason for allocate-and-free rather than a caller buffer: they are large, variable, and a two-call length probe doubles the P/Invoke count on the hottest path.
var sdp = Marshal.PtrToStringUTF8(sdpPtr);
NativeMethods.rtc_string_free(sdpPtr);A function pointer plus a void* user_data, always in that order, user_data first in the
signature:
typedef void (*rtc_on_ice_candidate_fn)(void* user_data,
const char* mid,
int32_t mline_index,
const char* sdp);Strings passed to a callback are owned by the shim and valid only for the duration of the call. Copy before returning.
On the C# side these are [UnmanagedCallersOnly] static methods, never delegates — a delegate
needs pinning and is the classic source of "worked in debug, crashed in release". Instance context
travels as a GCHandle:
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
static void OnIceCandidate(IntPtr ctx, IntPtr mid, int mline, IntPtr sdp)
{
var self = (PeerConnection)GCHandle.FromIntPtr(ctx).Target!;
self.RaiseIceCandidate(Marshal.PtrToStringUTF8(mid), mline, Marshal.PtrToStringUTF8(sdp));
}rtc_initialize holds a WinsockInitializer for the library's lifetime. Without it every UDP
socket fails with WSANOTINITIALISED, and the failure is quiet in a way that wastes an afternoon:
ICE still gathers TCP placeholder candidates, addIceCandidate still returns success, the
connection still reaches checking — and then never leaves it.
Set WEBRTC_INTEROP_VERBOSE to route WebRTC's own logging to stderr. The ICE layer is silent
otherwise, and that variable is how this was found.
Callbacks arrive on WebRTC's signalling thread, not yours. That thread must not be blocked; stalling it stalls the whole connection.
So a handler does one thing: marshal the data into managed memory and hand it off. WebRTCme.Middleware
is Rx-based, so the natural landing point is subject.OnNext(...) with observation moved to the UI
scheduler downstream. No await, no locks held across the call, no dispatcher round-trip inside
the handler.
Reentrancy: calling back into the shim from inside a callback is allowed for the state-free calls
(rtc_string_free, getters), and undefined for anything that mutates the peer connection. Queue
those.
A peer connection needs many callbacks, so they are registered as one struct of function pointers
at creation time. It is blittable, so C# declares it with IntPtr fields holding
&Method addresses.
typedef struct {
rtc_on_ice_candidate_fn on_ice_candidate;
rtc_on_connection_state_fn on_connection_state;
rtc_on_signaling_state_fn on_signaling_state;
rtc_on_track_fn on_track;
rtc_on_renegotiation_needed_fn on_renegotiation_needed;
} rtc_peer_connection_observer;Null members are permitted and simply not raised. The struct is copied at registration; the caller need not keep it alive.
Every struct is blittable on x64 — no [MarshalAs], no custom marshaller, no packing attribute.
Confirmed by compiling the header with the same clang-cl that builds webrtc.dll
(clang 23, x86_64-pc-windows-msvc):
| Type | Size | Field offsets |
|---|---|---|
rtc_status |
4 | — |
rtc_peer_connection_state |
4 | — |
rtc_ice_server |
24 |
urls 0, username 8, password 16 |
rtc_configuration |
16 |
ice_servers 0, ice_server_count 8 |
rtc_video_frame |
56 |
y 0, width 36, timestamp_us 48 |
rtc_peer_connection_observer |
48 | six function pointers, on_data_channel at 40 |
rtc_data_channel_init |
32 |
protocol 0, ordered 8, max_packet_life_time 12, max_retransmits 16, negotiated 20, id 24 |
rtc_data_channel_observer |
24 | three function pointers |
The header also compiles clean as C11 and as C++17 under /W4, so it can be consumed by a C
caller, a C++ caller, or read as documentation without a toolchain.
The smallest surface that carries an audio and video call between two Windows peers. Twenty-five
functions, declared in WebRtcInterop/include/Interop.h.
All twenty-five are implemented, and data channels have since been added on top — see below.
Six tests in WebRtcInterop/test/ cover the surface: Handshake.c drives a full offer/answer/ICE
exchange between two peer connections, FrameSink.c opens a camera and checks the delivered
frames, Devices.c enumerates every device kind and exercises the error paths, and
DataChannel.c opens a channel across a handshake and sends both ways, and Sender.c
replaces and removes a track on a live sender, and
DesktopCapture.c enumerates shareable sources and checks captured frames.
rtc_status rtc_initialize(void);
rtc_status rtc_terminate(void);
void rtc_string_free(char* s);rtc_initialize calls webrtc::InitializeSSL and starts the network, worker and signalling
threads; rtc_terminate stops them in reverse and cleans up SSL. Call once each, at assembly load
and unload. Calling either twice returns RTC_ERR_INVALID_STATE.
rtc_status rtc_factory_create(rtc_factory** out_factory);
void rtc_factory_release(rtc_factory* factory);Before rtc_initialize it returns RTC_ERR_INVALID_STATE; with a null out-parameter,
RTC_ERR_INVALID_ARG. rtc_factory_release(NULL) is a no-op, so a failed create needs no special
case.
Wraps CreatePeerConnectionFactory with the builtin audio and video encoder and decoder factories.
Note that on Windows the builtin video factory means VP8, VP9 and AV1 only — there is no H.264
in a standalone build. See Platform layers.
rtc_status rtc_video_device_count(rtc_factory* f, int32_t* out_count);
rtc_status rtc_video_device_info(rtc_factory* f, int32_t index,
char** out_name, char** out_id);
rtc_status rtc_audio_device_count(rtc_factory* f, rtc_audio_device_kind kind,
int32_t* out_count);
rtc_status rtc_audio_device_info(rtc_factory* f, rtc_audio_device_kind kind,
int32_t index,
char** out_name, char** out_id);Video is backed by modules/video_capture (DirectShow on Windows). Output strings are
caller-owned; an out-of-range index returns RTC_ERR_NOT_FOUND.
kind is RTC_AUDIO_DEVICE_RECORDING or RTC_AUDIO_DEVICE_PLAYOUT, mirroring W3C's
audioinput / audiooutput split.
Audio needs a live AudioDeviceModule, so rtc_factory_create builds one with
CreateAudioDeviceModule and hands that same instance to the peer connection factory. Enumeration
then reports the devices the engine will actually use, rather than a second module's view of them.
The module has thread affinity to the worker thread, so it is created there and every call hops
back with BlockingCall. A machine with no usable audio device is not fatal: the factory falls
back to building its own, and enumeration reports RTC_ERR_INTERNAL.
Some drivers report an empty endpoint GUID, so out_id falls back to the name — the caller always
has something to select with.
rtc_video_track_create distinguishes two failures that look identical from the outside.
RTC_ERR_NOT_FOUND means no device matches the id. RTC_ERR_INVALID_STATE means the device is
there but would not start, which is nearly always another application holding the camera. They
were originally both NOT_FOUND, and that cost real time twice in one afternoon: a camera busy on
one machine and a camera not yet enumerated on another both reported as missing hardware, so the
search started in the wrong place. test/FrameSink.c now asserts the pair by opening the camera a
second time while it is live.
rtc_status rtc_audio_track_create(rtc_factory* f, const char* label,
rtc_media_track** out_track);
rtc_status rtc_video_track_create(rtc_factory* f, const char* device_id,
const char* label,
int32_t width, int32_t height, int32_t fps,
rtc_media_track** out_track);
rtc_status rtc_media_track_set_enabled(rtc_media_track* t, int32_t enabled);
rtc_status rtc_media_track_get_id(rtc_media_track* t, char** out_id);
void rtc_media_track_release(rtc_media_track* t);This is getUserMedia reduced to its parts: enumerate, then create a track from a chosen device.
The constraint negotiation WebRTCme.Api exposes is resolved on the C# side, which then asks for
concrete numbers.
label becomes the track id and must be SDP-safe. It exists because the first implementation
passed the device id and produced a track whose id was a Windows device path — backslashes, braces
and all — headed for the msid attribute.
Video capture has no ready-made source upstream: modules/video_capture produces frames through a
VideoSinkInterface while a track needs a VideoTrackSourceInterface, so the shim carries a small
CameraSource bridging the two through AdaptedVideoTrackSource. The camera opens when the track
is created and closes when the last reference to it goes away.
typedef struct {
const char* urls; // comma separated
const char* username; // nullable
const char* password; // nullable
} rtc_ice_server;
typedef struct {
const rtc_ice_server* ice_servers;
int32_t ice_server_count;
} rtc_configuration;
rtc_status rtc_peer_connection_create(rtc_factory* f,
const rtc_configuration* config,
const rtc_peer_connection_observer* observer,
void* user_data,
rtc_peer_connection** out_pc);
rtc_status rtc_peer_connection_close(rtc_peer_connection* pc);
void rtc_peer_connection_release(rtc_peer_connection* pc);close is separate from release: W3C close() is an observable state transition, and the handle
must stay valid for callbacks still in flight.
typedef void (*rtc_on_sdp_success_fn)(void* user_data, const char* type, const char* sdp);
typedef void (*rtc_on_sdp_failure_fn)(void* user_data, const char* error);
typedef void (*rtc_on_void_success_fn)(void* user_data);
rtc_status rtc_peer_connection_create_offer(rtc_peer_connection* pc,
rtc_on_sdp_success_fn on_success,
rtc_on_sdp_failure_fn on_failure,
void* user_data);
rtc_status rtc_peer_connection_create_answer(rtc_peer_connection* pc,
rtc_on_sdp_success_fn on_success,
rtc_on_sdp_failure_fn on_failure,
void* user_data);
rtc_status rtc_peer_connection_set_local_description(rtc_peer_connection* pc,
const char* type, const char* sdp,
rtc_on_void_success_fn on_success,
rtc_on_sdp_failure_fn on_failure,
void* user_data);
rtc_status rtc_peer_connection_set_remote_description(rtc_peer_connection* pc,
const char* type, const char* sdp,
rtc_on_void_success_fn on_success,
rtc_on_sdp_failure_fn on_failure,
void* user_data);
rtc_status rtc_peer_connection_add_ice_candidate(rtc_peer_connection* pc,
const char* mid, int32_t mline_index,
const char* sdp);
rtc_status rtc_peer_connection_add_track(rtc_peer_connection* pc,
rtc_media_track* track,
const char* stream_id);These four are asynchronous in WebRTC and stay asynchronous here — the return value only reports
whether the request was accepted. The C# side turns each into a TaskCompletionSource so
WebRTCme.Api can present the Task shape it already has.
typedef struct {
const uint8_t* y; int32_t stride_y;
const uint8_t* u; int32_t stride_u;
const uint8_t* v; int32_t stride_v;
int32_t width, height;
int64_t timestamp_us;
} rtc_video_frame;
typedef void (*rtc_on_frame_fn)(void* user_data, const rtc_video_frame* frame);
rtc_status rtc_video_track_add_sink(rtc_media_track* t,
rtc_on_frame_fn on_frame, void* user_data);
rtc_status rtc_video_track_remove_sink(rtc_media_track* t);Frames arrive on a WebRTC capture or decode thread and the planes belong to WebRTC for exactly the duration of the callback, so the handler must copy or convert and return. Anything slower than the frame interval drops frames or stalls decoding.
The shim calls ToI420() on the incoming buffer: a no-op when the frame already is I420, which is
the common case for a camera, and a conversion otherwise. The returned reference is held for the
callback, which is what keeps the planes alive.
One sink per track. Adding a second returns RTC_ERR_INVALID_STATE rather than silently replacing
the first and leaking its registration, and adding one to an audio track returns
RTC_ERR_INVALID_ARG. RemoveSink is synchronous, so once rtc_video_track_remove_sink returns
no further callback can be in flight. Releasing a track that still has a sink unregisters it in the
destructor rather than leaving WebRTC holding a pointer into freed memory.
Measured against a real camera: 85 frames in 2.80 s — 30.4 fps against a requested 30 — at 640x480 with strides y=640, u=320, v=320, no null planes and no blank rows.
Nine functions, added after slice one and following the same conventions.
rtc_peer_connection_create_data_channel(pc, label, init, &channel);
rtc_data_channel_set_observer(channel, &observer, user_data);
rtc_data_channel_send(channel, data, size, is_binary);
rtc_data_channel_get_label(channel, &label); /* caller frees */
rtc_data_channel_get_id(channel, &id); /* -1 until open */
rtc_data_channel_get_state(channel, &state);
rtc_data_channel_get_buffered_amount(channel, &amount);
rtc_data_channel_close(channel);
rtc_data_channel_release(channel);Create the channel before the offer and it rides along as an m=application section; create
it afterwards and on_renegotiation_needed fires, exactly as in the W3C API.
Three decisions are worth knowing before adding to this area.
The observer is registered separately, not passed to the create call. A channel that arrives
through on_data_channel does not exist until that callback runs, so there is nowhere to have
passed an observer. Making registration a separate step gives both cases one shape — and it means
an incoming channel must be observed from inside the callback, because the open transition can
follow immediately and is otherwise missed.
rtc_data_channel_init carries its optional members as int32_t with -1 for unset, rather
than pointers to values. It keeps the struct blittable for P/Invoke, and -1 is not a value any of
max_packet_life_time, max_retransmits or id can legitimately take.
on_data_channel was appended to rtc_peer_connection_observer, not inserted. The struct is
passed by address and read field by field, so the five original members keep their offsets and a
caller compiled against the shorter struct still works. Anything added here later must go on the
end for the same reason.
Sending is asynchronous. The status reports that the channel was open and the payload was
accepted — not that it was delivered — which matches rtc_peer_connection_create_offer here and
the W3C send(), and avoids WebRTC's own Send() bool that its header documents as unreliable.
Failures after acceptance are logged, not surfaced.
The handle unregisters its observer explicitly in its destructor rather than relying on member destruction order, because the peer connection holds its own reference and the channel can outlive the handle.
test/DataChannel.c covers it against a real handshake: m=application in the offer, both ends
reaching open with matching ids, text and binary each way with the binary flag intact, the error
paths, and release with a live observer still registered.
video_track_create takes a width, height and frame rate, and those are a preference. The
capture module answers an unsupported request with its nearest supported format and says nothing
about having done so, which is how a track ends up claiming a size it is not running at. Android
had the same trap recorded in WebRTCme's notes; a 720x1280 request opened a camera at 1088x1088.
Two changes make that visible rather than silent.
The pixel format is no longer pinned. CameraSource::Create used to name
VideoType::kI420. Few USB cameras publish I420 at the larger sizes — they publish MJPEG, NV12 or
YUY2 — so the module accepted the request, picked a nearby capability and converted every frame,
which for MJPEG is a full decode per frame on the thread that feeds the encoder. It now asks with
kUnknown and lets DeviceInfo::GetBestMatchedCapability score on size and rate alone. The
module still converts to the I420 the track wants, but from a format the device actually produces.
The chosen capability is recorded and can be read back:
rtc_video_track_get_settings(track, &width, &height, &frame_rate);W3C getSettings(), narrowed to the three a video track can answer for, and it maps onto
IMediaStreamTrack.GetSettings() on the managed side — which previously echoed the request back.
RTC_ERR_NOT_FOUND for an audio track or one that arrived from the remote peer, since neither has
a capture format. A desktop track reports its rate with a zero size: the screen's size is not
fixed until the first frame arrives.
test/FrameSink.c asks a real camera for 999x777@29 and reports what it gets — a Logitech
C615 answers 1600x896@30 — then asserts on the ordinary track that the reported size equals
the size the frames actually arrive at. That equality is the whole value of the call; without it
the numbers are decoration.
add_track gained an out-parameter and three functions joined it.
rtc_peer_connection_add_track(pc, track, stream_id, &sender); /* sender may be null */
rtc_rtp_sender_replace_track(sender, track); /* track may be null */
rtc_rtp_sender_get_parameters(sender, buffer, capacity, &count);
rtc_rtp_sender_set_parameters(sender, encodings, count);
rtc_peer_connection_remove_track(pc, sender);
rtc_rtp_sender_release(sender);This changed an existing signature, unlike the data channel slice which only appended. A
caller built against the four-argument add_track will not work against the three-argument one
and vice versa, so the shim and the binding have to move together. That is acceptable here
because they ship as a pair, but it is the reason the observer struct got a new member on the end
rather than the same treatment.
out_sender is nullable, and that is the normal case: a caller that will never replace or remove
the track passes null and has no handle to release. The track is added either way.
replace_track is the point of the slice. It swaps what a sender transmits without
renegotiating — no new offer, no interruption, the connection stays up. Doing the same thing as
remove-then-add would force a fresh offer/answer round and a visible gap. A null track is
meaningful rather than an error: it stops the sender while leaving the transport in place, which
is how muting is done at the sender rather than at the source.
remove_track is idempotent, which surprised the test before it surprised anyone else. W3C
removeTrack aborts quietly when the sender's track is already null, so a second removal returns
RTC_OK rather than an error. The test originally asserted RTC_ERR_INVALID_STATE and was wrong.
There is deliberately no get_senders. The caller already knows what it added, and a list
function would hand back handles whose ownership is ambiguous — every other handle here has
exactly one owner and one release.
test/Sender.c covers it against a live connection: replacing with a same-kind track, with null,
and back; a video track refused on an audio sender; the connection still connected and no
renegotiation raised by any of it; removal, second removal, the null-argument paths, and release.
get_parameters / set_parameters cap a sender mid-call — the "low data" case, where a weak link
needs a smaller picture, fewer frames and a bitrate ceiling without dropping the call.
The transaction does not cross the ABI, and that is the whole design decision. WebRTC rejects
a setParameters whose argument did not come from a recent getParameters on the same sender:
the parameters carry a transaction id, and a stale one fails at run time with
InvalidModification. So set_parameters takes only the encodings, does its own get, applies the
writable fields and sets the result back. A caller never holds a token it has to keep fresh. The
alternative — handing out an opaque handle the set consumes — would put that lifetime on every
caller for no gain, since the C# side's RTCRtpSendParameters is a plain object anyway.
Two further constraints WebRTC imposes, both enforced here rather than discovered later: the
number of encodings may not change between the get and the set (RTC_ERR_INVALID_ARG if it
differs), and only active, max_bitrate, max_framerate, scale_resolution_down_by and
scalability_mode are writable. rid is left as negotiation settled it.
get_parameters is the same two-call shape as get_transceivers: null buffer for the count, then
a buffer of at least that size, and nothing is written if it is too small. rid and
scalability_mode come back caller-owned — free them with rtc_string_free — and the numeric
sentinels match rtc_rtp_encoding, so a get feeds straight back into a set. A sentinel on a set
clears rather than being ignored, which is how a cap is lifted.
Not uniform, and worth knowing before trusting a stats field as evidence. Measured on M152 with
test/SenderParameters.c, the same binary run against a capturer with and without AdaptFrame:
| source ignores adaptation | source calls AdaptFrame
|
|
|---|---|---|
outbound-rtp.frameWidth with scale_resolution_down_by 2 |
320 | 320 |
media-source.width |
absent | 640 |
media-source.framesPerSecond with max_framerate 10 |
29 | 10 |
-
max_bitrate— the encoder. Holds regardless. -
scale_resolution_down_by— the encoder also scales, invideo_stream_encoder.cc, when a frame does not match the configured resolution. The picture therefore shrinks even for a source that ignores adaptation.frameWidthis no evidence about the source, which is the trap: it looks like proof and is not. -
max_framerate— the source's frame adapter, and only there. A source that never callsAdaptFramekeeps delivering at capture rate and the request is silently lost.
AdaptedVideoTrackSource::GetStats reports nothing until AdaptFrame has recorded a size, so an
absent media-source.width is the reliable signal that a capturer is handing frames straight
through — and the same omission means the encoder's requests for fewer pixels under CPU or
bandwidth pressure have nowhere to land. Both CameraSource and DesktopSource call it.
getDisplayMedia, taken apart the same way getUserMedia was: enumerate, then create a track
from a chosen source.
rtc_desktop_source_count(kind, &count); /* screen or window */
rtc_desktop_source_info(kind, index, &title, &id); /* title caller-frees */
rtc_desktop_track_create(factory, kind, id, label, max_fps, &track);WebRTC ships modules/desktop_capture but nothing that turns a DesktopCapturer into a video
track, so the shim supplies that bridge — the same gap CameraSource fills for the camera.
The source is identified by id, not index. The list is rebuilt on every call and windows come and go, so an index taken from one call means nothing in the next.
A DesktopCapturer must be created, used and destroyed on one thread, and that thread cannot
be the caller's, because capture has to keep running after rtc_desktop_track_create returns. So
the source owns a capture thread and does all three there. Creation waits for the capturer to come
up, which is what lets a bad source id fail the create call rather than yield a track that never
produces a frame.
The loop is paced to a deadline, not by sleeping after each capture. Capturing a 4K screen through GDI costs tens of milliseconds; adding that to the interval halved the delivered rate. Measured on a 3840x2160 screen: 8.5 fps against a requested 15 before the fix, 15.4 after.
is_screencast() returns true, which shifts the encoder's degradation preference towards
holding resolution rather than frame rate. Screen content is read, not watched — text staying
legible when the link tightens is the whole difference between a useful shared screen and a
useless one.
Windows uses the GDI capturers. DirectX and Windows Graphics Capture are faster but need COM or WinRT initialised on the capture thread, which is a larger contract than this owes its caller.
test/DesktopCapture.c enumerates both kinds, captures a screen, and checks the frames — the
conversion from BGRA to I420 is ours rather than a capture module's, so a stride or plane mistake
would give a sheared or grey image that every status code would still call success.
typedef void (*rtc_on_stats_success_fn)(void* user_data, const char* json);
rtc_status rtc_peer_connection_get_stats(rtc_peer_connection* pc,
rtc_on_stats_success_fn on_success,
rtc_on_failure_fn on_failure,
void* user_data);Asynchronous, like negotiation: the return value says only that collection started, and the report arrives on the signalling thread.
The report crosses as JSON, and that is a deliberate choice rather than a shortcut. A stats
report is a heterogeneous bag — every type carries a different member set, defined by the
WebRTC Statistics specification, which versions separately
from webrtc-pc and gains and loses members between milestones. Modelling it structurally would
mean a report handle, a stats handle, an attribute type enum and an accessor per type: dozens of
exports, all of which would need revisiting whenever WebRTC adds a member. WebRTC already
serialises the report itself through RTCStatsReport::ToJson, so this is a copy rather than a
translation, and the managed side already parses JSON for the Blazor binding.
Two details that will otherwise be found by debugging:
-
Timestamps are microseconds, as WebRTC reports them, not the milliseconds a W3C
DOMHighResTimeStampuses. The conversion is left to the caller because the caller knows which of the two it wants;WebRTCmedivides by 1000 on Android and iOS for the same reason. -
on_failureis nearly dead. WebRTC's collection has no failure path, and a closed peer connection yields an empty report rather than an error, so it is raised only if no report arrives at all. It exists for symmetry with the negotiation calls. -
An empty report serialises to an empty string, not to
[], so the shim substitutes one. A caller should never receive a payload that is not JSON.
The shape below is read off M152 (branch-heads/7977), from api/stats/rtc_stats.cc and
api/stats/attribute.cc, rather than assumed:
[{"type":"outbound-rtp","id":"OT01A123","timestamp":1738000000000000,"bytesSent":4096,...},...]
RTCStats::ToJson writes type, id and timestamp first and always, then every attribute that
has_value(); absent attributes are omitted rather than emitted as null. Strings are quoted,
vector attributes become JSON arrays, and map attributes — qualityLimitationDurations and its
kind — become nested JSON objects. The payload is valid JSON throughout.
One upstream quirk worth knowing: 64-bit integers are printed through %.16g as doubles, so a
counter above 2^53 loses precision before it ever reaches the caller. That is WebRTC's choice, made
because JSON has no integer type, and nothing here can recover it.
ToJson is still not part of the ABI this repository controls, so re-check it when the milestone
moves. test/Stats.c prints the first 400 characters for exactly that.
test/Stats.c asserts the shape rather than the numbers: that the payload is an array, that every
entry carries id, type and timestamp, and that a connection with media flowing produces
outbound-rtp and candidate-pair entries. The managed parser keys on id and reads the other
two off every entry, so a missing one is a real break that an empty C# dictionary would otherwise
be the first sign of.
getUserMedia constraint negotiation, insertable streams, DTMF, ICE restart, audio device
selection (enumeration works; audio_track_create takes no device id), and an
on_ice_gathering_state callback — without which a caller can observe gathering starting but
never completing. Each is additive and none changes the conventions above.
-
Design against
WebRTCme.Apifirst. Its shape is fixed and mirrors W3C; the ABI exists to serve it, not the other way round. -
webrtc-sdk/libwebrtc is a useful blueprint but not a dependency. It
is MIT and its header set maps cleanly onto W3C, so it is worth reading for API shape. It is not
adopted because it builds against webrtc-sdk's fork, pinned to
m150_releasewith a 363-line core-audio patch per milestone — which would break the branch auto-detection this repository is built around. -
The shim is platform-neutral C++. Linux needs a new workflow, not new interop code, and
Bindings.Maui.Windowsbecomes the template for the Linux binding. -
Build before believing the page. Three things in this contract were wrong until the first
DLL was produced: the exception guard above, the assumption that
rtc::names still exist (M152 moved everything intonamespace webrtc), and the omission ofWEBRTC_LIBRARY_IMPLfrom the GN defines — without whichRTC_EXPORTbecomesdllimport, and lld-link rejects every call to WebRTC withLNK4217. - Verify the ownership rule with a leak test before growing the surface. Create and release a thousand peer connections and watch the process working set. Getting this wrong is cheap to fix at twenty functions and expensive at two hundred.
-
Repository layout — where
WebRtcInterop/sits and what it contains today - Workflow reference — how the shim is grafted into the WebRTC build
- Platform layers — why Windows has no SDK to bind to
Using it
Building on it
Reference
When it breaks