livekit 1.2.1__py3-none-win_arm64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- livekit/rtc/__init__.py +264 -0
- livekit/rtc/_ffi_client.py +321 -0
- livekit/rtc/_proto/__init__.py +0 -0
- livekit/rtc/_proto/audio_frame_pb2.py +159 -0
- livekit/rtc/_proto/audio_frame_pb2.pyi +1560 -0
- livekit/rtc/_proto/capture_pb2.py +70 -0
- livekit/rtc/_proto/capture_pb2.pyi +559 -0
- livekit/rtc/_proto/data_stream_pb2.py +171 -0
- livekit/rtc/_proto/data_stream_pb2.pyi +1498 -0
- livekit/rtc/_proto/data_track_pb2.py +120 -0
- livekit/rtc/_proto/data_track_pb2.pyi +1073 -0
- livekit/rtc/_proto/e2ee_pb2.py +81 -0
- livekit/rtc/_proto/e2ee_pb2.pyi +591 -0
- livekit/rtc/_proto/ffi_pb2.py +55 -0
- livekit/rtc/_proto/ffi_pb2.pyi +1246 -0
- livekit/rtc/_proto/handle_pb2.py +27 -0
- livekit/rtc/_proto/handle_pb2.pyi +51 -0
- livekit/rtc/_proto/participant_pb2.py +45 -0
- livekit/rtc/_proto/participant_pb2.pyi +313 -0
- livekit/rtc/_proto/room_pb2.py +311 -0
- livekit/rtc/_proto/room_pb2.pyi +3136 -0
- livekit/rtc/_proto/rpc_pb2.py +47 -0
- livekit/rtc/_proto/rpc_pb2.pyi +261 -0
- livekit/rtc/_proto/stats_pb2.py +123 -0
- livekit/rtc/_proto/stats_pb2.pyi +1514 -0
- livekit/rtc/_proto/track_pb2.py +76 -0
- livekit/rtc/_proto/track_pb2.pyi +544 -0
- livekit/rtc/_proto/track_publication_pb2.py +39 -0
- livekit/rtc/_proto/track_publication_pb2.pyi +145 -0
- livekit/rtc/_proto/video_frame_pb2.py +97 -0
- livekit/rtc/_proto/video_frame_pb2.pyi +838 -0
- livekit/rtc/_utils.py +187 -0
- livekit/rtc/apm.py +119 -0
- livekit/rtc/audio_filter.py +23 -0
- livekit/rtc/audio_frame.py +242 -0
- livekit/rtc/audio_mixer.py +247 -0
- livekit/rtc/audio_resampler.py +174 -0
- livekit/rtc/audio_source.py +191 -0
- livekit/rtc/audio_stream.py +354 -0
- livekit/rtc/data_stream.py +640 -0
- livekit/rtc/data_track.py +296 -0
- livekit/rtc/e2ee.py +320 -0
- livekit/rtc/encoded_video.py +127 -0
- livekit/rtc/event_emitter.py +215 -0
- livekit/rtc/frame_processor.py +37 -0
- livekit/rtc/jupyter.py +68 -0
- livekit/rtc/log.py +3 -0
- livekit/rtc/media_devices.py +596 -0
- livekit/rtc/participant.py +1171 -0
- livekit/rtc/platform_audio.py +426 -0
- livekit/rtc/py.typed +0 -0
- livekit/rtc/resources/LICENSE.md +4055 -0
- livekit/rtc/resources/__init__.py +1 -0
- livekit/rtc/resources/jupyter-html/index.html +101 -0
- livekit/rtc/resources/livekit_ffi.dll +0 -0
- livekit/rtc/resources/livekit_ffi.h +42 -0
- livekit/rtc/room.py +1304 -0
- livekit/rtc/rpc.py +244 -0
- livekit/rtc/synchronizer.py +202 -0
- livekit/rtc/track.py +264 -0
- livekit/rtc/track_publication.py +145 -0
- livekit/rtc/transcription.py +19 -0
- livekit/rtc/utils.py +138 -0
- livekit/rtc/version.py +1 -0
- livekit/rtc/video_frame.py +347 -0
- livekit/rtc/video_source.py +74 -0
- livekit/rtc/video_stream.py +191 -0
- livekit-1.2.1.dist-info/METADATA +80 -0
- livekit-1.2.1.dist-info/RECORD +71 -0
- livekit-1.2.1.dist-info/WHEEL +5 -0
- livekit-1.2.1.dist-info/top_level.txt +1 -0
livekit/rtc/room.py
ADDED
|
@@ -0,0 +1,1304 @@
|
|
|
1
|
+
# Copyright 2023 LiveKit, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import datetime
|
|
17
|
+
import asyncio
|
|
18
|
+
import ctypes
|
|
19
|
+
import logging
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Callable, Dict, Literal, Optional, cast, Mapping
|
|
22
|
+
import warnings
|
|
23
|
+
|
|
24
|
+
from .event_emitter import EventEmitter
|
|
25
|
+
from ._ffi_client import FfiClient, FfiHandle
|
|
26
|
+
from ._proto import ffi_pb2 as proto_ffi
|
|
27
|
+
from ._proto import participant_pb2 as proto_participant
|
|
28
|
+
from ._proto import room_pb2 as proto_room
|
|
29
|
+
from ._proto import stats_pb2 as proto_stats
|
|
30
|
+
from ._proto.participant_pb2 import DisconnectReason
|
|
31
|
+
from ._proto.room_pb2 import ConnectionState, SimulateScenarioKind
|
|
32
|
+
from ._proto.track_pb2 import TrackKind
|
|
33
|
+
from ._proto.rpc_pb2 import RpcMethodInvocationEvent
|
|
34
|
+
from ._utils import BroadcastQueue
|
|
35
|
+
from .e2ee import E2EEManager, E2EEOptions
|
|
36
|
+
from .log import logger
|
|
37
|
+
from .participant import (
|
|
38
|
+
LocalParticipant,
|
|
39
|
+
Participant,
|
|
40
|
+
RemoteParticipant,
|
|
41
|
+
_connection_quality_from_proto,
|
|
42
|
+
)
|
|
43
|
+
from .track import RemoteAudioTrack, RemoteVideoTrack
|
|
44
|
+
from .track_publication import RemoteTrackPublication, TrackPublication
|
|
45
|
+
from .transcription import TranscriptionSegment
|
|
46
|
+
from .data_stream import (
|
|
47
|
+
TextStreamReader,
|
|
48
|
+
ByteStreamReader,
|
|
49
|
+
TextStreamHandler,
|
|
50
|
+
ByteStreamHandler,
|
|
51
|
+
)
|
|
52
|
+
from .data_track import RemoteDataTrack
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
EventTypes = Literal[
|
|
56
|
+
"participant_connected",
|
|
57
|
+
"participant_disconnected",
|
|
58
|
+
"participant_active",
|
|
59
|
+
"local_track_published",
|
|
60
|
+
"local_track_unpublished",
|
|
61
|
+
"local_track_republished",
|
|
62
|
+
"local_track_subscribed",
|
|
63
|
+
"track_published",
|
|
64
|
+
"track_unpublished",
|
|
65
|
+
"track_subscribed",
|
|
66
|
+
"track_unsubscribed",
|
|
67
|
+
"track_subscription_failed",
|
|
68
|
+
"track_muted",
|
|
69
|
+
"track_unmuted",
|
|
70
|
+
"active_speakers_changed",
|
|
71
|
+
"room_metadata_changed",
|
|
72
|
+
"participant_metadata_changed",
|
|
73
|
+
"participant_name_changed",
|
|
74
|
+
"participant_attributes_changed",
|
|
75
|
+
"connection_quality_changed",
|
|
76
|
+
"participant_encryption_status_changed",
|
|
77
|
+
"participant_permissions_changed",
|
|
78
|
+
"data_received",
|
|
79
|
+
"sip_dtmf_received",
|
|
80
|
+
"transcription_received",
|
|
81
|
+
"e2ee_state_changed",
|
|
82
|
+
"connection_state_changed",
|
|
83
|
+
"connected",
|
|
84
|
+
"disconnected",
|
|
85
|
+
"reconnecting",
|
|
86
|
+
"reconnected",
|
|
87
|
+
"room_updated",
|
|
88
|
+
"moved",
|
|
89
|
+
"token_refreshed",
|
|
90
|
+
"data_track_published",
|
|
91
|
+
"data_track_unpublished",
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class RtcConfiguration:
|
|
97
|
+
ice_transport_type: proto_room.IceTransportType.ValueType = (
|
|
98
|
+
proto_room.IceTransportType.TRANSPORT_ALL
|
|
99
|
+
)
|
|
100
|
+
"""Specifies the type of ICE transport to be used (e.g., all, relay, etc.)."""
|
|
101
|
+
continual_gathering_policy: proto_room.ContinualGatheringPolicy.ValueType = (
|
|
102
|
+
proto_room.ContinualGatheringPolicy.GATHER_CONTINUALLY
|
|
103
|
+
)
|
|
104
|
+
"""Policy for continual gathering of ICE candidates."""
|
|
105
|
+
ice_servers: list[proto_room.IceServer] = field(default_factory=list)
|
|
106
|
+
"""List of ICE servers for STUN/TURN. When empty, it uses the default ICE servers provided by
|
|
107
|
+
the SFU."""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class DataStreamOptions:
|
|
112
|
+
max_payload_byte_length: int | None = None
|
|
113
|
+
"""Maximum decompressed payload size in bytes accepted for a single incoming
|
|
114
|
+
data stream; oversized streams terminate with StreamError on the receiver.
|
|
115
|
+
When None, uses the FFI default."""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class RoomOptions:
|
|
120
|
+
auto_subscribe: bool = True
|
|
121
|
+
"""Automatically subscribe to tracks when participants join."""
|
|
122
|
+
dynacast: bool = False
|
|
123
|
+
e2ee: E2EEOptions | None = None
|
|
124
|
+
"""Deprecated, use `encryption` field instead"""
|
|
125
|
+
encryption: E2EEOptions | None = None
|
|
126
|
+
"""Options for end-to-end encryption."""
|
|
127
|
+
rtc_config: RtcConfiguration | None = None
|
|
128
|
+
"""WebRTC-related configuration."""
|
|
129
|
+
connect_timeout: float | None = None
|
|
130
|
+
"""Timeout in seconds for each signal connection attempt. When None, uses the default (5s)."""
|
|
131
|
+
single_peer_connection: bool | None = None
|
|
132
|
+
"""Use a single peer connection for both publish and subscribe. When None, uses the default (true)."""
|
|
133
|
+
data_stream: DataStreamOptions | None = None
|
|
134
|
+
"""Options for incoming data streams. When None, uses the FFI defaults."""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class DataPacket:
|
|
139
|
+
data: bytes
|
|
140
|
+
"""The payload of the data packet."""
|
|
141
|
+
kind: proto_room.DataPacketKind.ValueType
|
|
142
|
+
"""Type of the data packet (e.g., RELIABLE, LOSSY)."""
|
|
143
|
+
participant: RemoteParticipant | None
|
|
144
|
+
"""Participant who sent the data. None when sent by a server SDK."""
|
|
145
|
+
topic: str | None = None
|
|
146
|
+
"""Topic associated with the data packet."""
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class SipDTMF:
|
|
151
|
+
code: int
|
|
152
|
+
"""DTMF code corresponding to the digit."""
|
|
153
|
+
digit: str
|
|
154
|
+
"""DTMF digit sent."""
|
|
155
|
+
participant: RemoteParticipant | None = None
|
|
156
|
+
"""Participant who sent the DTMF digit. None when sent by a server SDK."""
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass
|
|
160
|
+
class RtcStats:
|
|
161
|
+
publisher_stats: list[proto_stats.RtcStats]
|
|
162
|
+
subscriber_stats: list[proto_stats.RtcStats]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class ConnectError(Exception):
|
|
166
|
+
def __init__(self, message: str):
|
|
167
|
+
self.message = message
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class Room(EventEmitter[EventTypes]):
|
|
171
|
+
def __init__(
|
|
172
|
+
self,
|
|
173
|
+
loop: Optional[asyncio.AbstractEventLoop] = None,
|
|
174
|
+
) -> None:
|
|
175
|
+
"""Initializes a new Room instance.
|
|
176
|
+
|
|
177
|
+
Parameters:
|
|
178
|
+
loop (Optional[asyncio.AbstractEventLoop]): The event loop to use. If not provided, the default event loop is used.
|
|
179
|
+
"""
|
|
180
|
+
super().__init__()
|
|
181
|
+
|
|
182
|
+
self._ffi_handle: Optional[FfiHandle] = None
|
|
183
|
+
self._loop = loop or asyncio.get_event_loop()
|
|
184
|
+
self._room_queue = BroadcastQueue[proto_ffi.FfiEvent]()
|
|
185
|
+
self._info = proto_room.RoomInfo()
|
|
186
|
+
self._rpc_invocation_tasks: set[asyncio.Task] = set()
|
|
187
|
+
|
|
188
|
+
self._remote_participants: Dict[str, RemoteParticipant] = {}
|
|
189
|
+
self._connection_state = ConnectionState.CONN_DISCONNECTED
|
|
190
|
+
self._first_sid_future = asyncio.Future[str]()
|
|
191
|
+
self._local_participant: LocalParticipant | None = None
|
|
192
|
+
self._is_connecting: bool = False
|
|
193
|
+
|
|
194
|
+
# active incoming stream readers, keyed by FFI reader handle id
|
|
195
|
+
self._text_stream_readers: Dict[int, TextStreamReader] = {}
|
|
196
|
+
self._byte_stream_readers: Dict[int, ByteStreamReader] = {}
|
|
197
|
+
self._text_stream_handlers: Dict[str, TextStreamHandler] = {}
|
|
198
|
+
self._byte_stream_handlers: Dict[str, ByteStreamHandler] = {}
|
|
199
|
+
|
|
200
|
+
self._token: str | None = None
|
|
201
|
+
self._server_url: str | None = None
|
|
202
|
+
|
|
203
|
+
def __del__(self) -> None:
|
|
204
|
+
if self._ffi_handle is not None:
|
|
205
|
+
FfiClient.instance.queue.unsubscribe(self._ffi_queue)
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
async def sid(self) -> str:
|
|
209
|
+
"""Asynchronously retrieves the session ID (SID) of the room.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
str: The session ID of the room.
|
|
213
|
+
|
|
214
|
+
Raises:
|
|
215
|
+
RuntimeError: If the room is disconnected and no SID was received.
|
|
216
|
+
"""
|
|
217
|
+
if self._info.sid:
|
|
218
|
+
return self._info.sid
|
|
219
|
+
|
|
220
|
+
if self._connection_state == ConnectionState.CONN_DISCONNECTED and not self._is_connecting:
|
|
221
|
+
raise RuntimeError("cannot get sid of a disconnected room")
|
|
222
|
+
|
|
223
|
+
# This future is shared state for the Room. Caller-side timeouts, e.g.
|
|
224
|
+
# asyncio.wait_for(room.sid, ...), should not cancel it for future waiters.
|
|
225
|
+
return await asyncio.shield(self._first_sid_future)
|
|
226
|
+
|
|
227
|
+
def _resolve_first_sid(self, sid: str) -> None:
|
|
228
|
+
# The room SID can arrive through the initial connect result or later
|
|
229
|
+
# room update events. Resolve the first-SID waiter from any source that
|
|
230
|
+
# carries it so waiters do not depend on event ordering.
|
|
231
|
+
if sid:
|
|
232
|
+
self._info.sid = sid
|
|
233
|
+
if not self._first_sid_future.done():
|
|
234
|
+
self._first_sid_future.set_result(sid)
|
|
235
|
+
|
|
236
|
+
def _fail_first_sid(self, exc: BaseException) -> None:
|
|
237
|
+
if not self._first_sid_future.done():
|
|
238
|
+
self._first_sid_future.set_exception(exc)
|
|
239
|
+
# Retrieve the exception to suppress unretrieved future warnings if unawaited
|
|
240
|
+
self._first_sid_future.exception()
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def local_participant(self) -> LocalParticipant:
|
|
244
|
+
"""Gets the local participant in the room.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
LocalParticipant: The local participant in the room.
|
|
248
|
+
"""
|
|
249
|
+
if self._local_participant is None:
|
|
250
|
+
raise Exception("cannot access local participant before connecting")
|
|
251
|
+
|
|
252
|
+
return self._local_participant
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def connection_state(self) -> ConnectionState.ValueType:
|
|
256
|
+
"""Gets the connection state of the room.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
ConnectionState: The connection state of the room.
|
|
260
|
+
"""
|
|
261
|
+
return self._connection_state
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def remote_participants(self) -> Mapping[str, RemoteParticipant]:
|
|
265
|
+
"""Gets the remote participants in the room.
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
dict[str, RemoteParticipant]: A dictionary of remote participants indexed by their
|
|
269
|
+
identity.
|
|
270
|
+
"""
|
|
271
|
+
return self._remote_participants
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def name(self) -> str:
|
|
275
|
+
"""Gets the name of the room.
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
str: The name of the room.
|
|
279
|
+
"""
|
|
280
|
+
return self._info.name
|
|
281
|
+
|
|
282
|
+
@property
|
|
283
|
+
def metadata(self) -> str:
|
|
284
|
+
"""Gets the metadata associated with the room.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
str: The metadata of the room.
|
|
288
|
+
"""
|
|
289
|
+
return self._info.metadata
|
|
290
|
+
|
|
291
|
+
@property
|
|
292
|
+
def e2ee_manager(self) -> E2EEManager:
|
|
293
|
+
"""Gets the end-to-end encryption (E2EE) manager for the room.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
E2EEManager: The E2EE manager instance.
|
|
297
|
+
"""
|
|
298
|
+
return self._e2ee_manager
|
|
299
|
+
|
|
300
|
+
@property
|
|
301
|
+
def num_participants(self) -> int:
|
|
302
|
+
"""Gets the number of participants in the room.
|
|
303
|
+
This value is updated periodically, and is eventually consistent.
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
int: The number of participants in the room.
|
|
307
|
+
"""
|
|
308
|
+
return self._info.num_participants
|
|
309
|
+
|
|
310
|
+
@property
|
|
311
|
+
def num_publishers(self) -> int:
|
|
312
|
+
"""Gets the number of publishers in the room.
|
|
313
|
+
This value is updated periodically, and is eventually consistent.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
int: The number of publishers in the room.
|
|
317
|
+
"""
|
|
318
|
+
return self._info.num_publishers
|
|
319
|
+
|
|
320
|
+
@property
|
|
321
|
+
def creation_time(self) -> datetime.datetime:
|
|
322
|
+
"""Time when the room was created.
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
datetime.datetime: The creation time of the room.
|
|
326
|
+
"""
|
|
327
|
+
return datetime.datetime.fromtimestamp(
|
|
328
|
+
self._info.creation_time / 1000, datetime.timezone.utc
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
@property
|
|
332
|
+
def is_recording(self) -> bool:
|
|
333
|
+
"""Whether the room is actively recording.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
bool: True if actively recording, False otherwise.
|
|
337
|
+
"""
|
|
338
|
+
return self._info.active_recording
|
|
339
|
+
|
|
340
|
+
@property
|
|
341
|
+
def departure_timeout(self) -> float:
|
|
342
|
+
"""Amount of time to hold the room open after the last standard participant leaves.
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
float: The departure timeout of the room.
|
|
346
|
+
"""
|
|
347
|
+
return float(self._info.departure_timeout)
|
|
348
|
+
|
|
349
|
+
@property
|
|
350
|
+
def empty_timeout(self) -> float:
|
|
351
|
+
"""Amount of time to keep the room open if no participants join.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
float: The empty timeout of the room.
|
|
355
|
+
"""
|
|
356
|
+
return float(self._info.empty_timeout)
|
|
357
|
+
|
|
358
|
+
def isconnected(self) -> bool:
|
|
359
|
+
"""Checks if the room is currently connected.
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
bool: True if connected, False otherwise.
|
|
363
|
+
"""
|
|
364
|
+
return (
|
|
365
|
+
self._ffi_handle is not None
|
|
366
|
+
and self._connection_state != ConnectionState.CONN_DISCONNECTED
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
def on(self, event: EventTypes, callback: Optional[Callable] = None) -> Callable:
|
|
370
|
+
"""Registers an event handler for a specific event type.
|
|
371
|
+
|
|
372
|
+
Parameters:
|
|
373
|
+
event (EventTypes): The name of the event to listen for.
|
|
374
|
+
callback (Callable): The function to call when the event occurs.
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
Callable: The registered callback function.
|
|
378
|
+
|
|
379
|
+
Available events:
|
|
380
|
+
- **"participant_connected"**: Called when a new participant joins the room.
|
|
381
|
+
- Arguments: `participant` (RemoteParticipant)
|
|
382
|
+
- **"participant_disconnected"**: Called when a participant leaves the room.
|
|
383
|
+
- Arguments: `participant` (RemoteParticipant)
|
|
384
|
+
- **"participant_active"**: Called when a remote participant becomes active and is ready to receive data messages.
|
|
385
|
+
- Arguments: `participant` (RemoteParticipant)
|
|
386
|
+
- **"local_track_published"**: Called when a local track is published.
|
|
387
|
+
- Arguments: `publication` (LocalTrackPublication), `track` (Track)
|
|
388
|
+
- **"local_track_unpublished"**: Called when a local track is unpublished.
|
|
389
|
+
- Arguments: `publication` (LocalTrackPublication)
|
|
390
|
+
- **"local_track_republished"**: Called when the SDK auto-republished a local
|
|
391
|
+
track during a full reconnect. The publication object is updated in place
|
|
392
|
+
with the new server-assigned SIDs (its previous SID is passed alongside so
|
|
393
|
+
callers can reconcile any external state keyed by the old SID).
|
|
394
|
+
- Arguments: `publication` (LocalTrackPublication), `previous_sid` (str)
|
|
395
|
+
- **"local_track_subscribed"**: Called when a local track is subscribed.
|
|
396
|
+
- Arguments: `track` (Track)
|
|
397
|
+
- **"track_published"**: Called when a remote participant publishes a track.
|
|
398
|
+
- Arguments: `publication` (RemoteTrackPublication), `participant` (RemoteParticipant)
|
|
399
|
+
- **"track_unpublished"**: Called when a remote participant unpublishes a track.
|
|
400
|
+
- Arguments: `publication` (RemoteTrackPublication), `participant` (RemoteParticipant)
|
|
401
|
+
- **"track_subscribed"**: Called when a track is subscribed.
|
|
402
|
+
- Arguments: `track` (Track), `publication` (RemoteTrackPublication), `participant` (RemoteParticipant)
|
|
403
|
+
- **"track_unsubscribed"**: Called when a track is unsubscribed.
|
|
404
|
+
- Arguments: `track` (Track), `publication` (RemoteTrackPublication), `participant` (RemoteParticipant)
|
|
405
|
+
- **"track_subscription_failed"**: Called when a track subscription fails.
|
|
406
|
+
- Arguments: `participant` (RemoteParticipant), `track_sid` (str), `error` (str)
|
|
407
|
+
- **"track_muted"**: Called when a track is muted.
|
|
408
|
+
- Arguments: `participant` (Participant), `publication` (TrackPublication)
|
|
409
|
+
- **"track_unmuted"**: Called when a track is unmuted.
|
|
410
|
+
- Arguments: `participant` (Participant), `publication` (TrackPublication)
|
|
411
|
+
- **"active_speakers_changed"**: Called when the list of active speakers changes.
|
|
412
|
+
- Arguments: `speakers` (list[Participant])
|
|
413
|
+
- **"room_metadata_changed"**: Called when the room's metadata is updated.
|
|
414
|
+
- Arguments: `old_metadata` (str), `new_metadata` (str)
|
|
415
|
+
- **"participant_metadata_changed"**: Called when a participant's metadata is updated.
|
|
416
|
+
- Arguments: `participant` (Participant), `old_metadata` (str), `new_metadata` (str)
|
|
417
|
+
- **"participant_name_changed"**: Called when a participant's name is changed.
|
|
418
|
+
- Arguments: `participant` (Participant), `old_name` (str), `new_name` (str)
|
|
419
|
+
- **"participant_attributes_changed"**: Called when a participant's attributes change.
|
|
420
|
+
- Arguments: `changed_attributes` (dict), `participant` (Participant)
|
|
421
|
+
- **"participant_encryption_status_changed"**: Called when a participant's encryption status changes.
|
|
422
|
+
- Arguments `is_encrypted` (bool), `participant` (Participant)
|
|
423
|
+
- **"connection_quality_changed"**: Called when a participant's connection quality changes.
|
|
424
|
+
- Arguments: `participant` (Participant), `quality` (ConnectionQuality)
|
|
425
|
+
- **"transcription_received"**: Called when a transcription is received.
|
|
426
|
+
- Arguments: `segments` (list[TranscriptionSegment]), `participant` (Participant), `publication` (TrackPublication)
|
|
427
|
+
- **"data_received"**: Called when data is received.
|
|
428
|
+
- Arguments: `data_packet` (DataPacket)
|
|
429
|
+
- **"sip_dtmf_received"**: Called when a SIP DTMF signal is received.
|
|
430
|
+
- Arguments: `sip_dtmf` (SipDTMF)
|
|
431
|
+
- **"e2ee_state_changed"**: Called when a participant's E2EE state changes.
|
|
432
|
+
- Arguments: `participant` (Participant), `state` (EncryptionState)
|
|
433
|
+
- **"connection_state_changed"**: Called when the room's connection state changes.
|
|
434
|
+
- Arguments: `connection_state` (ConnectionState)
|
|
435
|
+
- **"connected"**: Called when the room is successfully connected.
|
|
436
|
+
- Arguments: None
|
|
437
|
+
- **"disconnected"**: Called when the room is disconnected.
|
|
438
|
+
- Arguments: `reason` (DisconnectReason)
|
|
439
|
+
- **"reconnecting"**: Called when the room is attempting to reconnect.
|
|
440
|
+
- Arguments: None
|
|
441
|
+
- **"reconnected"**: Called when the room has successfully reconnected.
|
|
442
|
+
- Arguments: None
|
|
443
|
+
- **"room_updated"**: Called when any information about the room is updated.
|
|
444
|
+
- Arguments: None
|
|
445
|
+
- **"moved"**: Called when the participant has been moved to another room.
|
|
446
|
+
- Arguments: None
|
|
447
|
+
- **"data_track_published"**: Called when a remote participant publishes a data track.
|
|
448
|
+
- Arguments: `track` (RemoteDataTrack)
|
|
449
|
+
- **"data_track_unpublished"**: Called when a remote participant unpublishes a data track.
|
|
450
|
+
- Arguments: `sid` (str)
|
|
451
|
+
|
|
452
|
+
Example:
|
|
453
|
+
```python
|
|
454
|
+
def on_participant_connected(participant):
|
|
455
|
+
print(f"Participant connected: {participant.identity}")
|
|
456
|
+
|
|
457
|
+
room.on("participant_connected", on_participant_connected)
|
|
458
|
+
```
|
|
459
|
+
"""
|
|
460
|
+
return super().on(event, callback)
|
|
461
|
+
|
|
462
|
+
async def connect(self, url: str, token: str, options: RoomOptions = RoomOptions()) -> None:
|
|
463
|
+
"""Connects to a LiveKit room using the specified URL and token.
|
|
464
|
+
|
|
465
|
+
Parameters:
|
|
466
|
+
url (str): The WebSocket URL of the LiveKit server to connect to.
|
|
467
|
+
token (str): The access token for authentication and authorization.
|
|
468
|
+
options (RoomOptions, optional): Additional options for the room connection.
|
|
469
|
+
|
|
470
|
+
Raises:
|
|
471
|
+
ConnectError: If the connection fails.
|
|
472
|
+
|
|
473
|
+
Example:
|
|
474
|
+
```python
|
|
475
|
+
room = Room()
|
|
476
|
+
|
|
477
|
+
# Listen for events before connecting to the room
|
|
478
|
+
@room.on("participant_connected")
|
|
479
|
+
def on_participant_connected(participant):
|
|
480
|
+
print(f"Participant connected: {participant.identity}")
|
|
481
|
+
|
|
482
|
+
await room.connect("ws://localhost:7880", "your_token")
|
|
483
|
+
```
|
|
484
|
+
"""
|
|
485
|
+
self._server_url = url
|
|
486
|
+
self._token = token
|
|
487
|
+
self._is_connecting = True
|
|
488
|
+
if self._first_sid_future.done() and not self._info.sid:
|
|
489
|
+
self._first_sid_future = asyncio.Future[str]()
|
|
490
|
+
req = proto_ffi.FfiRequest()
|
|
491
|
+
req.connect.url = url
|
|
492
|
+
req.connect.token = token
|
|
493
|
+
|
|
494
|
+
# options
|
|
495
|
+
req.connect.options.auto_subscribe = options.auto_subscribe
|
|
496
|
+
req.connect.options.dynacast = options.dynacast
|
|
497
|
+
|
|
498
|
+
if (
|
|
499
|
+
options.data_stream is not None
|
|
500
|
+
and options.data_stream.max_payload_byte_length is not None
|
|
501
|
+
):
|
|
502
|
+
req.connect.options.data_stream.max_payload_byte_length = (
|
|
503
|
+
options.data_stream.max_payload_byte_length
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
if options.connect_timeout is not None:
|
|
507
|
+
req.connect.options.connect_timeout_ms = int(options.connect_timeout * 1000)
|
|
508
|
+
|
|
509
|
+
if options.single_peer_connection is not None:
|
|
510
|
+
req.connect.options.single_peer_connection = options.single_peer_connection
|
|
511
|
+
|
|
512
|
+
if options.e2ee:
|
|
513
|
+
warnings.warn(
|
|
514
|
+
"options.e2ee is deprecated, use options.encryption instead",
|
|
515
|
+
DeprecationWarning,
|
|
516
|
+
stacklevel=2,
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
req.connect.options.e2ee.encryption_type = options.e2ee.encryption_type
|
|
520
|
+
if options.e2ee.key_provider_options.shared_key is not None:
|
|
521
|
+
req.connect.options.e2ee.key_provider_options.shared_key = (
|
|
522
|
+
options.e2ee.key_provider_options.shared_key
|
|
523
|
+
)
|
|
524
|
+
req.connect.options.e2ee.key_provider_options.ratchet_salt = (
|
|
525
|
+
options.e2ee.key_provider_options.ratchet_salt
|
|
526
|
+
)
|
|
527
|
+
req.connect.options.e2ee.key_provider_options.failure_tolerance = (
|
|
528
|
+
options.e2ee.key_provider_options.failure_tolerance
|
|
529
|
+
)
|
|
530
|
+
req.connect.options.e2ee.key_provider_options.ratchet_window_size = (
|
|
531
|
+
options.e2ee.key_provider_options.ratchet_window_size
|
|
532
|
+
)
|
|
533
|
+
req.connect.options.e2ee.key_provider_options.key_ring_size = (
|
|
534
|
+
options.e2ee.key_provider_options.key_ring_size
|
|
535
|
+
)
|
|
536
|
+
req.connect.options.e2ee.key_provider_options.key_derivation_function = (
|
|
537
|
+
options.e2ee.key_provider_options.key_derivation_function
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
if options.encryption:
|
|
541
|
+
req.connect.options.encryption.encryption_type = options.encryption.encryption_type
|
|
542
|
+
if options.encryption.key_provider_options.shared_key is not None:
|
|
543
|
+
req.connect.options.encryption.key_provider_options.shared_key = (
|
|
544
|
+
options.encryption.key_provider_options.shared_key
|
|
545
|
+
)
|
|
546
|
+
req.connect.options.encryption.key_provider_options.ratchet_salt = (
|
|
547
|
+
options.encryption.key_provider_options.ratchet_salt
|
|
548
|
+
)
|
|
549
|
+
req.connect.options.encryption.key_provider_options.failure_tolerance = (
|
|
550
|
+
options.encryption.key_provider_options.failure_tolerance
|
|
551
|
+
)
|
|
552
|
+
req.connect.options.encryption.key_provider_options.ratchet_window_size = (
|
|
553
|
+
options.encryption.key_provider_options.ratchet_window_size
|
|
554
|
+
)
|
|
555
|
+
req.connect.options.encryption.key_provider_options.key_ring_size = (
|
|
556
|
+
options.encryption.key_provider_options.key_ring_size
|
|
557
|
+
)
|
|
558
|
+
req.connect.options.encryption.key_provider_options.key_derivation_function = (
|
|
559
|
+
options.encryption.key_provider_options.key_derivation_function
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
if options.rtc_config:
|
|
563
|
+
req.connect.options.rtc_config.ice_transport_type = (
|
|
564
|
+
options.rtc_config.ice_transport_type
|
|
565
|
+
)
|
|
566
|
+
req.connect.options.rtc_config.continual_gathering_policy = (
|
|
567
|
+
options.rtc_config.continual_gathering_policy
|
|
568
|
+
)
|
|
569
|
+
req.connect.options.rtc_config.ice_servers.extend(options.rtc_config.ice_servers)
|
|
570
|
+
|
|
571
|
+
# subscribe before connecting so we don't miss any events
|
|
572
|
+
# Media streams have their own subscriptions. Keep other events, including
|
|
573
|
+
# the publish/unpublish callbacks forwarded through _room_queue.
|
|
574
|
+
self._ffi_queue = FfiClient.instance.queue.subscribe(
|
|
575
|
+
self._loop,
|
|
576
|
+
filter_fn=lambda e: (
|
|
577
|
+
e.WhichOneof("message") not in ("audio_stream_event", "video_stream_event")
|
|
578
|
+
),
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
try:
|
|
582
|
+
queue = FfiClient.instance.queue.subscribe()
|
|
583
|
+
try:
|
|
584
|
+
resp = FfiClient.instance.request(req)
|
|
585
|
+
cb: proto_ffi.FfiEvent = await queue.wait_for(
|
|
586
|
+
lambda e: e.connect.async_id == resp.connect.async_id
|
|
587
|
+
)
|
|
588
|
+
finally:
|
|
589
|
+
FfiClient.instance.queue.unsubscribe(queue)
|
|
590
|
+
|
|
591
|
+
if cb.connect.error:
|
|
592
|
+
FfiClient.instance.queue.unsubscribe(self._ffi_queue)
|
|
593
|
+
raise ConnectError(cb.connect.error)
|
|
594
|
+
|
|
595
|
+
self._ffi_handle = FfiHandle(cb.connect.result.room.handle.id)
|
|
596
|
+
|
|
597
|
+
self._e2ee_manager = E2EEManager(
|
|
598
|
+
self._ffi_handle.handle, options.encryption or options.e2ee
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
self._info = cb.connect.result.room.info
|
|
602
|
+
self._resolve_first_sid(self._info.sid)
|
|
603
|
+
self._connection_state = ConnectionState.CONN_CONNECTED
|
|
604
|
+
|
|
605
|
+
self._local_participant = LocalParticipant(
|
|
606
|
+
self._room_queue, cb.connect.result.local_participant
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
for pt in cb.connect.result.participants:
|
|
610
|
+
rp = self._create_remote_participant(pt.participant)
|
|
611
|
+
|
|
612
|
+
# add the initial remote participant tracks
|
|
613
|
+
for owned_publication_info in pt.publications:
|
|
614
|
+
publication = RemoteTrackPublication(owned_publication_info)
|
|
615
|
+
rp._track_publications[publication.sid] = publication
|
|
616
|
+
|
|
617
|
+
# start listening to room events
|
|
618
|
+
self._task = self._loop.create_task(self._listen_task())
|
|
619
|
+
except BaseException as e:
|
|
620
|
+
if self._ffi_handle is None and hasattr(self, "_ffi_queue"):
|
|
621
|
+
FfiClient.instance.queue.unsubscribe(self._ffi_queue)
|
|
622
|
+
self._fail_first_sid(
|
|
623
|
+
RuntimeError("room connection was cancelled")
|
|
624
|
+
if isinstance(e, asyncio.CancelledError)
|
|
625
|
+
else e
|
|
626
|
+
)
|
|
627
|
+
raise
|
|
628
|
+
finally:
|
|
629
|
+
self._is_connecting = False
|
|
630
|
+
|
|
631
|
+
# unsubscribe from the FFI queue once the listen task ends.
|
|
632
|
+
# disconnect() unsubscribes too, but it early-returns when the
|
|
633
|
+
# room is already disconnected (e.g. removed remotely).
|
|
634
|
+
ffi_queue = self._ffi_queue
|
|
635
|
+
self._task.add_done_callback(lambda _: FfiClient.instance.queue.unsubscribe(ffi_queue))
|
|
636
|
+
|
|
637
|
+
# Unblock the FFI server once this SDK is ready to receive room events.
|
|
638
|
+
ready_req = proto_ffi.FfiRequest()
|
|
639
|
+
ready_req.ready_for_room_event.room_handle = self._ffi_handle.handle
|
|
640
|
+
FfiClient.instance.request(ready_req)
|
|
641
|
+
|
|
642
|
+
async def get_rtc_stats(self) -> RtcStats:
|
|
643
|
+
if not self.isconnected():
|
|
644
|
+
raise RuntimeError("the room isn't connected")
|
|
645
|
+
|
|
646
|
+
req = proto_ffi.FfiRequest()
|
|
647
|
+
req.get_session_stats.room_handle = self._ffi_handle.handle # type: ignore
|
|
648
|
+
|
|
649
|
+
queue = FfiClient.instance.queue.subscribe()
|
|
650
|
+
try:
|
|
651
|
+
resp = FfiClient.instance.request(req)
|
|
652
|
+
cb: proto_ffi.FfiEvent = await queue.wait_for(
|
|
653
|
+
lambda e: e.get_session_stats.async_id == resp.get_session_stats.async_id
|
|
654
|
+
)
|
|
655
|
+
finally:
|
|
656
|
+
FfiClient.instance.queue.unsubscribe(queue)
|
|
657
|
+
|
|
658
|
+
if cb.get_session_stats.error:
|
|
659
|
+
raise RuntimeError(cb.get_session_stats.error)
|
|
660
|
+
|
|
661
|
+
publisher_stats = list(cb.get_session_stats.result.publisher_stats)
|
|
662
|
+
subscriber_stats = list(cb.get_session_stats.result.subscriber_stats)
|
|
663
|
+
|
|
664
|
+
return RtcStats(publisher_stats=publisher_stats, subscriber_stats=subscriber_stats)
|
|
665
|
+
|
|
666
|
+
def register_byte_stream_handler(self, topic: str, handler: ByteStreamHandler) -> None:
|
|
667
|
+
existing_handler = self._byte_stream_handlers.get(topic)
|
|
668
|
+
if existing_handler is None:
|
|
669
|
+
self._byte_stream_handlers[topic] = handler
|
|
670
|
+
else:
|
|
671
|
+
raise ValueError("byte stream handler for topic '%s' already set" % topic)
|
|
672
|
+
|
|
673
|
+
def unregister_byte_stream_handler(self, topic: str) -> None:
|
|
674
|
+
if self._byte_stream_handlers.get(topic):
|
|
675
|
+
self._byte_stream_handlers.pop(topic)
|
|
676
|
+
|
|
677
|
+
def register_text_stream_handler(self, topic: str, handler: TextStreamHandler) -> None:
|
|
678
|
+
existing_handler = self._text_stream_handlers.get(topic)
|
|
679
|
+
if existing_handler is None:
|
|
680
|
+
self._text_stream_handlers[topic] = handler
|
|
681
|
+
else:
|
|
682
|
+
raise ValueError("text stream handler for topic '%s' already set" % topic)
|
|
683
|
+
|
|
684
|
+
def unregister_text_stream_handler(self, topic: str) -> None:
|
|
685
|
+
if self._text_stream_handlers.get(topic):
|
|
686
|
+
self._text_stream_handlers.pop(topic)
|
|
687
|
+
|
|
688
|
+
async def simulate_scenario(self, scenario: SimulateScenarioKind.ValueType) -> None:
|
|
689
|
+
"""Trigger a reconnection / chaos scenario for testing.
|
|
690
|
+
|
|
691
|
+
See `SimulateScenarioKind` for the available variants. Most useful
|
|
692
|
+
in tests to deterministically force a Resume (signal-only reconnect
|
|
693
|
+
that preserves the PeerConnection and existing publications) or a
|
|
694
|
+
full reconnect (the SDK rebuilds the RtcSession and re-publishes
|
|
695
|
+
existing local tracks).
|
|
696
|
+
|
|
697
|
+
Raises a `RuntimeError` if the SDK reports a failure.
|
|
698
|
+
"""
|
|
699
|
+
if not self.isconnected() or self._ffi_handle is None:
|
|
700
|
+
raise RuntimeError("simulate_scenario requires a connected room")
|
|
701
|
+
|
|
702
|
+
req = proto_ffi.FfiRequest()
|
|
703
|
+
req.simulate_scenario.room_handle = self._ffi_handle.handle
|
|
704
|
+
req.simulate_scenario.scenario = scenario
|
|
705
|
+
queue = FfiClient.instance.queue.subscribe()
|
|
706
|
+
try:
|
|
707
|
+
resp = FfiClient.instance.request(req)
|
|
708
|
+
cb = await queue.wait_for(
|
|
709
|
+
lambda e: e.simulate_scenario.async_id == resp.simulate_scenario.async_id
|
|
710
|
+
)
|
|
711
|
+
finally:
|
|
712
|
+
FfiClient.instance.queue.unsubscribe(queue)
|
|
713
|
+
|
|
714
|
+
if cb.simulate_scenario.HasField("error") and cb.simulate_scenario.error:
|
|
715
|
+
raise RuntimeError(f"simulate_scenario failed: {cb.simulate_scenario.error}")
|
|
716
|
+
|
|
717
|
+
async def disconnect(
|
|
718
|
+
self, *, reason: DisconnectReason.ValueType = DisconnectReason.CLIENT_INITIATED
|
|
719
|
+
) -> None:
|
|
720
|
+
"""Disconnects from the room."""
|
|
721
|
+
if not self.isconnected():
|
|
722
|
+
return
|
|
723
|
+
|
|
724
|
+
await self._drain_rpc_invocation_tasks()
|
|
725
|
+
self._error_stream_readers()
|
|
726
|
+
self._dispose_open_stream_writers()
|
|
727
|
+
|
|
728
|
+
req = proto_ffi.FfiRequest()
|
|
729
|
+
req.disconnect.room_handle = self._ffi_handle.handle # type: ignore
|
|
730
|
+
req.disconnect.reason = reason
|
|
731
|
+
queue = FfiClient.instance.queue.subscribe()
|
|
732
|
+
try:
|
|
733
|
+
resp = FfiClient.instance.request(req)
|
|
734
|
+
await queue.wait_for(lambda e: e.disconnect.async_id == resp.disconnect.async_id)
|
|
735
|
+
finally:
|
|
736
|
+
FfiClient.instance.queue.unsubscribe(queue)
|
|
737
|
+
|
|
738
|
+
await self._task
|
|
739
|
+
FfiClient.instance.queue.unsubscribe(self._ffi_queue)
|
|
740
|
+
|
|
741
|
+
# we should manually flip the state, since the connection could have been torn down before
|
|
742
|
+
# the callbacks were processed
|
|
743
|
+
if self._connection_state != ConnectionState.CONN_DISCONNECTED:
|
|
744
|
+
self.local_participant._info.disconnect_reason = reason
|
|
745
|
+
self._connection_state = ConnectionState.CONN_DISCONNECTED
|
|
746
|
+
self._fail_first_sid(RuntimeError("room disconnected before receiving sid"))
|
|
747
|
+
self.emit("connection_state_changed", self._connection_state)
|
|
748
|
+
self.emit("disconnected", reason)
|
|
749
|
+
|
|
750
|
+
async def _listen_task(self) -> None:
|
|
751
|
+
# listen to incoming room events
|
|
752
|
+
while True:
|
|
753
|
+
event = await self._ffi_queue.get()
|
|
754
|
+
if event.WhichOneof("message") == "rpc_method_invocation":
|
|
755
|
+
self._on_rpc_method_invocation(event.rpc_method_invocation)
|
|
756
|
+
elif event.room_event.room_handle == self._ffi_handle.handle: # type: ignore
|
|
757
|
+
if event.room_event.HasField("eos"):
|
|
758
|
+
break
|
|
759
|
+
|
|
760
|
+
try:
|
|
761
|
+
self._on_room_event(event.room_event)
|
|
762
|
+
except Exception:
|
|
763
|
+
logging.exception(
|
|
764
|
+
"error running user callback for %s: %s",
|
|
765
|
+
event.room_event.WhichOneof("message"),
|
|
766
|
+
event.room_event,
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
# wait for the subscribers to process the event
|
|
770
|
+
# before processing the next one
|
|
771
|
+
self._room_queue.put_nowait(event)
|
|
772
|
+
await self._room_queue.join()
|
|
773
|
+
|
|
774
|
+
# Clean up any pending RPC invocation tasks
|
|
775
|
+
await self._drain_rpc_invocation_tasks()
|
|
776
|
+
self._error_stream_readers()
|
|
777
|
+
self._dispose_open_stream_writers()
|
|
778
|
+
self._fail_first_sid(RuntimeError("room disconnected before receiving sid"))
|
|
779
|
+
|
|
780
|
+
def _on_rpc_method_invocation(self, rpc_invocation: RpcMethodInvocationEvent) -> None:
|
|
781
|
+
if self._local_participant is None:
|
|
782
|
+
return
|
|
783
|
+
|
|
784
|
+
if rpc_invocation.local_participant_handle == self._local_participant._ffi_handle.handle:
|
|
785
|
+
task = self._loop.create_task(
|
|
786
|
+
self._local_participant._handle_rpc_method_invocation(
|
|
787
|
+
rpc_invocation.invocation_id,
|
|
788
|
+
rpc_invocation.method,
|
|
789
|
+
rpc_invocation.request_id,
|
|
790
|
+
rpc_invocation.caller_identity,
|
|
791
|
+
rpc_invocation.payload,
|
|
792
|
+
rpc_invocation.response_timeout_ms / 1000.0,
|
|
793
|
+
)
|
|
794
|
+
)
|
|
795
|
+
self._rpc_invocation_tasks.add(task)
|
|
796
|
+
task.add_done_callback(self._rpc_invocation_tasks.discard)
|
|
797
|
+
|
|
798
|
+
def _on_room_event(self, event: proto_room.RoomEvent) -> None:
|
|
799
|
+
which = event.WhichOneof("message")
|
|
800
|
+
if which == "participant_connected":
|
|
801
|
+
rparticipant = self._create_remote_participant(event.participant_connected.info)
|
|
802
|
+
self.emit("participant_connected", rparticipant)
|
|
803
|
+
elif which == "participant_disconnected":
|
|
804
|
+
identity = event.participant_disconnected.participant_identity
|
|
805
|
+
rparticipant = self._remote_participants.pop(identity)
|
|
806
|
+
rparticipant._info.disconnect_reason = event.participant_disconnected.disconnect_reason
|
|
807
|
+
self.emit("participant_disconnected", rparticipant)
|
|
808
|
+
elif which == "participant_active":
|
|
809
|
+
rp = self._retrieve_remote_participant(event.participant_active.participant_identity)
|
|
810
|
+
if rp:
|
|
811
|
+
rp._info.state = proto_participant.PARTICIPANT_STATE_ACTIVE
|
|
812
|
+
self.emit("participant_active", rp)
|
|
813
|
+
elif which == "local_track_published":
|
|
814
|
+
sid = event.local_track_published.track_sid
|
|
815
|
+
lpublication = self.local_participant.track_publications[sid]
|
|
816
|
+
ltrack = lpublication.track
|
|
817
|
+
if ltrack is not None:
|
|
818
|
+
ltrack._set_room(self)
|
|
819
|
+
self.emit("local_track_published", lpublication, ltrack)
|
|
820
|
+
elif which == "local_track_unpublished":
|
|
821
|
+
# During teardown the publication may already have been removed
|
|
822
|
+
# from the participant's dict by LocalParticipant.unpublish_track
|
|
823
|
+
# (the FFI event races that async response), so the SID can be gone
|
|
824
|
+
# by the time this event is dispatched. Look it up defensively and
|
|
825
|
+
# skip the emit when it is no longer tracked, mirroring the
|
|
826
|
+
# local_track_republished and remote track_unpublished handlers,
|
|
827
|
+
# instead of raising a KeyError that _listen_task logs as an error.
|
|
828
|
+
sid = event.local_track_unpublished.publication_sid
|
|
829
|
+
unpublished = self.local_participant._track_publications.get(sid)
|
|
830
|
+
if unpublished is not None:
|
|
831
|
+
del self.local_participant._track_publications[sid]
|
|
832
|
+
track = unpublished.track
|
|
833
|
+
if track is not None:
|
|
834
|
+
track._set_room(None)
|
|
835
|
+
# Emit while `publication.track` is still set, preserving the
|
|
836
|
+
# pre-existing payload for callbacks. This handler is synchronous
|
|
837
|
+
# and emit() invokes listeners synchronously, so nulling the track
|
|
838
|
+
# right after still completes before any other coroutine (e.g.
|
|
839
|
+
# unpublish_track) can interleave.
|
|
840
|
+
self.emit("local_track_unpublished", unpublished)
|
|
841
|
+
# Mirror track_unsubscribed: drop the publication's track
|
|
842
|
+
# reference. This also makes unpublish_track's own _set_room(None)
|
|
843
|
+
# a no-op when it loses the race (its `publication._track is not
|
|
844
|
+
# None` guard short-circuits), avoiding a redundant clear.
|
|
845
|
+
if track is not None:
|
|
846
|
+
unpublished._track = None
|
|
847
|
+
else:
|
|
848
|
+
logging.debug("local_track_unpublished for untracked publication sid %s", sid)
|
|
849
|
+
elif which == "local_track_republished":
|
|
850
|
+
# The SDK auto-republished a local track during a full
|
|
851
|
+
# reconnect: the underlying Track (and its bound source) is
|
|
852
|
+
# preserved, but the publication and track SIDs were re-issued
|
|
853
|
+
# by the server. Update the existing LocalTrackPublication
|
|
854
|
+
# object in place so application code holding a cached
|
|
855
|
+
# reference continues to see current state, then rekey it
|
|
856
|
+
# under the new SID in the participant's publications dict.
|
|
857
|
+
previous_sid = event.local_track_republished.previous_sid
|
|
858
|
+
republished = self.local_participant._track_publications.get(previous_sid)
|
|
859
|
+
if republished is not None:
|
|
860
|
+
del self.local_participant._track_publications[previous_sid]
|
|
861
|
+
republished._info = event.local_track_republished.info
|
|
862
|
+
self.local_participant._track_publications[republished.sid] = republished
|
|
863
|
+
if republished.track is not None:
|
|
864
|
+
# Keep the local-track invariant (track.sid == publication.sid,
|
|
865
|
+
# set at publish_track) intact across republish, then re-push
|
|
866
|
+
# metadata so any attached FrameProcessor learns the new
|
|
867
|
+
# publication SID / credentials. _set_room with the same room
|
|
868
|
+
# is a no-op for the token_refreshed listener but re-fans the
|
|
869
|
+
# metadata to every registered AudioStream.
|
|
870
|
+
republished.track._info.sid = republished.sid
|
|
871
|
+
republished.track._set_room(self)
|
|
872
|
+
self.emit("local_track_republished", republished, previous_sid)
|
|
873
|
+
elif which == "local_track_subscribed":
|
|
874
|
+
sid = event.local_track_subscribed.track_sid
|
|
875
|
+
lpublication = self.local_participant.track_publications[sid]
|
|
876
|
+
if not lpublication._first_subscription.done():
|
|
877
|
+
lpublication._first_subscription.set_result(None)
|
|
878
|
+
self.emit("local_track_subscribed", lpublication.track)
|
|
879
|
+
elif which == "track_published":
|
|
880
|
+
# The participant may already have been removed by a racing
|
|
881
|
+
# disconnect or a duplicate event, so the lookup is done defensively
|
|
882
|
+
# and the emit is skipped when the participant is gone, mirroring the
|
|
883
|
+
# track_unpublished handler, instead of raising a KeyError that
|
|
884
|
+
# _listen_task logs as an error.
|
|
885
|
+
identity = event.track_published.participant_identity
|
|
886
|
+
published_participant = self._remote_participants.get(identity)
|
|
887
|
+
if published_participant is not None:
|
|
888
|
+
published = RemoteTrackPublication(event.track_published.publication)
|
|
889
|
+
published_participant._track_publications[published.sid] = published
|
|
890
|
+
self.emit("track_published", published, published_participant)
|
|
891
|
+
else:
|
|
892
|
+
logger.debug("track_published for untracked participant %s", identity)
|
|
893
|
+
elif which == "track_unpublished":
|
|
894
|
+
# The participant or publication may already have been removed by a
|
|
895
|
+
# racing disconnect or a duplicate event, so both lookups are done
|
|
896
|
+
# defensively and the emit is skipped when the entry is gone,
|
|
897
|
+
# mirroring the local_track_unpublished handler, instead of raising a
|
|
898
|
+
# KeyError that _listen_task logs as an error.
|
|
899
|
+
identity = event.track_unpublished.participant_identity
|
|
900
|
+
sid = event.track_unpublished.publication_sid
|
|
901
|
+
rp = self._remote_participants.get(identity)
|
|
902
|
+
if rp is not None:
|
|
903
|
+
rpub = rp._track_publications.pop(sid, None)
|
|
904
|
+
if rpub is not None:
|
|
905
|
+
self.emit("track_unpublished", rpub, rp)
|
|
906
|
+
else:
|
|
907
|
+
logger.debug("track_unpublished for untracked publication sid %s", sid)
|
|
908
|
+
else:
|
|
909
|
+
logger.debug("track_unpublished for untracked participant %s", identity)
|
|
910
|
+
elif which == "track_subscribed":
|
|
911
|
+
# The participant or publication may already have been removed by a
|
|
912
|
+
# racing disconnect or unpublish, so both lookups are done
|
|
913
|
+
# defensively and the emit is skipped when either entry is gone,
|
|
914
|
+
# mirroring the track_unpublished handler, instead of raising a
|
|
915
|
+
# KeyError that _listen_task logs as an error.
|
|
916
|
+
owned_track_info = event.track_subscribed.track
|
|
917
|
+
track_info = owned_track_info.info
|
|
918
|
+
identity = event.track_subscribed.participant_identity
|
|
919
|
+
subscribed_participant = self._remote_participants.get(identity)
|
|
920
|
+
subscribed = (
|
|
921
|
+
subscribed_participant.track_publications.get(track_info.sid)
|
|
922
|
+
if subscribed_participant is not None
|
|
923
|
+
else None
|
|
924
|
+
)
|
|
925
|
+
if subscribed_participant is not None and subscribed is not None:
|
|
926
|
+
subscribed._subscribed = True
|
|
927
|
+
if track_info.kind == TrackKind.KIND_VIDEO:
|
|
928
|
+
remote_video_track = RemoteVideoTrack(owned_track_info)
|
|
929
|
+
remote_video_track._set_room(self)
|
|
930
|
+
subscribed._track = remote_video_track
|
|
931
|
+
self.emit(
|
|
932
|
+
"track_subscribed", remote_video_track, subscribed, subscribed_participant
|
|
933
|
+
)
|
|
934
|
+
elif track_info.kind == TrackKind.KIND_AUDIO:
|
|
935
|
+
remote_audio_track = RemoteAudioTrack(owned_track_info)
|
|
936
|
+
remote_audio_track._set_room(self)
|
|
937
|
+
subscribed._track = remote_audio_track
|
|
938
|
+
self.emit(
|
|
939
|
+
"track_subscribed", remote_audio_track, subscribed, subscribed_participant
|
|
940
|
+
)
|
|
941
|
+
else:
|
|
942
|
+
logger.debug(
|
|
943
|
+
"track_subscribed for untracked participant %s or publication %s",
|
|
944
|
+
identity,
|
|
945
|
+
track_info.sid,
|
|
946
|
+
)
|
|
947
|
+
elif which == "track_unsubscribed":
|
|
948
|
+
# The participant or publication may already have been removed by a
|
|
949
|
+
# racing disconnect or unpublish, so both lookups are done
|
|
950
|
+
# defensively and the emit is skipped when either entry is gone,
|
|
951
|
+
# mirroring the track_unpublished handler, instead of raising a
|
|
952
|
+
# KeyError that _listen_task logs as an error.
|
|
953
|
+
identity = event.track_unsubscribed.participant_identity
|
|
954
|
+
unsubscribed_participant = self._remote_participants.get(identity)
|
|
955
|
+
unsubscribed = (
|
|
956
|
+
unsubscribed_participant.track_publications.get(event.track_unsubscribed.track_sid)
|
|
957
|
+
if unsubscribed_participant is not None
|
|
958
|
+
else None
|
|
959
|
+
)
|
|
960
|
+
if unsubscribed_participant is not None and unsubscribed is not None:
|
|
961
|
+
rtrack = unsubscribed.track
|
|
962
|
+
if rtrack is not None:
|
|
963
|
+
rtrack._set_room(None)
|
|
964
|
+
unsubscribed._track = None
|
|
965
|
+
unsubscribed._subscribed = False
|
|
966
|
+
self.emit("track_unsubscribed", rtrack, unsubscribed, unsubscribed_participant)
|
|
967
|
+
else:
|
|
968
|
+
logger.debug(
|
|
969
|
+
"track_unsubscribed for untracked participant %s or publication %s",
|
|
970
|
+
identity,
|
|
971
|
+
event.track_unsubscribed.track_sid,
|
|
972
|
+
)
|
|
973
|
+
elif which == "track_subscription_failed":
|
|
974
|
+
# The participant may already have been removed by a racing
|
|
975
|
+
# disconnect, so the lookup is done defensively and the emit is
|
|
976
|
+
# skipped when the participant is gone, instead of raising a KeyError
|
|
977
|
+
# that _listen_task logs as an error.
|
|
978
|
+
identity = event.track_subscription_failed.participant_identity
|
|
979
|
+
failed_participant = self._remote_participants.get(identity)
|
|
980
|
+
if failed_participant is not None:
|
|
981
|
+
error = event.track_subscription_failed.error
|
|
982
|
+
self.emit(
|
|
983
|
+
"track_subscription_failed",
|
|
984
|
+
failed_participant,
|
|
985
|
+
event.track_subscription_failed.track_sid,
|
|
986
|
+
error,
|
|
987
|
+
)
|
|
988
|
+
else:
|
|
989
|
+
logger.debug("track_subscription_failed for untracked participant %s", identity)
|
|
990
|
+
elif which == "track_muted":
|
|
991
|
+
identity = event.track_muted.participant_identity
|
|
992
|
+
# TODO: pass participant identity
|
|
993
|
+
participant = self._retrieve_participant(identity)
|
|
994
|
+
assert isinstance(participant, Participant)
|
|
995
|
+
publication = participant.track_publications[event.track_muted.track_sid]
|
|
996
|
+
publication._info.muted = True
|
|
997
|
+
if publication.track:
|
|
998
|
+
publication.track._info.muted = True
|
|
999
|
+
|
|
1000
|
+
self.emit("track_muted", participant, publication)
|
|
1001
|
+
elif which == "track_unmuted":
|
|
1002
|
+
identity = event.track_unmuted.participant_identity
|
|
1003
|
+
# TODO: pass participant identity
|
|
1004
|
+
participant = self._retrieve_participant(identity)
|
|
1005
|
+
assert isinstance(participant, Participant)
|
|
1006
|
+
publication = participant.track_publications[event.track_unmuted.track_sid]
|
|
1007
|
+
publication._info.muted = False
|
|
1008
|
+
if publication.track:
|
|
1009
|
+
publication.track._info.muted = False
|
|
1010
|
+
|
|
1011
|
+
self.emit("track_unmuted", participant, publication)
|
|
1012
|
+
elif which == "active_speakers_changed":
|
|
1013
|
+
speakers: list[Participant] = []
|
|
1014
|
+
# TODO: pass participant identity
|
|
1015
|
+
for identity in event.active_speakers_changed.participant_identities:
|
|
1016
|
+
participant = self._retrieve_participant(identity)
|
|
1017
|
+
assert isinstance(participant, Participant)
|
|
1018
|
+
speakers.append(participant)
|
|
1019
|
+
|
|
1020
|
+
self.emit("active_speakers_changed", speakers)
|
|
1021
|
+
elif which == "room_metadata_changed":
|
|
1022
|
+
old_metadata = self.metadata
|
|
1023
|
+
self._info.metadata = event.room_metadata_changed.metadata
|
|
1024
|
+
self.emit("room_metadata_changed", old_metadata, self.metadata)
|
|
1025
|
+
elif which == "room_sid_changed":
|
|
1026
|
+
self._resolve_first_sid(event.room_sid_changed.sid)
|
|
1027
|
+
self._info.sid = event.room_sid_changed.sid
|
|
1028
|
+
# This is an internal event, not exposed to users
|
|
1029
|
+
elif which == "participant_metadata_changed":
|
|
1030
|
+
identity = event.participant_metadata_changed.participant_identity
|
|
1031
|
+
# TODO: pass participant identity
|
|
1032
|
+
participant = self._retrieve_participant(identity)
|
|
1033
|
+
assert isinstance(participant, Participant)
|
|
1034
|
+
old_metadata = participant.metadata
|
|
1035
|
+
participant._info.metadata = event.participant_metadata_changed.metadata
|
|
1036
|
+
self.emit(
|
|
1037
|
+
"participant_metadata_changed",
|
|
1038
|
+
participant,
|
|
1039
|
+
old_metadata,
|
|
1040
|
+
participant.metadata,
|
|
1041
|
+
)
|
|
1042
|
+
elif which == "participant_name_changed":
|
|
1043
|
+
identity = event.participant_name_changed.participant_identity
|
|
1044
|
+
participant = self._retrieve_participant(identity)
|
|
1045
|
+
assert isinstance(participant, Participant)
|
|
1046
|
+
old_name = participant.name
|
|
1047
|
+
participant._info.name = event.participant_name_changed.name
|
|
1048
|
+
self.emit("participant_name_changed", participant, old_name, participant.name)
|
|
1049
|
+
elif which == "participant_attributes_changed":
|
|
1050
|
+
identity = event.participant_attributes_changed.participant_identity
|
|
1051
|
+
attributes = event.participant_attributes_changed.attributes
|
|
1052
|
+
changed_attributes = dict(
|
|
1053
|
+
(entry.key, entry.value)
|
|
1054
|
+
for entry in event.participant_attributes_changed.changed_attributes
|
|
1055
|
+
)
|
|
1056
|
+
participant = self._retrieve_participant(identity)
|
|
1057
|
+
assert isinstance(participant, Participant)
|
|
1058
|
+
participant._info.attributes.clear()
|
|
1059
|
+
participant._info.attributes.update((entry.key, entry.value) for entry in attributes)
|
|
1060
|
+
self.emit(
|
|
1061
|
+
"participant_attributes_changed",
|
|
1062
|
+
changed_attributes,
|
|
1063
|
+
participant,
|
|
1064
|
+
)
|
|
1065
|
+
elif which == "participant_encryption_status_changed":
|
|
1066
|
+
identity = event.participant_encryption_status_changed.participant_identity
|
|
1067
|
+
participant = self._retrieve_participant(identity)
|
|
1068
|
+
self.emit(
|
|
1069
|
+
"participant_encryption_status_changed",
|
|
1070
|
+
participant,
|
|
1071
|
+
event.participant_encryption_status_changed.is_encrypted,
|
|
1072
|
+
)
|
|
1073
|
+
elif which == "participant_permission_changed":
|
|
1074
|
+
identity = event.participant_permission_changed.participant_identity
|
|
1075
|
+
participant = self._retrieve_participant(identity)
|
|
1076
|
+
assert isinstance(participant, Participant)
|
|
1077
|
+
participant._info.permission.CopyFrom(event.participant_permission_changed.permission)
|
|
1078
|
+
self.emit(
|
|
1079
|
+
"participant_permissions_changed",
|
|
1080
|
+
participant,
|
|
1081
|
+
participant.permissions,
|
|
1082
|
+
)
|
|
1083
|
+
elif which == "connection_quality_changed":
|
|
1084
|
+
identity = event.connection_quality_changed.participant_identity
|
|
1085
|
+
participant = self._retrieve_participant(identity)
|
|
1086
|
+
quality = _connection_quality_from_proto(event.connection_quality_changed.quality)
|
|
1087
|
+
if participant:
|
|
1088
|
+
participant._connection_quality = quality
|
|
1089
|
+
self.emit(
|
|
1090
|
+
"connection_quality_changed",
|
|
1091
|
+
participant,
|
|
1092
|
+
quality,
|
|
1093
|
+
)
|
|
1094
|
+
elif which == "transcription_received":
|
|
1095
|
+
transcription = event.transcription_received
|
|
1096
|
+
segments = [
|
|
1097
|
+
TranscriptionSegment(
|
|
1098
|
+
id=s.id,
|
|
1099
|
+
text=s.text,
|
|
1100
|
+
final=s.final,
|
|
1101
|
+
start_time=s.start_time,
|
|
1102
|
+
end_time=s.end_time,
|
|
1103
|
+
language=s.language,
|
|
1104
|
+
)
|
|
1105
|
+
for s in transcription.segments
|
|
1106
|
+
]
|
|
1107
|
+
part = self._retrieve_participant(transcription.participant_identity)
|
|
1108
|
+
pub: TrackPublication | None = None
|
|
1109
|
+
if part:
|
|
1110
|
+
pub = part.track_publications.get(transcription.track_sid)
|
|
1111
|
+
self.emit("transcription_received", segments, part, pub)
|
|
1112
|
+
elif which == "data_packet_received":
|
|
1113
|
+
packet = event.data_packet_received
|
|
1114
|
+
which_val = packet.WhichOneof("value")
|
|
1115
|
+
if which_val == "user":
|
|
1116
|
+
owned_buffer_info = packet.user.data
|
|
1117
|
+
buffer_info = owned_buffer_info.data
|
|
1118
|
+
data = ctypes.string_at(buffer_info.data_ptr, buffer_info.data_len)
|
|
1119
|
+
FfiHandle(owned_buffer_info.handle.id).dispose()
|
|
1120
|
+
rparticipant = cast(
|
|
1121
|
+
RemoteParticipant,
|
|
1122
|
+
self._retrieve_remote_participant(packet.participant_identity),
|
|
1123
|
+
)
|
|
1124
|
+
self.emit(
|
|
1125
|
+
"data_received",
|
|
1126
|
+
DataPacket(
|
|
1127
|
+
data=data,
|
|
1128
|
+
kind=packet.kind,
|
|
1129
|
+
participant=rparticipant,
|
|
1130
|
+
topic=packet.user.topic,
|
|
1131
|
+
),
|
|
1132
|
+
)
|
|
1133
|
+
elif which_val == "sip_dtmf":
|
|
1134
|
+
rparticipant = cast(
|
|
1135
|
+
RemoteParticipant,
|
|
1136
|
+
self._retrieve_remote_participant(packet.participant_identity),
|
|
1137
|
+
)
|
|
1138
|
+
self.emit(
|
|
1139
|
+
"sip_dtmf_received",
|
|
1140
|
+
SipDTMF(
|
|
1141
|
+
code=packet.sip_dtmf.code,
|
|
1142
|
+
digit=packet.sip_dtmf.digit,
|
|
1143
|
+
participant=rparticipant,
|
|
1144
|
+
),
|
|
1145
|
+
)
|
|
1146
|
+
elif which == "e2ee_state_changed":
|
|
1147
|
+
identity = event.e2ee_state_changed.participant_identity
|
|
1148
|
+
e2ee_state = event.e2ee_state_changed.state
|
|
1149
|
+
# TODO: pass participant identity
|
|
1150
|
+
self.emit("e2ee_state_changed", self._retrieve_participant(identity), e2ee_state)
|
|
1151
|
+
elif which == "connection_state_changed":
|
|
1152
|
+
connection_state = event.connection_state_changed.state
|
|
1153
|
+
self._connection_state = connection_state
|
|
1154
|
+
self.emit("connection_state_changed", connection_state)
|
|
1155
|
+
elif which == "disconnected":
|
|
1156
|
+
self._fail_first_sid(RuntimeError("room disconnected before receiving sid"))
|
|
1157
|
+
self.emit("disconnected", event.disconnected.reason)
|
|
1158
|
+
elif which == "reconnecting":
|
|
1159
|
+
self.emit("reconnecting")
|
|
1160
|
+
elif which == "reconnected":
|
|
1161
|
+
self.emit("reconnected")
|
|
1162
|
+
elif which == "text_stream_opened":
|
|
1163
|
+
self._handle_text_stream_opened(event.text_stream_opened)
|
|
1164
|
+
elif which == "byte_stream_opened":
|
|
1165
|
+
self._handle_byte_stream_opened(event.byte_stream_opened)
|
|
1166
|
+
elif which == "room_updated":
|
|
1167
|
+
self._info = event.room_updated
|
|
1168
|
+
self._resolve_first_sid(self._info.sid)
|
|
1169
|
+
self.emit("room_updated")
|
|
1170
|
+
|
|
1171
|
+
elif which == "moved":
|
|
1172
|
+
self._info = event.moved
|
|
1173
|
+
self._resolve_first_sid(self._info.sid)
|
|
1174
|
+
self.emit("moved")
|
|
1175
|
+
|
|
1176
|
+
elif which == "participants_updated":
|
|
1177
|
+
for info in event.participants_updated.participants:
|
|
1178
|
+
participant = self._retrieve_participant(info.identity)
|
|
1179
|
+
if participant:
|
|
1180
|
+
participant._info = info
|
|
1181
|
+
|
|
1182
|
+
elif which == "token_refreshed":
|
|
1183
|
+
self._token = event.token_refreshed.token
|
|
1184
|
+
self.emit("token_refreshed")
|
|
1185
|
+
|
|
1186
|
+
elif which == "data_track_published":
|
|
1187
|
+
remote_data_track = RemoteDataTrack(event.data_track_published.track)
|
|
1188
|
+
self.emit("data_track_published", remote_data_track)
|
|
1189
|
+
|
|
1190
|
+
elif which == "data_track_unpublished":
|
|
1191
|
+
self.emit("data_track_unpublished", event.data_track_unpublished.sid)
|
|
1192
|
+
|
|
1193
|
+
def _handle_text_stream_opened(self, opened: proto_room.TextStreamOpened) -> None:
|
|
1194
|
+
topic = opened.reader.info.topic
|
|
1195
|
+
text_stream_handler = self._text_stream_handlers.get(topic)
|
|
1196
|
+
handle_id = opened.reader.handle.id
|
|
1197
|
+
if text_stream_handler is None:
|
|
1198
|
+
logging.info(
|
|
1199
|
+
"ignoring text stream with topic '%s', no callback attached",
|
|
1200
|
+
topic,
|
|
1201
|
+
)
|
|
1202
|
+
# the reader is never consumed by read_incremental, so the owned
|
|
1203
|
+
# handle (and its buffered chunks) must be disposed here
|
|
1204
|
+
FfiHandle(handle_id).dispose()
|
|
1205
|
+
return
|
|
1206
|
+
|
|
1207
|
+
def on_close() -> None:
|
|
1208
|
+
self._text_stream_readers.pop(handle_id, None)
|
|
1209
|
+
|
|
1210
|
+
text_reader = TextStreamReader(opened.reader, on_close=on_close)
|
|
1211
|
+
self._text_stream_readers[handle_id] = text_reader
|
|
1212
|
+
text_stream_handler(text_reader, opened.participant_identity)
|
|
1213
|
+
|
|
1214
|
+
def _handle_byte_stream_opened(self, opened: proto_room.ByteStreamOpened) -> None:
|
|
1215
|
+
topic = opened.reader.info.topic
|
|
1216
|
+
byte_stream_handler = self._byte_stream_handlers.get(topic)
|
|
1217
|
+
handle_id = opened.reader.handle.id
|
|
1218
|
+
if byte_stream_handler is None:
|
|
1219
|
+
logging.info(
|
|
1220
|
+
"ignoring byte stream with topic '%s', no callback attached",
|
|
1221
|
+
topic,
|
|
1222
|
+
)
|
|
1223
|
+
# the reader is never consumed by read_incremental, so the owned
|
|
1224
|
+
# handle (and its buffered chunks) must be disposed here
|
|
1225
|
+
FfiHandle(handle_id).dispose()
|
|
1226
|
+
return
|
|
1227
|
+
|
|
1228
|
+
def on_close() -> None:
|
|
1229
|
+
self._byte_stream_readers.pop(handle_id, None)
|
|
1230
|
+
|
|
1231
|
+
byte_reader = ByteStreamReader(opened.reader, on_close=on_close)
|
|
1232
|
+
self._byte_stream_readers[handle_id] = byte_reader
|
|
1233
|
+
byte_stream_handler(byte_reader, opened.participant_identity)
|
|
1234
|
+
|
|
1235
|
+
def _dispose_open_stream_writers(self) -> None:
|
|
1236
|
+
"""Drops the native writers of data streams left open at disconnect.
|
|
1237
|
+
|
|
1238
|
+
A writer releases its handle only when it is closed, so one the
|
|
1239
|
+
application abandoned — or left behind after a failed write — would
|
|
1240
|
+
otherwise be held by the FFI server for the life of the process.
|
|
1241
|
+
"""
|
|
1242
|
+
if self._local_participant is not None:
|
|
1243
|
+
self._local_participant._dispose_open_stream_writers()
|
|
1244
|
+
|
|
1245
|
+
def _error_stream_readers(self) -> None:
|
|
1246
|
+
"""Wakes up any pending stream reads with a StreamError on disconnect.
|
|
1247
|
+
|
|
1248
|
+
This also drops each reader's FFI queue subscription, which would
|
|
1249
|
+
otherwise outlive the room: a reader unsubscribes itself only once a
|
|
1250
|
+
read consumes its end-of-stream event, so one the application never
|
|
1251
|
+
finished reading would stay subscribed for the life of the process.
|
|
1252
|
+
The readers stay open, so a read can still drain whatever arrived
|
|
1253
|
+
before the disconnect and then raise StreamError.
|
|
1254
|
+
"""
|
|
1255
|
+
for text_reader in list(self._text_stream_readers.values()):
|
|
1256
|
+
text_reader._signal_disconnect()
|
|
1257
|
+
for byte_reader in list(self._byte_stream_readers.values()):
|
|
1258
|
+
byte_reader._signal_disconnect()
|
|
1259
|
+
self._text_stream_readers.clear()
|
|
1260
|
+
self._byte_stream_readers.clear()
|
|
1261
|
+
|
|
1262
|
+
async def _drain_rpc_invocation_tasks(self) -> None:
|
|
1263
|
+
if self._rpc_invocation_tasks:
|
|
1264
|
+
for task in self._rpc_invocation_tasks:
|
|
1265
|
+
task.cancel()
|
|
1266
|
+
await asyncio.gather(*self._rpc_invocation_tasks, return_exceptions=True)
|
|
1267
|
+
|
|
1268
|
+
def _retrieve_remote_participant(self, identity: str) -> Optional[RemoteParticipant]:
|
|
1269
|
+
"""Retrieve a remote participant by identity"""
|
|
1270
|
+
return self._remote_participants.get(identity, None)
|
|
1271
|
+
|
|
1272
|
+
def _retrieve_participant(self, identity: str) -> Optional[Participant]:
|
|
1273
|
+
"""Retrieve a local or remote participant by identity"""
|
|
1274
|
+
if (
|
|
1275
|
+
identity
|
|
1276
|
+
and self._local_participant is not None
|
|
1277
|
+
and identity == self._local_participant.identity
|
|
1278
|
+
):
|
|
1279
|
+
return self._local_participant
|
|
1280
|
+
|
|
1281
|
+
return self._retrieve_remote_participant(identity)
|
|
1282
|
+
|
|
1283
|
+
def _create_remote_participant(
|
|
1284
|
+
self, owned_info: proto_participant.OwnedParticipant
|
|
1285
|
+
) -> RemoteParticipant:
|
|
1286
|
+
if owned_info.info.identity in self._remote_participants:
|
|
1287
|
+
raise Exception("participant already exists")
|
|
1288
|
+
|
|
1289
|
+
participant = RemoteParticipant(owned_info)
|
|
1290
|
+
self._remote_participants[participant.identity] = participant
|
|
1291
|
+
return participant
|
|
1292
|
+
|
|
1293
|
+
def __repr__(self) -> str:
|
|
1294
|
+
sid = "unknown"
|
|
1295
|
+
if self._info.sid:
|
|
1296
|
+
sid = self._info.sid
|
|
1297
|
+
elif (
|
|
1298
|
+
self._first_sid_future.done()
|
|
1299
|
+
and not self._first_sid_future.cancelled()
|
|
1300
|
+
and self._first_sid_future.exception() is None
|
|
1301
|
+
):
|
|
1302
|
+
sid = self._first_sid_future.result()
|
|
1303
|
+
|
|
1304
|
+
return f"rtc.Room(sid={sid}, name={self.name}, metadata={self.metadata}, connection_state={ConnectionState.Name(self._connection_state)})"
|