livekit 1.2.0__tar.gz → 1.2.1__tar.gz

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 (90) hide show
  1. {livekit-1.2.0 → livekit-1.2.1}/PKG-INFO +1 -1
  2. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_ffi_client.py +8 -3
  3. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/audio_source.py +11 -1
  4. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/audio_stream.py +13 -4
  5. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/participant.py +2 -4
  6. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/room.py +80 -42
  7. livekit-1.2.1/livekit/rtc/version.py +1 -0
  8. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/video_stream.py +9 -3
  9. {livekit-1.2.0 → livekit-1.2.1}/livekit.egg-info/PKG-INFO +1 -1
  10. {livekit-1.2.0 → livekit-1.2.1}/livekit.egg-info/SOURCES.txt +1 -0
  11. {livekit-1.2.0 → livekit-1.2.1}/setup.py +1 -4
  12. livekit-1.2.1/tests/test_basic.py +124 -0
  13. {livekit-1.2.0 → livekit-1.2.1}/tests/test_dc.py +44 -0
  14. livekit-1.2.0/livekit/rtc/version.py +0 -1
  15. {livekit-1.2.0 → livekit-1.2.1}/README.md +0 -0
  16. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/__init__.py +0 -0
  17. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/__init__.py +0 -0
  18. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/audio_frame_pb2.py +0 -0
  19. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/audio_frame_pb2.pyi +0 -0
  20. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/capture_pb2.py +0 -0
  21. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/capture_pb2.pyi +0 -0
  22. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/data_stream_pb2.py +0 -0
  23. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/data_stream_pb2.pyi +0 -0
  24. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/data_track_pb2.py +0 -0
  25. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/data_track_pb2.pyi +0 -0
  26. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/e2ee_pb2.py +0 -0
  27. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/e2ee_pb2.pyi +0 -0
  28. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/ffi_pb2.py +0 -0
  29. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/ffi_pb2.pyi +0 -0
  30. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/handle_pb2.py +0 -0
  31. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/handle_pb2.pyi +0 -0
  32. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/participant_pb2.py +0 -0
  33. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/participant_pb2.pyi +0 -0
  34. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/room_pb2.py +0 -0
  35. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/room_pb2.pyi +0 -0
  36. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/rpc_pb2.py +0 -0
  37. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/rpc_pb2.pyi +0 -0
  38. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/stats_pb2.py +0 -0
  39. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/stats_pb2.pyi +0 -0
  40. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/track_pb2.py +0 -0
  41. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/track_pb2.pyi +0 -0
  42. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/track_publication_pb2.py +0 -0
  43. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/track_publication_pb2.pyi +0 -0
  44. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/video_frame_pb2.py +0 -0
  45. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/video_frame_pb2.pyi +0 -0
  46. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_utils.py +0 -0
  47. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/apm.py +0 -0
  48. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/audio_filter.py +0 -0
  49. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/audio_frame.py +0 -0
  50. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/audio_mixer.py +0 -0
  51. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/audio_resampler.py +0 -0
  52. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/data_stream.py +0 -0
  53. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/data_track.py +0 -0
  54. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/e2ee.py +0 -0
  55. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/encoded_video.py +0 -0
  56. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/event_emitter.py +0 -0
  57. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/frame_processor.py +0 -0
  58. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/jupyter.py +0 -0
  59. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/log.py +0 -0
  60. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/media_devices.py +0 -0
  61. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/platform_audio.py +0 -0
  62. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/py.typed +0 -0
  63. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/resources/__init__.py +0 -0
  64. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/resources/jupyter-html/index.html +0 -0
  65. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/rpc.py +0 -0
  66. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/synchronizer.py +0 -0
  67. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/track.py +0 -0
  68. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/track_publication.py +0 -0
  69. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/transcription.py +0 -0
  70. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/utils.py +0 -0
  71. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/video_frame.py +0 -0
  72. {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/video_source.py +0 -0
  73. {livekit-1.2.0 → livekit-1.2.1}/livekit.egg-info/dependency_links.txt +0 -0
  74. {livekit-1.2.0 → livekit-1.2.1}/livekit.egg-info/requires.txt +0 -0
  75. {livekit-1.2.0 → livekit-1.2.1}/livekit.egg-info/top_level.txt +0 -0
  76. {livekit-1.2.0 → livekit-1.2.1}/pyproject.toml +0 -0
  77. {livekit-1.2.0 → livekit-1.2.1}/setup.cfg +0 -0
  78. {livekit-1.2.0 → livekit-1.2.1}/tests/test_audio.py +0 -0
  79. {livekit-1.2.0 → livekit-1.2.1}/tests/test_audio_stream_init.py +0 -0
  80. {livekit-1.2.0 → livekit-1.2.1}/tests/test_audio_stream_room_lifecycle.py +0 -0
  81. {livekit-1.2.0 → livekit-1.2.1}/tests/test_broadcast_queue.py +0 -0
  82. {livekit-1.2.0 → livekit-1.2.1}/tests/test_change_video_quality.py +0 -0
  83. {livekit-1.2.0 → livekit-1.2.1}/tests/test_data_stream_writer.py +0 -0
  84. {livekit-1.2.0 → livekit-1.2.1}/tests/test_e2ee.py +0 -0
  85. {livekit-1.2.0 → livekit-1.2.1}/tests/test_e2ee_per_participant.py +0 -0
  86. {livekit-1.2.0 → livekit-1.2.1}/tests/test_e2ee_shared_key.py +0 -0
  87. {livekit-1.2.0 → livekit-1.2.1}/tests/test_ffi_client.py +0 -0
  88. {livekit-1.2.0 → livekit-1.2.1}/tests/test_ffi_handle_lifecycle.py +0 -0
  89. {livekit-1.2.0 → livekit-1.2.1}/tests/test_rpc_interceptors.py +0 -0
  90. {livekit-1.2.0 → livekit-1.2.1}/tests/test_video.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: livekit
3
- Version: 1.2.0
3
+ Version: 1.2.1
4
4
  Summary: Python Real-time SDK for LiveKit
5
5
  Author-email: LiveKit <support@livekit.io>
6
6
  License-Expression: Apache-2.0
@@ -231,12 +231,17 @@ def to_python_level(level: proto_ffi.LogLevel.ValueType) -> Optional[int]:
231
231
  class FfiClient:
232
232
  _instance: Optional["FfiClient"] = None
233
233
  _ffi_disposed: bool = False
234
+ _instance_lock = threading.Lock()
234
235
 
235
236
  @classproperty
236
237
  def instance(cls) -> "FfiClient":
237
- if cls._instance is None:
238
- cls._instance = FfiClient()
239
- return cls._instance
238
+ if cls._instance is not None:
239
+ return cls._instance
240
+ # threads that race here would each get their own client and lose events sent to the other
241
+ with cls._instance_lock:
242
+ if cls._instance is None:
243
+ cls._instance = FfiClient()
244
+ return cls._instance
240
245
 
241
246
  def __init__(self) -> None:
242
247
  FfiClient._ffi_disposed = False
@@ -136,9 +136,19 @@ class AudioSource:
136
136
  req.capture_audio_frame.source_handle = self._ffi_handle.handle
137
137
  req.capture_audio_frame.buffer.CopyFrom(frame._proto_info())
138
138
 
139
- queue = FfiClient.instance.queue.subscribe(loop=self._loop)
139
+ # The async_id is only known once the request returns; until then every capture
140
+ # callback is let through, and wait_for drops the ones of other captures.
141
+ async_id: int | None = None
142
+ queue = FfiClient.instance.queue.subscribe(
143
+ loop=self._loop,
144
+ filter_fn=lambda e: (
145
+ e.WhichOneof("message") == "capture_audio_frame"
146
+ and (async_id is None or e.capture_audio_frame.async_id == async_id)
147
+ ),
148
+ )
140
149
  try:
141
150
  resp = FfiClient.instance.request(req)
151
+ async_id = resp.capture_audio_frame.async_id
142
152
  cb: proto_ffi.FfiEvent = await queue.wait_for(
143
153
  lambda e: e.capture_audio_frame.async_id == resp.capture_audio_frame.async_id
144
154
  )
@@ -106,11 +106,16 @@ class AudioStream:
106
106
  self._num_channels = num_channels
107
107
  self._frame_size_ms = frame_size_ms
108
108
  self._loop = loop or asyncio.get_event_loop()
109
- # Only subscribe to audio_stream_event to avoid unnecessary memory allocations
110
- # from other event types (room_event, track_event, etc.)
109
+ # Subscribe before creating the stream so none of its events are missed. Its
110
+ # handle is only known once the request returns; until then every audio stream
111
+ # event is let through, and _run drops the ones of other streams.
112
+ handle_id: int | None = None
111
113
  self._ffi_queue = FfiClient.instance.queue.subscribe(
112
114
  self._loop,
113
- filter_fn=lambda e: e.WhichOneof("message") == "audio_stream_event",
115
+ filter_fn=lambda e: (
116
+ e.WhichOneof("message") == "audio_stream_event"
117
+ and (handle_id is None or e.audio_stream_event.stream_handle == handle_id)
118
+ ),
114
119
  )
115
120
  self._queue: RingQueue[AudioFrameEvent | None] = RingQueue(capacity)
116
121
 
@@ -133,6 +138,7 @@ class AudioStream:
133
138
  else:
134
139
  stream = self._create_owned_stream()
135
140
  self._ffi_handle = FfiHandle(stream.handle.id)
141
+ handle_id = stream.handle.id
136
142
  self._info = stream.info
137
143
 
138
144
  # Start _run only after _ffi_handle is set, so a failure above doesn't orphan
@@ -248,7 +254,10 @@ class AudioStream:
248
254
  )
