Repository: aiortc/aiortc

Issue:
bundlePolicy support
Web browser support bundlePolicy set to "max-bundle"  now,

can we support it?

Hints from issue comments:
@jlaine: I don't understand the question, what do you mean? aiortc's policy is equivalent to max-compat, it initially creates one DTLS transport per media track and checks whether the remote peer supports bundling. If so only one DTLS transport is kept and the others are discarded.

Solution patch:
diff --git a/docs/api.rst b/docs/api.rst
index 1d3d367b..27a3e1a5 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -12,6 +12,9 @@ WebRTC
    .. autoclass:: RTCSessionDescription
       :members:
 
+   .. autoclass:: RTCBundlePolicy()
+      :members:
+
    .. autoclass:: RTCConfiguration
       :members:
 
diff --git a/pyproject.toml b/pyproject.toml
index c4a7679a..0acb1be3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -25,7 +25,7 @@ classifiers = [
     "Programming Language :: Python :: 3.13",
 ]
 dependencies = [
-    "aioice>=0.9.0,<1.0.0",
+    "aioice>=0.10.1,<1.0.0",
     "av>=14.0.0,<15.0.0",
     "cffi>=1.0.0",
     "cryptography>=44.0.0",
diff --git a/src/aiortc/__init__.py b/src/aiortc/__init__.py
index d9a718a4..1da35436 100644
--- a/src/aiortc/__init__.py
+++ b/src/aiortc/__init__.py
@@ -8,7 +8,7 @@
     MediaStreamTrack,
     VideoStreamTrack,
 )
-from .rtcconfiguration import RTCConfiguration, RTCIceServer
+from .rtcconfiguration import RTCBundlePolicy, RTCConfiguration, RTCIceServer
 from .rtcdatachannel import RTCDataChannel, RTCDataChannelParameters
 from .rtcdtlstransport import (
     RTCCertificate,
@@ -61,6 +61,7 @@
     "InvalidStateError",
     "MediaStreamError",
     "MediaStreamTrack",
+    "RTCBundlePolicy",
     "RTCCertificate",
     "RTCConfiguration",
     "RTCDataChannel",
diff --git a/src/aiortc/rtcconfiguration.py b/src/aiortc/rtcconfiguration.py
index e70bd18a..30d83e42 100644
--- a/src/aiortc/rtcconfiguration.py
+++ b/src/aiortc/rtcconfiguration.py
@@ -1,3 +1,4 @@
+import enum
 from dataclasses import dataclass
 from typing import Optional, Union
 
@@ -22,6 +23,35 @@ class RTCIceServer:
     credentialType: str = "password"
 
 
+class RTCBundlePolicy(enum.Enum):
+    """
+    The :class:`RTCBundlePolicy` affects which media tracks are negotiated if
+    the remote endpoint is not bundle-aware, and what ICE candidates are
+    gathered.
+
+    See https://w3c.github.io/webrtc-pc/#rtcbundlepolicy-enum
+    """
+
+    BALANCED = "balanced"
+    """
+    Gather ICE candidates for each media type in use (audio, video, and data).
+    If the remote endpoint is not bundle-aware, negotiate only one audio and
+    video track on separate transports.
+    """
+
+    MAX_COMPAT = "max-compat"
+    """
+    Gather ICE candidates for each track. If the remote endpoint is not
+    bundle-aware, negotiate all media tracks on separate transports.
+    """
+
+    MAX_BUNDLE = "max-bundle"
+    """
+    Gather ICE candidates for only one track. If the remote endpoint is not
+    bundle-aware, negotiate only one media track.
+    """
+
+
 @dataclass
 class RTCConfiguration:
     """
@@ -31,3 +61,6 @@ class RTCConfiguration:
 
     iceServers: Optional[list[RTCIceServer]] = None
     "A list of :class:`RTCIceServer` objects to configure STUN / TURN servers."
+
+    bundlePolicy: RTCBundlePolicy = RTCBundlePolicy.BALANCED
+    "The media-bundling policy to use when gathering ICE candidates."
diff --git a/src/aiortc/rtcicetransport.py b/src/aiortc/rtcicetransport.py
index 37a136f0..28ad6281 100644
--- a/src/aiortc/rtcicetransport.py
+++ b/src/aiortc/rtcicetransport.py
@@ -174,7 +174,12 @@ class RTCIceGatherer(AsyncIOEventEmitter):
     exchanged in signaling.
     """
 
-    def __init__(self, iceServers: Optional[list[RTCIceServer]] = None) -> None:
+    def __init__(
+        self,
+        iceServers: Optional[list[RTCIceServer]] = None,
+        local_username: Optional[str] = None,
+        local_password: Optional[str] = None,
+    ) -> None:
         super().__init__()
 
         if iceServers is None:
diff --git a/src/aiortc/rtcpeerconnection.py b/src/aiortc/rtcpeerconnection.py
index e2db491c..b3ce1ac0 100644
--- a/src/aiortc/rtcpeerconnection.py
+++ b/src/aiortc/rtcpeerconnection.py
@@ -16,7 +16,7 @@
     OperationError,
 )
 from .mediastreams import MediaStreamTrack
