Skip to content

Commit 64754d9

Browse files
authored
Remove the usage of the forked aiortc-getstream lib (#221)
1 parent efc3da8 commit 64754d9

File tree

6 files changed

+1788
-1380
lines changed

6 files changed

+1788
-1380
lines changed

getstream/video/rtc/__init__.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,29 @@
22
from typing import Optional
33

44
from getstream.video.async_call import Call
5+
from getstream.video.rtc.audio_track import AudioStreamTrack
6+
from getstream.video.rtc.connection_manager import ConnectionManager
7+
from getstream.video.rtc.connection_utils import join_call_coordinator_request
8+
from getstream.video.rtc.g711 import (
9+
G711Encoding,
10+
G711Mapping,
11+
)
512
from getstream.video.rtc.location_discovery import (
6-
HTTPHintLocationDiscovery,
7-
HEADER_CLOUDFRONT_POP,
813
FALLBACK_LOCATION_NAME,
14+
HEADER_CLOUDFRONT_POP,
915
STREAM_PROD_URL,
16+
HTTPHintLocationDiscovery,
1017
)
1118
from getstream.video.rtc.models import (
19+
Credentials,
1220
JoinCallRequest,
1321
JoinCallResponse,
1422
ServerCredentials,
15-
Credentials,
1623
)
17-
from getstream.video.rtc.connection_utils import join_call_coordinator_request
18-
from getstream.video.rtc.connection_manager import ConnectionManager
19-
from getstream.video.rtc.audio_track import AudioStreamTrack
2024
from getstream.video.rtc.track_util import (
25+
AudioFormat,
2126
PcmData,
2227
Resampler,
23-
AudioFormat,
24-
)
25-
from getstream.video.rtc.g711 import (
26-
G711Encoding,
27-
G711Mapping,
2828
)
2929

3030
logger = logging.getLogger(__name__)
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import logging
2+
import os
3+
from typing import Optional
4+
5+
from aiortc import RTCRtpCodecParameters
6+
from aiortc.codecs.h264 import H264Encoder
7+
from aiortc.codecs.vpx import Vp8Encoder
8+
from aiortc.rtcrtpsender import RTCEncodedFrame, RTCRtpSender
9+
10+
logger = logging.getLogger(__name__)
11+
12+
# Stream video bitrate overrides (applied per-connection, not globally).
13+
# These are higher than aiortc defaults to ensure acceptable video quality.
14+
STREAM_VIDEO_DEFAULT_BITRATE = 2_500_000 # 2.5 Mbps
15+
STREAM_VIDEO_MIN_BITRATE = 1_500_000 # 1.5 Mbps
16+
STREAM_VIDEO_MAX_BITRATE = 3_000_000 # 3 Mbps
17+
18+
19+
# Check if the Stream bitrate patching is disabled via environment variable
20+
BITRATE_PATCH_DISABLED = os.getenv(
21+
"STREAM_PATCH_AIORTC_BITRATES", ""
22+
).lower().strip() in (
23+
"0",
24+
"false",
25+
"no",
26+
"off",
27+
)
28+
29+
30+
try:
31+
# Verify the name-mangled attributes we depend on still exist.
32+
assert hasattr(Vp8Encoder(), "_Vp8Encoder__target_bitrate")
33+
assert hasattr(H264Encoder(), "_H264Encoder__target_bitrate")
34+
35+
class StreamVp8Encoder(Vp8Encoder):
36+
"""Vp8Encoder subclass with higher bitrate bounds for Stream calls."""
37+
38+
def __init__(self) -> None:
39+
super().__init__()
40+
self._Vp8Encoder__target_bitrate = STREAM_VIDEO_DEFAULT_BITRATE
41+
42+
@property
43+
def target_bitrate(self) -> int:
44+
return self._Vp8Encoder__target_bitrate
45+
46+
@target_bitrate.setter
47+
def target_bitrate(self, bitrate: int) -> None:
48+
bitrate = max(
49+
STREAM_VIDEO_MIN_BITRATE, min(bitrate, STREAM_VIDEO_MAX_BITRATE)
50+
)
51+
self._Vp8Encoder__target_bitrate = bitrate
52+
53+
class StreamH264Encoder(H264Encoder):
54+
"""H264Encoder subclass with higher bitrate bounds for Stream calls."""
55+
56+
def __init__(self) -> None:
57+
super().__init__()
58+
self._H264Encoder__target_bitrate = STREAM_VIDEO_DEFAULT_BITRATE
59+
60+
@property
61+
def target_bitrate(self) -> int:
62+
return self._H264Encoder__target_bitrate
63+
64+
@target_bitrate.setter
65+
def target_bitrate(self, bitrate: int) -> None:
66+
bitrate = max(
67+
STREAM_VIDEO_MIN_BITRATE, min(bitrate, STREAM_VIDEO_MAX_BITRATE)
68+
)
69+
self._H264Encoder__target_bitrate = bitrate
70+
71+
except Exception:
72+
logger.warning(
73+
"Failed to patch aiortc video encoder subclasses with Stream bitrate values (aiortc internals may have changed), "
74+
"falling back to default aiortc bitrates. \n"
75+
"Set STREAM_PATCH_AIORTC_BITRATES=0 to disable patching.",
76+
exc_info=True,
77+
)
78+
StreamVp8Encoder = None # type: ignore[assignment, misc]
79+
StreamH264Encoder = None # type: ignore[assignment, misc]
80+
81+
82+
def patch_sender_encoder(sender: RTCRtpSender) -> None:
83+
"""Patch a video sender to use Stream's higher-bitrate encoders.
84+
85+
If anything goes wrong (e.g. aiortc internals changed), the sender
86+
is left untouched and will use the stock encoder via get_encoder().
87+
"""
88+
if StreamVp8Encoder is None or StreamH264Encoder is None:
89+
return
90+
91+
try:
92+
_orig_next = sender._next_encoded_frame
93+
94+
async def _next_with_stream_encoder(
95+
codec: RTCRtpCodecParameters, _orig_next=_orig_next
96+
) -> Optional[RTCEncodedFrame]:
97+
if sender._RTCRtpSender__encoder is None: # type: ignore[attr-defined]
98+
mime = codec.mimeType.lower()
99+
if mime == "video/vp8":
100+
sender._RTCRtpSender__encoder = StreamVp8Encoder() # type: ignore[attr-defined]
101+
elif mime == "video/h264":
102+
sender._RTCRtpSender__encoder = StreamH264Encoder() # type: ignore[attr-defined]
103+
return await _orig_next(codec)
104+
105+
sender._next_encoded_frame = _next_with_stream_encoder # type: ignore[method-assign]
106+
except Exception:
107+
logger.warning(
108+
"Failed to patch aiortc video encoder subclasses with Stream bitrate values (aiortc internals may have changed), "
109+
"falling back to default aiortc bitrates. \n"
110+
"Set STREAM_PATCH_AIORTC_BITRATES=0 to disable patching.",
111+
exc_info=True,
112+
)

getstream/video/rtc/pc.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@
44

55
import aiortc
66
from aiortc.contrib.media import MediaRelay
7+
from aiortc.mediastreams import MediaStreamTrack
8+
from aiortc.rtcrtpparameters import RTCRtpCodecCapability
9+
from aiortc.rtcrtpsender import RTCRtpSender
10+
from pyee.asyncio import AsyncIOEventEmitter
711

812
from getstream.common import telemetry
13+
from getstream.video.rtc.encoders_patches import (
14+
BITRATE_PATCH_DISABLED,
15+
patch_sender_encoder,
16+
)
917
from getstream.video.rtc.track_util import AudioTrackHandler
10-
from pyee.asyncio import AsyncIOEventEmitter
11-
from aiortc.rtcrtpsender import RTCRtpSender
12-
from aiortc.rtcrtpparameters import RTCRtpCodecCapability
13-
1418

1519
logger = logging.getLogger(__name__)
1620

@@ -72,6 +76,12 @@ def on_connectionstatechange():
7276
if self.connectionState == "connected":
7377
self._connected_event.set()
7478

79+
def addTrack(self, track: MediaStreamTrack) -> RTCRtpSender:
80+
sender = super().addTrack(track)
81+
if track.kind == "video" and not BITRATE_PATCH_DISABLED:
82+
patch_sender_encoder(sender)
83+
return sender
84+
7585
async def handle_answer(self, response):
7686
"""Handles the SDP answer received from the SFU for the publisher connection."""
7787

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ openai-realtime = [
3535
"openai[realtime]>=1.93.0",
3636
]
3737
webrtc = [
38-
"aiortc-getstream==1.13.0.post1",
39-
"av>=14.0.0,<14.3", # Pin to versions with pre-built wheels
38+
"aiortc>=1.14.0, <1.15.0",
39+
"av>=14.2.0, <17",
4040
"numpy>=2.2.6,<2.3",
4141
"soundfile>=0.13.1",
4242
"scipy>=1.15.3,<1.16",

tests/rtc/test_encoders_patches.py

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import importlib
2+
from unittest.mock import MagicMock
3+
4+
import pytest
5+
from aiortc.codecs.h264 import H264Encoder
6+
from aiortc.codecs.vpx import Vp8Encoder
7+
8+
from getstream.video.rtc.encoders_patches import (
9+
STREAM_VIDEO_DEFAULT_BITRATE,
10+
STREAM_VIDEO_MAX_BITRATE,
11+
STREAM_VIDEO_MIN_BITRATE,
12+
StreamH264Encoder,
13+
StreamVp8Encoder,
14+
patch_sender_encoder,
15+
)
16+
17+
# Original upstream values
18+
ORIGINAL_VPX = {
19+
"DEFAULT_BITRATE": 500_000,
20+
"MIN_BITRATE": 250_000,
21+
"MAX_BITRATE": 1_500_000,
22+
}
23+
ORIGINAL_H264 = {
24+
"DEFAULT_BITRATE": 1_000_000,
25+
"MIN_BITRATE": 500_000,
26+
}
27+
28+
29+
class TestStreamVp8Encoder:
30+
def test_default_bitrate(self):
31+
enc = StreamVp8Encoder()
32+
assert enc.target_bitrate == STREAM_VIDEO_DEFAULT_BITRATE
33+
34+
def test_clamps_above_max(self):
35+
enc = StreamVp8Encoder()
36+
enc.target_bitrate = 999_999_999
37+
assert enc.target_bitrate == STREAM_VIDEO_MAX_BITRATE
38+
39+
def test_clamps_below_min(self):
40+
enc = StreamVp8Encoder()
41+
enc.target_bitrate = 1
42+
assert enc.target_bitrate == STREAM_VIDEO_MIN_BITRATE
43+
44+
def test_accepts_in_range(self):
45+
enc = StreamVp8Encoder()
46+
enc.target_bitrate = 2_000_000
47+
assert enc.target_bitrate == 2_000_000
48+
49+
50+
class TestStreamH264Encoder:
51+
def test_default_bitrate(self):
52+
enc = StreamH264Encoder()
53+
assert enc.target_bitrate == STREAM_VIDEO_DEFAULT_BITRATE
54+
55+
def test_clamps_above_max(self):
56+
enc = StreamH264Encoder()
57+
enc.target_bitrate = 999_999_999
58+
assert enc.target_bitrate == STREAM_VIDEO_MAX_BITRATE
59+
60+
def test_clamps_below_min(self):
61+
enc = StreamH264Encoder()
62+
enc.target_bitrate = 1
63+
assert enc.target_bitrate == STREAM_VIDEO_MIN_BITRATE
64+
65+
def test_accepts_in_range(self):
66+
enc = StreamH264Encoder()
67+
enc.target_bitrate = 2_000_000
68+
assert enc.target_bitrate == 2_000_000
69+
70+
71+
class TestBitratePatchDisabled:
72+
@pytest.mark.parametrize("env_val", [None, "", "1", "true", "yes", "on"])
73+
def test_enabled_by_default(self, monkeypatch, env_val):
74+
"""BITRATE_PATCH_DISABLED is False when env var is unset or truthy."""
75+
if env_val is None:
76+
monkeypatch.delenv("STREAM_PATCH_AIORTC_BITRATES", raising=False)
77+
else:
78+
monkeypatch.setenv("STREAM_PATCH_AIORTC_BITRATES", env_val)
79+
80+
import getstream.video.rtc.encoders_patches as mod
81+
82+
importlib.reload(mod)
83+
assert mod.BITRATE_PATCH_DISABLED is False
84+
85+
@pytest.mark.parametrize("env_val", ["0", "false", "no", "off"])
86+
def test_disabled_via_env(self, monkeypatch, env_val):
87+
"""BITRATE_PATCH_DISABLED is True when env var is a falsy value."""
88+
monkeypatch.setenv("STREAM_PATCH_AIORTC_BITRATES", env_val)
89+
90+
import getstream.video.rtc.encoders_patches as mod
91+
92+
importlib.reload(mod)
93+
assert mod.BITRATE_PATCH_DISABLED is True
94+
95+
96+
class TestPatchSenderEncoder:
97+
@pytest.mark.asyncio
98+
async def test_installs_vp8_encoder(self):
99+
sender = MagicMock()
100+
sender._RTCRtpSender__encoder = None
101+
102+
async def _orig_coro(codec):
103+
return None
104+
105+
sender._next_encoded_frame = _orig_coro
106+
patch_sender_encoder(sender)
107+
108+
codec = MagicMock()
109+
codec.mimeType = "video/VP8"
110+
await sender._next_encoded_frame(codec)
111+
112+
from getstream.video.rtc.encoders_patches import StreamVp8Encoder as Vp8Cls
113+
114+
assert isinstance(sender._RTCRtpSender__encoder, Vp8Cls)
115+
116+
@pytest.mark.asyncio
117+
async def test_installs_h264_encoder(self):
118+
sender = MagicMock()
119+
sender._RTCRtpSender__encoder = None
120+
121+
async def _orig_coro(codec):
122+
return None
123+
124+
sender._next_encoded_frame = _orig_coro
125+
patch_sender_encoder(sender)
126+
127+
codec = MagicMock()
128+
codec.mimeType = "video/H264"
129+
await sender._next_encoded_frame(codec)
130+
131+
from getstream.video.rtc.encoders_patches import StreamH264Encoder as H264Cls
132+
133+
assert isinstance(sender._RTCRtpSender__encoder, H264Cls)
134+
135+
@pytest.mark.asyncio
136+
async def test_does_not_replace_existing_encoder(self):
137+
"""Already-set encoder is not replaced."""
138+
sender = MagicMock()
139+
existing_encoder = MagicMock()
140+
sender._RTCRtpSender__encoder = existing_encoder
141+
142+
async def _orig_coro(codec):
143+
return None
144+
145+
sender._next_encoded_frame = _orig_coro
146+
patch_sender_encoder(sender)
147+
148+
codec = MagicMock()
149+
codec.mimeType = "video/H264"
150+
await sender._next_encoded_frame(codec)
151+
assert sender._RTCRtpSender__encoder is existing_encoder
152+
153+
def test_noop_when_encoders_none(self, monkeypatch):
154+
"""patch_sender_encoder is a no-op when encoder classes failed to load."""
155+
import getstream.video.rtc.encoders_patches as mod
156+
157+
monkeypatch.setattr(mod, "StreamVp8Encoder", None)
158+
sender = MagicMock()
159+
orig = sender._next_encoded_frame
160+
mod.patch_sender_encoder(sender)
161+
assert sender._next_encoded_frame is orig
162+
163+
164+
class TestUpstreamAssumptions:
165+
def test_upstream_assumptions_hold(self):
166+
"""Canary: fails if aiortc changes its internal API.
167+
168+
Our per-sender patching relies on name-mangled attributes and
169+
the _next_encoded_frame method. If this breaks after an aiortc
170+
upgrade, review the upstream code and update accordingly.
171+
"""
172+
vp8 = Vp8Encoder()
173+
assert hasattr(vp8, "target_bitrate"), "Vp8Encoder lost target_bitrate"
174+
175+
h264 = H264Encoder()
176+
assert hasattr(h264, "target_bitrate"), "H264Encoder lost target_bitrate"
177+
178+
# Name-mangled __target_bitrate is accessible from subclasses
179+
assert hasattr(vp8, "_Vp8Encoder__target_bitrate"), (
180+
"Vp8Encoder name-mangled __target_bitrate changed"
181+
)
182+
assert hasattr(h264, "_H264Encoder__target_bitrate"), (
183+
"H264Encoder name-mangled __target_bitrate changed"
184+
)
185+
186+
# RTCRtpSender has _next_encoded_frame and uses __encoder
187+
import inspect
188+
189+
from aiortc.rtcrtpsender import RTCRtpSender
190+
191+
assert hasattr(RTCRtpSender, "_next_encoded_frame"), (
192+
"RTCRtpSender lost _next_encoded_frame"
193+
)
194+
source = inspect.getsource(RTCRtpSender._next_encoded_frame)
195+
assert "self.__encoder" in source, (
196+
"RTCRtpSender._next_encoded_frame no longer uses self.__encoder"
197+
)

0 commit comments

Comments
 (0)