249
255
 
250
256
  def __del__(self) -> None:
251
- FfiClient.instance.queue.unsubscribe(self._ffi_queue)
257
+ # _ffi_queue is unset if __init__ raised early or the instance was built via __new__
258
+ ffi_queue = getattr(self, "_ffi_queue", None)
259
+ if ffi_queue is not None:
260
+ FfiClient.instance.queue.unsubscribe(ffi_queue)
252
261
 
253
262
  def _create_owned_stream(self) -> Any:
254
263
  assert self._track is not None
@@ -14,7 +14,6 @@
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
- import ctypes
18
17
  import asyncio
19
18
  import functools
20
19
  import inspect
@@ -42,7 +41,7 @@ from ._proto.room_pb2 import (
42
41
  from ._proto.track_pb2 import (
43
42
  ParticipantTrackPermission,
44
43
  )
45
- from ._utils import BroadcastQueue
44
+ from ._utils import BroadcastQueue, get_address
46
45
  from .track import LocalTrack
47
46
  from .track_publication import (
48
47
  LocalTrackPublication,
@@ -336,11 +335,10 @@ class LocalParticipant(Participant):
336
335
  payload = payload.encode("utf-8")
337
336
 
338
337
  data_len = len(payload)
339
- cdata = (ctypes.c_byte * data_len)(*payload)
340
338
 
341
339
  req = proto_ffi.FfiRequest()
342
340
  req.publish_data.local_participant_handle = self._ffi_handle.handle
343
- req.publish_data.data_ptr = ctypes.addressof(cdata)
341
+ req.publish_data.data_ptr = get_address(payload)
344
342
  req.publish_data.data_len = data_len
345
343
  req.publish_data.reliable = reliable
346
344
  req.publish_data.topic = topic
@@ -129,7 +129,7 @@ class RoomOptions:
129
129
  connect_timeout: float | None = None
130
130
  """Timeout in seconds for each signal connection attempt. When None, uses the default (5s)."""
131
131
  single_peer_connection: bool | None = None
132
- """Use a single peer connection for both publish and subscribe. When None, uses the default (false)."""
132
+ """Use a single peer connection for both publish and subscribe. When None, uses the default (true)."""
133
133
  data_stream: DataStreamOptions | None = None
134
134
  """Options for incoming data streams. When None, uses the FFI defaults."""
135
135
 
@@ -189,6 +189,7 @@ class Room(EventEmitter[EventTypes]):
189
189
  self._connection_state = ConnectionState.CONN_DISCONNECTED
190
190
  self._first_sid_future = asyncio.Future[str]()
191
191
  self._local_participant: LocalParticipant | None = None
192
+ self._is_connecting: bool = False
192
193
 
193
194
  # active incoming stream readers, keyed by FFI reader handle id
194
195
  self._text_stream_readers: Dict[int, TextStreamReader] = {}
@@ -209,10 +210,16 @@ class Room(EventEmitter[EventTypes]):
209
210
 
210
211
  Returns:
211
212
  str: The session ID of the room.
213
+
214
+ Raises:
215
+ RuntimeError: If the room is disconnected and no SID was received.
212
216
  """
213
217
  if self._info.sid:
214
218
  return self._info.sid
215
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
+
216
223
  # This future is shared state for the Room. Caller-side timeouts, e.g.
217
224
  # asyncio.wait_for(room.sid, ...), should not cancel it for future waiters.
218
225
  return await asyncio.shield(self._first_sid_future)
@@ -221,8 +228,16 @@ class Room(EventEmitter[EventTypes]):
221
228
  # The room SID can arrive through the initial connect result or later
222
229
  # room update events. Resolve the first-SID waiter from any source that
223
230
  # carries it so waiters do not depend on event ordering.
224
- if sid and not self._first_sid_future.done():
225
- self._first_sid_future.set_result(sid)
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()
226
241
 
227
242
  @property
228
243
  def local_participant(self) -> LocalParticipant:
@@ -469,6 +484,9 @@ class Room(EventEmitter[EventTypes]):
469
484
  """
470
485
  self._server_url = url
471
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]()
472
490
  req = proto_ffi.FfiRequest()
473
491
  req.connect.url = url
474
492
  req.connect.token = token
@@ -560,43 +578,55 @@ class Room(EventEmitter[EventTypes]):
560
578
  ),
