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.
Files changed (71) hide show
  1. livekit/rtc/__init__.py +264 -0
  2. livekit/rtc/_ffi_client.py +321 -0
  3. livekit/rtc/_proto/__init__.py +0 -0
  4. livekit/rtc/_proto/audio_frame_pb2.py +159 -0
  5. livekit/rtc/_proto/audio_frame_pb2.pyi +1560 -0
  6. livekit/rtc/_proto/capture_pb2.py +70 -0
  7. livekit/rtc/_proto/capture_pb2.pyi +559 -0
  8. livekit/rtc/_proto/data_stream_pb2.py +171 -0
  9. livekit/rtc/_proto/data_stream_pb2.pyi +1498 -0
  10. livekit/rtc/_proto/data_track_pb2.py +120 -0
  11. livekit/rtc/_proto/data_track_pb2.pyi +1073 -0
  12. livekit/rtc/_proto/e2ee_pb2.py +81 -0
  13. livekit/rtc/_proto/e2ee_pb2.pyi +591 -0
  14. livekit/rtc/_proto/ffi_pb2.py +55 -0
  15. livekit/rtc/_proto/ffi_pb2.pyi +1246 -0
  16. livekit/rtc/_proto/handle_pb2.py +27 -0
  17. livekit/rtc/_proto/handle_pb2.pyi +51 -0
  18. livekit/rtc/_proto/participant_pb2.py +45 -0
  19. livekit/rtc/_proto/participant_pb2.pyi +313 -0
  20. livekit/rtc/_proto/room_pb2.py +311 -0
  21. livekit/rtc/_proto/room_pb2.pyi +3136 -0
  22. livekit/rtc/_proto/rpc_pb2.py +47 -0
  23. livekit/rtc/_proto/rpc_pb2.pyi +261 -0
  24. livekit/rtc/_proto/stats_pb2.py +123 -0
  25. livekit/rtc/_proto/stats_pb2.pyi +1514 -0
  26. livekit/rtc/_proto/track_pb2.py +76 -0
  27. livekit/rtc/_proto/track_pb2.pyi +544 -0
  28. livekit/rtc/_proto/track_publication_pb2.py +39 -0
  29. livekit/rtc/_proto/track_publication_pb2.pyi +145 -0
  30. livekit/rtc/_proto/video_frame_pb2.py +97 -0
  31. livekit/rtc/_proto/video_frame_pb2.pyi +838 -0
  32. livekit/rtc/_utils.py +187 -0
  33. livekit/rtc/apm.py +119 -0
  34. livekit/rtc/audio_filter.py +23 -0
  35. livekit/rtc/audio_frame.py +242 -0
  36. livekit/rtc/audio_mixer.py +247 -0
  37. livekit/rtc/audio_resampler.py +174 -0
  38. livekit/rtc/audio_source.py +191 -0
  39. livekit/rtc/audio_stream.py +354 -0
  40. livekit/rtc/data_stream.py +640 -0
  41. livekit/rtc/data_track.py +296 -0
  42. livekit/rtc/e2ee.py +320 -0
  43. livekit/rtc/encoded_video.py +127 -0
  44. livekit/rtc/event_emitter.py +215 -0
  45. livekit/rtc/frame_processor.py +37 -0
  46. livekit/rtc/jupyter.py +68 -0
  47. livekit/rtc/log.py +3 -0
  48. livekit/rtc/media_devices.py +596 -0
  49. livekit/rtc/participant.py +1171 -0
  50. livekit/rtc/platform_audio.py +426 -0
  51. livekit/rtc/py.typed +0 -0
  52. livekit/rtc/resources/LICENSE.md +4055 -0
  53. livekit/rtc/resources/__init__.py +1 -0
  54. livekit/rtc/resources/jupyter-html/index.html +101 -0
  55. livekit/rtc/resources/livekit_ffi.dll +0 -0
  56. livekit/rtc/resources/livekit_ffi.h +42 -0
  57. livekit/rtc/room.py +1304 -0
  58. livekit/rtc/rpc.py +244 -0
  59. livekit/rtc/synchronizer.py +202 -0
  60. livekit/rtc/track.py +264 -0
  61. livekit/rtc/track_publication.py +145 -0
  62. livekit/rtc/transcription.py +19 -0
  63. livekit/rtc/utils.py +138 -0
  64. livekit/rtc/version.py +1 -0
  65. livekit/rtc/video_frame.py +347 -0
  66. livekit/rtc/video_source.py +74 -0
  67. livekit/rtc/video_stream.py +191 -0
  68. livekit-1.2.1.dist-info/METADATA +80 -0
  69. livekit-1.2.1.dist-info/RECORD +71 -0
  70. livekit-1.2.1.dist-info/WHEEL +5 -0
  71. 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)})"