-from .rtcconfiguration import RTCConfiguration
+from .rtcconfiguration import RTCBundlePolicy, RTCConfiguration
 from .rtcdatachannel import RTCDataChannel, RTCDataChannelParameters
 from .rtcdtlstransport import RTCCertificate, RTCDtlsParameters, RTCDtlsTransport
 from .rtcicetransport import (
@@ -1061,7 +1061,23 @@ def __assertTrackHasNoSender(self, track: MediaStreamTrack) -> None:
 
     def __createDtlsTransport(self) -> RTCDtlsTransport:
         # create ICE transport
-        iceGatherer = RTCIceGatherer(iceServers=self.__configuration.iceServers)
+        if len(self.__transceivers) > 0 or self.__sctp:
+            if len(self.__transceivers) > 0:
+                parameters = self.__transceivers[
+                    0
+                ].transport.transport.iceGatherer.getLocalParameters()
+            else:
+                parameters = (
+                    self.__sctp.transport.transport.iceGatherer.getLocalParameters()
+                )
+            iceGatherer = RTCIceGatherer(
+                iceServers=self.__configuration.iceServers,
+                local_username=parameters.usernameFragment,
+                local_password=parameters.password,
+            )
+        else:
+            iceGatherer = RTCIceGatherer(iceServers=self.__configuration.iceServers)
+
         iceGatherer.on("statechange", self.__updateIceGatheringState)
         iceTransport = RTCIceTransport(iceGatherer)
         iceTransport.on("statechange", self.__updateIceConnectionState)
@@ -1081,8 +1097,17 @@ def __createDtlsTransport(self) -> RTCDtlsTransport:
         return dtlsTransport
 
     def __createSctpTransport(self) -> None:
-        self.__sctp = RTCSctpTransport(self.__createDtlsTransport())
-        self.__sctp._bundled = False
+        dtlsTransport = None
+        bundled = (
+            self.__configuration.bundlePolicy == RTCBundlePolicy.MAX_BUNDLE
+            and len(self.__transceivers) > 0
+        )
+        if bundled:
+            dtlsTransport = self.__transceivers[0].transport
+        else:
+            dtlsTransport = self.__createDtlsTransport()
+        self.__sctp = RTCSctpTransport(dtlsTransport)
+        self.__sctp._bundled = bundled
         self.__sctp.mid = None
 
         @self.__sctp.on("datachannel")
@@ -1092,7 +1117,26 @@ def on_datachannel(channel):
     def __createTransceiver(
         self, direction: str, kind: str, sender_track=None
     ) -> RTCRtpTransceiver:
-        dtlsTransport = self.__createDtlsTransport()
+        dtlsTransport = None
+        bundled = False
+        if self.__configuration.bundlePolicy == RTCBundlePolicy.MAX_BUNDLE:
+            if len(self.__transceivers) > 0:
+                dtlsTransport = self.__transceivers[0].transport
+                bundled = True
+            elif self.__sctp:
+                dtlsTransport = self.__sctp.transport
+                bundled = True
+        elif self.__configuration.bundlePolicy == RTCBundlePolicy.BALANCED:
+            transceiver = next(
+                filter(lambda t: t.kind == kind, self.__transceivers), None
+            )
+            if transceiver:
+                dtlsTransport = transceiver.transport
+                bundled = True
+
+        if not dtlsTransport:
+            dtlsTransport = self.__createDtlsTransport()
+
         transceiver = RTCRtpTransceiver(
             direction=direction,
             kind=kind,
@@ -1101,7 +1145,7 @@ def __createTransceiver(
         )
         transceiver.receiver._set_rtcp_ssrc(transceiver.sender._ssrc)
         transceiver.sender._stream_id = self.__stream_id
-        transceiver._bundled = False
+        transceiver._bundled = bundled
         transceiver._transport = dtlsTransport
         self.__transceivers.append(transceiver)
         return transceiver
diff --git a/src/aiortc/rtcrtptransceiver.py b/src/aiortc/rtcrtptransceiver.py
index 120e4226..bbbb02fb 100644
--- a/src/aiortc/rtcrtptransceiver.py
+++ b/src/aiortc/rtcrtptransceiver.py
@@ -130,6 +130,13 @@ async def stop(self) -> None:
         await self.__sender.stop()
         self.__stopped = True
 
+    @property
+    def transport(self) -> RTCDtlsTransport:
+        """
+        The :class:`RTCDtlsTransport` over which media are transmitted.
+        """
+        return self._transport
+
     def _setCurrentDirection(self, direction: str) -> None:
         self.__currentDirection = direction
 


Test patch:
diff --git a/tests/test_rtcpeerconnection.py b/tests/test_rtcpeerconnection.py
index bc5ff3ab..77976c18 100644
--- a/tests/test_rtcpeerconnection.py
+++ b/tests/test_rtcpeerconnection.py
@@ -6,6 +6,7 @@
 import aioice.ice
 import aioice.stun
 from aiortc import (
+    RTCBundlePolicy,
     RTCConfiguration,
     RTCDataChannel,
     RTCIceCandidate,
@@ -2022,8 +2023,7 @@ async def test_connect_audio_two_tracks(self) -> None:
             ["stable", "have-remote-offer", "stable", "closed"],
         )
 
-    @asynctest
-    async def test_connect_audio_and_video(self) -> None:
+    async def _test_connect_audio_and_video(self, pc1, pc2) -> None:
         pc1 = RTCPeerConnection()
         pc1_states = track_states(pc1)
 
@@ -2122,6 +2122,28 @@ async def test_connect_audio_and_video(self) -> None:
             ["stable", "have-remote-offer", "stable", "closed"],
         )
 
+    @asynctest
+    async def test_connect_audio_and_video(self):
+        pc1 = RTCPeerConnection()
+        pc2 = RTCPeerConnection()
+        await self._test_connect_audio_and_video(pc1, pc2)
+
+    @asynctest
+    async def test_connect_audio_and_video_bundlepolicy_max_compat(self):
+        pc1 = RTCPeerConnection(
+            RTCConfiguration(bundlePolicy=RTCBundlePolicy.MAX_COMPAT)
+        )
+        pc2 = RTCPeerConnection()
+        await self._test_connect_audio_and_video(pc1, pc2)
+
+    @asynctest
+    async def test_connect_audio_and_video_bundlepolicy_max_bundle(self):
+        pc1 = RTCPeerConnection(
+            RTCConfiguration(bundlePolicy=RTCBundlePolicy.MAX_BUNDLE)
+        )
+        pc2 = RTCPeerConnection()
+        await self._test_connect_audio_and_video(pc1, pc2)
+
     async def _test_connect_audio_and_video_mediaplayer(
         self, stop_tracks: bool
     ) -> None:
@@ -2248,12 +2270,8 @@ async def test_connect_audio_and_video_mediaplayer(self) -> None:
     async def test_connect_audio_and_video_mediaplayer_stop_tracks(self) -> None:
         await self._test_connect_audio_and_video_mediaplayer(stop_tracks=True)
 
-    @asynctest
-    async def test_connect_audio_and_video_and_data_channel(self) -> None:
-        pc1 = RTCPeerConnection()
+    async def _test_connect_audio_and_video_and_data_channel(self, pc1, pc2) -> None:
         pc1_states = track_states(pc1)
-
-        pc2 = RTCPeerConnection()
         pc2_states = track_states(pc2)
 
         self.assertEqual(pc1.iceConnectionState, "new")
@@ -2351,7 +2369,21 @@ async def test_connect_audio_and_video_and_data_channel(self) -> None:
         )
 
     @asynctest