561
579
  )
562
580
 
563
- queue = FfiClient.instance.queue.subscribe()
564
581
  try:
565
- resp = FfiClient.instance.request(req)
566
- cb: proto_ffi.FfiEvent = await queue.wait_for(
567
- lambda e: e.connect.async_id == resp.connect.async_id
568
- )
569
- finally:
570
- FfiClient.instance.queue.unsubscribe(queue)
571
-
572
- if cb.connect.error:
573
- FfiClient.instance.queue.unsubscribe(self._ffi_queue)
574
- raise ConnectError(cb.connect.error)
575
-
576
- self._ffi_handle = FfiHandle(cb.connect.result.room.handle.id)
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)
577
590
 
578
- self._e2ee_manager = E2EEManager(
579
- self._ffi_handle.handle, options.encryption or options.e2ee
580
- )
591
+ if cb.connect.error:
592
+ FfiClient.instance.queue.unsubscribe(self._ffi_queue)
593
+ raise ConnectError(cb.connect.error)
581
594
 
582
- self._info = cb.connect.result.room.info
583
- self._resolve_first_sid(self._info.sid)
584
- self._connection_state = ConnectionState.CONN_CONNECTED
595
+ self._ffi_handle = FfiHandle(cb.connect.result.room.handle.id)
585
596
 
