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.
- {livekit-1.2.0 → livekit-1.2.1}/PKG-INFO +1 -1
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_ffi_client.py +8 -3
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/audio_source.py +11 -1
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/audio_stream.py +13 -4
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/participant.py +2 -4
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/room.py +80 -42
- livekit-1.2.1/livekit/rtc/version.py +1 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/video_stream.py +9 -3
- {livekit-1.2.0 → livekit-1.2.1}/livekit.egg-info/PKG-INFO +1 -1
- {livekit-1.2.0 → livekit-1.2.1}/livekit.egg-info/SOURCES.txt +1 -0
- {livekit-1.2.0 → livekit-1.2.1}/setup.py +1 -4
- livekit-1.2.1/tests/test_basic.py +124 -0
- {livekit-1.2.0 → livekit-1.2.1}/tests/test_dc.py +44 -0
- livekit-1.2.0/livekit/rtc/version.py +0 -1
- {livekit-1.2.0 → livekit-1.2.1}/README.md +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/__init__.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/__init__.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/audio_frame_pb2.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/audio_frame_pb2.pyi +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/capture_pb2.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/capture_pb2.pyi +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/data_stream_pb2.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/data_stream_pb2.pyi +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/data_track_pb2.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/data_track_pb2.pyi +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/e2ee_pb2.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/e2ee_pb2.pyi +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/ffi_pb2.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/ffi_pb2.pyi +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/handle_pb2.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/handle_pb2.pyi +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/participant_pb2.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/participant_pb2.pyi +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/room_pb2.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/room_pb2.pyi +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/rpc_pb2.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/rpc_pb2.pyi +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/stats_pb2.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/stats_pb2.pyi +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/track_pb2.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/track_pb2.pyi +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/track_publication_pb2.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/track_publication_pb2.pyi +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/video_frame_pb2.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_proto/video_frame_pb2.pyi +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/_utils.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/apm.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/audio_filter.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/audio_frame.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/audio_mixer.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/audio_resampler.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/data_stream.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/data_track.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/e2ee.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/encoded_video.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/event_emitter.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/frame_processor.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/jupyter.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/log.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/media_devices.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/platform_audio.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/py.typed +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/resources/__init__.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/resources/jupyter-html/index.html +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/rpc.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/synchronizer.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/track.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/track_publication.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/transcription.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/utils.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/video_frame.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit/rtc/video_source.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit.egg-info/dependency_links.txt +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit.egg-info/requires.txt +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/livekit.egg-info/top_level.txt +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/pyproject.toml +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/setup.cfg +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/tests/test_audio.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/tests/test_audio_stream_init.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/tests/test_audio_stream_room_lifecycle.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/tests/test_broadcast_queue.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/tests/test_change_video_quality.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/tests/test_data_stream_writer.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/tests/test_e2ee.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/tests/test_e2ee_per_participant.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/tests/test_e2ee_shared_key.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/tests/test_ffi_client.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/tests/test_ffi_handle_lifecycle.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/tests/test_rpc_interceptors.py +0 -0
- {livekit-1.2.0 → livekit-1.2.1}/tests/test_video.py +0 -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
|
|
239
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
110
|
-
#
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
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
|
|
225
|
-
self.
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
591
|
+
if cb.connect.error:
|
|
592
|
+
FfiClient.instance.queue.unsubscribe(self._ffi_queue)
|
|
593
|
+
raise ConnectError(cb.connect.error)
|
|
581
594
|
|
|
582
|
-
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
597
|
+
self._e2ee_manager = E2EEManager(
|
|
598
|
+
self._ffi_handle.handle, options.encryption or options.e2ee
|
|
599
|
+
)
|
|
589
600
|
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
|
|
599
|
-
|
|
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
|
-
|
|
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
|
|
1247
|
-
|
|
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.
|
|
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
|
-
#
|
|
52
|
-
#
|
|
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:
|
|
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())
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|