reactor-webrtc (Python)
Python bindings for the reactor-webrtc WebRTC engine, built with
PyO3 and distributed as a self-contained wheel — no separate
native library required at runtime.
Installation
pip install reactor-webrtc
Requires Python ≥ 3.10.
Quick start
PeerConnection's signaling methods are natively awaitable, so this runs
inside an asyncio event loop:
import asyncio
import reactor_webrtc as rw
async def main():
factory = rw.PeerConnectionFactory()
obs = rw.PeerConnectionObserver()
obs.on_ice_candidate = lambda c: relay_to_peer(c)
obs.on_connection_state_change = lambda s: print("state:", s)
config = rw.RtcConfiguration(ice_servers=[
rw.IceServer(urls=["stun:stun.l.google.com:19302"]),
# A turn:/turns: entry needs both credentials, or libwebrtc rejects the
# whole configuration.
rw.IceServer(urls=["turn:turn.example.com:3478"], username="alice", password="secret"),
])
pc = factory.create_peer_connection(config, obs)
offer = await pc.create_offer()
await pc.set_local_description(offer)
# Exchange offer.sdp with the remote peer via your signaling channel, then:
# await pc.set_remote_description(remote_answer)
# await pc.add_ice_candidate(candidate)
asyncio.run(main())
Audio
# Headless / server: push PCM programmatically (synthetic ADM, default)
factory = rw.PeerConnectionFactory()
factory.push_audio_frame(pcm_bytes, sample_rate=48000, channels=1)
# Desktop client: real mic + AEC3 + noise suppression
factory = rw.PeerConnectionFactory(
platform_adm=True,
echo_canceller=True,
noise_suppression=True,
)
# Per-peer track, fed directly: pass a capture time so the receiver can line
# this audio up with the video produced alongside it.
audio = factory.create_audio_track_with_local_source("mic")
now = rw.time_micros()
video.push_video_frame(bgra, 320, 240, capture_time_us=now)
audio.push_pcm(pcm_bytes, 48000, 1, capture_time_us=now)
An audio track's RTP timestamp counts the samples it has been handed, so it
says how much audio exists but not when it happened — hence capture_time_us,
and hence the rule that a producer owes the track a frame (silence, if it has
nothing) on every tick. See docs/av-sync.md.
Factory builder + per-track options
Every process-physical choice composes on one builder; every per-track choice arrives as kwargs on the track creation:
builder = rw.PeerConnectionFactoryBuilder()
builder.with_platform_adm() # real mic + AEC3/NS/AGC/high_pass
builder.with_metadata(False) # factory-wide frame-metadata kill switch
factory = builder.build()
# Raw video with an H.264 backend chosen per track:
camera = factory.create_video_track_with_options(
"cam", h264_backend=rw.H264Backend.VideoToolbox)
# Pre-encoded video (previously with_encoded_video_track's factory):
screen = factory.create_video_track_with_options(
"screen", pre_encoded=(1920, 1080))
# Encoder feedback: keyframe requests and BWE rate updates:
screen.on_encoder_feedback(
lambda fb: push_an_idr_soon() if isinstance(fb, rw.KeyFrameRequest)
else adapt(bitrate=fb.bitrate_bps))
# Music next to the mic — a per-track push pipe:
music = factory.create_audio_track_with_options(
"music", source=rw.AudioTrackSource.LocalPush,
echo_cancellation=False, noise_suppression=False)
Pre-encoded video
factory, video = rw.PeerConnectionFactory.with_encoded_video_track(
"camera", width=1280, height=720
)
pc = factory.create_peer_connection(config, obs)
tx = video.add_transceiver(pc, rw.TransceiverDirection.SendOnly)
# From your encoder thread:
video.push_encoded_frame(
data=h264_annex_b,
is_key_frame=True,
width=1280, height=720,
)
Codec preferences
tx = pc.add_transceiver(rw.MediaKind.Video, rw.TransceiverDirection.SendOnly)
await tx.set_track(video_track)
await tx.set_codec_preferences([rw.VideoCodec.Vp9, rw.VideoCodec.Vp8])
answer = await pc.create_answer()
await pc.set_local_description(answer)
set_local_description/set_remote_description also make this
transceiver's own sender actually encode with whichever preferred codec was
negotiated, once negotiation completes — not just list it first in the SDP.
No further call needed.
Receiving media
obs = rw.PeerConnectionObserver()
def on_track(kind, track):
if kind == rw.MediaKind.Video:
track.on_video_frame(lambda bgra, w, h: display(bgra, w, h))
elif kind == rw.MediaKind.Audio:
track.on_audio_frame(lambda pcm, sr, ch, n: play(pcm))
obs.on_track = on_track
Stats
report = await pc.get_stats()
for pair in report.candidate_pairs:
print(pair.state, f"{pair.current_round_trip_time_s * 1000:.1f}ms")
API reference
| Class | Description |
|---|---|
PeerConnectionFactory |
Entry point; creates peer connections and tracks |
PeerConnection |
SDP offer/answer, ICE, tracks, data channels, stats |
PeerConnectionObserver |
Callbacks: state, ICE candidate, track, data channel |
RtcConfiguration |
ICE servers, ICE transport type, gathering policy |
IceServer |
A STUN or TURN server entry |
IceCandidate |
A trickled ICE candidate |
SessionDescription |
SDP offer or answer (kind, sdp, ice_ufrags, with_ice_credentials, declares_frame_metadata, with_frame_metadata) |
FrameMetadata |
Per-frame frame_id, timestamp, user_data |
FrameMetadataGate |
What the remote declared about per-frame metadata |
Track |
Local (push frames) or remote (attach sink) media track; push_video_frame and push_pcm take an optional capture_time_us |
EncodedVideoTrack |
Push pre-encoded video (H.264 Annex-B, VP8, VP9, …) |
Transceiver |
RTP m-section: mid, kind, set_track, set_direction, set_codec_preferences, set_sender_transform, set_receiver_transform |
DataChannel |
SCTP data channel: send, on_message, on_open, … |
StatsReport |
inbound_rtp, outbound_rtp, candidate_pairs |
FrameMetadata, FrameAction, EncodedFrame, FrameTransform |
Per-frame metadata trailers and custom encoded-frame transforms — see docs/frame-metadata.md |
| Function | Description |
|---|---|
time_micros() |
The engine's monotonic clock in µs — the epoch every capture_time_us is read in, see docs/av-sync.md |
| Enum | Values |
|---|---|
PeerConnectionState |
New, Connecting, Connected, Disconnected, Failed, Closed |
IceGatheringState |
New, Gathering, Complete |
TransceiverDirection |
SendRecv, SendOnly, RecvOnly, Inactive |
VideoCodec |
Vp8, Vp9, Av1, H264, H265 |
MediaKind |
Audio, Video |
DataChannelState |
Connecting, Open, Closing, Closed |
IceCandidatePairState |
Waiting, InProgress, Failed, Succeeded, Cancelled |
| String-valued field | Values |
|---|---|
RtcConfiguration.ice_transport_type |
all (default), relay, no_host, none |
RtcConfiguration.continual_gathering_policy |
once (default), continually |
RtcConfiguration.bundle_policy |
Balanced (default), MaxBundle, MaxCompat |
RtcConfiguration.tcp_candidate_policy |
Disabled (default), Enabled |
RtcConfiguration also takes a min_port/max_port pair (UDP port range),
ice_connection_receiving_timeout_ms, and
ice_check_interval_strong_connectivity_ms; PeerConnection.set_bitrate
sets congestion-control bitrate limits after the connection is created. All
covered in docs/configuration.md.
Per-frame metadata
Arbitrary bytes can ride alongside each encoded video frame, in a protobuf trailer appended to the payload:
video.push_video_frame(bgra, 320, 240, user_data=b"anything you like")
def on_frame(bgra, w, h, meta):
if meta is not None:
print(meta.frame_id, meta.timestamp, meta.user_data)
track.on_video_frame(on_frame)
That only works if the far end strips the trailer before its decoder sees it, so support is negotiated in the SDP and you do not have to do anything for it:
create_offeradvertises the capability as one session-levela=x-reactor-frame-metadata:1(rw.FRAME_METADATA_ATTRIBUTE,rw.FRAME_METADATA_VERSION).create_answermirrors an offer that asked for it.set_remote_descriptionarmspc.frame_metadata_gate()and, when it is open, installs the embed and strip transforms on the video transceivers. The sender transform still checks the gate per frame, so a renegotiation that drops support stops the trailers.
A peer that has never heard of the attribute ignores it, the gate stays closed,
and user_data is silently dropped rather than corrupting that peer's decode.
Read pc.frame_metadata_gate().is_open() if you want to know whether the peer
agreed.
Read the declaration from the signalled SDP string, not from pc.remoteDescription
or its equivalents: libwebrtc and browsers both discard a= lines they do not
recognise while parsing.
A FrameTransform of your own composes with the metadata step rather than
displacing it — the library owns libwebrtc's single transformer slot per
sender/receiver and runs both. Your callback goes first in both directions, so it
sees exactly the bytes that traverse the network.
To keep frame metadata out of a connection entirely:
config = rw.RtcConfiguration(frame_metadata=False)
No a=extmap, no mirroring, no transforms, and user_data is dropped — the
connection is indistinguishable from one built before the capability existed.
Choosing your own ICE credentials
libwebrtc generates the ICE ufrag and password itself and offers no setter. If something upstream needs to recognise a session by its ufrag — an edge relay that demultiplexes on it, say — substitute the credentials in the description before setting it locally:
answer = await pc.create_answer()
answer = answer.with_ice_credentials(my_ufrag, my_password)
await pc.set_local_description(answer)
answer.ice_ufrags() # ["<my_ufrag>", ...] — one per m-section
The local description is what libwebrtc reads the transport's ICE parameters from, so the substituted values are the ones that end up on the wire.
Two things to get right:
- Order. Setting the local description is what creates the transport and starts gathering, so substituting afterwards acts on nothing.
- Renegotiation. Changing the credentials between generations is an ICE restart (RFC 8445 §9). On a renegotiation that is not meant to restart ICE, pass the values the session already uses.
Raises if a value is outside RFC 8445's ranges (ufrag 4–256 characters, password
22–256) or contains anything outside ice-char — which is also what stops a
newline in a credential from injecting an SDP line.
Thread safety
PeerConnection's signaling methods (create_offer, create_answer,
set_local_description, set_remote_description, add_ice_candidate,
get_stats, transceivers) plus set_bitrate are natively awaitable —
await them directly, no asyncio.to_thread()/executor wrapping needed.
They still take a few milliseconds to resolve while the WebRTC engine
responds, but that wait happens off the event loop thread, so it never
blocks other coroutines. On Transceiver, set_direction, set_track,
and set_codec_preferences are the same way.
Every other method (add_track, add_transceiver, create_data_channel,
Transceiver.set_sender_transform/set_receiver_transform, and everything
on Track/DataChannel) is a fast synchronous call with no native
round-trip, and stays a plain function call — no await.
Callbacks fire on WebRTC internal threads with the GIL acquired; keep them fast.
License
Apache-2.0. Upstream WebRTC is BSD-3-Clause + the WebRTC patent grant.
Metadata
Release files for reactor-webrtc 0.14.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Built distributions (wheels)
| File | Reset | |||
|---|---|---|---|---|
| reactor_webrtc-0.14.1-cp310-abi3-win_amd64.whl | CPython 3.10 | abi3 | Windows x86-64 | Details |
| reactor_webrtc-0.14.1-cp310-abi3-manylinux_2_34_x86_64.whl | CPython 3.10 | abi3 | Linux glibc 2.34+ x86-64 | Details |
| reactor_webrtc-0.14.1-cp310-abi3-manylinux_2_34_aarch64.whl | CPython 3.10 | abi3 | Linux glibc 2.34+ ARM64 | Details |
| reactor_webrtc-0.14.1-cp310-abi3-macosx_13_0_x86_64.whl | CPython 3.10 | abi3 | macOS 13.0+ x86-64 | Details |
| reactor_webrtc-0.14.1-cp310-abi3-macosx_11_0_arm64.whl | CPython 3.10 | abi3 | macOS 11.0+ ARM64 | Details |
Total release size: 42.1 MB
Release files / reactor_webrtc-0.14.1-cp310-abi3-win_amd64.whl
| Download URL | reactor_webrtc-0.14.1-cp310-abi3-win_amd64.whl |
|---|---|
| Size | 8.2 MB |
| Tags | CPython 3.10 Windows x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
da5a98074ba267ed50ec9412384d45ec4946e7a86884f1eb94ba64cc178b7511
|
|
BLAKE2b-256 checksum How to use checksums |
ff0e95f014c1e139c86c30dae4a46c1bd3eb358ebad49ea7d9dc368a11cd44f9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 28, 2026.
Transparency logRelease files / reactor_webrtc-0.14.1-cp310-abi3-manylinux_2_34_x86_64.whl
| Download URL | reactor_webrtc-0.14.1-cp310-abi3-manylinux_2_34_x86_64.whl |
|---|---|
| Size | 9.2 MB |
| Tags | CPython 3.10 Linux glibc 2.34+ x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
ebd08580b9027b5f9fc51adce40327065f31b35ff0b6e2d0aad6caf910fac4cc
|
|
BLAKE2b-256 checksum How to use checksums |
8f3311108bd786ed1caeeba339da8a4b528816e4ca7f3cb026b8a83f43e824f9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 28, 2026.
Transparency logRelease files / reactor_webrtc-0.14.1-cp310-abi3-manylinux_2_34_aarch64.whl
| Download URL | reactor_webrtc-0.14.1-cp310-abi3-manylinux_2_34_aarch64.whl |
|---|---|
| Size | 8.7 MB |
| Tags | CPython 3.10 Linux glibc 2.34+ ARM64 abi3 |
|
SHA-256 checksum How to use checksums |
aeb5e4fb0a679672ca31e852af06cfeee7a6949bf92ae8f8fae84d39879bf4ad
|
|
BLAKE2b-256 checksum How to use checksums |
ff40b93af9f7aeea85607df1abdea350b0a4ad0afcdda0eaedcf5b9e74a9daca
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 28, 2026.
Transparency logRelease files / reactor_webrtc-0.14.1-cp310-abi3-macosx_13_0_x86_64.whl
| Download URL | reactor_webrtc-0.14.1-cp310-abi3-macosx_13_0_x86_64.whl |
|---|---|
| Size | 8.5 MB |
| Tags | CPython 3.10 abi3 macOS 13.0+ x86-64 |
|
SHA-256 checksum How to use checksums |
5c9059725c8802bc69512f6a6ff4776f9fe7bb03ce77726e5ef90f8a2418ada3
|
|
BLAKE2b-256 checksum How to use checksums |
6a29e0575a1eca9d2459efdbc1361e9d765efadb9a7fe74eb102d9480d0b3f64
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 28, 2026.
Transparency logRelease files / reactor_webrtc-0.14.1-cp310-abi3-macosx_11_0_arm64.whl
| Download URL | reactor_webrtc-0.14.1-cp310-abi3-macosx_11_0_arm64.whl |
|---|---|
| Size | 7.5 MB |
| Tags | CPython 3.10 abi3 macOS 11.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
76c39ff5a70d466146ea9b9478580d6abe2b6feba5e950c39dfbdec7e2e774d7
|
|
BLAKE2b-256 checksum How to use checksums |
1a2727b74697e803a2d21041c1bfe5b1d4ff7895a829240091cea6b68c1542c6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 28, 2026.
Transparency log