-    async def test_connect_audio_and_video_and_data_channel_ice_fail(self) -> None:
+    async def test_connect_audio_and_video_and_data_channel(self) -> None:
+        pc1 = RTCPeerConnection()
+        pc2 = RTCPeerConnection()
+        await self._test_connect_audio_and_video_and_data_channel(pc1, pc2)
+
+    @asynctest
+    async def test_connect_audio_and_video_and_data_channel_max_bundle(self):
+        pc1 = RTCPeerConnection(
+            RTCConfiguration(bundlePolicy=RTCBundlePolicy.MAX_BUNDLE)
+        )
+        pc2 = RTCPeerConnection()
+        await self._test_connect_audio_and_video_and_data_channel(pc1, pc2)
+
+    @asynctest
+    async def test_connect_audio_and_video_and_data_channel_ice_fail(self):
         pc1 = RTCPeerConnection()
         pc1_states = track_states(pc1)
 
@@ -5205,3 +5237,72 @@ async def test_right_mid_order(self) -> None:
         await pc2.close()
         self.assertClosed(pc1)
         self.assertClosed(pc2)
+
+    @asynctest
+    async def test_bundlepolicy_max_bundle_ufrag_and_pwd(self):
+        pc = RTCPeerConnection(
+            RTCConfiguration(bundlePolicy=RTCBundlePolicy.MAX_BUNDLE)
+        )
+        transceiver1 = pc.addTransceiver("audio")
+        transceiver2 = pc.addTransceiver("video")
+
+        await pc.createOffer()
+        param1 = transceiver1.transport.transport.iceGatherer.getLocalParameters()
+        param2 = transceiver2.transport.transport.iceGatherer.getLocalParameters()
+        self.assertEqual(param1.usernameFragment, param2.usernameFragment)
+        self.assertEqual(param1.password, param2.password)
+        self.assertEqual(transceiver1.transport, transceiver2.transport)
+
+    @asynctest
+    async def test_bundlepolicy_max_bundle_ufrag_and_pwd_datachannel(self):
+        pc = RTCPeerConnection(
+            RTCConfiguration(bundlePolicy=RTCBundlePolicy.MAX_BUNDLE)
+        )
+        pc.createDataChannel("somechannel")
+        transceiver = pc.addTransceiver("audio")
+
+        await pc.createOffer()
+        param1 = transceiver.transport.transport.iceGatherer.getLocalParameters()
+        param2 = pc.sctp.transport.transport.iceGatherer.getLocalParameters()
+        self.assertEqual(param1.usernameFragment, param2.usernameFragment)
+        self.assertEqual(param1.password, param2.password)
+        self.assertEqual(transceiver.transport, pc.sctp.transport)
+
+    @asynctest
+    async def test_bundlepolicy_transports_balanced(self):
+        pc = RTCPeerConnection(RTCConfiguration(bundlePolicy=RTCBundlePolicy.BALANCED))
+        transceiver1 = pc.addTransceiver("audio")
+        transceiver2 = pc.addTransceiver("video")
+        transceiver3 = pc.addTransceiver("audio")
+        pc.createDataChannel("somechannel")
+        self.assertNotEqual(transceiver1.transport, transceiver2.transport)
+        self.assertEqual(transceiver1.transport, transceiver3.transport)
+        self.assertNotEqual(transceiver1.transport, pc.sctp.transport)
+        self.assertNotEqual(transceiver2.transport, pc.sctp.transport)
+
+    @asynctest
+    async def test_bundlepolicy_transports_max_compat(self):
+        pc = RTCPeerConnection(
+            RTCConfiguration(bundlePolicy=RTCBundlePolicy.MAX_COMPAT)
+        )
+        transceiver1 = pc.addTransceiver("audio")
+        transceiver2 = pc.addTransceiver("video")
+        transceiver3 = pc.addTransceiver("audio")
+        pc.createDataChannel("somechannel")
+        self.assertNotEqual(transceiver1.transport, transceiver2.transport)
+        self.assertNotEqual(transceiver1.transport, transceiver3.transport)
+        self.assertNotEqual(transceiver1.transport, pc.sctp.transport)
+        self.assertNotEqual(transceiver2.transport, pc.sctp.transport)
+
+    @asynctest
+    async def test_bundlepolicy_transports_max_bundle(self):
+        pc = RTCPeerConnection(
+            RTCConfiguration(bundlePolicy=RTCBundlePolicy.MAX_BUNDLE)
+        )
+        transceiver1 = pc.addTransceiver("audio")
+        transceiver2 = pc.addTransceiver("video")
+        transceiver3 = pc.addTransceiver("audio")
+        pc.createDataChannel("somechannel")
+        self.assertEqual(transceiver1.transport, transceiver2.transport)
+        self.assertEqual(transceiver1.transport, transceiver3.transport)
+        self.assertEqual(transceiver1.transport, pc.sctp.transport)