586
- self._local_participant = LocalParticipant(
587
- self._room_queue, cb.connect.result.local_participant
588
- )
597
+ self._e2ee_manager = E2EEManager(
598
+ self._ffi_handle.handle, options.encryption or options.e2ee
599
+ )
589
600
 
590
- for pt in cb.connect.result.participants:
591
- rp = self._create_remote_participant(pt.participant)
601
+ self._info = cb.connect.result.room.info
602
+ self._resolve_first_sid(self._info.sid)
603
+ self._connection_state = ConnectionState.CONN_CONNECTED
592
604
 
593
- # add the initial remote participant tracks
594
- for owned_publication_info in pt.publications:
595
- publication = RemoteTrackPublication(owned_publication_info)
596
- rp._track_publications[publication.sid] = publication
605
+ self._local_participant = LocalParticipant(
606
+ self._room_queue, cb.connect.result.local_participant
607
+ )
597
608
 
598
- # start listening to room events
599
- self._task = self._loop.create_task(self._listen_task())
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
600
630
 
601
631
  # unsubscribe from the FFI queue once the listen task ends.
602
632
  # disconnect() unsubscribes too, but it early-returns when the
@@ -713,6 +743,7 @@ class Room(EventEmitter[EventTypes]):
713
743
  if self._connection_state != ConnectionState.CONN_DISCONNECTED:
714
744
  self.local_participant._info.disconnect_reason = reason
715
745
  self._connection_state = ConnectionState.CONN_DISCONNECTED
746
+ self._fail_first_sid(RuntimeError("room disconnected before receiving sid"))
716
747
  self.emit("connection_state_changed", self._connection_state)
717
748
  self.emit("disconnected", reason)
718
749
 
@@ -744,6 +775,7 @@ class Room(EventEmitter[EventTypes]):
744
775
  await self._drain_rpc_invocation_tasks()
745
776
  self._error_stream_readers()
746
777
  self._dispose_open_stream_writers()
778
+ self._fail_first_sid(RuntimeError("room disconnected before receiving sid"))
747
779
 
748
780
  def _on_rpc_method_invocation(self, rpc_invocation: RpcMethodInvocationEvent) -> None:
749
781
  if self._local_participant is None:
@@ -1083,12 +1115,7 @@ class Room(EventEmitter[EventTypes]):
1083
1115
  if which_val == "user":
1084
1116
  owned_buffer_info = packet.user.data
1085
1117
  buffer_info = owned_buffer_info.data
1086
- native_data = ctypes.cast(
1087
- buffer_info.data_ptr,
1088
- ctypes.POINTER(ctypes.c_byte * buffer_info.data_len),
1089
- ).contents
1090
-
1091
- data = bytes(native_data)
1118
+ data = ctypes.string_at(buffer_info.data_ptr, buffer_info.data_len)
1092
1119
  FfiHandle(owned_buffer_info.handle.id).dispose()
1093
1120
  rparticipant = cast(
1094
1121
  RemoteParticipant,
@@ -1126,6 +1153,7 @@ class Room(EventEmitter[EventTypes]):
1126
1153
  self._connection_state = connection_state
1127
1154
  self.emit("connection_state_changed", connection_state)
1128
1155
  elif which == "disconnected":
1156
+ self._fail_first_sid(RuntimeError("room disconnected before receiving sid"))
1129
1157
  self.emit("disconnected", event.disconnected.reason)
1130
1158
  elif which == "reconnecting":
1131
1159
  self.emit("reconnecting")
@@ -1243,8 +1271,12 @@ class Room(EventEmitter[EventTypes]):
1243
1271
 
1244
1272
  def _retrieve_participant(self, identity: str) -> Optional[Participant]:
1245
1273
  """Retrieve a local or remote participant by identity"""
1246
- if identity and identity == self.local_participant.identity:
1247
- return self.local_participant
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
1248
1280
 
1249
1281
  return self._retrieve_remote_participant(identity)
1250
1282
 
@@ -1260,7 +1292,13 @@ class Room(EventEmitter[EventTypes]):
1260
1292
 
1261
1293
  def __repr__(self) -> str:
1262
1294
  sid = "unknown"
1263
- if self._first_sid_future.done():
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
+ ):
1264
1302
  sid = self._first_sid_future.result()
1265
1303
 
1266
1304
  return f"rtc.Room(sid={sid}, name={self.name}, metadata={self.metadata}, connection_state={ConnectionState.Name(self._connection_state)})"
@@ -0,0 +1 @@
1
+ __version__ = "1.2.1"
@@ -48,11 +48,16 @@ class VideoStream:
48
48
  **kwargs: Any,
49
49
  ) -> None:
50
50
  self._loop = loop or asyncio.get_event_loop()
51
- # Only subscribe to video_stream_event to avoid unnecessary memory allocations
52
- # from other event types (room_event, track_event, etc.)
51
+ # Subscribe before creating the stream so none of its events are missed. Its
52
+ # handle is only known once the request returns; until then every video stream
53
+ # event is let through, and _run drops the ones of other streams.
54
+ handle_id: int | None = None
53
55
  self._ffi_queue = FfiClient.instance.queue.subscribe(
54
56
  self._loop,
55
- filter_fn=lambda e: e.WhichOneof("message") == "video_stream_event",
57
+ filter_fn=lambda e: (
58
+ e.WhichOneof("message") == "video_stream_event"
59
+ and (handle_id is None or e.video_stream_event.stream_handle == handle_id)
60
+ ),
56
61
  )
57
62
  self._queue: RingQueue[VideoFrameEvent | None] = RingQueue(capacity)
58
63
  self._track: Track | None = track
@@ -68,6 +73,7 @@ class VideoStream:
68
73
  stream = self._create_owned_stream()
69
74
 
70
75
  self._ffi_handle = FfiHandle(stream.handle.id)
76
+ handle_id = stream.handle.id
71
77
  self._info = stream.info
72
78
 
73
79
  self._task = self._loop.create_task(self._run())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: livekit
3
- Version: 1.2.0
3
+ Version: 1.2.1
4
4
  Summary: Python Real-time SDK for LiveKit
5
5
  Author-email: LiveKit <support@livekit.io>
6
6
  License-Expression: Apache-2.0
@@ -73,6 +73,7 @@ livekit/rtc/resources/jupyter-html/index.html
73
73
  tests/test_audio.py
74
74
  tests/test_audio_stream_init.py
75
75
  tests/test_audio_stream_room_lifecycle.py
76
+ tests/test_basic.py
76
77
  tests/test_broadcast_queue.py
77
78
  tests/test_change_video_quality.py
78
79
  tests/test_data_stream_writer.py
@@ -56,10 +56,7 @@ def get_platform_tag():
56
56
  elif sys.platform == "linux":
57
57
  return f"linux_{platform.machine()}"
58
58
  elif sys.platform == "win32":
59
- arch = platform.machine()
60
- if arch == "AMD64":
61
- arch = "amd64"
62
- return f"win_{arch}"
59
+ return f"win_{platform.machine().lower()}"
63
60
  else:
64
61
  return f"{platform.system().lower()}_{platform.machine()}"
65
62
 
@@ -0,0 +1,124 @@
1
+ """End-to-end Test for basic scenarios.
2
+
3
+ The test exercises connect/disconnect lifecycle, participant
4
+ visibility, reconnection, and track publish/subscribe between four rooms.
5
+
6
+ Requires the following environment variables to run:
7
+ LIVEKIT_URL
8
+ LIVEKIT_API_KEY
9
+ LIVEKIT_API_SECRET
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+
16
+ import pytest
17
+
18
+ from livekit import rtc
19
+
20
+ from utils import ( # type: ignore[import-not-found]
21
+ await_event,
22
+ connect_room,
23
+ create_token,
24
+ ensure_rooms_all_connected,
25
+ ensure_track_subscribed,
26
+ expect_room_event,
27
+ skip_if_no_credentials,
28
+ unique_room_name,
29
+ wait_until,
30
+ )
31
+
32
+
33
+ async def _publish_video(room: rtc.Room, track_name: str) -> rtc.LocalTrackPublication:
34
+ source = rtc.VideoSource(320, 240)
35
+ track = rtc.LocalVideoTrack.create_video_track(track_name, source)
36
+ options = rtc.TrackPublishOptions(source=rtc.TrackSource.SOURCE_CAMERA)
37
+ return await room.local_participant.publish_track(track, options)
38
+
39
+
40
+ async def _publish_audio(room: rtc.Room, track_name: str) -> rtc.LocalTrackPublication:
41
+ source = rtc.AudioSource(48000, 1)
42
+ track = rtc.LocalAudioTrack.create_audio_track(track_name, source)
43
+ options = rtc.TrackPublishOptions(source=rtc.TrackSource.SOURCE_MICROPHONE)
44
+ return await room.local_participant.publish_track(track, options)
45
+
46
+
47
+ @skip_if_no_credentials() # type: ignore[untyped-decorator]
48
+ @pytest.mark.asyncio
49
+ async def test_connection_basics() -> None:
50
+ room_name = unique_room_name("py-basics")
51
+
52
+ p1 = await connect_room("p1", room_name)
53
+ p2 = await connect_room("p2", room_name)
54
+ await ensure_rooms_all_connected([p1, p2])
55
+
56
+ # p2 should observe p1 leaving
57
+ p2_saw_p1_left = expect_room_event(
58
+ p2,
59
+ "participant_disconnected",
60
+ predicate=lambda p: p.identity == "p1",
61
+ )
62
+ await p1.disconnect()
63
+ await await_event(p2_saw_p1_left)
64
+
65
+ await wait_until(
66
+ lambda: p1.connection_state == rtc.ConnectionState.CONN_DISCONNECTED,
67
+ message="p1 did not reach CONN_DISCONNECTED",
68
+ )
69
+
70
+ await p2.disconnect()
71
+ await wait_until(
72
+ lambda: p2.connection_state == rtc.ConnectionState.CONN_DISCONNECTED,
73
+ message="p2 did not reach CONN_DISCONNECTED",
74
+ )
75
+
76
+ # p3: connect, disconnect, reconnect, disconnect cycle
77
+ p3 = await connect_room("p3", room_name)
78
+ p3_url = os.environ["LIVEKIT_URL"]
79
+ p3_token = create_token("p3", room_name)
80
+
81
+ await p3.disconnect()
82
+ assert p3.connection_state == rtc.ConnectionState.CONN_DISCONNECTED, (
83
+ f"expected p3 disconnected, got {p3.connection_state}"
84
+ )
85
+
86
+ await p3.connect(p3_url, p3_token)
87
+ assert p3.connection_state == rtc.ConnectionState.CONN_CONNECTED, (
88
+ f"expected p3 connected, got {p3.connection_state}"
89
+ )
90
+
91
+ await p3.disconnect()
92
+ assert p3.connection_state == rtc.ConnectionState.CONN_DISCONNECTED, (
93
+ f"expected p3 disconnected, got {p3.connection_state}"
94
+ )
95
+
96
+ # p4 joins, then p3 reconnects to publish to p4
97
+ p4 = await connect_room("p4", room_name)
98
+
99
+ await p3.connect(p3_url, p3_token)
100
+ assert p3.connection_state == rtc.ConnectionState.CONN_CONNECTED, (
101
+ f"expected p3 reconnected, got {p3.connection_state}"
102
+ )
103
+
104
+ # publish camera from p3, expect p4 to see track_published
105
+ video_published = expect_room_event(p4, "track_published")
106
+ video_pub = await _publish_video(p3, "p3-camera")
107
+ await await_event(video_published)
108
+ await ensure_track_subscribed(p4, video_pub.sid)
109
+
110
+ # publish microphone from p3, expect p4 to see another track_published
111
+ audio_published = expect_room_event(
112
+ p4,
113
+ "track_published",
114
+ predicate=lambda pub, _p: pub.sid != video_pub.sid,
115
+ )
116
+ audio_pub = await _publish_audio(p3, "p3-mic")
117
+ await await_event(audio_published)
118
+ await ensure_track_subscribed(p4, audio_pub.sid)
119
+
120
+ await p3.disconnect()
121
+ await p4.disconnect()
122
+
123
+ assert p3.connection_state == rtc.ConnectionState.CONN_DISCONNECTED
124
+ assert p4.connection_state == rtc.ConnectionState.CONN_DISCONNECTED
@@ -12,6 +12,8 @@ Requires the following environment variables to run:
12
12
  from __future__ import annotations
13
13
 
14
14
  import asyncio
15
+ import ctypes
16
+ import gc
15
17
  from typing import Optional
16
18
 
17
19
  import pytest
@@ -277,3 +279,45 @@ async def test_data_targeted_with_topic() -> None:
277
279
  await _assert_no_data(other, other_collector)
278
280
 
279
281
  await asyncio.gather(sender.disconnect(), target.disconnect(), other.disconnect())
282
+
283
+
284
+ @skip_if_no_credentials() # type: ignore[untyped-decorator]
285
+ @pytest.mark.asyncio
286
+ async def test_publish_data_makes_no_ctypes_array_type() -> None:
287
+ """Sending and receiving payloads of new lengths makes no ctypes array type."""
288
+ room_name = unique_room_name("py-dc-array-type")
289
+
290
+ sender = rtc.Room()
291
+ receiver = rtc.Room()
292
+
293
+ await connect_room("sender", room_name, room=sender)
294
+ await connect_room("receiver", room_name, room=receiver)
295
+ await ensure_rooms_all_connected([sender, receiver])
296
+ await ensure_participants_visible(sender, ["receiver"])
297
+
298
+ collector = _DataCollector(receiver, sender_identity="sender")
299
+ lengths = range(1001, 1011)
300
+ payloads = [bytes([i]) * n for i, n in enumerate(lengths)]
301
+
302
+ gc.collect()
303
+ gc.disable()
304
+ try:
305
+ for payload in payloads:
306
+ await sender.local_participant.publish_data(payload)
307
+ await wait_until(
308
+ lambda: len(collector.packets) >= len(payloads),
309
+ message="receiver did not get all payloads",
310
+ )
311
+ array_types = [
312
+ o
313
+ for o in gc.get_objects()
314
+ if isinstance(o, type)
315
+ and issubclass(o, ctypes.Array)
316
+ and getattr(o, "_length_", None) in lengths
317
+ ]
318
+ finally:
319
+ gc.enable()
320
+ assert array_types == []
321
+ assert collector.payloads() == payloads
322
+
323
+ await asyncio.gather(sender.disconnect(), receiver.disconnect())
@@ -1 +0,0 @@
1
- __version__ = "1.2.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes