diff --git a/.github/actions/python-uv-setup/action.yml b/.github/actions/python-uv-setup/action.yml index 0f5f0f16..bc11270c 100644 --- a/.github/actions/python-uv-setup/action.yml +++ b/.github/actions/python-uv-setup/action.yml @@ -13,9 +13,34 @@ inputs: description: Directory to run in required: false default: "." + build-extension: + description: Build getstream-rtc (the Rust extension) into the environment; "false" installs everything else + required: false + default: "true" runs: using: composite steps: + - name: Install the native build dependencies + if: ${{ inputs.build-extension == 'true' }} + shell: bash + # libvpx is linked from the system; bindgen needs libclang to generate its bindings. + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends libvpx-dev libclang-dev + + - name: Install the Rust toolchain + if: ${{ inputs.build-extension == 'true' }} + shell: bash + working-directory: ${{ inputs.working-directory }}/getstream-rtc + # Installs the toolchain pinned in getstream-rtc/rust-toolchain.toml. + run: rustup toolchain install + + - name: Cache the Rust build + if: ${{ inputs.build-extension == 'true' }} + uses: Swatinem/rust-cache@v2 + with: + workspaces: ${{ inputs.working-directory }}/getstream-rtc + - name: Install the latest version of uv id: setup_uv uses: astral-sh/setup-uv@v7 @@ -38,5 +63,4 @@ runs: - name: Install the project dependencies shell: bash working-directory: ${{ inputs.working-directory }} - run: uv sync ${{ inputs.sync-flags }} - + run: uv sync ${{ inputs.sync-flags }} ${{ inputs.build-extension != 'true' && '--no-install-package getstream-rtc' || '' }} diff --git a/.github/workflows/run_integration.yml b/.github/workflows/run_integration.yml index 8526e419..11fafeec 100644 --- a/.github/workflows/run_integration.yml +++ b/.github/workflows/run_integration.yml @@ -31,10 +31,13 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + persist-credentials: false - name: Install dependencies uses: ./.github/actions/python-uv-setup with: python-version: ${{ matrix.python-version }} + build-extension: "false" - name: Run integration tests env: STREAM_API_KEY: ${{ vars.STREAM_CHAT_API_KEY }} @@ -55,6 +58,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + persist-credentials: false - name: Install dependencies uses: ./.github/actions/python-uv-setup with: diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index a10cb469..ff6427af 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -23,8 +23,12 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + persist-credentials: false - name: Install dependencies uses: ./.github/actions/python-uv-setup + with: + build-extension: "false" - name: Run linter run: make lint @@ -34,11 +38,49 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + persist-credentials: false - name: Install dependencies uses: ./.github/actions/python-uv-setup + # ty reads getstream_rtc from getstream-rtc/ (see make typecheck), not the built extension. + with: + build-extension: "false" - name: Run type checker run: make typecheck + rust: + name: 🦀 Rust + runs-on: ubuntu-latest + env: + # The same interpreter for clippy and the tests, so the PyO3 build is reused. + PYO3_PYTHON: /usr/bin/python3 + defaults: + run: + # rustup reads the toolchain from the current directory. + working-directory: getstream-rtc + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + persist-credentials: false + - name: Install the native build dependencies + # The tests link libpython of the system Python. + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends libvpx-dev libclang-dev python3-dev + - name: Install the Rust toolchain + run: rustup toolchain install + - name: Cache the Rust build + uses: Swatinem/rust-cache@v2 + with: + workspaces: getstream-rtc + - name: Check formatting + run: cargo fmt --check + - name: Run clippy + run: cargo clippy --all-targets -- -D warnings + - name: Run the unit tests + run: cargo test --lib + # ── Non-video tests (chat, feeds, etc.) ──────────────────────────── # Uses STREAM_CHAT_* credentials which do NOT have video enabled. # Video paths and manual test paths are defined in the Makefile. @@ -53,10 +95,13 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + persist-credentials: false - name: Install dependencies uses: ./.github/actions/python-uv-setup with: python-version: ${{ matrix.python-version }} + build-extension: "false" - name: Run tests run: make test @@ -73,6 +118,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + persist-credentials: false - name: Install dependencies uses: ./.github/actions/python-uv-setup with: diff --git a/.gitignore b/.gitignore index dc30fdd7..c17d6321 100644 --- a/.gitignore +++ b/.gitignore @@ -22,5 +22,10 @@ build/ __pycache__/ *.py[cod] *$py.class -\n+# Local protocol folder (handled by generate_webrtc.sh) + +# Rust build artifacts +target/ +getstream-rtc/getstream_rtc/*.so +getstream-rtc/getstream_rtc/*.pyd +# Local protocol folder (handled by generate_webrtc.sh) protocol/ diff --git a/AGENTS.md b/AGENTS.md index 2c11065e..2fea8e47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,10 @@ ## Project setup 1. This project uses `uv`, `pyproject.toml` and venv to manage dependencies -2. Never use pip directly, use `uv add` to add dependencies and `uv sync --dev --all-packages` to install the dependency +2. Never use pip directly, use `uv add` to add dependencies and `uv sync --dev --all-packages --all-extras` to install the dependency (without `--all-extras` the sync removes the packages of the optional dependency groups, for example `aiortc`, `av` and `numpy`) 3. Do not change code generated python code, `./generate.sh` is the script responsible of rebuilding all API endpoints and API models -4. **WebRTC Dependencies**: All dependencies related to WebRTC, audio, video processing (like `aiortc`, `numpy`, `torch`, `torchaudio`, `soundfile`, `scipy`, `deepgram-sdk`, `elevenlabs`, etc.) are organized under the `webrtc` optional dependencies group. Plugins that work with audio, video, or WebRTC functionality should depend on `getstream[webrtc]` instead of just `getstream`. +4. **WebRTC Dependencies**: The dependencies of `getstream.video.rtc` (`getstream-rtc`, `aiortc`, `av`, `numpy`, and `twirp`, `protobuf` and `aiohttp` for the generated SFU code) are organized under the `webrtc` optional dependencies group. Plugins that work with audio, video, or WebRTC functionality should depend on `getstream[webrtc]` instead of just `getstream`, and declare every other package they import themselves. +5. **Rust bindings**: `getstream-rtc` (directory `getstream-rtc/`) is a separate distribution and a member of the uv workspace. Its package `getstream_rtc` contains the PyO3 extension `getstream_rtc._bindings`, which maturin builds from `getstream-rtc/Cargo.toml` (sources in `getstream-rtc/rustsrc/`), and exports every class at its root. It wraps the Rust SDK (`getstream` crate from https://github.com/GetStream/stream-video-rust). It does not depend on `getstream`; stream-py code imports `getstream_rtc` as a separate package, and only the `webrtc` extra installs it. Its version is independent of the `getstream` version and is only in `getstream-rtc/pyproject.toml` (the crate has no version). `uv sync` compiles it when it installs the `webrtc` extra or all workspace packages; this needs the Rust toolchain pinned in `getstream-rtc/rust-toolchain.toml`, a C compiler, `cmake`, `pkg-config`, `libvpx`, and libclang for bindgen (macOS: `brew install libvpx cmake pkg-config`, libclang comes with Xcode; Debian/Ubuntu: `apt install libvpx-dev libclang-dev cmake pkg-config build-essential`). After Rust changes, run `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings` and `cargo test --lib` in `getstream-rtc/`; rustup selects the toolchain from the current directory, not from `--manifest-path`. ## Python testing @@ -17,3 +18,92 @@ 8. tests that rely on file assets should always rely on files inside the `tests/assets/` folder, new files should be added there and existing ones used if possible. Do not use files larger than 256 kilobytes. 9. do not use mocks or mock things in general unless you are asked to do that directly 10. always run tests using `uv run pytest` from the root of the project, dont cd into folders to run tests + + +## Rust and async expectations + +- Ensure [rust-skills](https://github.com/leonardomso/rust-skills) is available + before coding or review. Install or refresh it with + `npx add-skill leonardomso/rust-skills`, then apply the relevant rules rather + than loading unrelated categories. +- Prefer typed errors and `Result`; avoid panics and `unwrap` in library code. +- Do not circumvent borrow checker with .clone() for no reason. +- Keep queues and buffers bounded. Make background-task cancellation and cleanup + deterministic, and do not hold synchronous locks across `.await` points. +- Preserve compatibility with the Rust version declared in + `getstream-rtc/Cargo.toml`. + +## Rust wrapper: GIL, locks and deadlocks + +Python threads and tokio worker threads use SDK locks (`std::sync::Mutex`, +tokio locks and channels) and the GIL. A deadlock needs a thread that holds an +SDK lock and waits for the GIL while another thread holds the GIL and waits for +that lock. Before you add or change a binding, read the SDK functions it calls +and find the locks they take. Keep both halves impossible: + +- A Python thread must not hold the GIL while it waits for an SDK lock. Release + the GIL with `py.detach` in every synchronous method that reads or changes SDK + state (for example `Call.participants()`, `LocalAudioTrack.flush()`). A method + whose locks no other thread can hold yet needs no `py.detach` (`Client.call` + creates the call it locks). +- A thread that holds an SDK lock must never wait for the GIL. Callbacks given + to the SDK (for example `on_track`) must not call `Python::attach` or drop + Python objects; they hand data over through a bounded channel. +- `future_into_py` futures run on tokio without the GIL. Do not call + `Python::attach` in them; return the value and let pyo3-async-runtimes convert + it. +- Never call Python from a `tracing` layer or a `log` logger. Records go + through the bounded queue in `getstream-rtc/rustsrc/logging.rs`. The Python + thread `getstream-logging` (`getstream-rtc/getstream_rtc/logging.py`) takes + them with `_next_logs()`, which waits without the GIL; Rust never calls + Python to log. +- Python destructors drop the SDK objects they own with the GIL held. When you + update the SDK, check that these drops (`Call`, `RemoteTrack`, + `VideoFrameStream`, local tracks, tracks in the track queue) still take no + lock that another thread can hold. +- While the GIL is released, do not read memory that Python owns (for example a + numpy buffer); copy it first. + +### Calling Python from Rust + +- Do not call Python code from Rust (`py.import(...)`, `call_method`, `call1`, + `getattr` of a Python object) unless the user agreed to it first. It makes the + code more complex and slower, so it is always a developer decision. +- Return values that PyO3 converts by itself (`bool`, integers, `f64`, `String`, + `Vec`, `Option`, pyclasses) and let the Python caller convert them further, + for example a Unix time in seconds to a `datetime`. + +### Logging in the wrapper + +- Log with the `tracing` macros (`tracing::debug!` and the others). Do not use + `println!`, the `log` macros, or Python `logging` through `Python::attach`. +- Use the default target. Records of the wrapper (`_bindings::…`) and of the SDK + (`getstream::…`) use the `level` of `configure_logging`; records of other + crates use `third_party_level`. A record reaches the configured logger's + child, for example `getstream._bindings.call` for + `getstream-rtc/rustsrc/call.rs` and `getstream.rtc.join` for the SDK's + `getstream::rtc::join`. +- The message is an event name in the SDK style, `stream..`. Put + values in fields, not in the message: + `tracing::debug!(dropped_samples = n, "stream.rtc.audio.pcm_queue_overflow")`. +- Each field becomes an attribute of the Python `LogRecord`. Dots in field names + become `_`, a name that `LogRecord` already has gets the prefix `rust_`, and + `rust_target` holds the Rust target. Booleans, integers, floats and strings + keep their type; other values become strings (`?value` for `Debug`, `%value` + for `Display`). An error field includes its source chain. +- Levels map to Python as ERROR 40, WARN 30, INFO 20, DEBUG 10, TRACE 5. +- A log call never blocks and never takes the GIL, so it is safe while a lock or + the GIL is held. When the queue (1024 records) is full, records are dropped and + counted; a `stream.rust.log_records_dropped` warning with `dropped` comes with + the next batch that the Python thread takes, before its records. +- Nothing is forwarded until `configure_logging` sets a logger. Disabled + records are filtered before their fields are formatted. `configure_logging` + returns at once. In a process forked after the logging thread started, + `configure_logging` with a logger raises `RuntimeError`. +- Never log secrets or tokens. +- To assert on records in tests, including DEBUG ones, use the `sdk_logs` + fixture in `getstream-rtc/tests/conftest.py`, and call + `getstream_rtc.logging._flush()` before you assert: it returns after the + records queued before the call are delivered. Do not call it on a thread + that a handler waits for, such as an event loop that a handler runs + coroutines on; there, use `await asyncio.to_thread(getstream_rtc.logging._flush)`. diff --git a/Makefile b/Makefile index c4d97f70..492efd27 100644 --- a/Makefile +++ b/Makefile @@ -11,9 +11,8 @@ MARKER ?= not integration VIDEO_PATHS := \ getstream/video \ tests/rtc \ + getstream-rtc/tests \ tests/test_audio_stream_track.py \ - tests/test_connection_utils.py \ - tests/test_signaling.py \ tests/test_video_examples.py \ tests/test_video_integration.py \ tests/test_video_openai.py \ @@ -66,7 +65,7 @@ test-video: ## Run all tests (non-video + video), excluding manual tests test-all: - uv run pytest -m "$(MARKER)" tests/ getstream/ $(MANUAL_IGNORE) + uv run pytest -m "$(MARKER)" tests/ getstream/ getstream-rtc/tests $(MANUAL_IGNORE) ## Run integration tests for both groups test-integration: @@ -93,7 +92,7 @@ format: ## Run ty type checker typecheck: - uvx ty@0.0.24 check getstream/ $(TY_EXCLUDES) + uvx ty@0.0.24 check getstream/ --extra-search-path getstream-rtc $(TY_EXCLUDES) ## Run full check: lint + typecheck + non-video tests check: lint typecheck test diff --git a/examples/rtc/echo_agent.py b/examples/rtc/echo_agent.py new file mode 100644 index 00000000..0c29976c --- /dev/null +++ b/examples/rtc/echo_agent.py @@ -0,0 +1,156 @@ +"""Echo agent: joins a call, plays back what the person in the browser says, +and shows their camera mirrored in its own video tile. + +It uses only the public `getstream.video.rtc` API: `rtc.join` with a +subscription config, the `audio` and `track_added` events, +`RemoteTrack.video_frames()`, `add_tracks` and `wait`. + +It reads the credentials (STREAM_API_KEY and STREAM_API_SECRET) from the +environment or from the `.env` file of the repository: + + uv run --extra webrtc python examples/rtc/echo_agent.py + +It opens the Stream demo app in the browser for a new call. Allow the +microphone and the camera, then speak: the agent plays back your audio and +shows your camera mirrored. Stop it with Ctrl+C. Use one person in the +browser: the audio of several people is played back one after another, not +mixed. +""" + +import asyncio +import contextlib +import logging +import uuid +from fractions import Fraction +from typing import Optional + +import aiortc +import av +import numpy as np +from dotenv import load_dotenv + +from getstream import AsyncStream +from getstream.models import CallRequest, UserRequest +from getstream.video import rtc +from getstream.video.rtc import ( + AudioStreamTrack, + PcmData, + RemoteTrack, + TrackType, + VideoFrame, + VideoFrameStream, +) +from getstream.video.rtc.pb.stream.video.sfu.models.models_pb2 import ( + TRACK_TYPE_AUDIO, + TRACK_TYPE_VIDEO, +) +from getstream.video.rtc.tracks import SubscriptionConfig, TrackSubscriptionConfig +from getstream.video.rtc.utils import open_browser + +AGENT_ID = "echo-agent" +HUMAN_ID = "echo-human" +# The RTP clock of WebRTC video. +VIDEO_TIME_BASE = Fraction(1, 90000) + +logger = logging.getLogger("echo_agent") + + +def mirrored(frame: VideoFrame) -> av.VideoFrame: + """The frame flipped left to right, as a PyAV frame.""" + width, height = frame.width, frame.height + # Packed I420: the Y plane, then the U and V planes at half size, rounded up. + chroma = ((height + 1) // 2, (width + 1) // 2) + image = av.VideoFrame(width, height, "yuv420p") + offset = 0 + for plane, (rows, columns) in zip(image.planes, [(height, width), chroma, chroma]): + pixels = frame.data[offset : offset + rows * columns].reshape(rows, columns) + offset += rows * columns + # A PyAV plane can have padding at the end of each row. + padded = np.zeros((rows, plane.line_size), dtype=np.uint8) + padded[:, :columns] = pixels[:, ::-1] + plane.update(padded) + image.pts = frame.rtp_timestamp + image.time_base = VIDEO_TIME_BASE + return image + + +class MirrorTrack(aiortc.MediaStreamTrack): + """A video track with the mirrored frames of the latest remote camera.""" + + kind = "video" + + def __init__(self) -> None: + super().__init__() + self._frames: Optional[VideoFrameStream] = None + self._camera = asyncio.Event() + + def show(self, frames: VideoFrameStream) -> None: + self._frames = frames + self._camera.set() + + async def recv(self) -> av.VideoFrame: + while True: + await self._camera.wait() + frames = self._frames + assert frames is not None + try: + return mirrored(await anext(frames)) + except StopAsyncIteration: + # The camera track ended; wait for the next one. + if self._frames is frames: + self._camera.clear() + + +async def main() -> None: + async with AsyncStream() as client: + await client.upsert_users(UserRequest(id=AGENT_ID), UserRequest(id=HUMAN_ID)) + call_id = str(uuid.uuid4()) + call = client.video.call("default", call_id) + await call.get_or_create(data=CallRequest(created_by_id=AGENT_ID)) + + connection = await rtc.join( + call, + AGENT_ID, + subscription_config=SubscriptionConfig( + default=TrackSubscriptionConfig( + track_types=[TRACK_TYPE_AUDIO, TRACK_TYPE_VIDEO] + ) + ), + ) + audio = AudioStreamTrack() + mirror = MirrorTrack() + + @connection.on("audio") + async def echo(pcm: PcmData) -> None: + await audio.write(pcm) + + @connection.on("track_added") + def show_camera(track: RemoteTrack) -> None: + if track.track_type == TrackType.VIDEO: + # The stream keeps the track subscribed. + mirror.show(track.video_frames()) + + for event_name in ( + "participant_joined", + "participant_left", + "track_published", + "track_unpublished", + ): + connection.on(event_name, lambda event: logger.info("%r", event)) + connection.on( + "connection.state_changed", + lambda change: logger.info("state %s -> %s", change["old"], change["new"]), + ) + + async with connection: + await connection.add_tracks(audio=audio, video=mirror) + open_browser(client.api_key, client.create_token(HUMAN_ID), call_id) + # Returns when the call ends; Ctrl+C leaves the call. + await connection.wait() + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + load_dotenv() + with contextlib.suppress(KeyboardInterrupt): + asyncio.run(main()) diff --git a/examples/rtc/record_audio.py b/examples/rtc/record_audio.py new file mode 100644 index 00000000..a666ed5b --- /dev/null +++ b/examples/rtc/record_audio.py @@ -0,0 +1,94 @@ +"""Record audio: joins a call and writes the audio of each participant to a +WAV file, one file for each participant session. + +It uses the public `getstream.video.rtc` API: `rtc.join` and the `audio` +event, whose `PcmData` has the participant and 48 kHz mono samples. + +It reads the credentials (STREAM_API_KEY and STREAM_API_SECRET) from the +environment or from the `.env` file of the repository: + + uv run --extra webrtc python examples/rtc/record_audio.py + +It opens the Stream demo app in the browser for a new call. Allow the +microphone and speak, then stop it with Ctrl+C. The files are written to the +current directory as `_.wav`. + +Two limits: +- No audio arrives while a participant is muted, so a file is shorter than + the call. The `pts` of each `PcmData` tells where to put silence. +- The microphone and the screen-share audio of one participant go into the + same file. +""" + +import asyncio +import contextlib +import logging +import uuid +import wave + +from dotenv import load_dotenv + +from getstream import AsyncStream +from getstream.models import CallRequest, UserRequest +from getstream.video import rtc +from getstream.video.rtc import PcmData +from getstream.video.rtc.utils import open_browser + +RECORDER_ID = "audio-recorder" +SPEAKER_ID = "audio-speaker" + +logger = logging.getLogger("record_audio") + + +class Recorder: + """Writes the audio of each participant session to its own WAV file.""" + + def __init__(self) -> None: + self._files: dict[str, wave.Wave_write] = {} + + def write(self, pcm: PcmData) -> None: + participant = pcm.participant + file = self._files.get(participant.session_id) + if file is None: + path = f"{participant.user_id}_{participant.session_id}.wav" + file = wave.open(path, "wb") + file.setnchannels(pcm.channels) + file.setsampwidth(2) # 16-bit samples + file.setframerate(pcm.sample_rate) + self._files[participant.session_id] = file + logger.info("Recording %s", path) + file.writeframes(pcm.samples.tobytes()) + + def close(self) -> None: + for file in self._files.values(): + file.close() + + +async def main() -> None: + recorder = Recorder() + async with AsyncStream() as client: + await client.upsert_users( + UserRequest(id=RECORDER_ID), UserRequest(id=SPEAKER_ID) + ) + call_id = str(uuid.uuid4()) + call = client.video.call("default", call_id) + await call.get_or_create(data=CallRequest(created_by_id=RECORDER_ID)) + + # Remote audio arrives without a subscription config. + connection = await rtc.join(call, RECORDER_ID) + connection.on("audio", recorder.write) + try: + async with connection: + open_browser(client.api_key, client.create_token(SPEAKER_ID), call_id) + # Returns when the call ends; Ctrl+C leaves the call. + await connection.wait() + finally: + # The WAV header gets its length only when the file is closed. + recorder.close() + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + load_dotenv() + with contextlib.suppress(KeyboardInterrupt): + asyncio.run(main()) diff --git a/getstream-rtc/Cargo.lock b/getstream-rtc/Cargo.lock new file mode 100644 index 00000000..80adcd72 --- /dev/null +++ b/getstream-rtc/Cargo.lock @@ -0,0 +1,4240 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure 0.13.2", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "audio-codec-algorithms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1254ebf6529f3763c491acfb5ab6e960809e3c75a38584cc664f8c5667fb7107" + +[[package]] +name = "audioadapter" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1292ef9edf681b7426ed089004b021a42896492f58bd0c909ad44e5faec9ac4" + +[[package]] +name = "audioadapter-buffers" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46289a81a3bfa26d0f8b2415f8ca9decde9a66308a150568af5c102381b3c8ce" +dependencies = [ + "audioadapter", + "audioadapter-sample", + "num-traits", +] + +[[package]] +name = "audioadapter-sample" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5258faecf4edbe35ec483bda9b1f7438e6b391891819269c6b5461aeaa0e8d7d" +dependencies = [ + "audio-codec-algorithms", + "num-traits", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" + +[[package]] +name = "bindgen" +version = "0.68.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "726e4313eb6ec35d2730258ad4e15b547ee75d6afaa1361a922e78e59b7d8078" +dependencies = [ + "bitflags 2.13.2", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex 1.3.0", + "syn 2.0.119", + "which", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecheck" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26333eeac754f0ad8a6bcd0eb0ac012156302e4e16b852b72ee399aea4f12c29" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "rancor", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46d07918caa9eeaaf06b7873925c53a61daac173539b4f7715090745e44e4e69" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "ccm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae3c82e4355234767756212c570e29833699ab63e6ffd161887314cc5b43847" +dependencies = [ + "aead", + "cipher", + "ctr", + "subtle", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", + "zeroize", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dtls" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a431e87fc386bd5e02deb554a013f97bc406e47a7d8e97efb7c6366b980e9c" +dependencies = [ + "aes", + "aes-gcm", + "async-trait", + "bytecheck", + "byteorder", + "cbc", + "ccm", + "chacha20poly1305", + "der-parser", + "hmac 0.12.1", + "log", + "p256", + "p384", + "portable-atomic", + "rand 0.9.5", + "rand_core 0.6.4", + "rcgen", + "ring", + "rkyv", + "rustls", + "sec1", + "sha1 0.10.7", + "sha2 0.10.9", + "thiserror 1.0.69", + "tokio", + "webrtc-util", + "x25519-dalek", + "x509-parser", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "env-libvpx-sys" +version = "5.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ecdc636a02003406cc821aa9d703c888a966a3fd9bbdae9f7cf27d71720147" +dependencies = [ + "bindgen", + "pkg-config", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "getstream" +version = "0.1.0-preview.2" +source = "git+https://github.com/GetStream/stream-video-rust?rev=edd995b269471e53d40371a15dda007549bad6f8#edd995b269471e53d40371a15dda007549bad6f8" +dependencies = [ + "base64 0.23.1", + "bytes", + "env-libvpx-sys", + "flate2", + "futures-util", + "hmac 0.13.0", + "indexmap", + "opus", + "prost", + "prost-build", + "prost-types", + "protox", + "reqwest", + "rubato", + "rustls", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.21", + "tokio", + "tokio-tungstenite", + "tracing", + "url", + "uuid", + "webrtc", +] + +[[package]] +name = "getstream-rtc" +version = "0.0.0" +dependencies = [ + "getstream", + "log", + "numpy", + "prost-types", + "pyo3", + "pyo3-async-runtimes", + "serde_json", + "tokio", + "tracing", + "tracing-log", + "tracing-subscriber", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.5", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "interceptor" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88c11a956a48159f7fe539b8198f12b4db9b709ae5f94385b840db38f97fed74" +dependencies = [ + "async-trait", + "bytes", + "futures", + "log", + "portable-atomic", + "rand 0.9.5", + "rtcp", + "rtp", + "thiserror 1.0.69", + "tokio", + "waitgroup", + "webrtc-srtp", + "webrtc-util", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "logos" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff472f899b4ec2d99161c51f60ff7075eeb3097069a36050d8037a6325eb8154" +dependencies = [ + "logos-derive 0.15.1", +] + +[[package]] +name = "logos" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2c55a318a87600ea870ff8c2012148b44bf18b74fad48d0f835c38c7d07c5f" +dependencies = [ + "logos-derive 0.16.1", +] + +[[package]] +name = "logos-codegen" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "192a3a2b90b0c05b27a0b2c43eecdb7c415e29243acc3f89cc8247a5b693045c" +dependencies = [ + "beef", + "fnv", + "lazy_static", + "proc-macro2", + "quote", + "regex-syntax", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "logos-codegen" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58b3ffaa284e1350d017a57d04ada118c4583cf260c8fb01e0fe28a2e9cf8970" +dependencies = [ + "fnv", + "proc-macro2", + "quote", + "regex-automata", + "regex-syntax", + "syn 2.0.119", +] + +[[package]] +name = "logos-derive" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "605d9697bcd5ef3a42d38efc51541aa3d6a4a25f7ab6d1ed0da5ac632a26b470" +dependencies = [ + "logos-codegen 0.15.1", +] + +[[package]] +name = "logos-derive" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d3a9855747c17eaf4383823f135220716ab49bea5fbea7dd42cc9a92f8aa31" +dependencies = [ + "logos-codegen 0.16.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "munge" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" +dependencies = [ + "munge_macro", +] + +[[package]] +name = "munge_macro" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset", + "pin-utils", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "numpy" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a5b15d63a5ff39e378daed0e1340d3a5964703ea9712eb09a0dc66fade996f4" +dependencies = [ + "libc", + "ndarray", + "num-complex", + "num-integer", + "num-traits", + "pyo3", + "pyo3-build-config", + "rustc-hash 2.1.3", +] + +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "opus" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33718946cc77d4032911d4efe03a66dbcbfbd2bb16c3da06aaeadcc637c32216" +dependencies = [ + "opusic-sys", +] + +[[package]] +name = "opusic-sys" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9d1ecdf206421bc74343ab3bb2f30ad2abbfee41fa341f7181fecbaf957769a" +dependencies = [ + "cmake", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.119", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-reflect" +version = "0.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01b80ea363c31af2de2b92e3c07ed1156628f7838c4afb4df75ee78a37fedbd1" +dependencies = [ + "logos 0.16.1", + "miette", + "prost", + "prost-types", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + +[[package]] +name = "protox" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f25a07a73c6717f0b9bbbd685918f5df9815f7efba450b83d9c9dea41f0e3a1" +dependencies = [ + "bytes", + "miette", + "prost", + "prost-reflect", + "prost-types", + "protox-parse", + "thiserror 2.0.21", +] + +[[package]] +name = "protox-parse" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "072eee358134396a4643dff81cfff1c255c9fbd3fb296be14bdb6a26f9156366" +dependencies = [ + "logos 0.15.1", + "miette", + "prost-types", + "thiserror 2.0.21", +] + +[[package]] +name = "ptr_meta" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743da816b98c921cdbe8628ef7381b76f25ecf4da599fc80aca90eae7ef70cc0" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "pyo3" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4688ddedf473e32662b9b067670129a8afb8c18e351482c70d62ba4a88171e8b" +dependencies = [ + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", +] + +[[package]] +name = "pyo3-async-runtimes" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3ef68daa7316a3fac65e5e18b2203f010346de1c1c53456811a2624673ab046" +dependencies = [ + "futures-channel", + "futures-util", + "once_cell", + "pin-project-lite", + "pyo3", + "tokio", +] + +[[package]] +name = "pyo3-build-config" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f41027e41b4bd03f6e60f9f417fe24a6341a6bb744edd62b6f709f2a52ea30e9" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e591a95526fead067432c3b3a33fc74770b87b1e04e73671090d9c2055a2b327" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73225868fc1cd84eef2c3c230ddb91273bf1de46aeb8a4248da76d32a0924a1c" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "571575aa3749fa6216757dd47d2a3e7ef360f329a40f0666a9fbd14889024952" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls", + "socket2 0.6.5", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.5", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rancor" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b534442d0fcdb55d66f373d9cac6d33b6293a2335bc2136dbd06ce0e87d2572" +dependencies = [ + "ptr_meta", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20 0.10.2", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rend" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "663ba70707f96e871406fe10d68128412e619b06d1d47cb91c3a4c6501176240" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9776093b7ca170454ab1406954f7b7d97a57c51dc6c0642957fb2ef25c2d399" +dependencies = [ + "bytecheck", + "bytes", + "hashbrown 0.17.1", + "indexmap", + "munge", + "ptr_meta", + "rancor", + "rend", + "rkyv_derive", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "rtcp" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adad7f6a501162881032fc84b4bc78ae11f1b30748180b9f5cbe8810bf613aab" +dependencies = [ + "bytes", + "thiserror 1.0.69", + "webrtc-util", +] + +[[package]] +name = "rtp" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "149329e78ada26b5e174a4c281a7a7e5c8bda6008adc90daddd6f98fce56db29" +dependencies = [ + "bytes", + "memchr", + "portable-atomic", + "rand 0.9.5", + "serde", + "thiserror 1.0.69", + "webrtc-util", +] + +[[package]] +name = "rubato" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1e951b9f5432ec1422f4dcfa5733fc501265902612edf5ef8bd8ecdd960930" +dependencies = [ + "audioadapter", + "audioadapter-buffers", + "num-integer", + "num-traits", + "visibility", + "windowfunctions", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "aws-lc-rs", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sdp" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b6eecfa5151edef84d544ff3885dff98e5b5fe97586757a4b5118bde51c958" +dependencies = [ + "rand 0.9.5", + "substring", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stun" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e3b8d5ad1dd4c8cd0595440f26521a71dd593cb5873ee8e06b91510b7d97269" +dependencies = [ + "base64 0.22.1", + "crc", + "lazy_static", + "md-5", + "rand 0.9.5", + "ring", + "subtle", + "thiserror 1.0.69", + "tokio", + "url", + "webrtc-util", +] + +[[package]] +name = "substring" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ee6433ecef213b2e72f587ef64a2f5943e7cd16fbd82dbe8bc07486c534c86" +dependencies = [ + "autocfg", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "once_cell", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.5", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "sharded-slab", + "thread_local", + "tracing-core", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.10.3", + "rustls", + "rustls-pki-types", + "sha1 0.11.0", + "thiserror 2.0.21", +] + +[[package]] +name = "turn" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d99249a493335eb44c4d7943a8751b22561a82c9903d8eb2d29780b3927880a7" +dependencies = [ + "async-trait", + "base64 0.22.1", + "futures", + "log", + "md-5", + "portable-atomic", + "rand 0.9.5", + "ring", + "stun", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "webrtc-util", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "visibility" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "waitgroup" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1f50000a783467e6c0200f9d10642f4bc424e39efc1b770203e88b488f79292" +dependencies = [ + "atomic-waker", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webrtc" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaacdf9d96224d7b6e2872ba065578f38775f7634367e3ac2cc87c7271da433" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "dtls", + "hex", + "interceptor", + "lazy_static", + "log", + "portable-atomic", + "rand 0.9.5", + "rcgen", + "regex", + "ring", + "rtcp", + "rtp", + "sdp", + "serde", + "serde_json", + "sha2 0.10.9", + "smol_str", + "stun", + "thiserror 1.0.69", + "tokio", + "turn", + "unicase", + "url", + "waitgroup", + "webrtc-data", + "webrtc-ice", + "webrtc-mdns", + "webrtc-media", + "webrtc-sctp", + "webrtc-srtp", + "webrtc-util", +] + +[[package]] +name = "webrtc-data" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd470286275809f2fcfcdb1e73ef5f1500be82eff7fe98150ce81b20aad5a2a4" +dependencies = [ + "bytes", + "log", + "portable-atomic", + "thiserror 1.0.69", + "tokio", + "webrtc-sctp", + "webrtc-util", +] + +[[package]] +name = "webrtc-ice" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b7fd30f52e6fda8664779b84b7904b2553b76fee24d9ca665e774ae32b13f53" +dependencies = [ + "arc-swap", + "async-trait", + "crc", + "log", + "portable-atomic", + "rand 0.9.5", + "serde", + "serde_json", + "stun", + "thiserror 1.0.69", + "tokio", + "turn", + "url", + "uuid", + "waitgroup", + "webrtc-mdns", + "webrtc-util", +] + +[[package]] +name = "webrtc-mdns" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ffa0ea00c0fae979aafa5db285fec5eeb360e1ea246ebec529ba4e09f8e03e" +dependencies = [ + "log", + "socket2 0.5.10", + "thiserror 1.0.69", + "tokio", + "webrtc-util", +] + +[[package]] +name = "webrtc-media" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26a6c7335bdd03dc023cb9a3bd7966866d969c92c86987b73aa16dfa39c7ca29" +dependencies = [ + "byteorder", + "bytes", + "rand 0.9.5", + "rtp", + "thiserror 1.0.69", +] + +[[package]] +name = "webrtc-sctp" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c4b637f0d8eb96d900ac0f79b3060ddd21ca88dfefa467e96e67ab0016f2574" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "crc", + "log", + "portable-atomic", + "rand 0.9.5", + "thiserror 1.0.69", + "tokio", + "webrtc-util", +] + +[[package]] +name = "webrtc-srtp" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45d2b667a0b5d04eebcb7cb22fd51b2af3721f418097b496e72de16386c8d0c3" +dependencies = [ + "aead", + "aes", + "aes-gcm", + "byteorder", + "bytes", + "ctr", + "hmac 0.12.1", + "log", + "rtcp", + "rtp", + "sha1 0.10.7", + "subtle", + "thiserror 1.0.69", + "tokio", + "webrtc-util", +] + +[[package]] +name = "webrtc-util" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1beae0b4f24969741c26ca282a257dc5823bd9cbe608f7e3ca3775102d323ad9" +dependencies = [ + "async-trait", + "bitflags 1.3.2", + "bytes", + "ipnet", + "lazy_static", + "log", + "nix", + "portable-atomic", + "rand 0.9.5", + "thiserror 1.0.69", + "tokio", + "winapi", +] + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windowfunctions" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90628d739333b7c5d2ee0b70210b97b8cddc38440c682c96fd9e2c24c2db5f3a" +dependencies = [ + "num-traits", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure 0.14.0", +] + +[[package]] +name = "zerocopy" +version = "0.8.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17e8fafad82b542ff3717217ecdc736231b59e387768c9630123b4ce4d2db44" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "595f56e044df4f46a0c9a626f65c3d99eb8488f7e8a8baa12dd76326d9710bf2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure 0.14.0", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/getstream-rtc/Cargo.toml b/getstream-rtc/Cargo.toml new file mode 100644 index 00000000..6d2e5f09 --- /dev/null +++ b/getstream-rtc/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "getstream-rtc" +edition = "2024" +rust-version = "1.88" +publish = false + +[lib] +name = "_bindings" +path = "rustsrc/lib.rs" +crate-type = ["cdylib"] + +[dependencies] +getstream = { git = "https://github.com/GetStream/stream-video-rust", rev = "edd995b269471e53d40371a15dda007549bad6f8" } +log = "0.4" +numpy = "0.29" +prost-types = "0.14" +serde_json = { version = "1", features = ["float_roundtrip", "preserve_order"] } +pyo3 = { version = "0.29.2", features = ["abi3-py310"] } +pyo3-async-runtimes = { version = "0.29.0", features = ["tokio-runtime"] } +tokio = { version = "1.53.1", features = ["macros", "rt", "sync"] } +tracing = "0.1" +tracing-log = { version = "0.2", default-features = false, features = ["log-tracer", "std"] } +tracing-subscriber = { version = "0.3", default-features = false, features = ["registry", "std"] } diff --git a/getstream-rtc/getstream_rtc/__init__.py b/getstream-rtc/getstream_rtc/__init__.py new file mode 100644 index 00000000..b59ca3a0 --- /dev/null +++ b/getstream-rtc/getstream_rtc/__init__.py @@ -0,0 +1,108 @@ +import importlib.metadata + +from getstream_rtc.errors import ( + ApiError, + ConfigError, + CoordinatorError, + IllegalStateError, + MediaError, + PcmQueueOverflowError, + PcmRateMismatchError, + PermissionDeniedError, + RtcError, + RustError, +) +from getstream_rtc._bindings import ( + AudioLevelChanged, + Call, + CallEnded, + CallEvent, + CallGrantsUpdated, + CallingState, + CallingStateChanged, + ChangePublishOptions, + ChangePublishQuality, + Client, + ConnectionQualityChanged, + CoordinatorEvent, + DominantSpeakerChanged, + Error, + EventStream, + EventsLagged, + ICERestart, + InboundStateNotification, + LocalAudioTrack, + LocalVideoTrack, + ParticipantCountChanged, + ParticipantJoined, + ParticipantLeft, + ParticipantUpdated, + PcmFrame, + PinsChanged, + RemoteParticipant, + RemoteTrack, + StreamResampler, + SubscriptionConfig, + TrackPublished, + TrackStream, + TrackSubscriptionConfig, + TrackType, + TrackUnpublished, + VideoFrame, + VideoFrameStream, +) +from getstream_rtc.logging import configure_logging + +__version__ = importlib.metadata.version("getstream-rtc") + +__all__ = [ + "ApiError", + "AudioLevelChanged", + "Call", + "CallEnded", + "CallEvent", + "CallGrantsUpdated", + "CallingState", + "CallingStateChanged", + "ChangePublishOptions", + "ChangePublishQuality", + "Client", + "ConfigError", + "ConnectionQualityChanged", + "CoordinatorError", + "CoordinatorEvent", + "DominantSpeakerChanged", + "Error", + "EventStream", + "EventsLagged", + "ICERestart", + "IllegalStateError", + "InboundStateNotification", + "LocalAudioTrack", + "LocalVideoTrack", + "MediaError", + "ParticipantCountChanged", + "ParticipantJoined", + "ParticipantLeft", + "ParticipantUpdated", + "PcmFrame", + "PcmQueueOverflowError", + "PcmRateMismatchError", + "PermissionDeniedError", + "PinsChanged", + "RemoteParticipant", + "RemoteTrack", + "RtcError", + "RustError", + "StreamResampler", + "SubscriptionConfig", + "TrackPublished", + "TrackStream", + "TrackSubscriptionConfig", + "TrackType", + "TrackUnpublished", + "VideoFrame", + "VideoFrameStream", + "__version__", + "configure_logging", +] diff --git a/getstream-rtc/getstream_rtc/_bindings.pyi b/getstream-rtc/getstream_rtc/_bindings.pyi new file mode 100644 index 00000000..f722d2f9 --- /dev/null +++ b/getstream-rtc/getstream_rtc/_bindings.pyi @@ -0,0 +1,573 @@ +from enum import Enum +from typing import Any, Awaitable + +import numpy as np +import numpy.typing as npt + +def _set_log_filters(level: int | None, third_party_level: int) -> None: + """Queue SDK records at `level` or above, and third-party records at the + less verbose of `level` and `third_party_level`; `level=None` queues + none.""" + +def _next_logs() -> tuple[ + int, + list[tuple[str | None, int, str, int, str, int, str, dict[str, object]]], + int | None, +]: + """Wait without the GIL for queued records. Return the number of records + dropped since the last call, at most 256 records as the arguments of + `getstream_rtc.logging.handle_record` after its logger, and the marker that + ended the batch, if any.""" + +def _push_log_marker(marker: int) -> None: + """Put `marker` into the queue after the queued records; wait without the + GIL while the queue is full.""" + +class CallingState(Enum): + IDLE = ... + JOINING = ... + JOINED = ... + RECONNECTING = ... + MIGRATING = ... + RECONNECTING_FAILED = ... + LEFT = ... + +class TrackType(Enum): + UNSPECIFIED = ... + AUDIO = ... + VIDEO = ... + SCREEN_SHARE = ... + SCREEN_SHARE_AUDIO = ... + +class RemoteParticipant: + @property + def user_id(self) -> str: ... + @property + def session_id(self) -> str: ... + @property + def name(self) -> str: ... + @property + def image(self) -> str: ... + @property + def roles(self) -> list[str]: ... + @property + def published_tracks(self) -> list[TrackType]: ... + @property + def connection_quality(self) -> int: + """The protobuf ``ConnectionQuality`` value.""" + @property + def is_speaking(self) -> bool: ... + @property + def is_dominant_speaker(self) -> bool: ... + @property + def audio_level(self) -> float: ... + @property + def source(self) -> int: + """The protobuf ``ParticipantSource`` value.""" + @property + def joined_at(self) -> float | None: + """Unix time in seconds, or ``None`` if the SFU did not send it.""" + @property + def custom(self) -> dict[str, Any]: + """Empty if the SFU did not send it.""" + +class TrackSubscriptionConfig: + """The subscription rule for the participants of one role, or the default + rule. A ``None`` argument keeps the SDK default (no track types, + 1920x1080).""" + + def __init__( + self, + track_types: list[TrackType] | None = None, + video_dimension: tuple[int, int] | None = None, + screenshare_dimension: tuple[int, int] | None = None, + ) -> None: ... + @property + def track_types(self) -> list[TrackType]: ... + @property + def video_dimension(self) -> tuple[int, int]: ... + @property + def screenshare_dimension(self) -> tuple[int, int]: ... + +class SubscriptionConfig: + """The SDK applies the rule of the first role in ``participant.roles`` that + has an entry in ``role_filters``, or else ``default``, and keeps at most + ``max_subscriptions`` tracks.""" + + def __init__( + self, + default: TrackSubscriptionConfig | None = None, + role_filters: dict[str, TrackSubscriptionConfig] | None = None, + max_subscriptions: int | None = None, + ) -> None: ... + @property + def default(self) -> TrackSubscriptionConfig: ... + @property + def role_filters(self) -> dict[str, TrackSubscriptionConfig]: ... + @property + def max_subscriptions(self) -> int | None: ... + +class CallStateSnapshot: + @property + def participants(self) -> list[RemoteParticipant]: ... + @property + def own_capabilities(self) -> list[str]: ... + +class CallEvent: + @property + def name(self) -> str: + """The stable SDK event name: the ``SfuEvent`` oneof field name for SFU + events, the ``type`` for coordinator events.""" + +class ParticipantJoined(CallEvent): + @property + def call_cid(self) -> str: ... + @property + def participant(self) -> RemoteParticipant: ... + +class ParticipantLeft(CallEvent): + @property + def call_cid(self) -> str: ... + @property + def participant(self) -> RemoteParticipant: ... + +class TrackPublished(CallEvent): + @property + def user_id(self) -> str: ... + @property + def session_id(self) -> str: ... + @property + def track_type(self) -> TrackType: ... + @property + def participant(self) -> RemoteParticipant | None: ... + +class TrackUnpublished(CallEvent): + @property + def user_id(self) -> str: ... + @property + def session_id(self) -> str: ... + @property + def track_type(self) -> TrackType: ... + @property + def cause(self) -> int: + """The protobuf ``TrackUnpublishReason`` value.""" + @property + def participant(self) -> RemoteParticipant | None: ... + +class CallEnded(CallEvent): + def __init__(self) -> None: + """For an end that comes without the SFU ``call_ended``; its ``reason`` + is 0 (``CALL_ENDED_REASON_UNSPECIFIED``).""" + @property + def reason(self) -> int: + """The protobuf ``CallEndedReason`` value.""" + +class CallingStateChanged(CallEvent): + @property + def state(self) -> CallingState: ... + +class CoordinatorEvent(CallEvent): + """A coordinator event of this call; ``name`` is its JSON ``type``.""" + + @property + def data(self) -> dict[str, Any]: + """The whole JSON message.""" + +class ParticipantUpdated(CallEvent): + @property + def call_cid(self) -> str: ... + @property + def participant(self) -> RemoteParticipant: ... + +class DominantSpeakerChanged(CallEvent): + @property + def user_id(self) -> str: ... + @property + def session_id(self) -> str: ... + +class AudioLevel: + @property + def user_id(self) -> str: ... + @property + def session_id(self) -> str: ... + @property + def level(self) -> float: + """0.0 is silence, 1.0 the loudest.""" + @property + def is_speaking(self) -> bool: ... + +class AudioLevelChanged(CallEvent): + @property + def audio_levels(self) -> list[AudioLevel]: ... + +class ConnectionQualityInfo: + @property + def user_id(self) -> str: ... + @property + def session_id(self) -> str: ... + @property + def connection_quality(self) -> int: + """The protobuf ``ConnectionQuality`` value.""" + +class ConnectionQualityChanged(CallEvent): + @property + def connection_quality_updates(self) -> list[ConnectionQualityInfo]: ... + +class ParticipantCountChanged(CallEvent): + """Sent by the SDK when the SFU's participant count changes.""" + + @property + def total(self) -> int: ... + @property + def anonymous(self) -> int: ... + +class Pin: + @property + def user_id(self) -> str: ... + @property + def session_id(self) -> str: ... + +class PinsChanged(CallEvent): + @property + def pins(self) -> list[Pin]: ... + +class InboundVideoState: + @property + def user_id(self) -> str: ... + @property + def session_id(self) -> str: ... + @property + def track_type(self) -> TrackType: ... + @property + def paused(self) -> bool: ... + +class InboundStateNotification(CallEvent): + @property + def inbound_video_states(self) -> list[InboundVideoState]: ... + +class CallGrants: + @property + def can_publish_audio(self) -> bool: ... + @property + def can_publish_video(self) -> bool: ... + @property + def can_screenshare(self) -> bool: ... + +class CallGrantsUpdated(CallEvent): + @property + def current_grants(self) -> CallGrants | None: ... + @property + def message(self) -> str: ... + +class ICERestart(CallEvent): + """Sent after the SDK has handled the SFU's ICE restart request.""" + + @property + def peer_type(self) -> int: + """The protobuf ``PeerType`` value.""" + +class Codec: + @property + def payload_type(self) -> int: ... + @property + def name(self) -> str: ... + @property + def clock_rate(self) -> int: ... + @property + def encoding_parameters(self) -> str: ... + @property + def fmtp(self) -> str: ... + +class VideoDimension: + @property + def width(self) -> int: ... + @property + def height(self) -> int: ... + +class AudioBitrate: + @property + def profile(self) -> int: + """The protobuf ``AudioBitrateProfile`` value.""" + @property + def bitrate(self) -> int: ... + +class PublishOption: + @property + def track_type(self) -> TrackType: ... + @property + def codec(self) -> Codec | None: ... + @property + def bitrate(self) -> int: ... + @property + def fps(self) -> int: ... + @property + def max_spatial_layers(self) -> int: ... + @property + def max_temporal_layers(self) -> int: ... + @property + def video_dimension(self) -> VideoDimension | None: ... + @property + def id(self) -> int: ... + @property + def use_single_layer(self) -> bool: ... + @property + def audio_bitrate_profiles(self) -> list[AudioBitrate]: ... + @property + def degradation_preference(self) -> int: + """The protobuf ``DegradationPreference`` value.""" + +class ChangePublishOptions(CallEvent): + @property + def publish_options(self) -> list[PublishOption]: ... + @property + def reason(self) -> str: ... + +class AudioSender: + @property + def codec(self) -> Codec | None: ... + @property + def track_type(self) -> TrackType: ... + @property + def publish_option_id(self) -> int: ... + +class VideoLayerSetting: + @property + def name(self) -> str: ... + @property + def active(self) -> bool: ... + @property + def max_bitrate(self) -> int: ... + @property + def scale_resolution_down_by(self) -> float: ... + @property + def codec(self) -> Codec | None: ... + @property + def max_framerate(self) -> int: ... + @property + def scalability_mode(self) -> str: ... + +class VideoSender: + @property + def codec(self) -> Codec | None: ... + @property + def layers(self) -> list[VideoLayerSetting]: ... + @property + def track_type(self) -> TrackType: ... + @property + def publish_option_id(self) -> int: ... + @property + def degradation_preference(self) -> int: + """The protobuf ``DegradationPreference`` value.""" + +class ChangePublishQuality(CallEvent): + @property + def audio_senders(self) -> list[AudioSender]: ... + @property + def video_senders(self) -> list[VideoSender]: ... + +class ErrorDetails: + """The protobuf ``models.Error`` inside the ``Error`` event.""" + + @property + def code(self) -> int: + """The protobuf ``ErrorCode`` value.""" + @property + def message(self) -> str: ... + @property + def should_retry(self) -> bool: ... + +class EventsLagged(CallEvent): + """Sent by the wrapper when this stream fell behind and lost ``skipped`` + events; the stream continues with the oldest event still buffered.""" + + @property + def skipped(self) -> int: ... + +class Error(CallEvent): + @property + def error(self) -> ErrorDetails: ... + @property + def reconnect_strategy(self) -> int: + """The protobuf ``WebsocketReconnectStrategy`` value.""" + +Event = ( + ParticipantJoined + | ParticipantLeft + | TrackPublished + | TrackUnpublished + | CallEnded + | ParticipantUpdated + | DominantSpeakerChanged + | AudioLevelChanged + | ConnectionQualityChanged + | ParticipantCountChanged + | PinsChanged + | InboundStateNotification + | CallGrantsUpdated + | ICERestart + | ChangePublishOptions + | ChangePublishQuality + | Error + | EventsLagged + | CallingStateChanged + | CoordinatorEvent +) + +class EventStream: + """Ends when the call ends (``CallingStateChanged(LEFT)``), after the events + that are already buffered, or at once if the call had already ended when + the stream was created.""" + + def __aiter__(self) -> EventStream: ... + def __anext__(self) -> Awaitable[Event]: ... + +class PcmFrame: + @property + def samples(self) -> npt.NDArray[np.int16]: ... + @property + def sample_rate(self) -> int: ... + @property + def channels(self) -> int: ... + @property + def pts(self) -> int | None: + """The RTP timestamp of the first sample, on the 48 kHz Opus clock; it + wraps at 2**32.""" + +class VideoFrame: + @property + def width(self) -> int: ... + @property + def height(self) -> int: ... + @property + def data(self) -> npt.NDArray[np.uint8]: ... + @property + def rtp_timestamp(self) -> int: ... + +class VideoFrameStream: + """Decoded frames of one video track. Each ``RemoteTrack.video_frames()`` + call gives an independent stream with its own copy of each frame. A stream + that reads slower than the track gets the latest frame and skips the older + ones. Ends when the track ends, or at once for a track with no video + decoder.""" + + def __aiter__(self) -> VideoFrameStream: ... + def __anext__(self) -> Awaitable[VideoFrame]: ... + +class RemoteTrack: + @property + def participant(self) -> RemoteParticipant: ... + @property + def track_type(self) -> TrackType: ... + def next_pcm(self) -> Awaitable[PcmFrame | None]: ... + def video_frames(self) -> VideoFrameStream: + """A new stream of the frames decoded after this call. The track stays + subscribed while one of its streams exists.""" + +class TrackStream: + """All streams of one call share one queue. Ends when the call ends, or at + once if the call had already ended when the stream was created.""" + + def __aiter__(self) -> TrackStream: ... + def __anext__(self) -> Awaitable[RemoteTrack]: ... + +class LocalAudioTrack: + def __init__(self, pcm_queue_capacity: float | None = None) -> None: + """``pcm_queue_capacity``: the seconds of PCM that ``write_pcm`` queues + (``None``: the SDK default, 60 s; below 0.02 raises ``MediaError``). A + write above it drops the oldest samples and raises + ``PcmQueueOverflowError``.""" + def write_pcm( + self, samples: npt.NDArray[np.int16], sample_rate: int, channels: int + ) -> Awaitable[None]: + """Await each write before the next; writes that run at the same time + can reach the SDK out of order. The first frame sets the sample rate of + the track; a frame at another rate raises ``PcmRateMismatchError``.""" + def flush(self) -> None: ... + +class StreamResampler: + """Resamples one stream of interleaved int16 PCM to ``sample_rate`` with a + windowed-sinc filter, without a change of the channel count. The filter + state carries over between calls. The filter delays the audio by 128 input + frames (8 ms at 16 kHz) and holds them until the next ``push`` or + ``flush``.""" + + def __init__(self, sample_rate: int, channels: int) -> None: ... + def push( + self, samples: npt.NDArray[np.int16], sample_rate: int, channels: int + ) -> PcmFrame: + """The first call sets the input rate. A later frame at another rate + raises ``PcmRateMismatchError``, a frame with another channel count + ``IllegalStateError``. Input at the output rate is copied.""" + def flush(self) -> PcmFrame: + """Returns the audio that the filter holds, followed by near-silence + (about 132 input frames). Returns an empty frame when nothing was pushed + since the last flush, or when the input is at the output rate. Later + pushes continue at the same input rate.""" + +class LocalVideoTrack: + @staticmethod + def vp8() -> LocalVideoTrack: ... + @staticmethod + def vp9() -> LocalVideoTrack: ... + def write_i420( + self, data: npt.NDArray[np.uint8], width: int, height: int, duration: float + ) -> Awaitable[None]: + """Await each write before the next; writes that run at the same time + can reach the SDK out of order.""" + +class Call: + def join(self, user_id: str, create: bool = True) -> Awaitable[None]: ... + def leave(self) -> Awaitable[None]: ... + def session_id(self) -> Awaitable[str | None]: ... + @property + def calling_state(self) -> CallingState: ... + def participants(self) -> list[RemoteParticipant]: ... + def call_state(self) -> CallStateSnapshot: ... + def sfu_events(self) -> EventStream: + """SFU events. The SFU ``call_ended`` may not come: the SDK also leaves + on the coordinator ``call.ended``.""" + def coordinator_events(self) -> EventStream: + """``CoordinatorEvent`` for each coordinator event of this call.""" + def client_events(self) -> EventStream: + """``CallingStateChanged``; ``LEFT`` is the reliable end of the call.""" + def tracks(self) -> TrackStream: ... + def publish_audio(self, track: LocalAudioTrack) -> Awaitable[None]: ... + def publish_video(self, track: LocalVideoTrack) -> Awaitable[None]: ... + def publish_screen_share(self, track: LocalVideoTrack) -> Awaitable[None]: + """The SFU accepts VP8 for screen share (``LocalVideoTrack.vp8()``).""" + def stop_publish_audio(self, track: LocalAudioTrack) -> Awaitable[None]: + """Stops the track: later writes raise ``IllegalStateError``. A track + that is not published is ignored.""" + def stop_publish_video(self, track: LocalVideoTrack) -> Awaitable[None]: + """Stops the track: later writes raise ``IllegalStateError``. A track + that is not published is ignored.""" + def stop_publish_screen_share(self, track: LocalVideoTrack) -> Awaitable[None]: + """Stops the track: later writes raise ``IllegalStateError``. A track + that is not published is ignored.""" + def mute_track(self, track_type: TrackType) -> Awaitable[None]: + """Keeps the published track; others get ``TrackUnpublished``.""" + def unmute_track(self, track_type: TrackType) -> Awaitable[None]: + """Others get ``TrackPublished``; their track delivers frames again.""" + def update_subscriptions(self, config: SubscriptionConfig) -> Awaitable[None]: + """The SDK applies ``config`` again on each participant and track event. + Remote audio can arrive without a subscription: the SFU sends it.""" + +class Client: + def __init__( + self, + api_key: str, + api_secret: str | None = None, + *, + token: str | None = None, + base_url: str | None = None, + ws_url: str | None = None, + log_bodies: bool = False, + call_event_capacity: int | None = None, + ) -> None: + """Pass exactly one of ``api_secret`` or ``token`` (a user token); else + ``ConfigError``. A token client joins only as the token's user. + + ``call_event_capacity``: the events that each event stream of a call + buffers for a reader that falls behind (``None``: the SDK default, 256); + 0 raises ``ConfigError``.""" + def call(self, call_type: str, call_id: str) -> Call: ... diff --git a/getstream-rtc/getstream_rtc/errors.py b/getstream-rtc/getstream_rtc/errors.py new file mode 100644 index 00000000..dd99e54b --- /dev/null +++ b/getstream-rtc/getstream_rtc/errors.py @@ -0,0 +1,71 @@ +class RustError(Exception): + """Base class of the errors that ``getstream_rtc`` raises.""" + + +class ConfigError(RustError): + """The client configuration is invalid.""" + + +class RtcError(RustError): + """An RTC operation of the Rust SDK failed.""" + + +class ApiError(RtcError): + """The Stream API returned an error response. ``message`` is the message + of the API; ``str(error)`` also contains the code and the HTTP status.""" + + def __init__( + self, + text: str, + code: int, + status_code: int, + message: str, + unrecoverable: bool, + ): + super().__init__(text) + self.code = code + self.status_code = status_code + self.message = message + self.unrecoverable = unrecoverable + + +class CoordinatorError(RtcError): + """A coordinator step of the join failed, for example the coordinator + rejected the credentials.""" + + +class IllegalStateError(RtcError): + """The operation is not valid in the current state of the call or track.""" + + +class MediaError(RtcError): + """Encoding, decoding or packetizing media failed, or a frame is invalid.""" + + +class PermissionDeniedError(RtcError): + """The participant lacks ``capability`` (for example ``send-audio``), which + the operation requires.""" + + def __init__(self, message: str, capability: str): + super().__init__(message) + self.capability = capability + + +class PcmQueueOverflowError(RtcError): + """A PCM write exceeded the queue capacity. The newest samples were kept + and ``dropped_samples`` oldest samples were discarded.""" + + def __init__(self, message: str, dropped_samples: int, capacity_samples: int): + super().__init__(message) + self.dropped_samples = dropped_samples + self.capacity_samples = capacity_samples + + +class PcmRateMismatchError(RtcError): + """A PCM frame is not at the sample rate ``expected`` of the first frame; + ``actual`` is its rate. The frame was not used.""" + + def __init__(self, message: str, expected: int, actual: int): + super().__init__(message) + self.expected = expected + self.actual = actual diff --git a/getstream-rtc/getstream_rtc/logging.py b/getstream-rtc/getstream_rtc/logging.py new file mode 100644 index 00000000..750102b3 --- /dev/null +++ b/getstream-rtc/getstream_rtc/logging.py @@ -0,0 +1,163 @@ +"""Hands the records that Rust queues to Python `logging`. + +Rust only queues records. The thread `getstream-logging` takes them with +`_bindings._next_logs()`, which waits without the GIL, and hands them to the +configured logger. Rust never calls Python to log. +""" + +import atexit +import itertools +import logging +import os +import threading +import traceback + +from getstream_rtc import _bindings + +_lock = threading.Lock() +_logger: logging.Logger | None = None +_thread: threading.Thread | None = None +# The process that started `_thread`. A child of fork() has no `_thread`. It +# changes only once, from `None`, so it is read without `_lock`: a child forked +# while `_lock` was held cannot take it. +_pid: int | None = None +_stop_marker: int | None = None +_markers: dict[int, threading.Event] = {} +_marker_ids = itertools.count() + + +def configure_logging( + logger: logging.Logger | None, + level: int, + third_party_level: int = logging.WARNING, +) -> None: + """Send SDK records at `level` or above, and records of third-party Rust + crates at the less verbose of `level` and `third_party_level`, to `logger` + and the loggers below it. `logger=None` stops forwarding. Process-wide: the + last call wins. Returns at once: the thread `getstream-logging` delivers + the records. + + Raises `RuntimeError` for a `logger` in a process forked after the logging + was configured: the child has no thread of the parent. Configure the + logging and use `getstream_rtc` only after fork().""" + global _logger, _thread, _pid + if _pid is not None and _pid != os.getpid(): + if logger is None: + return + raise RuntimeError( + "the logging of getstream_rtc was configured before this process " + "was forked: configure the logging and use getstream_rtc only " + "after fork()" + ) + with _lock: + if _stop_marker is not None: + return + _bindings._set_log_filters(None if logger is None else level, third_party_level) + _logger = logger + if logger is not None and (_thread is None or not _thread.is_alive()): + _thread = threading.Thread( + target=_forward, name="getstream-logging", daemon=True + ) + _thread.start() + _pid = os.getpid() + + +def handle_record( + base: logging.Logger, + name: str | None, + level: int, + pathname: str, + lineno: int, + msg: str, + created_ns: int, + rust_target: str, + fields: dict[str, object], +) -> None: + logger = base.getChild(name) if name else base + if not logger.isEnabledFor(level): + return + record = logger.makeRecord(logger.name, level, pathname, lineno, msg, (), None) + for key, value in fields.items(): + setattr(record, f"rust_{key}" if hasattr(record, key) else key, value) + record.rust_target = rust_target + # The time of the Rust event, not of this call. LogRecord derives msecs + # and relativeCreated from the same clock reading as created. + created = created_ns / 1e9 + record.relativeCreated -= (record.created - created) * 1000 + record.created = created + record.msecs = (created_ns % 1_000_000_000) // 1_000_000 + 0.0 + logger.handle(record) + + +def _forward() -> None: + while True: + dropped, records, marker = _bindings._next_logs() + logger = _logger + if logger is not None: + # An error of one record is printed, as `logging.Handler.handleError` + # does, and the thread goes on: a `_flush()` that waits for a + # marker in the queue needs it. + if dropped: + try: + logger.warning( + "stream.rust.log_records_dropped", extra={"dropped": dropped} + ) + except Exception: + traceback.print_exc() + for record in records: + try: + handle_record(logger, *record) + except Exception: + traceback.print_exc() + if marker is not None: + _markers.pop(marker).set() + if marker == _stop_marker: + return + + +def _flush() -> None: + """Return after the records queued before this call are handled. + + Returns at once on the thread `getstream-logging` itself, when that thread + does not run or is stopping, and in a child of fork(). Do not call it on a + thread that a handler waits for, for example the event loop that a handler + runs coroutines on: use `await asyncio.to_thread(_flush)` there. + """ + if _pid != os.getpid(): + return + with _lock: + thread = _thread + if ( + thread is None + or not thread.is_alive() + or _stop_marker is not None + or threading.current_thread() is thread + ): + return + marker = next(_marker_ids) + event = _markers[marker] = threading.Event() + _bindings._push_log_marker(marker) + event.wait() + + +def _stop() -> None: + """At exit, hand the queued records to the logger and stop the thread, so + it does not call Python while the interpreter finalizes.""" + global _logger, _stop_marker + if _pid != os.getpid(): + return + with _lock: + thread = _thread + if thread is None or not thread.is_alive() or _stop_marker is not None: + return + _bindings._set_log_filters(None, logging.WARNING) + _stop_marker = marker = next(_marker_ids) + _markers[marker] = threading.Event() + _bindings._push_log_marker(marker) + thread.join() + _logger = None + + +# Registered after `logging` registers `logging.shutdown`, so it runs first, +# while the handlers are still open. +atexit.register(_stop) diff --git a/getstream-rtc/getstream_rtc/py.typed b/getstream-rtc/getstream_rtc/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/getstream-rtc/pyproject.toml b/getstream-rtc/pyproject.toml new file mode 100644 index 00000000..b082ddb7 --- /dev/null +++ b/getstream-rtc/pyproject.toml @@ -0,0 +1,24 @@ +[project] +name = "getstream-rtc" +version = "0.1.0" +description = "Python bindings for the Stream Video Rust SDK" +requires-python = ">=3.10" +dependencies = [ + "numpy>=2.2.6", +] + +[build-system] +requires = ["maturin>=1.9.4,<2.0"] +build-backend = "maturin" + +[tool.maturin] +module-name = "getstream_rtc._bindings" +exclude = ["tests/**"] + +[tool.uv] +cache-keys = [ + { file = "pyproject.toml" }, + { file = "Cargo.toml" }, + { file = "Cargo.lock" }, + { file = "rustsrc/**/*.rs" }, +] diff --git a/getstream-rtc/rust-toolchain.toml b/getstream-rtc/rust-toolchain.toml new file mode 100644 index 00000000..724b93e6 --- /dev/null +++ b/getstream-rtc/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.88" +components = ["rustfmt", "clippy", "rust-analyzer"] diff --git a/getstream-rtc/rustsrc/call.rs b/getstream-rtc/rustsrc/call.rs new file mode 100644 index 00000000..fd1bc411 --- /dev/null +++ b/getstream-rtc/rustsrc/call.rs @@ -0,0 +1,327 @@ +use std::sync::Arc; + +use getstream::ClientConfig; +use getstream::rtc::proto::models; +use getstream::rtc::{JoinCallData, LocalTrack, RtcCall, RtcClient}; +use pyo3::prelude::*; +use pyo3_async_runtimes::tokio::future_into_py; +use tokio::sync::{Mutex, watch}; + +use crate::call_end::{self, CallEnd}; +use crate::errors::{ConfigError, rtc_error, sdk_error}; +use crate::events::{EventStream, Source}; +use crate::participants::{CallStateSnapshot, RemoteParticipant, TrackType}; +use crate::subscriptions::SubscriptionConfig; +use crate::tracks::{LocalAudioTrack, LocalVideoTrack, TrackQueue, TrackStream}; + +enum Credentials { + /// Mints a user token for each join. + ApiSecret(getstream::Stream), + /// Joins with the user token of one user. + UserToken(RtcClient), +} + +#[pyclass(frozen, module = "getstream_rtc")] +pub struct Client { + inner: Credentials, +} + +#[pymethods] +impl Client { + #[new] + #[pyo3(signature = (api_key, api_secret=None, *, token=None, base_url=None, ws_url=None, log_bodies=false, call_event_capacity=None))] + fn new( + api_key: String, + api_secret: Option, + token: Option, + base_url: Option, + ws_url: Option, + log_bodies: bool, + call_event_capacity: Option, + ) -> PyResult { + let mut config = ClientConfig::default(); + if let Some(base_url) = base_url { + config.base_url = base_url; + } + if let Some(ws_url) = ws_url { + config.coordinator_ws_url = ws_url; + } + if let Some(call_event_capacity) = call_event_capacity { + config.call_event_capacity = call_event_capacity; + } + config.log_bodies = log_bodies; + let inner = match (api_secret, token) { + (Some(api_secret), None) => Credentials::ApiSecret( + getstream::Stream::with_config(api_key, api_secret, config).map_err(sdk_error)?, + ), + (None, Some(token)) => Credentials::UserToken( + RtcClient::with_config(api_key, token, config).map_err(sdk_error)?, + ), + _ => { + return Err(ConfigError::new_err( + "pass exactly one of api_secret or token", + )); + } + }; + Ok(Self { inner }) + } + + fn call(&self, call_type: String, call_id: String) -> Call { + let cid = format!("{call_type}:{call_id}"); + let inner = match &self.inner { + Credentials::ApiSecret(stream) => stream.video().call(call_type, call_id).rtc(), + Credentials::UserToken(client) => client.call(call_type, call_id), + }; + let end = call_end::watch_call_end(inner.client_events()); + let tracks = TrackQueue::register(&inner); + Call { + inner, + cid, + tracks, + end, + join_lock: Arc::default(), + } + } +} + +#[pyclass(frozen, module = "getstream_rtc")] +pub struct Call { + inner: RtcCall, + /// `":"`, for the `call_cid` of the SFU events. + cid: String, + tracks: Arc, + end: watch::Receiver, + /// Python sees a cancelled `join()` at once, but the SDK future is dropped + /// (and resets the call to `Idle`) only later on the tokio runtime. The + /// next `join()` waits for this lock until that has happened. + join_lock: Arc>, +} + +#[pymethods] +impl Call { + #[pyo3(signature = (user_id, create=true))] + fn join<'py>( + &self, + py: Python<'py>, + user_id: String, + create: bool, + ) -> PyResult> { + let call = self.inner.clone(); + let join_lock = self.join_lock.clone(); + let data = if create { + JoinCallData::create(user_id) + } else { + JoinCallData::new(user_id) + }; + future_into_py(py, async move { + let _join_guard = join_lock.lock_owned().await; + // Pinned in a local declared after the guard, so a cancelled join + // drops the SDK future before it releases the lock. + let join = call.join(data); + tokio::pin!(join); + join.as_mut().await.map_err(rtc_error) + }) + } + + fn leave<'py>(&self, py: Python<'py>) -> PyResult> { + let call = self.inner.clone(); + future_into_py(py, async move { call.leave().await.map_err(rtc_error) }) + } + + fn session_id<'py>(&self, py: Python<'py>) -> PyResult> { + let call = self.inner.clone(); + future_into_py(py, async move { Ok(call.session_id().await) }) + } + + #[getter] + fn calling_state(&self, py: Python<'_>) -> CallingState { + py.detach(|| self.inner.calling_state()).into() + } + + fn participants(&self, py: Python<'_>) -> Vec { + py.detach(|| { + self.inner + .participants() + .into_iter() + .map(Into::into) + .collect() + }) + } + + fn call_state(&self, py: Python<'_>) -> CallStateSnapshot { + py.detach(|| self.inner.call_state()).into() + } + + fn sfu_events(&self, py: Python<'_>) -> EventStream { + py.detach(|| { + let source = Source::Sfu(self.inner.sfu_events()); + EventStream::new(source, &self.inner, self.cid.clone(), self.end.clone()) + }) + } + + fn coordinator_events(&self, py: Python<'_>) -> EventStream { + py.detach(|| { + let source = Source::Coordinator(self.inner.coordinator_events()); + EventStream::new(source, &self.inner, self.cid.clone(), self.end.clone()) + }) + } + + fn client_events(&self, py: Python<'_>) -> EventStream { + py.detach(|| { + let source = Source::Client(self.inner.client_events()); + EventStream::new(source, &self.inner, self.cid.clone(), self.end.clone()) + }) + } + + fn tracks(&self, py: Python<'_>) -> TrackStream { + py.detach(|| TrackStream::new(self.tracks.clone(), &self.inner, self.end.clone())) + } + + fn publish_audio<'py>( + &self, + py: Python<'py>, + track: &Bound<'py, LocalAudioTrack>, + ) -> PyResult> { + let call = self.inner.clone(); + let track = track.get().inner.clone(); + future_into_py(py, async move { + call.publish_audio(track).await.map_err(rtc_error) + }) + } + + fn publish_video<'py>( + &self, + py: Python<'py>, + track: &Bound<'py, LocalVideoTrack>, + ) -> PyResult> { + let call = self.inner.clone(); + let track = track.get().inner.clone(); + future_into_py(py, async move { + call.publish_video(track).await.map_err(rtc_error) + }) + } + + fn publish_screen_share<'py>( + &self, + py: Python<'py>, + track: &Bound<'py, LocalVideoTrack>, + ) -> PyResult> { + let call = self.inner.clone(); + let track = track.get().inner.clone(); + future_into_py(py, async move { + call.publish_screen_share(track).await.map_err(rtc_error) + }) + } + + fn stop_publish_audio<'py>( + &self, + py: Python<'py>, + track: &Bound<'py, LocalAudioTrack>, + ) -> PyResult> { + let call = self.inner.clone(); + let track = LocalTrack::Audio(track.get().inner.clone()); + future_into_py(py, async move { + call.stop_publish(track).await.map_err(rtc_error) + }) + } + + fn stop_publish_video<'py>( + &self, + py: Python<'py>, + track: &Bound<'py, LocalVideoTrack>, + ) -> PyResult> { + let call = self.inner.clone(); + let track = LocalTrack::Video { + track: track.get().inner.clone(), + track_type: models::TrackType::Video, + }; + future_into_py(py, async move { + call.stop_publish(track).await.map_err(rtc_error) + }) + } + + fn stop_publish_screen_share<'py>( + &self, + py: Python<'py>, + track: &Bound<'py, LocalVideoTrack>, + ) -> PyResult> { + let call = self.inner.clone(); + let track = LocalTrack::Video { + track: track.get().inner.clone(), + track_type: models::TrackType::ScreenShare, + }; + future_into_py(py, async move { + call.stop_publish(track).await.map_err(rtc_error) + }) + } + + fn mute_track<'py>( + &self, + py: Python<'py>, + track_type: TrackType, + ) -> PyResult> { + let call = self.inner.clone(); + future_into_py(py, async move { + call.mute_track(track_type.into()).await.map_err(rtc_error) + }) + } + + fn unmute_track<'py>( + &self, + py: Python<'py>, + track_type: TrackType, + ) -> PyResult> { + let call = self.inner.clone(); + future_into_py(py, async move { + call.unmute_track(track_type.into()) + .await + .map_err(rtc_error) + }) + } + + fn update_subscriptions<'py>( + &self, + py: Python<'py>, + config: SubscriptionConfig, + ) -> PyResult> { + let call = self.inner.clone(); + let config = getstream::rtc::SubscriptionConfig::from(config); + future_into_py(py, async move { + call.update_subscriptions(config).await.map_err(rtc_error) + }) + } +} + +#[pyclass( + frozen, + module = "getstream_rtc", + eq, + eq_int, + skip_from_py_object, + rename_all = "SCREAMING_SNAKE_CASE" +)] +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum CallingState { + Idle, + Joining, + Joined, + Reconnecting, + Migrating, + ReconnectingFailed, + Left, +} + +impl From for CallingState { + fn from(state: getstream::rtc::CallingState) -> Self { + use getstream::rtc::CallingState as Sdk; + match state { + Sdk::Idle => Self::Idle, + Sdk::Joining => Self::Joining, + Sdk::Joined => Self::Joined, + Sdk::Reconnecting => Self::Reconnecting, + Sdk::Migrating => Self::Migrating, + Sdk::ReconnectingFailed => Self::ReconnectingFailed, + Sdk::Left => Self::Left, + } + } +} diff --git a/getstream-rtc/rustsrc/call_end.rs b/getstream-rtc/rustsrc/call_end.rs new file mode 100644 index 00000000..e96965f0 --- /dev/null +++ b/getstream-rtc/rustsrc/call_end.rs @@ -0,0 +1,73 @@ +use getstream::rtc::{CallingState, ClientCallEvent, RtcCall}; +use tokio::sync::broadcast::Receiver; +use tokio::sync::broadcast::error::RecvError; +use tokio::sync::watch; + +/// The call ends when this participant's state becomes `Left`; the SDK leaves +/// on the SFU `call_ended` and on the coordinator `call.ended`. `ends` counts +/// the ends. +#[derive(Clone, Copy)] +pub struct CallEnd { + ends: u64, +} + +/// Watches the call's client events on the pyo3-async-runtimes tokio runtime. The +/// task stops when every receiver is dropped or the SDK core is gone. +pub fn watch_call_end(mut events: Receiver) -> watch::Receiver { + let (sender, receiver) = watch::channel(CallEnd { ends: 0 }); + pyo3_async_runtimes::tokio::get_runtime().spawn(async move { + loop { + let event = tokio::select! { + event = events.recv() => event, + () = sender.closed() => return, + }; + match event { + Ok(ClientCallEvent::CallingStateChanged(CallingState::Left)) => { + sender.send_modify(|end| end.ends = end.ends.wrapping_add(1)); + } + Ok(_) | Err(RecvError::Lagged(_)) => {} + Err(RecvError::Closed) => return, + } + } + }); + receiver +} + +/// The end count that a new stream waits for, or `None` if the call has +/// already ended. The SDK sets `Left` before `leave()` returns, but the watcher +/// counts that end later, so the state comes from the SDK. Takes the SDK +/// lifecycle lock: call it without the GIL. +pub fn start(call: &RtcCall, end: &watch::Receiver) -> Option { + // Read before the state, so an end between the two reads gives `None` + // and not a count that the stream would wait past. + let ends = end.borrow().ends; + (call.calling_state() != CallingState::Left).then_some(ends) +} + +/// Resolves when the call has ended after `start` was taken. +pub async fn ended(mut end: watch::Receiver, start: Option) { + if let Some(start) = start { + // An error means the watcher stopped, which happens only when the SDK + // core is gone. + let _ = end.wait_for(|end| end.ends != start).await; + } +} + +#[cfg(test)] +mod tests { + use getstream::rtc::RtcClient; + + use super::*; + + #[tokio::test] + async fn a_stream_created_after_leave_has_ended() { + let call = RtcClient::new("key", "token") + .expect("a client") + .call("default", "call"); + call.leave().await.expect("a leave"); + // No watcher has seen the leave yet. + let (_sender, end) = watch::channel(CallEnd { ends: 0 }); + + assert_eq!(start(&call, &end), None); + } +} diff --git a/getstream-rtc/rustsrc/errors.rs b/getstream-rtc/rustsrc/errors.rs new file mode 100644 index 00000000..1c052025 --- /dev/null +++ b/getstream-rtc/rustsrc/errors.rs @@ -0,0 +1,49 @@ +use pyo3::PyErr; +use pyo3::import_exception; + +import_exception!(getstream_rtc.errors, RustError); +import_exception!(getstream_rtc.errors, ConfigError); +import_exception!(getstream_rtc.errors, RtcError); +import_exception!(getstream_rtc.errors, ApiError); +import_exception!(getstream_rtc.errors, CoordinatorError); +import_exception!(getstream_rtc.errors, IllegalStateError); +import_exception!(getstream_rtc.errors, MediaError); +import_exception!(getstream_rtc.errors, PermissionDeniedError); +import_exception!(getstream_rtc.errors, PcmQueueOverflowError); +import_exception!(getstream_rtc.errors, PcmRateMismatchError); + +pub fn sdk_error(err: getstream::Error) -> PyErr { + let message = err.to_string(); + match err { + getstream::Error::Config(_) => ConfigError::new_err(message), + _ => RustError::new_err(message), + } +} + +pub fn rtc_error(err: getstream::rtc::RtcError) -> PyErr { + use getstream::rtc::RtcError as Sdk; + let message = err.to_string(); + match err { + Sdk::Api(api) => ApiError::new_err(( + message, + api.code, + api.status, + api.message, + api.unrecoverable, + )), + Sdk::Coordinator(_) => CoordinatorError::new_err(message), + Sdk::IllegalState(_) => IllegalStateError::new_err(message), + Sdk::Media(_) => MediaError::new_err(message), + Sdk::PermissionDenied { capability } => { + PermissionDeniedError::new_err((message, capability)) + } + Sdk::PcmQueueOverflow { + dropped_samples, + capacity_samples, + } => PcmQueueOverflowError::new_err((message, dropped_samples, capacity_samples)), + Sdk::PcmRateMismatch { expected, actual } => { + PcmRateMismatchError::new_err((message, expected, actual)) + } + _ => RtcError::new_err(message), + } +} diff --git a/getstream-rtc/rustsrc/events.rs b/getstream-rtc/rustsrc/events.rs new file mode 100644 index 00000000..3ffff6f2 --- /dev/null +++ b/getstream-rtc/rustsrc/events.rs @@ -0,0 +1,1520 @@ +use std::sync::Arc; + +use getstream::rtc::proto::event; +use getstream::rtc::proto::models::{self, CallEndedReason}; +use getstream::rtc::{ClientCallEvent, RtcCall, SfuCallEvent}; +use pyo3::IntoPyObjectExt; +use pyo3::exceptions::PyStopAsyncIteration; +use pyo3::prelude::*; +use pyo3::types::{PyDict, PyList}; +use pyo3_async_runtimes::tokio::future_into_py; +use tokio::sync::broadcast::Receiver; +use tokio::sync::broadcast::error::RecvError; +use tokio::sync::{Mutex, watch}; + +use crate::call::CallingState; +use crate::call_end::{self, CallEnd}; +use crate::participants::{RemoteParticipant, TrackType}; +use crate::repr::repr; + +/// The base class of all events. `name` is the stable SDK event name: the +/// `SfuEvent` oneof field name for SFU events, the `type` for coordinator events. +#[pyclass(frozen, subclass, name = "CallEvent", module = "getstream_rtc")] +pub struct CallEventBase { + #[pyo3(get)] + name: String, +} + +#[pyclass(frozen, extends = CallEventBase, module = "getstream_rtc")] +pub struct ParticipantJoined { + /// The cid of this call, `":"`. + #[pyo3(get)] + call_cid: String, + #[pyo3(get)] + participant: RemoteParticipant, +} + +#[pymethods] +impl ParticipantJoined { + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!( + "ParticipantJoined(call_cid={}, participant={})", + repr(py, &self.call_cid)?, + repr(py, self.participant.clone())? + )) + } +} + +#[pyclass(frozen, extends = CallEventBase, module = "getstream_rtc")] +pub struct ParticipantLeft { + /// The cid of this call, `":"`. + #[pyo3(get)] + call_cid: String, + #[pyo3(get)] + participant: RemoteParticipant, +} + +#[pymethods] +impl ParticipantLeft { + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!( + "ParticipantLeft(call_cid={}, participant={})", + repr(py, &self.call_cid)?, + repr(py, self.participant.clone())? + )) + } +} + +#[pyclass(frozen, extends = CallEventBase, module = "getstream_rtc")] +pub struct TrackPublished { + #[pyo3(get)] + user_id: String, + #[pyo3(get)] + session_id: String, + #[pyo3(get)] + track_type: TrackType, + /// `None` if the SFU did not send it. + #[pyo3(get)] + participant: Option, +} + +#[pymethods] +impl TrackPublished { + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!( + "TrackPublished(user_id={}, session_id={}, track_type={})", + repr(py, &self.user_id)?, + repr(py, &self.session_id)?, + repr(py, self.track_type)?, + )) + } +} + +#[pyclass(frozen, extends = CallEventBase, module = "getstream_rtc")] +pub struct TrackUnpublished { + #[pyo3(get)] + user_id: String, + #[pyo3(get)] + session_id: String, + #[pyo3(get)] + track_type: TrackType, + /// The protobuf `TrackUnpublishReason` value. + #[pyo3(get)] + cause: i32, + /// `None` if the SFU did not send it. + #[pyo3(get)] + participant: Option, +} + +#[pymethods] +impl TrackUnpublished { + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!( + "TrackUnpublished(user_id={}, session_id={}, track_type={}, cause={})", + repr(py, &self.user_id)?, + repr(py, &self.session_id)?, + repr(py, self.track_type)?, + self.cause, + )) + } +} + +#[pyclass(frozen, extends = CallEventBase, module = "getstream_rtc")] +pub struct CallEnded { + /// The protobuf `CallEndedReason` value. + #[pyo3(get)] + reason: i32, +} + +#[pymethods] +impl CallEnded { + /// For an end that comes without the SFU `call_ended`, for example when the + /// coordinator ends the call. + #[new] + fn new() -> PyClassInitializer { + let name = SfuCallEvent::CallEnded { + reason: CallEndedReason::Unspecified, + } + .name(); + PyClassInitializer::from(CallEventBase { + name: name.to_owned(), + }) + .add_subclass(Self { + reason: CallEndedReason::Unspecified as i32, + }) + } + + fn __repr__(&self) -> String { + format!("CallEnded(reason={})", self.reason) + } +} + +#[pyclass(frozen, extends = CallEventBase, module = "getstream_rtc")] +pub struct CallingStateChanged { + #[pyo3(get)] + state: CallingState, +} + +#[pymethods] +impl CallingStateChanged { + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!( + "CallingStateChanged(state={})", + repr(py, self.state)? + )) + } +} + +#[pyclass(frozen, extends = CallEventBase, module = "getstream_rtc")] +pub struct ParticipantUpdated { + /// The cid of this call, `":"`. + #[pyo3(get)] + call_cid: String, + #[pyo3(get)] + participant: RemoteParticipant, +} + +#[pymethods] +impl ParticipantUpdated { + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!( + "ParticipantUpdated(call_cid={}, participant={})", + repr(py, &self.call_cid)?, + repr(py, self.participant.clone())? + )) + } +} + +#[pyclass(frozen, extends = CallEventBase, module = "getstream_rtc")] +pub struct DominantSpeakerChanged { + #[pyo3(get)] + user_id: String, + #[pyo3(get)] + session_id: String, +} + +#[pymethods] +impl DominantSpeakerChanged { + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!( + "DominantSpeakerChanged(user_id={}, session_id={})", + repr(py, &self.user_id)?, + repr(py, &self.session_id)? + )) + } +} + +#[pyclass(frozen, module = "getstream_rtc", skip_from_py_object)] +#[derive(Clone)] +pub struct AudioLevel { + #[pyo3(get)] + user_id: String, + #[pyo3(get)] + session_id: String, + /// 0.0 is silence, 1.0 the loudest. + #[pyo3(get)] + level: f32, + #[pyo3(get)] + is_speaking: bool, +} + +#[pymethods] +impl AudioLevel { + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!( + "AudioLevel(user_id={}, session_id={}, level={}, is_speaking={})", + repr(py, &self.user_id)?, + repr(py, &self.session_id)?, + self.level, + repr(py, self.is_speaking)? + )) + } +} + +impl From for AudioLevel { + fn from(level: event::AudioLevel) -> Self { + Self { + user_id: level.user_id, + session_id: level.session_id, + level: level.level, + is_speaking: level.is_speaking, + } + } +} + +#[pyclass(frozen, extends = CallEventBase, module = "getstream_rtc")] +pub struct AudioLevelChanged { + #[pyo3(get)] + audio_levels: Vec, +} + +#[pymethods] +impl AudioLevelChanged { + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!( + "AudioLevelChanged(audio_levels={})", + repr(py, self.audio_levels.clone())? + )) + } +} + +#[pyclass(frozen, module = "getstream_rtc", skip_from_py_object)] +#[derive(Clone)] +pub struct ConnectionQualityInfo { + #[pyo3(get)] + user_id: String, + #[pyo3(get)] + session_id: String, + /// The protobuf `ConnectionQuality` value. + #[pyo3(get)] + connection_quality: i32, +} + +#[pymethods] +impl ConnectionQualityInfo { + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!( + "ConnectionQualityInfo(user_id={}, session_id={}, connection_quality={})", + repr(py, &self.user_id)?, + repr(py, &self.session_id)?, + self.connection_quality + )) + } +} + +impl From for ConnectionQualityInfo { + fn from(info: event::ConnectionQualityInfo) -> Self { + Self { + user_id: info.user_id, + session_id: info.session_id, + connection_quality: info.connection_quality, + } + } +} + +#[pyclass(frozen, extends = CallEventBase, module = "getstream_rtc")] +pub struct ConnectionQualityChanged { + #[pyo3(get)] + connection_quality_updates: Vec, +} + +#[pymethods] +impl ConnectionQualityChanged { + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!( + "ConnectionQualityChanged(connection_quality_updates={})", + repr(py, self.connection_quality_updates.clone())? + )) + } +} + +/// Sent by the SDK when the SFU's participant count changes. +#[pyclass(frozen, extends = CallEventBase, module = "getstream_rtc")] +pub struct ParticipantCountChanged { + #[pyo3(get)] + total: u32, + #[pyo3(get)] + anonymous: u32, +} + +#[pymethods] +impl ParticipantCountChanged { + fn __repr__(&self) -> String { + format!( + "ParticipantCountChanged(total={}, anonymous={})", + self.total, self.anonymous + ) + } +} + +#[pyclass(frozen, module = "getstream_rtc", skip_from_py_object)] +#[derive(Clone)] +pub struct Pin { + #[pyo3(get)] + user_id: String, + #[pyo3(get)] + session_id: String, +} + +#[pymethods] +impl Pin { + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!( + "Pin(user_id={}, session_id={})", + repr(py, &self.user_id)?, + repr(py, &self.session_id)? + )) + } +} + +impl From for Pin { + fn from(pin: models::Pin) -> Self { + Self { + user_id: pin.user_id, + session_id: pin.session_id, + } + } +} + +#[pyclass(frozen, extends = CallEventBase, module = "getstream_rtc")] +pub struct PinsChanged { + #[pyo3(get)] + pins: Vec, +} + +#[pymethods] +impl PinsChanged { + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!( + "PinsChanged(pins={})", + repr(py, self.pins.clone())? + )) + } +} + +#[pyclass(frozen, module = "getstream_rtc", skip_from_py_object)] +#[derive(Clone)] +pub struct InboundVideoState { + #[pyo3(get)] + user_id: String, + #[pyo3(get)] + session_id: String, + #[pyo3(get)] + track_type: TrackType, + #[pyo3(get)] + paused: bool, +} + +#[pymethods] +impl InboundVideoState { + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!( + "InboundVideoState(user_id={}, session_id={}, track_type={}, paused={})", + repr(py, &self.user_id)?, + repr(py, &self.session_id)?, + repr(py, self.track_type)?, + repr(py, self.paused)? + )) + } +} + +impl From for InboundVideoState { + fn from(state: event::InboundVideoState) -> Self { + Self { + user_id: state.user_id, + session_id: state.session_id, + track_type: TrackType::from_i32(state.track_type), + paused: state.paused, + } + } +} + +#[pyclass(frozen, extends = CallEventBase, module = "getstream_rtc")] +pub struct InboundStateNotification { + #[pyo3(get)] + inbound_video_states: Vec, +} + +#[pymethods] +impl InboundStateNotification { + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!( + "InboundStateNotification(inbound_video_states={})", + repr(py, self.inbound_video_states.clone())? + )) + } +} + +#[pyclass(frozen, module = "getstream_rtc", skip_from_py_object)] +#[derive(Clone)] +pub struct CallGrants { + #[pyo3(get)] + can_publish_audio: bool, + #[pyo3(get)] + can_publish_video: bool, + #[pyo3(get)] + can_screenshare: bool, +} + +#[pymethods] +impl CallGrants { + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!( + "CallGrants(can_publish_audio={}, can_publish_video={}, can_screenshare={})", + repr(py, self.can_publish_audio)?, + repr(py, self.can_publish_video)?, + repr(py, self.can_screenshare)? + )) + } +} + +impl From for CallGrants { + fn from(grants: models::CallGrants) -> Self { + Self { + can_publish_audio: grants.can_publish_audio, + can_publish_video: grants.can_publish_video, + can_screenshare: grants.can_screenshare, + } + } +} + +#[pyclass(frozen, extends = CallEventBase, module = "getstream_rtc")] +pub struct CallGrantsUpdated { + /// `None` if the SFU did not send it. + #[pyo3(get)] + current_grants: Option, + #[pyo3(get)] + message: String, +} + +#[pymethods] +impl CallGrantsUpdated { + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!( + "CallGrantsUpdated(current_grants={}, message={})", + repr(py, self.current_grants.clone())?, + repr(py, &self.message)? + )) + } +} + +/// Sent after the SDK has handled the SFU's ICE restart request. +#[pyclass( + frozen, + extends = CallEventBase, + name = "ICERestart", + module = "getstream_rtc" +)] +pub struct IceRestart { + /// The protobuf `PeerType` value. + #[pyo3(get)] + peer_type: i32, +} + +#[pymethods] +impl IceRestart { + fn __repr__(&self) -> String { + format!("ICERestart(peer_type={})", self.peer_type) + } +} + +#[pyclass(frozen, module = "getstream_rtc", skip_from_py_object)] +#[derive(Clone)] +pub struct Codec { + #[pyo3(get)] + payload_type: u32, + #[pyo3(get)] + name: String, + #[pyo3(get)] + clock_rate: u32, + #[pyo3(get)] + encoding_parameters: String, + #[pyo3(get)] + fmtp: String, +} + +#[pymethods] +impl Codec { + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!( + "Codec(name={}, payload_type={}, clock_rate={})", + repr(py, &self.name)?, + self.payload_type, + self.clock_rate + )) + } +} + +impl From for Codec { + fn from(codec: models::Codec) -> Self { + Self { + payload_type: codec.payload_type, + name: codec.name, + clock_rate: codec.clock_rate, + encoding_parameters: codec.encoding_parameters, + fmtp: codec.fmtp, + } + } +} + +#[pyclass(frozen, module = "getstream_rtc", skip_from_py_object)] +#[derive(Clone)] +pub struct VideoDimension { + #[pyo3(get)] + width: u32, + #[pyo3(get)] + height: u32, +} + +#[pymethods] +impl VideoDimension { + fn __repr__(&self) -> String { + format!( + "VideoDimension(width={}, height={})", + self.width, self.height + ) + } +} + +impl From for VideoDimension { + fn from(dimension: models::VideoDimension) -> Self { + Self { + width: dimension.width, + height: dimension.height, + } + } +} + +#[pyclass(frozen, module = "getstream_rtc", skip_from_py_object)] +#[derive(Clone)] +pub struct AudioBitrate { + /// The protobuf `AudioBitrateProfile` value. + #[pyo3(get)] + profile: i32, + #[pyo3(get)] + bitrate: i32, +} + +#[pymethods] +impl AudioBitrate { + fn __repr__(&self) -> String { + format!( + "AudioBitrate(profile={}, bitrate={})", + self.profile, self.bitrate + ) + } +} + +impl From for AudioBitrate { + fn from(bitrate: models::AudioBitrate) -> Self { + Self { + profile: bitrate.profile, + bitrate: bitrate.bitrate, + } + } +} + +#[pyclass(frozen, module = "getstream_rtc", skip_from_py_object)] +#[derive(Clone)] +pub struct PublishOption { + #[pyo3(get)] + track_type: TrackType, + /// `None` if the SFU did not send it. + #[pyo3(get)] + codec: Option, + #[pyo3(get)] + bitrate: i32, + #[pyo3(get)] + fps: i32, + #[pyo3(get)] + max_spatial_layers: i32, + #[pyo3(get)] + max_temporal_layers: i32, + /// `None` if the SFU did not send it. + #[pyo3(get)] + video_dimension: Option, + #[pyo3(get)] + id: i32, + #[pyo3(get)] + use_single_layer: bool, + #[pyo3(get)] + audio_bitrate_profiles: Vec, + /// The protobuf `DegradationPreference` value. + #[pyo3(get)] + degradation_preference: i32, +} + +#[pymethods] +impl PublishOption { + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!( + "PublishOption(id={}, track_type={}, codec={}, bitrate={})", + self.id, + repr(py, self.track_type)?, + repr(py, self.codec.clone())?, + self.bitrate + )) + } +} + +impl From for PublishOption { + fn from(option: models::PublishOption) -> Self { + Self { + track_type: TrackType::from_i32(option.track_type), + codec: option.codec.map(Into::into), + bitrate: option.bitrate, + fps: option.fps, + max_spatial_layers: option.max_spatial_layers, + max_temporal_layers: option.max_temporal_layers, + video_dimension: option.video_dimension.map(Into::into), + id: option.id, + use_single_layer: option.use_single_layer, + audio_bitrate_profiles: option + .audio_bitrate_profiles + .into_iter() + .map(Into::into) + .collect(), + degradation_preference: option.degradation_preference, + } + } +} + +#[pyclass(frozen, extends = CallEventBase, module = "getstream_rtc")] +pub struct ChangePublishOptions { + #[pyo3(get)] + publish_options: Vec, + #[pyo3(get)] + reason: String, +} + +#[pymethods] +impl ChangePublishOptions { + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!( + "ChangePublishOptions(publish_options={}, reason={})", + repr(py, self.publish_options.clone())?, + repr(py, &self.reason)? + )) + } +} + +#[pyclass(frozen, module = "getstream_rtc", skip_from_py_object)] +#[derive(Clone)] +pub struct AudioSender { + /// `None` if the SFU did not send it. + #[pyo3(get)] + codec: Option, + #[pyo3(get)] + track_type: TrackType, + #[pyo3(get)] + publish_option_id: i32, +} + +#[pymethods] +impl AudioSender { + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!( + "AudioSender(codec={}, track_type={}, publish_option_id={})", + repr(py, self.codec.clone())?, + repr(py, self.track_type)?, + self.publish_option_id + )) + } +} + +impl From for AudioSender { + fn from(sender: event::AudioSender) -> Self { + Self { + codec: sender.codec.map(Into::into), + track_type: TrackType::from_i32(sender.track_type), + publish_option_id: sender.publish_option_id, + } + } +} + +#[pyclass(frozen, module = "getstream_rtc", skip_from_py_object)] +#[derive(Clone)] +pub struct VideoLayerSetting { + #[pyo3(get)] + name: String, + #[pyo3(get)] + active: bool, + #[pyo3(get)] + max_bitrate: i32, + #[pyo3(get)] + scale_resolution_down_by: f32, + /// `None` if the SFU did not send it. + #[pyo3(get)] + codec: Option, + #[pyo3(get)] + max_framerate: u32, + #[pyo3(get)] + scalability_mode: String, +} + +#[pymethods] +impl VideoLayerSetting { + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!( + "VideoLayerSetting(name={}, active={}, max_bitrate={})", + repr(py, &self.name)?, + repr(py, self.active)?, + self.max_bitrate + )) + } +} + +impl From for VideoLayerSetting { + fn from(layer: event::VideoLayerSetting) -> Self { + Self { + name: layer.name, + active: layer.active, + max_bitrate: layer.max_bitrate, + scale_resolution_down_by: layer.scale_resolution_down_by, + codec: layer.codec.map(Into::into), + max_framerate: layer.max_framerate, + scalability_mode: layer.scalability_mode, + } + } +} + +#[pyclass(frozen, module = "getstream_rtc", skip_from_py_object)] +#[derive(Clone)] +pub struct VideoSender { + /// `None` if the SFU did not send it. + #[pyo3(get)] + codec: Option, + #[pyo3(get)] + layers: Vec, + #[pyo3(get)] + track_type: TrackType, + #[pyo3(get)] + publish_option_id: i32, + /// The protobuf `DegradationPreference` value. + #[pyo3(get)] + degradation_preference: i32, +} + +#[pymethods] +impl VideoSender { + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!( + "VideoSender(codec={}, track_type={}, layers={})", + repr(py, self.codec.clone())?, + repr(py, self.track_type)?, + repr(py, self.layers.clone())? + )) + } +} + +impl From for VideoSender { + fn from(sender: event::VideoSender) -> Self { + Self { + codec: sender.codec.map(Into::into), + layers: sender.layers.into_iter().map(Into::into).collect(), + track_type: TrackType::from_i32(sender.track_type), + publish_option_id: sender.publish_option_id, + degradation_preference: sender.degradation_preference, + } + } +} + +#[pyclass(frozen, extends = CallEventBase, module = "getstream_rtc")] +pub struct ChangePublishQuality { + #[pyo3(get)] + audio_senders: Vec, + #[pyo3(get)] + video_senders: Vec, +} + +#[pymethods] +impl ChangePublishQuality { + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!( + "ChangePublishQuality(audio_senders={}, video_senders={})", + repr(py, self.audio_senders.clone())?, + repr(py, self.video_senders.clone())? + )) + } +} + +/// The protobuf `models.Error` inside the `Error` event. +#[pyclass(frozen, module = "getstream_rtc", skip_from_py_object)] +#[derive(Clone)] +pub struct ErrorDetails { + /// The protobuf `ErrorCode` value. + #[pyo3(get)] + code: i32, + #[pyo3(get)] + message: String, + #[pyo3(get)] + should_retry: bool, +} + +#[pymethods] +impl ErrorDetails { + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!( + "ErrorDetails(code={}, message={}, should_retry={})", + self.code, + repr(py, &self.message)?, + repr(py, self.should_retry)? + )) + } +} + +#[pyclass( + frozen, + extends = CallEventBase, + name = "Error", + module = "getstream_rtc" +)] +pub struct SfuError { + #[pyo3(get)] + error: ErrorDetails, + /// The protobuf `WebsocketReconnectStrategy` value. + #[pyo3(get)] + reconnect_strategy: i32, +} + +#[pymethods] +impl SfuError { + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!( + "Error(error={}, reconnect_strategy={})", + repr(py, self.error.clone())?, + self.reconnect_strategy + )) + } +} + +/// Sent by the wrapper when this stream fell behind and lost `skipped` events. +/// The stream continues with the oldest event that is still buffered. +#[pyclass(frozen, extends = CallEventBase, module = "getstream_rtc")] +pub struct EventsLagged { + #[pyo3(get)] + skipped: u64, +} + +#[pymethods] +impl EventsLagged { + fn __repr__(&self) -> String { + format!("EventsLagged(skipped={})", self.skipped) + } +} + +/// A coordinator event of this call. `name` is its JSON `type`. +#[pyclass(frozen, extends = CallEventBase, module = "getstream_rtc")] +pub struct CoordinatorEvent { + /// The whole JSON message. + #[pyo3(get)] + data: Py, +} + +#[pymethods] +impl CoordinatorEvent { + fn __repr__(slf: PyRef<'_, Self>, py: Python<'_>) -> PyResult { + Ok(format!( + "CoordinatorEvent(name={})", + repr(py, &slf.as_super().name)? + )) + } +} + +enum Event { + ParticipantJoined(ParticipantJoined), + ParticipantLeft(ParticipantLeft), + TrackPublished(TrackPublished), + TrackUnpublished(TrackUnpublished), + CallEnded(CallEnded), + ParticipantUpdated(ParticipantUpdated), + DominantSpeakerChanged(DominantSpeakerChanged), + AudioLevelChanged(AudioLevelChanged), + ConnectionQualityChanged(ConnectionQualityChanged), + ParticipantCountChanged(ParticipantCountChanged), + PinsChanged(PinsChanged), + InboundStateNotification(InboundStateNotification), + CallGrantsUpdated(CallGrantsUpdated), + IceRestart(IceRestart), + ChangePublishOptions(ChangePublishOptions), + ChangePublishQuality(ChangePublishQuality), + SfuError(SfuError), + EventsLagged(EventsLagged), + CallingStateChanged(CallingStateChanged), + /// The JSON message; it becomes a `dict` when the Python object is made, + /// with the GIL. + Coordinator(serde_json::Value), +} + +impl Event { + /// Returns `None` for SDK events that have no Python class. + fn from_sfu(event: SfuCallEvent, call_cid: &str) -> Option { + Some(match event { + SfuCallEvent::ParticipantJoined(participant) => { + Self::ParticipantJoined(ParticipantJoined { + call_cid: call_cid.to_owned(), + participant: participant.into(), + }) + } + SfuCallEvent::ParticipantLeft(participant) => Self::ParticipantLeft(ParticipantLeft { + call_cid: call_cid.to_owned(), + participant: participant.into(), + }), + SfuCallEvent::TrackPublished { + user_id, + session_id, + track_type, + participant, + } => Self::TrackPublished(TrackPublished { + user_id, + session_id, + track_type: track_type.into(), + participant: participant.map(Into::into), + }), + SfuCallEvent::TrackUnpublished { + user_id, + session_id, + track_type, + cause, + participant, + } => Self::TrackUnpublished(TrackUnpublished { + user_id, + session_id, + track_type: track_type.into(), + cause: cause as i32, + participant: participant.map(Into::into), + }), + SfuCallEvent::CallEnded { reason } => Self::CallEnded(CallEnded { + reason: reason as i32, + }), + SfuCallEvent::ParticipantUpdated(participant) => { + Self::ParticipantUpdated(ParticipantUpdated { + call_cid: call_cid.to_owned(), + participant: participant.into(), + }) + } + SfuCallEvent::DominantSpeakerChanged { + user_id, + session_id, + } => Self::DominantSpeakerChanged(DominantSpeakerChanged { + user_id, + session_id, + }), + SfuCallEvent::AudioLevelChanged(levels) => Self::AudioLevelChanged(AudioLevelChanged { + audio_levels: levels.into_iter().map(Into::into).collect(), + }), + SfuCallEvent::ConnectionQualityChanged(updates) => { + Self::ConnectionQualityChanged(ConnectionQualityChanged { + connection_quality_updates: updates.into_iter().map(Into::into).collect(), + }) + } + SfuCallEvent::ParticipantCountChanged(count) => { + Self::ParticipantCountChanged(ParticipantCountChanged { + total: count.total, + anonymous: count.anonymous, + }) + } + SfuCallEvent::PinsUpdated(pins) => Self::PinsChanged(PinsChanged { + pins: pins.into_iter().map(Into::into).collect(), + }), + SfuCallEvent::InboundStateChanged(states) => { + Self::InboundStateNotification(InboundStateNotification { + inbound_video_states: states.into_iter().map(Into::into).collect(), + }) + } + SfuCallEvent::CallGrantsUpdated(event) => Self::CallGrantsUpdated(CallGrantsUpdated { + current_grants: event.current_grants.map(Into::into), + message: event.message, + }), + SfuCallEvent::IceRestarted(peer_type) => Self::IceRestart(IceRestart { + peer_type: peer_type as i32, + }), + SfuCallEvent::PublishOptionsChanged { + publish_options, + reason, + } => Self::ChangePublishOptions(ChangePublishOptions { + publish_options: publish_options.into_iter().map(Into::into).collect(), + reason, + }), + SfuCallEvent::PublishQualityChanged(quality) => { + Self::ChangePublishQuality(ChangePublishQuality { + audio_senders: quality.audio_senders.into_iter().map(Into::into).collect(), + video_senders: quality.video_senders.into_iter().map(Into::into).collect(), + }) + } + SfuCallEvent::Error(error) => Self::SfuError(SfuError { + error: ErrorDetails { + code: error.code, + message: error.message, + should_retry: error.should_retry, + }, + reconnect_strategy: error.reconnect_strategy, + }), + other => { + tracing::debug!(event = other.name(), "stream.rtc.events.unsupported"); + return None; + } + }) + } + + /// Returns `None` for SDK events that have no Python class. + fn from_client(event: ClientCallEvent) -> Option { + match event { + ClientCallEvent::CallingStateChanged(state) => { + Some(Self::CallingStateChanged(CallingStateChanged { + state: state.into(), + })) + } + other => { + tracing::debug!(event = other.name(), "stream.rtc.events.unsupported"); + None + } + } + } +} + +/// The SDK receiver of one event stream. +pub enum Source { + Sfu(Receiver), + Coordinator(Receiver), + Client(Receiver), +} + +impl Source { + /// The next event, or `None` for an SDK event that has no Python class. A + /// receiver that fell behind gives `EventsLagged` and then continues. + async fn recv(&mut self, call_cid: &str) -> Result, RecvError> { + Ok(match self { + Self::Sfu(receiver) => match receiver.recv().await { + Ok(event) => { + let name = event.name().to_owned(); + Event::from_sfu(event, call_cid).map(|event| NamedEvent { name, event }) + } + Err(RecvError::Lagged(skipped)) => Some(NamedEvent::lagged(skipped)), + Err(error) => return Err(error), + }, + Self::Coordinator(receiver) => match receiver.recv().await { + Ok(event) => Some(NamedEvent { + event: Event::Coordinator(event.raw), + name: event.event_type, + }), + Err(RecvError::Lagged(skipped)) => Some(NamedEvent::lagged(skipped)), + Err(error) => return Err(error), + }, + Self::Client(receiver) => match receiver.recv().await { + Ok(event) => { + let name = event.name().to_owned(); + Event::from_client(event).map(|event| NamedEvent { name, event }) + } + Err(RecvError::Lagged(skipped)) => Some(NamedEvent::lagged(skipped)), + Err(error) => return Err(error), + }, + }) + } +} + +struct NamedEvent { + name: String, + event: Event, +} + +impl NamedEvent { + fn lagged(skipped: u64) -> Self { + tracing::warn!(skipped, "stream.rtc.events.lagged"); + Self { + name: "events_lagged".to_owned(), + event: Event::EventsLagged(EventsLagged { skipped }), + } + } +} + +impl<'py> IntoPyObject<'py> for NamedEvent { + type Target = PyAny; + type Output = Bound<'py, PyAny>; + type Error = PyErr; + + fn into_pyobject(self, py: Python<'py>) -> PyResult> { + let base = PyClassInitializer::from(CallEventBase { name: self.name }); + Ok(match self.event { + Event::ParticipantJoined(event) => Bound::new(py, base.add_subclass(event))?.into_any(), + Event::ParticipantLeft(event) => Bound::new(py, base.add_subclass(event))?.into_any(), + Event::TrackPublished(event) => Bound::new(py, base.add_subclass(event))?.into_any(), + Event::TrackUnpublished(event) => Bound::new(py, base.add_subclass(event))?.into_any(), + Event::CallEnded(event) => Bound::new(py, base.add_subclass(event))?.into_any(), + Event::ParticipantUpdated(event) => { + Bound::new(py, base.add_subclass(event))?.into_any() + } + Event::DominantSpeakerChanged(event) => { + Bound::new(py, base.add_subclass(event))?.into_any() + } + Event::AudioLevelChanged(event) => Bound::new(py, base.add_subclass(event))?.into_any(), + Event::ConnectionQualityChanged(event) => { + Bound::new(py, base.add_subclass(event))?.into_any() + } + Event::ParticipantCountChanged(event) => { + Bound::new(py, base.add_subclass(event))?.into_any() + } + Event::PinsChanged(event) => Bound::new(py, base.add_subclass(event))?.into_any(), + Event::InboundStateNotification(event) => { + Bound::new(py, base.add_subclass(event))?.into_any() + } + Event::CallGrantsUpdated(event) => Bound::new(py, base.add_subclass(event))?.into_any(), + Event::IceRestart(event) => Bound::new(py, base.add_subclass(event))?.into_any(), + Event::ChangePublishOptions(event) => { + Bound::new(py, base.add_subclass(event))?.into_any() + } + Event::ChangePublishQuality(event) => { + Bound::new(py, base.add_subclass(event))?.into_any() + } + Event::SfuError(event) => Bound::new(py, base.add_subclass(event))?.into_any(), + Event::EventsLagged(event) => Bound::new(py, base.add_subclass(event))?.into_any(), + Event::CallingStateChanged(event) => { + Bound::new(py, base.add_subclass(event))?.into_any() + } + Event::Coordinator(message) => { + let data = json_to_py(py, &message)?.unbind(); + Bound::new(py, base.add_subclass(CoordinatorEvent { data }))?.into_any() + } + }) + } +} + +/// The same objects as `json.loads`: integers become `int`, other numbers +/// `float`. +fn json_to_py<'py>(py: Python<'py>, value: &serde_json::Value) -> PyResult> { + use serde_json::Value; + Ok(match value { + Value::Null => py.None().into_bound(py), + Value::Bool(boolean) => boolean.into_bound_py_any(py)?, + Value::Number(number) => match (number.as_i64(), number.as_u64()) { + (Some(integer), _) => integer.into_bound_py_any(py)?, + (None, Some(integer)) => integer.into_bound_py_any(py)?, + (None, None) => number.as_f64().into_bound_py_any(py)?, + }, + Value::String(string) => string.into_bound_py_any(py)?, + Value::Array(items) => PyList::new( + py, + items + .iter() + .map(|item| json_to_py(py, item)) + .collect::>>()?, + )? + .into_any(), + Value::Object(fields) => { + let dict = PyDict::new(py); + for (key, field) in fields { + dict.set_item(key, json_to_py(py, field)?)?; + } + dict.into_any() + } + }) +} + +struct EventStreamState { + source: Source, + call_cid: String, + finished: bool, +} + +/// Async iterator over one of the call's event streams. Each stream has its own +/// SDK subscription, so it sees only events sent after it was created. It ends +/// when the call ends, after the events that are already buffered, or at once +/// if the call had already ended when the stream was created. +#[pyclass(frozen, module = "getstream_rtc")] +pub struct EventStream { + state: Arc>, + end: watch::Receiver, + start: Option, +} + +impl EventStream { + pub fn new( + source: Source, + call: &RtcCall, + call_cid: String, + end: watch::Receiver, + ) -> Self { + Self { + state: Arc::new(Mutex::new(EventStreamState { + source, + call_cid, + finished: false, + })), + start: call_end::start(call, &end), + end, + } + } +} + +#[pymethods] +impl EventStream { + fn __aiter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + + fn __anext__<'py>(&self, py: Python<'py>) -> PyResult> { + let state = self.state.clone(); + let ended = call_end::ended(self.end.clone(), self.start); + future_into_py(py, async move { + let mut state = state.lock().await; + let EventStreamState { + source, + call_cid, + finished, + } = &mut *state; + tokio::pin!(ended); + loop { + if *finished { + return Err(PyStopAsyncIteration::new_err(())); + } + // Biased: events that are already buffered, including the event + // that ends the call, are returned before the stream stops. + let event = tokio::select! { + biased; + event = source.recv(call_cid) => Some(event), + () = &mut ended => None, + }; + match event { + Some(Ok(Some(event))) => return Ok(event), + Some(Ok(None)) => {} + // `Source::recv` turns a lag into `EventsLagged`, so the + // only error left is `Closed`. + Some(Err(_)) | None => *finished = true, + } + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn convert(event: SfuCallEvent) -> Event { + Event::from_sfu(event, "default:call").expect("an event with a Python class") + } + + #[tokio::test] + async fn a_lagged_stream_continues_with_the_retained_events() { + let (sender, receiver) = tokio::sync::broadcast::channel(1); + let mut source = Source::Sfu(receiver); + for user_id in ["dropped", "retained"] { + sender + .send(SfuCallEvent::DominantSpeakerChanged { + user_id: user_id.to_owned(), + session_id: String::new(), + }) + .expect("a receiver"); + } + + let mut next = async || { + source + .recv("default:call") + .await + .expect("an event") + .expect("an event with a Python class") + .event + }; + + let Event::EventsLagged(lagged) = next().await else { + panic!("expected EventsLagged"); + }; + assert_eq!(lagged.skipped, 1); + let Event::DominantSpeakerChanged(event) = next().await else { + panic!("expected DominantSpeakerChanged"); + }; + assert_eq!(event.user_id, "retained"); + } + + #[test] + fn audio_levels_keep_their_fields() { + let event = SfuCallEvent::AudioLevelChanged(vec![event::AudioLevel { + user_id: "user".to_owned(), + session_id: "session".to_owned(), + level: 0.5, + is_speaking: true, + }]); + + let Event::AudioLevelChanged(event) = convert(event) else { + panic!("expected AudioLevelChanged"); + }; + let level = &event.audio_levels[0]; + assert_eq!( + (level.user_id.as_str(), level.session_id.as_str()), + ("user", "session") + ); + assert_eq!((level.level, level.is_speaking), (0.5, true)); + } + + #[test] + fn connection_quality_keeps_the_protobuf_value() { + let event = SfuCallEvent::ConnectionQualityChanged(vec![event::ConnectionQualityInfo { + user_id: "user".to_owned(), + session_id: "session".to_owned(), + connection_quality: models::ConnectionQuality::Good as i32, + }]); + + let Event::ConnectionQualityChanged(event) = convert(event) else { + panic!("expected ConnectionQualityChanged"); + }; + assert_eq!( + event.connection_quality_updates[0].connection_quality, + models::ConnectionQuality::Good as i32 + ); + } + + #[test] + fn participant_count_keeps_both_counts() { + let event = SfuCallEvent::ParticipantCountChanged(models::ParticipantCount { + total: 3, + anonymous: 1, + }); + + let Event::ParticipantCountChanged(event) = convert(event) else { + panic!("expected ParticipantCountChanged"); + }; + assert_eq!((event.total, event.anonymous), (3, 1)); + } + + #[test] + fn inbound_state_has_the_track_type() { + let event = SfuCallEvent::InboundStateChanged(vec![event::InboundVideoState { + user_id: "user".to_owned(), + session_id: "session".to_owned(), + track_type: models::TrackType::ScreenShare as i32, + paused: true, + }]); + + let Event::InboundStateNotification(event) = convert(event) else { + panic!("expected InboundStateNotification"); + }; + let state = &event.inbound_video_states[0]; + assert!(state.track_type == TrackType::ScreenShare && state.paused); + } + + #[test] + fn call_grants_are_none_when_absent() { + let event = SfuCallEvent::CallGrantsUpdated(event::CallGrantsUpdated { + current_grants: None, + message: "revoked".to_owned(), + }); + + let Event::CallGrantsUpdated(event) = convert(event) else { + panic!("expected CallGrantsUpdated"); + }; + assert!(event.current_grants.is_none()); + assert_eq!(event.message, "revoked"); + } + + #[test] + fn ice_restart_keeps_the_peer_type() { + let event = SfuCallEvent::IceRestarted(models::PeerType::Subscriber); + + let Event::IceRestart(event) = convert(event) else { + panic!("expected IceRestart"); + }; + assert_eq!(event.peer_type, models::PeerType::Subscriber as i32); + } + + #[test] + fn error_keeps_the_nested_protobuf_shape() { + let error = getstream::rtc::SfuJoinError::from_event( + Some(models::Error { + code: models::ErrorCode::ParticipantNotFound as i32, + message: "gone".to_owned(), + should_retry: true, + }), + models::WebsocketReconnectStrategy::Rejoin as i32, + ); + + let Event::SfuError(event) = convert(SfuCallEvent::Error(error)) else { + panic!("expected SfuError"); + }; + assert_eq!( + event.error.code, + models::ErrorCode::ParticipantNotFound as i32 + ); + assert_eq!(event.error.message, "gone"); + assert!(event.error.should_retry); + assert_eq!( + event.reconnect_strategy, + models::WebsocketReconnectStrategy::Rejoin as i32 + ); + } + + #[test] + fn publish_options_keep_their_fields() { + let event = SfuCallEvent::PublishOptionsChanged { + publish_options: vec![models::PublishOption { + track_type: models::TrackType::Video as i32, + codec: Some(models::Codec { + name: "vp9".to_owned(), + ..Default::default() + }), + bitrate: 1000, + video_dimension: None, + audio_bitrate_profiles: vec![models::AudioBitrate { + profile: 1, + bitrate: 64, + }], + ..Default::default() + }], + reason: "codec".to_owned(), + }; + + let Event::ChangePublishOptions(event) = convert(event) else { + panic!("expected ChangePublishOptions"); + }; + let option = &event.publish_options[0]; + assert!(option.track_type == TrackType::Video); + assert_eq!( + option.codec.as_ref().map(|codec| codec.name.as_str()), + Some("vp9") + ); + assert_eq!(option.bitrate, 1000); + assert!(option.video_dimension.is_none()); + assert_eq!(option.audio_bitrate_profiles[0].bitrate, 64); + assert_eq!(event.reason, "codec"); + } + + #[test] + fn publish_quality_keeps_the_video_layers() { + let event = SfuCallEvent::PublishQualityChanged(event::ChangePublishQuality { + audio_senders: vec![], + video_senders: vec![event::VideoSender { + layers: vec![event::VideoLayerSetting { + name: "f".to_owned(), + active: true, + max_bitrate: 500, + ..Default::default() + }], + track_type: models::TrackType::Video as i32, + ..Default::default() + }], + }); + + let Event::ChangePublishQuality(event) = convert(event) else { + panic!("expected ChangePublishQuality"); + }; + let layer = &event.video_senders[0].layers[0]; + assert_eq!( + (layer.name.as_str(), layer.active, layer.max_bitrate), + ("f", true, 500) + ); + } + + #[test] + fn participant_updated_has_the_call_cid() { + let event = SfuCallEvent::ParticipantUpdated(models::Participant::default()); + + let Event::ParticipantUpdated(event) = convert(event) else { + panic!("expected ParticipantUpdated"); + }; + assert_eq!(event.call_cid, "default:call"); + } + + #[test] + fn call_ended_keeps_the_sfu_reason() { + let event = SfuCallEvent::CallEnded { + reason: CallEndedReason::Kicked, + }; + + let Some(Event::CallEnded(event)) = Event::from_sfu(event, "default:call") else { + panic!("expected CallEnded"); + }; + assert_eq!(event.reason, CallEndedReason::Kicked as i32); + } + + #[test] + fn participant_joined_has_the_call_cid() { + let event = SfuCallEvent::ParticipantJoined(models::Participant { + user_id: "user".to_owned(), + ..Default::default() + }); + + let Some(Event::ParticipantJoined(event)) = Event::from_sfu(event, "default:call") else { + panic!("expected ParticipantJoined"); + }; + assert_eq!(event.call_cid, "default:call"); + } +} diff --git a/getstream-rtc/rustsrc/lib.rs b/getstream-rtc/rustsrc/lib.rs new file mode 100644 index 00000000..e3a8bf71 --- /dev/null +++ b/getstream-rtc/rustsrc/lib.rs @@ -0,0 +1,72 @@ +use pyo3::prelude::*; + +mod call; +mod call_end; +mod errors; +mod events; +mod logging; +mod participants; +mod repr; +mod resample; +mod subscriptions; +mod tracks; + +#[pymodule] +fn _bindings(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(logging::set_log_filters, m)?)?; + m.add_function(wrap_pyfunction!(logging::next_logs, m)?)?; + m.add_function(wrap_pyfunction!(logging::push_log_marker, m)?)?; + logging::install()?; + Ok(()) +} diff --git a/getstream-rtc/rustsrc/logging.rs b/getstream-rtc/rustsrc/logging.rs new file mode 100644 index 00000000..d9d08deb --- /dev/null +++ b/getstream-rtc/rustsrc/logging.rs @@ -0,0 +1,487 @@ +//! Queues Rust log records for Python `logging`. +//! +//! The SDK emits `tracing` events and third-party crates emit `log` records +//! (routed into `tracing` by `LogTracer`). A thread that logs only puts the +//! record into a bounded queue: it never blocks and never takes the GIL, so it +//! may hold an SDK lock. The Python thread `getstream-logging` +//! (`getstream_rtc/logging.py`) takes the records with `_next_logs`, which +//! waits without the GIL. Rust never calls Python to log. + +use std::error::Error as StdError; +use std::fmt; +use std::sync::atomic::{AtomicU8, AtomicU64, Ordering}; +use std::sync::mpsc::{Receiver, RecvError, SyncSender, TryRecvError, sync_channel}; +use std::sync::{Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use pyo3::IntoPyObjectExt; +use pyo3::exceptions::PyRuntimeError; +use pyo3::prelude::*; +use pyo3::types::{PyDict, PyTuple}; +use tracing::field::{Field, Visit}; +use tracing::level_filters::LevelFilter; +use tracing::{Event, Level, Metadata, Subscriber}; +use tracing_log::{AsLog, NormalizeEvent}; +use tracing_subscriber::layer::{Context, Layer, SubscriberExt}; + +const QUEUE_CAPACITY: usize = 1024; +/// The most records that one `_next_logs` returns, so the Python thread soon +/// reaches a marker and holds the GIL for a bounded time. +const BATCH_SIZE: usize = 256; +const SDK_CRATE: &str = "getstream"; +/// Records of this crate use the SDK level. +const WRAPPER_CRATE: &str = env!("CARGO_CRATE_NAME"); +/// Index = the value stored in the filter atomics. +const FILTERS: [LevelFilter; 6] = [ + LevelFilter::OFF, + LevelFilter::ERROR, + LevelFilter::WARN, + LevelFilter::INFO, + LevelFilter::DEBUG, + LevelFilter::TRACE, +]; + +static SINK: OnceLock = OnceLock::new(); + +struct Sink { + sender: SyncSender, + /// Taken only by the Python thread `getstream-logging`, without the GIL. + receiver: Mutex>, + dropped: AtomicU64, + sdk_filter: AtomicU8, + third_party_filter: AtomicU8, +} + +enum Message { + Record(Box), + /// Put by `_push_log_marker`; it ends the batch that contains it. + Marker(u64), +} + +struct Captured { + level: Level, + target: String, + file: Option, + line: Option, + message: String, + fields: Vec<(&'static str, FieldValue)>, + created_ns: u64, +} + +enum FieldValue { + Bool(bool), + I64(i64), + U64(u64), + F64(f64), + Str(String), +} + +impl Sink { + /// Queues nothing until `_set_log_filters` enables a level. + fn new() -> Self { + let (sender, receiver) = sync_channel(QUEUE_CAPACITY); + Self { + sender, + receiver: Mutex::new(receiver), + dropped: AtomicU64::new(0), + sdk_filter: AtomicU8::new(encode(LevelFilter::OFF)), + third_party_filter: AtomicU8::new(encode(LevelFilter::OFF)), + } + } + + fn enabled(&self, target: &str, level: &Level) -> bool { + let filter = if is_sdk_target(target) { + &self.sdk_filter + } else { + &self.third_party_filter + }; + *level <= decode(filter.load(Ordering::Relaxed)) + } + + fn max_filter(&self) -> LevelFilter { + decode(self.sdk_filter.load(Ordering::Relaxed)) + .max(decode(self.third_party_filter.load(Ordering::Relaxed))) + } + + fn set_filters(&self, sdk: LevelFilter, third_party: LevelFilter) { + self.sdk_filter.store(encode(sdk), Ordering::Relaxed); + self.third_party_filter + .store(encode(third_party), Ordering::Relaxed); + } + + /// Never blocks: a record that does not fit into the queue is counted. + fn send(&self, record: Captured) { + if self + .sender + .try_send(Message::Record(Box::new(record))) + .is_err() + { + self.dropped.fetch_add(1, Ordering::Relaxed); + } + } +} + +struct ForwardingLayer; + +impl Layer for ForwardingLayer { + fn enabled(&self, metadata: &Metadata<'_>, _ctx: Context<'_, S>) -> bool { + SINK.get() + .is_some_and(|sink| sink.enabled(metadata.target(), metadata.level())) + } + + fn max_level_hint(&self) -> Option { + SINK.get().map(Sink::max_filter) + } + + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + let Some(sink) = SINK.get() else { + return; + }; + // Records from `LogTracer` carry their real target in their fields. + let normalized = event.normalized_metadata(); + let metadata = normalized.as_ref().unwrap_or_else(|| event.metadata()); + if !sink.enabled(metadata.target(), metadata.level()) { + return; + } + let mut visitor = FieldVisitor::default(); + event.record(&mut visitor); + sink.send(Captured { + level: *metadata.level(), + target: metadata.target().to_owned(), + file: metadata.file().map(str::to_owned), + line: metadata.line(), + message: visitor.message.unwrap_or_default(), + fields: visitor.fields, + created_ns: SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |elapsed| { + u64::try_from(elapsed.as_nanos()).unwrap_or(u64::MAX) + }), + }); + } +} + +#[derive(Default)] +struct FieldVisitor { + message: Option, + fields: Vec<(&'static str, FieldValue)>, +} + +impl FieldVisitor { + fn push(&mut self, field: &Field, value: FieldValue) { + match field.name() { + "message" => self.message = Some(value.to_string()), + // `LogTracer` metadata, already used through `normalized_metadata`. + name if name.starts_with("log.") => {} + name => self.fields.push((name, value)), + } + } +} + +impl Visit for FieldVisitor { + fn record_bool(&mut self, field: &Field, value: bool) { + self.push(field, FieldValue::Bool(value)); + } + + fn record_i64(&mut self, field: &Field, value: i64) { + self.push(field, FieldValue::I64(value)); + } + + fn record_u64(&mut self, field: &Field, value: u64) { + self.push(field, FieldValue::U64(value)); + } + + fn record_f64(&mut self, field: &Field, value: f64) { + self.push(field, FieldValue::F64(value)); + } + + fn record_str(&mut self, field: &Field, value: &str) { + self.push(field, FieldValue::Str(value.to_owned())); + } + + fn record_error(&mut self, field: &Field, value: &(dyn StdError + 'static)) { + let mut text = value.to_string(); + let mut source = value.source(); + while let Some(error) = source { + text.push_str(": "); + text.push_str(&error.to_string()); + source = error.source(); + } + self.push(field, FieldValue::Str(text)); + } + + fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { + self.push(field, FieldValue::Str(format!("{value:?}"))); + } +} + +impl fmt::Display for FieldValue { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Bool(value) => value.fmt(f), + Self::I64(value) => value.fmt(f), + Self::U64(value) => value.fmt(f), + Self::F64(value) => value.fmt(f), + Self::Str(value) => value.fmt(f), + } + } +} + +impl FieldValue { + fn into_python<'py>(self, py: Python<'py>) -> PyResult> { + match self { + Self::Bool(value) => value.into_bound_py_any(py), + Self::I64(value) => value.into_bound_py_any(py), + Self::U64(value) => value.into_bound_py_any(py), + Self::F64(value) => value.into_bound_py_any(py), + Self::Str(value) => value.into_bound_py_any(py), + } + } +} + +/// Installs the `tracing` subscriber and `LogTracer`. Nothing is queued until +/// `_set_log_filters` enables a level. +pub fn install() -> PyResult<()> { + if SINK.get().is_some() { + return Ok(()); + } + tracing::subscriber::set_global_default(tracing_subscriber::registry().with(ForwardingLayer)) + .map_err(|error| PyRuntimeError::new_err(error.to_string()))?; + tracing_log::LogTracer::builder() + .with_max_level(log::LevelFilter::Off) + .init() + .map_err(|error| PyRuntimeError::new_err(error.to_string()))?; + // Last: if a global subscriber or logger was already set, each import + // raises instead of running without a subscriber. + let _ = SINK.set(Sink::new()); + Ok(()) +} + +/// Queues SDK records at `level` or above, and third-party records at the less +/// verbose of `level` and `third_party_level`; `level=None` queues none. +#[pyfunction] +#[pyo3(name = "_set_log_filters")] +pub fn set_log_filters(py: Python<'_>, level: Option, third_party_level: i32) -> PyResult<()> { + let sink = sink()?; + let sdk = level.map_or(LevelFilter::OFF, filter_from_python); + let third_party = sdk.min(filter_from_python(third_party_level)); + py.detach(|| { + sink.set_filters(sdk, third_party); + tracing::callsite::rebuild_interest_cache(); + log::set_max_level(sdk.max(third_party).as_log()); + }); + Ok(()) +} + +/// Waits without the GIL for queued records. Returns the number of records +/// dropped since the last call, at most `BATCH_SIZE` records as the arguments +/// of `getstream_rtc.logging.handle_record` after its logger, and the marker +/// that ended the batch, if any. +#[pyfunction] +#[pyo3(name = "_next_logs")] +pub fn next_logs(py: Python<'_>) -> PyResult<(u64, Vec>, Option)> { + let sink = sink()?; + let (records, marker) = py + .detach(|| { + let receiver = sink + .receiver + .lock() + .unwrap_or_else(|error| error.into_inner()); + take_batch(&receiver) + }) + .map_err(|error| PyRuntimeError::new_err(error.to_string()))?; + let dropped = sink.dropped.swap(0, Ordering::Relaxed); + let records = records + .into_iter() + .map(|record| record_args(py, record)) + .collect::>()?; + Ok((dropped, records, marker)) +} + +/// Puts `marker` into the queue after the records queued before it. Waits +/// without the GIL while the queue is full. +#[pyfunction] +#[pyo3(name = "_push_log_marker")] +pub fn push_log_marker(py: Python<'_>, marker: u64) -> PyResult<()> { + let sink = sink()?; + py.detach(|| sink.sender.send(Message::Marker(marker))) + .map_err(|error| PyRuntimeError::new_err(error.to_string())) +} + +fn sink() -> PyResult<&'static Sink> { + SINK.get() + .ok_or_else(|| PyRuntimeError::new_err("the logging is not installed")) +} + +/// Waits for the first message, then takes the queued messages until the +/// batch has `BATCH_SIZE` records or a marker ends it. +fn take_batch(receiver: &Receiver) -> Result<(Vec, Option), RecvError> { + let mut records = Vec::new(); + let mut message = receiver.recv()?; + loop { + match message { + Message::Marker(marker) => return Ok((records, Some(marker))), + Message::Record(record) => records.push(*record), + } + if records.len() == BATCH_SIZE { + return Ok((records, None)); + } + message = match receiver.try_recv() { + Ok(message) => message, + Err(TryRecvError::Empty | TryRecvError::Disconnected) => return Ok((records, None)), + }; + } +} + +/// The arguments of `getstream_rtc.logging.handle_record` after its logger. +fn record_args(py: Python<'_>, record: Captured) -> PyResult> { + let fields = PyDict::new(py); + for (name, value) in record.fields { + fields.set_item(name.replace('.', "_"), value.into_python(py)?)?; + } + ( + logger_name(&record.target), + python_level(record.level), + record.file.unwrap_or_default(), + record.line.unwrap_or_default(), + record.message, + record.created_ns, + record.target, + fields, + ) + .into_pyobject(py) +} + +/// The Python logger name below the configured logger, or `None` for the +/// configured logger itself. SDK targets drop the crate name +/// (`getstream::rtc::join` -> `rtc.join`); third-party targets keep it +/// (`webrtc_ice::agent` -> `webrtc_ice.agent`). +fn logger_name(target: &str) -> Option { + match target.strip_prefix(SDK_CRATE) { + Some("") => None, + Some(rest) if rest.starts_with("::") => Some(rest[2..].replace("::", ".")), + _ => Some(target.replace("::", ".")), + } +} + +fn is_sdk_target(target: &str) -> bool { + [SDK_CRATE, WRAPPER_CRATE].into_iter().any(|name| { + target + .strip_prefix(name) + .is_some_and(|rest| rest.is_empty() || rest.starts_with("::")) + }) +} + +fn python_level(level: Level) -> i32 { + match level { + Level::ERROR => 40, + Level::WARN => 30, + Level::INFO => 20, + Level::DEBUG => 10, + Level::TRACE => 5, + } +} + +fn filter_from_python(level: i32) -> LevelFilter { + match level { + ..=5 => LevelFilter::TRACE, + 6..=10 => LevelFilter::DEBUG, + 11..=20 => LevelFilter::INFO, + 21..=30 => LevelFilter::WARN, + 31..=50 => LevelFilter::ERROR, + _ => LevelFilter::OFF, + } +} + +fn encode(filter: LevelFilter) -> u8 { + FILTERS + .iter() + .position(|candidate| *candidate == filter) + .and_then(|index| u8::try_from(index).ok()) + .unwrap_or_default() +} + +fn decode(value: u8) -> LevelFilter { + FILTERS + .get(usize::from(value)) + .copied() + .unwrap_or(LevelFilter::OFF) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn captured(message: &str) -> Captured { + Captured { + level: Level::INFO, + target: "getstream::rtc".to_owned(), + file: None, + line: None, + message: message.to_owned(), + fields: Vec::new(), + created_ns: 0, + } + } + + fn messages(records: &[Captured]) -> Vec<&str> { + records + .iter() + .map(|record| record.message.as_str()) + .collect() + } + + #[test] + fn wrapper_records_use_the_sdk_level() { + let sink = Sink::new(); + sink.set_filters(LevelFilter::DEBUG, LevelFilter::WARN); + + assert!(sink.enabled("_bindings::call", &Level::DEBUG)); + assert!(sink.enabled("getstream::rtc::join", &Level::DEBUG)); + assert!(!sink.enabled("webrtc_ice::agent", &Level::DEBUG)); + } + + #[test] + fn a_marker_ends_the_batch() { + let sink = Sink::new(); + sink.send(captured("before")); + sink.sender + .send(Message::Marker(7)) + .expect("room in the queue"); + sink.send(captured("after")); + let receiver = sink.receiver.lock().expect("an unpoisoned lock"); + + let (records, marker) = take_batch(&receiver).expect("a batch"); + assert_eq!(messages(&records), ["before"]); + assert_eq!(marker, Some(7)); + + let (records, marker) = take_batch(&receiver).expect("a batch"); + assert_eq!(messages(&records), ["after"]); + assert_eq!(marker, None); + } + + #[test] + fn a_batch_holds_at_most_batch_size_records() { + let sink = Sink::new(); + for _ in 0..=BATCH_SIZE { + sink.send(captured("record")); + } + let receiver = sink.receiver.lock().expect("an unpoisoned lock"); + + let (records, _) = take_batch(&receiver).expect("a batch"); + assert_eq!(records.len(), BATCH_SIZE); + + let (records, _) = take_batch(&receiver).expect("a batch"); + assert_eq!(records.len(), 1); + } + + #[test] + fn records_that_do_not_fit_are_counted() { + let sink = Sink::new(); + for _ in 0..QUEUE_CAPACITY + 2 { + sink.send(captured("record")); + } + + assert_eq!(sink.dropped.load(Ordering::Relaxed), 2); + } +} diff --git a/getstream-rtc/rustsrc/participants.rs b/getstream-rtc/rustsrc/participants.rs new file mode 100644 index 00000000..55476723 --- /dev/null +++ b/getstream-rtc/rustsrc/participants.rs @@ -0,0 +1,245 @@ +use getstream::rtc::proto::models; +use prost_types::value::Kind; +use prost_types::{Struct, Timestamp, Value}; +use pyo3::IntoPyObjectExt; +use pyo3::prelude::*; +use pyo3::types::{PyDict, PyList}; + +use crate::repr::repr; + +#[pyclass( + frozen, + module = "getstream_rtc", + eq, + eq_int, + from_py_object, + rename_all = "SCREAMING_SNAKE_CASE" +)] +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum TrackType { + Unspecified, + Audio, + Video, + ScreenShare, + ScreenShareAudio, +} + +impl From for TrackType { + fn from(track_type: models::TrackType) -> Self { + match track_type { + models::TrackType::Unspecified => Self::Unspecified, + models::TrackType::Audio => Self::Audio, + models::TrackType::Video => Self::Video, + models::TrackType::ScreenShare => Self::ScreenShare, + models::TrackType::ScreenShareAudio => Self::ScreenShareAudio, + } + } +} + +impl From for models::TrackType { + fn from(track_type: TrackType) -> Self { + match track_type { + TrackType::Unspecified => Self::Unspecified, + TrackType::Audio => Self::Audio, + TrackType::Video => Self::Video, + TrackType::ScreenShare => Self::ScreenShare, + TrackType::ScreenShareAudio => Self::ScreenShareAudio, + } + } +} + +impl TrackType { + pub fn from_i32(value: i32) -> Self { + models::TrackType::try_from(value).map_or(Self::Unspecified, Self::from) + } +} + +/// The fields of the SFU `Participant` message, except `track_lookup_prefix`. +/// Protobuf enum fields keep their `int` values. +#[pyclass(frozen, module = "getstream_rtc", skip_from_py_object)] +#[derive(Clone)] +pub struct RemoteParticipant { + #[pyo3(get)] + user_id: String, + #[pyo3(get)] + session_id: String, + #[pyo3(get)] + name: String, + #[pyo3(get)] + image: String, + #[pyo3(get)] + roles: Vec, + #[pyo3(get)] + published_tracks: Vec, + #[pyo3(get)] + connection_quality: i32, + #[pyo3(get)] + is_speaking: bool, + #[pyo3(get)] + is_dominant_speaker: bool, + #[pyo3(get)] + audio_level: f32, + #[pyo3(get)] + source: i32, + /// Unix time in seconds, or `None` if the SFU did not send it. + #[pyo3(get)] + joined_at: Option, + custom: Option, +} + +#[pymethods] +impl RemoteParticipant { + /// The custom data as a `dict`; empty if the SFU did not send it. + #[getter] + fn custom<'py>(&self, py: Python<'py>) -> PyResult> { + match &self.custom { + Some(custom) => struct_to_dict(py, custom), + None => Ok(PyDict::new(py)), + } + } + + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!( + "RemoteParticipant(user_id={}, session_id={}, published_tracks={})", + repr(py, &self.user_id)?, + repr(py, &self.session_id)?, + repr(py, self.published_tracks.clone())?, + )) + } +} + +impl From for RemoteParticipant { + fn from(participant: getstream::rtc::RemoteParticipant) -> Self { + Self { + user_id: participant.user_id, + session_id: participant.session_id, + name: participant.name, + image: participant.image, + roles: participant.roles, + published_tracks: participant + .published_tracks + .into_iter() + .map(TrackType::from) + .collect(), + connection_quality: participant.connection_quality as i32, + is_speaking: participant.is_speaking, + is_dominant_speaker: participant.is_dominant_speaker, + audio_level: participant.audio_level, + source: participant.source as i32, + joined_at: participant.joined_at.map(unix_seconds), + custom: participant.custom, + } + } +} + +impl From for RemoteParticipant { + fn from(participant: models::Participant) -> Self { + Self { + user_id: participant.user_id, + session_id: participant.session_id, + name: participant.name, + image: participant.image, + roles: participant.roles, + published_tracks: participant + .published_tracks + .into_iter() + .filter_map(|value| models::TrackType::try_from(value).ok()) + .map(TrackType::from) + .collect(), + connection_quality: models::ConnectionQuality::try_from(participant.connection_quality) + .unwrap_or(models::ConnectionQuality::Unspecified) + as i32, + is_speaking: participant.is_speaking, + is_dominant_speaker: participant.is_dominant_speaker, + audio_level: participant.audio_level, + source: models::ParticipantSource::try_from(participant.source) + .unwrap_or(models::ParticipantSource::WebrtcUnspecified) as i32, + joined_at: participant.joined_at.map(unix_seconds), + custom: participant.custom, + } + } +} + +fn unix_seconds(timestamp: Timestamp) -> f64 { + timestamp.seconds as f64 + f64::from(timestamp.nanos) / 1e9 +} + +/// The same `dict` as `MessageToDict` of the protobuf `Struct`: numbers are +/// `float`. +fn struct_to_dict<'py>(py: Python<'py>, value: &Struct) -> PyResult> { + let dict = PyDict::new(py); + for (key, field) in &value.fields { + dict.set_item(key, value_to_py(py, field)?)?; + } + Ok(dict) +} + +fn value_to_py<'py>(py: Python<'py>, value: &Value) -> PyResult> { + Ok(match &value.kind { + None | Some(Kind::NullValue(_)) => py.None().into_bound(py), + Some(Kind::NumberValue(number)) => number.into_bound_py_any(py)?, + Some(Kind::StringValue(string)) => string.into_bound_py_any(py)?, + Some(Kind::BoolValue(boolean)) => boolean.into_bound_py_any(py)?, + Some(Kind::StructValue(value)) => struct_to_dict(py, value)?.into_any(), + Some(Kind::ListValue(list)) => PyList::new( + py, + list.values + .iter() + .map(|value| value_to_py(py, value)) + .collect::>>()?, + )? + .into_any(), + }) +} + +#[pyclass(frozen, module = "getstream_rtc")] +pub struct CallStateSnapshot { + #[pyo3(get)] + participants: Vec, + #[pyo3(get)] + own_capabilities: Vec, +} + +#[pymethods] +impl CallStateSnapshot { + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!( + "CallStateSnapshot(participants={}, own_capabilities={})", + repr(py, self.participants.clone())?, + repr(py, &self.own_capabilities)?, + )) + } +} + +impl From for CallStateSnapshot { + fn from(state: getstream::rtc::CallStateSnapshot) -> Self { + Self { + participants: state.participants.into_iter().map(Into::into).collect(), + own_capabilities: state.own_capabilities, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unknown_enum_values_are_converted_as_in_the_sdk() { + let participant = RemoteParticipant::from(models::Participant { + published_tracks: vec![models::TrackType::Audio as i32, 99], + connection_quality: 99, + source: 99, + ..Default::default() + }); + + assert!(participant.published_tracks == [TrackType::Audio]); + assert_eq!( + (participant.connection_quality, participant.source), + ( + models::ConnectionQuality::Unspecified as i32, + models::ParticipantSource::WebrtcUnspecified as i32 + ) + ); + } +} diff --git a/getstream-rtc/rustsrc/repr.rs b/getstream-rtc/rustsrc/repr.rs new file mode 100644 index 00000000..32ff212b --- /dev/null +++ b/getstream-rtc/rustsrc/repr.rs @@ -0,0 +1,8 @@ +use pyo3::IntoPyObjectExt; +use pyo3::prelude::*; + +/// Python `repr()` of `value`, so `__repr__` shows field values as Python +/// shows them (quoted strings, `TrackType.AUDIO`, nested reprs). +pub fn repr<'py, T: IntoPyObject<'py>>(py: Python<'py>, value: T) -> PyResult { + Ok(value.into_bound_py_any(py)?.repr()?.to_string()) +} diff --git a/getstream-rtc/rustsrc/resample.rs b/getstream-rtc/rustsrc/resample.rs new file mode 100644 index 00000000..60a697e3 --- /dev/null +++ b/getstream-rtc/rustsrc/resample.rs @@ -0,0 +1,62 @@ +use std::sync::{Mutex, PoisonError}; + +use numpy::{PyReadonlyArray1, PyUntypedArrayMethods}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::errors::rtc_error; +use crate::tracks::PcmFrameData; + +/// One stream of interleaved int16 PCM resampled to a fixed rate. The filter +/// state carries over between calls. +#[pyclass(frozen, module = "getstream_rtc")] +pub struct StreamResampler { + inner: Mutex, +} + +#[pymethods] +impl StreamResampler { + #[new] + fn new(sample_rate: u32, channels: u16) -> Self { + Self { + inner: Mutex::new(getstream::rtc::StreamResampler::new(sample_rate, channels)), + } + } + + /// Copies `samples` (interleaved int16) and resamples them. + fn push( + &self, + py: Python<'_>, + samples: PyReadonlyArray1<'_, i16>, + sample_rate: u32, + channels: u16, + ) -> PyResult { + // numpy's `as_array` requires an aligned buffer. + if !samples.is_aligned() { + return Err(PyValueError::new_err( + "samples must be an aligned int16 array", + )); + } + let frame = + getstream::rtc::PcmFrame::new(samples.as_array().to_vec(), sample_rate, channels); + py.detach(|| { + self.inner + .lock() + .unwrap_or_else(PoisonError::into_inner) + .push(&frame) + }) + .map(PcmFrameData) + .map_err(rtc_error) + } + + fn flush(&self, py: Python<'_>) -> PyResult { + py.detach(|| { + self.inner + .lock() + .unwrap_or_else(PoisonError::into_inner) + .flush() + }) + .map(PcmFrameData) + .map_err(rtc_error) + } +} diff --git a/getstream-rtc/rustsrc/subscriptions.rs b/getstream-rtc/rustsrc/subscriptions.rs new file mode 100644 index 00000000..d5ffd7f3 --- /dev/null +++ b/getstream-rtc/rustsrc/subscriptions.rs @@ -0,0 +1,124 @@ +use std::collections::HashMap; + +use pyo3::prelude::*; + +use crate::participants::TrackType; +use crate::repr::repr; + +/// The subscription rule for the participants of one role, or the default rule. +#[pyclass(frozen, module = "getstream_rtc", from_py_object)] +#[derive(Clone)] +pub struct TrackSubscriptionConfig { + #[pyo3(get)] + track_types: Vec, + /// `(width, height)` requested for `VIDEO` tracks. + #[pyo3(get)] + video_dimension: (u32, u32), + /// `(width, height)` requested for `SCREEN_SHARE` tracks. + #[pyo3(get)] + screenshare_dimension: (u32, u32), +} + +#[pymethods] +impl TrackSubscriptionConfig { + /// A `None` argument keeps the SDK default. + #[new] + #[pyo3(signature = (track_types=None, video_dimension=None, screenshare_dimension=None))] + fn new( + track_types: Option>, + video_dimension: Option<(u32, u32)>, + screenshare_dimension: Option<(u32, u32)>, + ) -> Self { + let default = Self::from(getstream::rtc::TrackSubscriptionConfig::default()); + Self { + track_types: track_types.unwrap_or(default.track_types), + video_dimension: video_dimension.unwrap_or(default.video_dimension), + screenshare_dimension: screenshare_dimension.unwrap_or(default.screenshare_dimension), + } + } + + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!( + "TrackSubscriptionConfig(track_types={}, video_dimension={}, screenshare_dimension={})", + repr(py, self.track_types.clone())?, + repr(py, self.video_dimension)?, + repr(py, self.screenshare_dimension)? + )) + } +} + +impl From for TrackSubscriptionConfig { + fn from(config: getstream::rtc::TrackSubscriptionConfig) -> Self { + Self { + track_types: config.track_types.into_iter().map(Into::into).collect(), + video_dimension: config.video_dimension, + screenshare_dimension: config.screenshare_dimension, + } + } +} + +impl From for getstream::rtc::TrackSubscriptionConfig { + fn from(config: TrackSubscriptionConfig) -> Self { + Self { + track_types: config.track_types.into_iter().map(Into::into).collect(), + video_dimension: config.video_dimension, + screenshare_dimension: config.screenshare_dimension, + } + } +} + +/// Which remote tracks to receive. The SDK applies the rule of the first role +/// in `participant.roles` that has an entry in `role_filters`, or else +/// `default`, and keeps at most `max_subscriptions` tracks. +#[pyclass(frozen, module = "getstream_rtc", from_py_object)] +#[derive(Clone)] +pub struct SubscriptionConfig { + #[pyo3(get)] + default: TrackSubscriptionConfig, + #[pyo3(get)] + role_filters: HashMap, + #[pyo3(get)] + max_subscriptions: Option, +} + +#[pymethods] +impl SubscriptionConfig { + /// A `None` argument keeps the SDK default. + #[new] + #[pyo3(signature = (default=None, role_filters=None, max_subscriptions=None))] + fn new( + default: Option, + role_filters: Option>, + max_subscriptions: Option, + ) -> Self { + Self { + default: default + .unwrap_or_else(|| getstream::rtc::TrackSubscriptionConfig::default().into()), + role_filters: role_filters.unwrap_or_default(), + max_subscriptions, + } + } + + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!( + "SubscriptionConfig(default={}, role_filters={}, max_subscriptions={})", + repr(py, self.default.clone())?, + repr(py, self.role_filters.clone())?, + repr(py, self.max_subscriptions)? + )) + } +} + +impl From for getstream::rtc::SubscriptionConfig { + fn from(config: SubscriptionConfig) -> Self { + Self { + default: config.default.into(), + role_filters: config + .role_filters + .into_iter() + .map(|(role, rule)| (role, rule.into())) + .collect(), + max_subscriptions: config.max_subscriptions, + } + } +} diff --git a/getstream-rtc/rustsrc/tracks.rs b/getstream-rtc/rustsrc/tracks.rs new file mode 100644 index 00000000..2a1225e5 --- /dev/null +++ b/getstream-rtc/rustsrc/tracks.rs @@ -0,0 +1,426 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex as StdMutex, PoisonError}; +use std::time::Duration; + +use getstream::rtc::{LocalAudioTrackConfig, RtcCall}; +use numpy::{PyArray1, PyReadonlyArray1, PyUntypedArrayMethods}; +use pyo3::exceptions::{PyStopAsyncIteration, PyValueError}; +use pyo3::prelude::*; +use pyo3_async_runtimes::tokio::future_into_py; +use tokio::sync::{Mutex, mpsc, watch}; + +use crate::call_end::{self, CallEnd}; +use crate::errors::{RtcError, rtc_error}; +use crate::participants::{RemoteParticipant, TrackType}; +use crate::repr::repr; + +const TRACK_QUEUE_CAPACITY: usize = 64; + +/// Remote tracks from the SDK `on_track` callback, registered when the call +/// handle is created so tracks that arrive during join are kept. +pub struct TrackQueue { + receiver: Mutex>, + overflowed: AtomicBool, +} + +impl TrackQueue { + pub fn register(call: &RtcCall) -> Arc { + let (sender, receiver) = mpsc::channel(TRACK_QUEUE_CAPACITY); + let queue = Arc::new(Self { + receiver: Mutex::new(receiver), + overflowed: AtomicBool::new(false), + }); + // The SDK core owns this callback and the Python handles own the queue. + // A weak reference lets the queue, with any tracks still in it, be freed + // together with the Python handles. + let callback_queue = Arc::downgrade(&queue); + call.on_track(move |track| { + if sender.try_send(track).is_err() + && let Some(queue) = callback_queue.upgrade() + { + queue.overflowed.store(true, Ordering::Relaxed); + } + }); + queue + } +} + +/// Async iterator over remote tracks. All streams of one call share a single +/// queue, so each track is delivered to exactly one stream. A stream ends when +/// the call ends, or at once if the call had already ended when the stream was +/// created. +#[pyclass(frozen, module = "getstream_rtc")] +pub struct TrackStream { + queue: Arc, + end: watch::Receiver, + start: Option, +} + +impl TrackStream { + pub fn new(queue: Arc, call: &RtcCall, end: watch::Receiver) -> Self { + Self { + queue, + start: call_end::start(call, &end), + end, + } + } +} + +#[pymethods] +impl TrackStream { + fn __aiter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + + fn __anext__<'py>(&self, py: Python<'py>) -> PyResult> { + let queue = self.queue.clone(); + let ended = call_end::ended(self.end.clone(), self.start); + future_into_py(py, async move { + if queue.overflowed.swap(false, Ordering::Relaxed) { + return Err(RtcError::new_err( + "track queue overflowed: remote tracks were dropped", + )); + } + let mut receiver = queue.receiver.lock().await; + // Biased: once the call has ended, tracks still in the queue are + // not returned. + let track = tokio::select! { + biased; + () = ended => None, + track = receiver.recv() => track, + }; + match track { + Some(track) => Ok(RemoteTrack::new(track)), + None => Err(PyStopAsyncIteration::new_err(())), + } + }) + } +} + +#[pyclass(frozen, module = "getstream_rtc")] +pub struct RemoteTrack { + inner: Arc, + video: Arc, + #[pyo3(get)] + participant: RemoteParticipant, + #[pyo3(get)] + track_type: TrackType, +} + +impl RemoteTrack { + fn new(track: getstream::rtc::RemoteTrack) -> Self { + let inner = Arc::new(track); + Self { + participant: inner.participant().clone().into(), + track_type: inner.track_type().into(), + video: Arc::new(VideoFrames { + track: Arc::clone(&inner), + latest: watch::Sender::new(LatestFrame::Waiting), + decoding: StdMutex::new(false), + }), + inner, + } + } +} + +#[pymethods] +impl RemoteTrack { + /// The next decoded audio frame, or `None` when the track has ended or is + /// not an audio track. + fn next_pcm<'py>(&self, py: Python<'py>) -> PyResult> { + let track = self.inner.clone(); + future_into_py( + py, + async move { Ok(track.next_pcm().await.map(PcmFrameData)) }, + ) + } + + /// A new stream of the frames decoded after this call. + fn video_frames(&self) -> VideoFrameStream { + VideoFrameStream { + receiver: Arc::new(Mutex::new(self.video.latest.subscribe())), + frames: Arc::clone(&self.video), + } + } + + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!( + "RemoteTrack(participant={}, track_type={})", + repr(py, self.participant.clone())?, + repr(py, self.track_type)?, + )) + } +} + +/// Interleaved int16 samples; the array owns the SDK buffer without a copy. +#[pyclass(frozen, module = "getstream_rtc")] +pub struct PcmFrame { + #[pyo3(get)] + samples: Py>, + #[pyo3(get)] + sample_rate: u32, + #[pyo3(get)] + channels: u16, + /// The RTP timestamp of the first sample, on the 48 kHz Opus clock. It + /// wraps at 2^32, like RTP. + #[pyo3(get)] + pts: Option, +} + +#[pymethods] +impl PcmFrame { + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!( + "PcmFrame(sample_rate={}, channels={}, samples={}, pts={})", + self.sample_rate, + self.channels, + self.samples.bind(py).len(), + repr(py, self.pts)? + )) + } +} + +pub(crate) struct PcmFrameData(pub(crate) getstream::rtc::PcmFrame); + +impl<'py> IntoPyObject<'py> for PcmFrameData { + type Target = PcmFrame; + type Output = Bound<'py, PcmFrame>; + type Error = PyErr; + + fn into_pyobject(self, py: Python<'py>) -> PyResult> { + let frame = self.0; + Bound::new( + py, + PcmFrame { + samples: PyArray1::from_vec(py, frame.samples).unbind(), + sample_rate: frame.sample_rate, + channels: frame.channels, + pts: frame.pts, + }, + ) + } +} + +/// Packed I420 pixels; each stream gets its own copy of the SDK buffer. +#[pyclass(frozen, module = "getstream_rtc")] +pub struct VideoFrame { + #[pyo3(get)] + width: u32, + #[pyo3(get)] + height: u32, + #[pyo3(get)] + data: Py>, + #[pyo3(get)] + rtp_timestamp: u32, +} + +#[pymethods] +impl VideoFrame { + fn __repr__(&self) -> String { + format!( + "VideoFrame(width={}, height={}, rtp_timestamp={})", + self.width, self.height, self.rtp_timestamp + ) + } +} + +struct VideoFrameData(Arc); + +impl<'py> IntoPyObject<'py> for VideoFrameData { + type Target = VideoFrame; + type Output = Bound<'py, VideoFrame>; + type Error = PyErr; + + fn into_pyobject(self, py: Python<'py>) -> PyResult> { + let frame = self.0; + Bound::new( + py, + VideoFrame { + width: frame.width, + height: frame.height, + data: PyArray1::from_slice(py, &frame.data).unbind(), + rtp_timestamp: frame.rtp_timestamp, + }, + ) + } +} + +#[derive(Clone)] +enum LatestFrame { + /// No frame has been decoded yet. + Waiting, + Frame(Arc), + /// The last value: the track has ended or has no video decoder. + TrackEnded, +} + +/// The decoded frames of one video track, shared by the track and its +/// streams. One task decodes from the first read of a stream until the track +/// ends or no stream is left, and keeps only the latest frame. +struct VideoFrames { + track: Arc, + latest: watch::Sender, + /// Whether the task runs; it stays `true` after the track ends. + decoding: StdMutex, +} + +impl VideoFrames { + fn start(self: &Arc) { + let mut decoding = self.decoding.lock().unwrap_or_else(PoisonError::into_inner); + if !*decoding { + *decoding = true; + pyo3_async_runtimes::tokio::get_runtime().spawn(Arc::clone(self).decode()); + } + } + + async fn decode(self: Arc) { + loop { + tokio::select! { + frame = self.track.next_video_frame() => { + let Some(frame) = frame else { + self.latest.send_replace(LatestFrame::TrackEnded); + return; + }; + self.latest.send_replace(LatestFrame::Frame(Arc::new(frame))); + } + () = self.latest.closed() => { + let mut decoding = self.decoding.lock().unwrap_or_else(PoisonError::into_inner); + // A stream created after `closed` resolved can find + // `decoding` still set and start no task, so this one + // continues for it. + if self.latest.receiver_count() == 0 { + *decoding = false; + return; + } + } + } + } + } +} + +/// Async iterator over the decoded frames of one video track. Each stream has +/// its own receiver, so a slow stream skips frames without delaying the others. +#[pyclass(frozen, module = "getstream_rtc")] +pub struct VideoFrameStream { + frames: Arc, + receiver: Arc>>, +} + +#[pymethods] +impl VideoFrameStream { + fn __aiter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + + fn __anext__<'py>(&self, py: Python<'py>) -> PyResult> { + self.frames.start(); + let receiver = Arc::clone(&self.receiver); + future_into_py(py, async move { + let mut receiver = receiver.lock().await; + let ended = matches!(*receiver.borrow(), LatestFrame::TrackEnded); + // `changed` fails only when the sender is dropped, after the + // decoding has stopped. + if ended || receiver.changed().await.is_err() { + return Err(PyStopAsyncIteration::new_err(())); + } + match &*receiver.borrow_and_update() { + LatestFrame::Frame(frame) => Ok(VideoFrameData(Arc::clone(frame))), + LatestFrame::Waiting | LatestFrame::TrackEnded => { + Err(PyStopAsyncIteration::new_err(())) + } + } + }) + } +} + +#[pyclass(frozen, module = "getstream_rtc")] +pub struct LocalAudioTrack { + pub(crate) inner: getstream::rtc::LocalAudioTrack, +} + +#[pymethods] +impl LocalAudioTrack { + /// `pcm_queue_capacity` is in seconds; `None` keeps the SDK default. + #[new] + #[pyo3(signature = (pcm_queue_capacity=None))] + fn new(pcm_queue_capacity: Option) -> PyResult { + let mut config = LocalAudioTrackConfig::default(); + if let Some(capacity) = pcm_queue_capacity { + let capacity = Duration::try_from_secs_f64(capacity) + .map_err(|error| PyValueError::new_err(error.to_string()))?; + config = config.with_pcm_queue_capacity(capacity); + } + Ok(Self { + inner: getstream::rtc::LocalAudioTrack::opus_with_config(config).map_err(rtc_error)?, + }) + } + + /// Copies `samples` (interleaved int16) into the SDK queue. + fn write_pcm<'py>( + &self, + py: Python<'py>, + samples: PyReadonlyArray1<'py, i16>, + sample_rate: u32, + channels: u16, + ) -> PyResult> { + // numpy's `as_array` requires an aligned buffer. + if !samples.is_aligned() { + return Err(PyValueError::new_err( + "samples must be an aligned int16 array", + )); + } + let frame = + getstream::rtc::PcmFrame::new(samples.as_array().to_vec(), sample_rate, channels); + let track = self.inner.clone(); + future_into_py(py, async move { + track.write_pcm(frame).await.map_err(rtc_error) + }) + } + + fn flush(&self, py: Python<'_>) { + py.detach(|| self.inner.flush()); + } +} + +#[pyclass(frozen, module = "getstream_rtc")] +pub struct LocalVideoTrack { + pub(crate) inner: getstream::rtc::LocalVideoTrack, +} + +#[pymethods] +impl LocalVideoTrack { + #[staticmethod] + fn vp8() -> PyResult { + Ok(Self { + inner: getstream::rtc::LocalVideoTrack::vp8().map_err(rtc_error)?, + }) + } + + #[staticmethod] + fn vp9() -> PyResult { + Ok(Self { + inner: getstream::rtc::LocalVideoTrack::vp9().map_err(rtc_error)?, + }) + } + + /// Copies `data` (packed I420) and encodes it; `duration` is in seconds. + fn write_i420<'py>( + &self, + py: Python<'py>, + data: PyReadonlyArray1<'py, u8>, + width: u32, + height: u32, + duration: f64, + ) -> PyResult> { + let duration = Duration::try_from_secs_f64(duration) + .map_err(|error| PyValueError::new_err(error.to_string()))?; + let data = data.as_array().to_vec(); + let track = self.inner.clone(); + future_into_py(py, async move { + track + .write_i420(&data, width, height, duration) + .await + .map_err(rtc_error) + }) + } +} diff --git a/getstream-rtc/tests/conftest.py b/getstream-rtc/tests/conftest.py new file mode 100644 index 00000000..e7935981 --- /dev/null +++ b/getstream-rtc/tests/conftest.py @@ -0,0 +1,62 @@ +import logging +import os +import uuid +from typing import Iterator + +import getstream_rtc +import pytest +from dotenv import load_dotenv + +from getstream import Stream +from getstream.models import FullUserResponse, UserRequest + + +@pytest.fixture(scope="session", autouse=True) +def load_env() -> None: + load_dotenv() + + +@pytest.fixture +def client() -> Stream: + return Stream(timeout=10.0) + + +@pytest.fixture(scope="session") +def call_users() -> Iterator[list[FullUserResponse]]: + client = Stream(timeout=10.0) + user_ids = [str(uuid.uuid4()) for _ in range(2)] + response = client.update_users( + users={user_id: UserRequest(id=user_id, name=user_id) for user_id in user_ids} + ) + yield [response.data.users[user_id] for user_id in user_ids] + client.delete_users( + user_ids=user_ids, user="hard", conversations="hard", messages="hard" + ) + + +@pytest.fixture(autouse=True) +def forward_sdk_logs() -> Iterator[None]: + logger = logging.getLogger("getstream") + getstream_rtc.configure_logging(logger, logger.getEffectiveLevel()) + yield + # The records of this test must not reach the next test. + getstream_rtc.configure_logging(None, logging.NOTSET) + getstream_rtc.logging._flush() + + +@pytest.fixture +def rust_client() -> getstream_rtc.Client: + return getstream_rtc.Client( + os.environ["STREAM_API_KEY"], os.environ["STREAM_API_SECRET"] + ) + + +@pytest.fixture +def sdk_logs(caplog: pytest.LogCaptureFixture) -> Iterator[pytest.LogCaptureFixture]: + caplog.set_level(logging.DEBUG, logger="getstream") + logger = logging.getLogger("getstream") + getstream_rtc.configure_logging(logger, logger.getEffectiveLevel()) + yield caplog + # The records of this test must not reach the next test. + getstream_rtc.configure_logging(None, logging.NOTSET) + getstream_rtc.logging._flush() diff --git a/getstream-rtc/tests/test_call.py b/getstream-rtc/tests/test_call.py new file mode 100644 index 00000000..1aa63797 --- /dev/null +++ b/getstream-rtc/tests/test_call.py @@ -0,0 +1,752 @@ +import asyncio +import contextlib +import os +import time +import uuid +from typing import AsyncIterator, Iterator + +import getstream_rtc +import numpy as np +import pytest + +from getstream import Stream +from getstream.models import CallRequest, FullUserResponse +from getstream.video.rtc.pb.stream.video.sfu.models import models_pb2 + +SAMPLE_RATE = 48000 +FRAME_SAMPLES = SAMPLE_RATE // 50 +VIDEO_WIDTH = 320 +VIDEO_HEIGHT = 240 +VIDEO_FPS = 15 +AUDIO = getstream_rtc.SubscriptionConfig( + default=getstream_rtc.TrackSubscriptionConfig( + track_types=[getstream_rtc.TrackType.AUDIO] + ) +) +AUDIO_AND_VIDEO = getstream_rtc.SubscriptionConfig( + default=getstream_rtc.TrackSubscriptionConfig( + track_types=[getstream_rtc.TrackType.AUDIO, getstream_rtc.TrackType.VIDEO] + ) +) + + +@pytest.fixture +def call_id() -> str: + return str(uuid.uuid4()) + + +@pytest.fixture +async def joined_call( + rust_client: getstream_rtc.Client, + call_id: str, + call_users: list[FullUserResponse], +) -> AsyncIterator[getstream_rtc.Call]: + call = rust_client.call("default", call_id) + await call.join(call_users[0].id) + yield call + await call.leave() + + +@pytest.fixture +async def joining_call( + rust_client: getstream_rtc.Client, + call_id: str, + call_users: list[FullUserResponse], +) -> AsyncIterator[getstream_rtc.Call]: + call = rust_client.call("default", call_id) + yield call + await call.leave() + + +@pytest.fixture +def tone() -> np.ndarray: + t = np.arange(FRAME_SAMPLES) / SAMPLE_RATE + return (np.sin(2 * np.pi * 440 * t) * 10000).astype(np.int16) + + +@pytest.fixture +async def published_audio( + joined_call: getstream_rtc.Call, tone: np.ndarray +) -> AsyncIterator[getstream_rtc.LocalAudioTrack]: + track = getstream_rtc.LocalAudioTrack() + await joined_call.publish_audio(track) + + async def write_forever() -> None: + while True: + await track.write_pcm(tone, SAMPLE_RATE, 1) + await asyncio.sleep(FRAME_SAMPLES / SAMPLE_RATE) + + task = asyncio.create_task(write_forever()) + yield track + task.cancel() + + +@pytest.fixture +async def published_video( + joined_call: getstream_rtc.Call, +) -> AsyncIterator[getstream_rtc.LocalVideoTrack]: + track = getstream_rtc.LocalVideoTrack.vp9() + await joined_call.publish_video(track) + frame = np.full(VIDEO_WIDTH * VIDEO_HEIGHT * 3 // 2, 128, dtype=np.uint8) + + async def write_forever() -> None: + while True: + await track.write_i420(frame, VIDEO_WIDTH, VIDEO_HEIGHT, 1 / VIDEO_FPS) + await asyncio.sleep(1 / VIDEO_FPS) + + task = asyncio.create_task(write_forever()) + yield track + task.cancel() + + +@pytest.fixture +async def remote_video( + published_video: getstream_rtc.LocalVideoTrack, + joining_call: getstream_rtc.Call, + call_users: list[FullUserResponse], +) -> getstream_rtc.RemoteTrack: + """The track of `published_video` in `joining_call`.""" + tracks = joining_call.tracks() + await joining_call.join(call_users[1].id) + await joining_call.update_subscriptions(AUDIO_AND_VIDEO) + return await anext( + track + async for track in tracks + if track.track_type == getstream_rtc.TrackType.VIDEO + ) + + +async def rtp_timestamps( + frames: getstream_rtc.VideoFrameStream, count: int +) -> list[int]: + return [(await anext(frames)).rtp_timestamp for _ in range(count)] + + +async def read_to_end( + frames: getstream_rtc.VideoFrameStream, +) -> list[getstream_rtc.VideoFrame]: + return [frame async for frame in frames] + + +async def write_gray_video(track: getstream_rtc.LocalVideoTrack) -> None: + frame = np.full(VIDEO_WIDTH * VIDEO_HEIGHT * 3 // 2, 128, dtype=np.uint8) + while True: + await track.write_i420(frame, VIDEO_WIDTH, VIDEO_HEIGHT, 1 / VIDEO_FPS) + await asyncio.sleep(1 / VIDEO_FPS) + + +@pytest.fixture +def live_call_id( + client: Stream, call_id: str, call_users: list[FullUserResponse] +) -> Iterator[str]: + call = client.video.call("livestream", call_id) + call.get_or_create(data=CallRequest(created_by_id=call_users[0].id)) + call.go_live() + yield call_id + call.end() + + +@pytest.fixture +async def viewer_call( + rust_client: getstream_rtc.Client, + live_call_id: str, + call_users: list[FullUserResponse], +) -> AsyncIterator[getstream_rtc.Call]: + call = rust_client.call("livestream", live_call_id) + await call.join(call_users[1].id, create=False) + yield call + await call.leave() + + +@pytest.mark.integration +class TestCallJoin: + async def test_join_and_leave( + self, rust_client: getstream_rtc.Client, call_users: list[FullUserResponse] + ): + call = rust_client.call("default", str(uuid.uuid4())) + + await call.join(call_users[0].id) + assert call.calling_state == getstream_rtc.CallingState.JOINED + assert await call.session_id() + + await call.leave() + assert call.calling_state == getstream_rtc.CallingState.LEFT + + async def test_join_with_user_token( + self, + client: Stream, + joined_call: getstream_rtc.Call, + call_id: str, + call_users: list[FullUserResponse], + ): + token = client.create_call_token( + call_users[1].id, call_cids=[f"default:{call_id}"] + ) + call = getstream_rtc.Client(os.environ["STREAM_API_KEY"], token=token).call( + "default", call_id + ) + + await call.join(call_users[1].id, create=False) + assert call.calling_state == getstream_rtc.CallingState.JOINED + await call.leave() + + async def test_join_with_wrong_secret_raises( + self, call_users: list[FullUserResponse] + ): + client = getstream_rtc.Client(os.environ["STREAM_API_KEY"], "wrong-secret") + call = client.call("default", str(uuid.uuid4())) + + with pytest.raises(getstream_rtc.CoordinatorError): + await call.join(call_users[0].id) + + async def test_join_with_unknown_call_type_raises( + self, rust_client: getstream_rtc.Client, call_users: list[FullUserResponse] + ): + call = rust_client.call("missingtype", str(uuid.uuid4())) + + with pytest.raises(getstream_rtc.ApiError) as exc_info: + await call.join(call_users[0].id) + + assert exc_info.value.status_code == 404 + assert exc_info.value.code == 16 + assert exc_info.value.message + + async def test_cancelled_join_allows_new_join( + self, rust_client: getstream_rtc.Client, call_users: list[FullUserResponse] + ): + call = rust_client.call("default", str(uuid.uuid4())) + + join = call.join(call_users[0].id) + await asyncio.sleep(0.1) + join.cancel() + with pytest.raises(asyncio.CancelledError): + await join + + await call.join(call_users[0].id) + assert call.calling_state == getstream_rtc.CallingState.JOINED + await call.leave() + + async def test_leave_during_join( + self, rust_client: getstream_rtc.Client, call_users: list[FullUserResponse] + ): + call = rust_client.call("default", str(uuid.uuid4())) + + join = call.join(call_users[0].id) + await asyncio.sleep(0.1) + await call.leave() + # A fast join can finish before the leave stops it. + with contextlib.suppress(getstream_rtc.RtcError): + await join + assert call.calling_state == getstream_rtc.CallingState.LEFT + + await call.join(call_users[0].id) + assert call.calling_state == getstream_rtc.CallingState.JOINED + await call.leave() + + async def test_join_during_leave( + self, rust_client: getstream_rtc.Client, call_users: list[FullUserResponse] + ): + call = rust_client.call("default", str(uuid.uuid4())) + await call.join(call_users[0].id) + + leave = call.leave() + with contextlib.suppress(getstream_rtc.IllegalStateError): + await call.join(call_users[0].id) + await leave + + # The join failed while the leave ran (LEFT), or it ran after the + # leave finished (JOINED). + if call.calling_state == getstream_rtc.CallingState.LEFT: + await call.join(call_users[0].id) + assert call.calling_state == getstream_rtc.CallingState.JOINED + await call.leave() + + +class TestCallPublish: + async def test_publish_before_join_raises(self, call_id: str): + call = getstream_rtc.Client("key", "secret").call("default", call_id) + + with pytest.raises(getstream_rtc.IllegalStateError): + await call.publish_audio(getstream_rtc.LocalAudioTrack()) + + @pytest.mark.integration + async def test_publish_without_capability_raises( + self, viewer_call: getstream_rtc.Call + ): + with pytest.raises(getstream_rtc.PermissionDeniedError) as exc_info: + await viewer_call.publish_audio(getstream_rtc.LocalAudioTrack()) + + assert exc_info.value.capability == "send-audio" + + @pytest.mark.integration + async def test_stop_publish_audio_unpublishes_track( + self, + joined_call: getstream_rtc.Call, + joining_call: getstream_rtc.Call, + call_users: list[FullUserResponse], + tone: np.ndarray, + ): + track = getstream_rtc.LocalAudioTrack() + await joined_call.publish_audio(track) + await joining_call.join(call_users[1].id) + events = joining_call.sfu_events() + + await joined_call.stop_publish_audio(track) + async for event in events: + if ( + isinstance(event, getstream_rtc.TrackUnpublished) + and event.user_id == call_users[0].id + ): + break + assert event.track_type == getstream_rtc.TrackType.AUDIO + with pytest.raises(getstream_rtc.IllegalStateError): + await track.write_pcm(tone, SAMPLE_RATE, 1) + + @pytest.mark.integration + async def test_mute_unpublishes_track_with_user_muted( + self, + published_audio: getstream_rtc.LocalAudioTrack, + joined_call: getstream_rtc.Call, + joining_call: getstream_rtc.Call, + call_users: list[FullUserResponse], + ): + await joining_call.join(call_users[1].id) + events = joining_call.sfu_events() + + await joined_call.mute_track(getstream_rtc.TrackType.AUDIO) + async for event in events: + if ( + isinstance(event, getstream_rtc.TrackUnpublished) + and event.user_id == call_users[0].id + ): + break + assert event.cause == models_pb2.TRACK_UNPUBLISH_REASON_USER_MUTED + + @pytest.mark.integration + async def test_screen_share_is_published_and_unpublished( + self, + joined_call: getstream_rtc.Call, + joining_call: getstream_rtc.Call, + call_users: list[FullUserResponse], + ): + await joining_call.join(call_users[1].id) + events = joining_call.sfu_events() + track = getstream_rtc.LocalVideoTrack.vp8() + + await joined_call.publish_screen_share(track) + async for event in events: + if ( + isinstance(event, getstream_rtc.TrackPublished) + and event.user_id == call_users[0].id + ): + break + assert event.track_type == getstream_rtc.TrackType.SCREEN_SHARE + + await joined_call.stop_publish_screen_share(track) + async for event in events: + if ( + isinstance(event, getstream_rtc.TrackUnpublished) + and event.user_id == call_users[0].id + ): + break + assert event.track_type == getstream_rtc.TrackType.SCREEN_SHARE + + @pytest.mark.integration + async def test_video_is_published_again_after_stop( + self, + joined_call: getstream_rtc.Call, + joining_call: getstream_rtc.Call, + call_users: list[FullUserResponse], + ): + events = joining_call.sfu_events() + tracks = joining_call.tracks() + await joining_call.join(call_users[1].id) + await joining_call.update_subscriptions(AUDIO_AND_VIDEO) + first = getstream_rtc.LocalVideoTrack.vp9() + await joined_call.publish_video(first) + writing = asyncio.create_task(write_gray_video(first)) + remote = await asyncio.wait_for(anext(tracks), timeout=15) + await asyncio.wait_for(anext(remote.video_frames()), timeout=10) + writing.cancel() + await joined_call.stop_publish_video(first) + async for event in events: + if ( + isinstance(event, getstream_rtc.TrackUnpublished) + and event.user_id == call_users[0].id + ): + break + frames = remote.video_frames() + + second = getstream_rtc.LocalVideoTrack.vp9() + await joined_call.publish_video(second) + writing = asyncio.create_task(write_gray_video(second)) + + await asyncio.wait_for(anext(frames), timeout=15) + writing.cancel() + + +@pytest.mark.integration +class TestCallEvents: + async def test_calling_state_changed_to_joined( + self, rust_client: getstream_rtc.Client, call_users: list[FullUserResponse] + ): + call = rust_client.call("default", str(uuid.uuid4())) + events = call.client_events() + + await call.join(call_users[0].id) + async for event in events: + if ( + isinstance(event, getstream_rtc.CallingStateChanged) + and event.state == getstream_rtc.CallingState.JOINED + ): + break + await call.leave() + + async def test_participant_joined( + self, + joined_call: getstream_rtc.Call, + joining_call: getstream_rtc.Call, + call_id: str, + call_users: list[FullUserResponse], + ): + events = joined_call.sfu_events() + + await joining_call.join(call_users[1].id) + async for event in events: + if ( + isinstance(event, getstream_rtc.ParticipantJoined) + and event.participant.user_id == call_users[1].id + ): + break + assert event.call_cid == f"default:{call_id}" + participant = event.participant + assert abs(participant.joined_at - time.time()) < 60 + assert participant.custom == {} + assert participant.connection_quality in models_pb2.ConnectionQuality.values() + assert participant.source == models_pb2.PARTICIPANT_SOURCE_WEBRTC_UNSPECIFIED + + async def test_events_end_when_call_is_left( + self, rust_client: getstream_rtc.Client, call_users: list[FullUserResponse] + ): + call = rust_client.call("default", str(uuid.uuid4())) + events = call.client_events() + + await call.join(call_users[0].id) + await call.leave() + received = [event async for event in events] + + assert isinstance(received[-1], getstream_rtc.CallingStateChanged) + assert received[-1].state == getstream_rtc.CallingState.LEFT + + async def test_events_created_after_leave_are_empty( + self, rust_client: getstream_rtc.Client, call_users: list[FullUserResponse] + ): + call = rust_client.call("default", str(uuid.uuid4())) + await call.join(call_users[0].id) + await call.leave() + + for events in ( + call.sfu_events(), + call.coordinator_events(), + call.client_events(), + ): + assert [event async for event in events] == [] + + async def test_events_end_when_call_ends( + self, client: Stream, joined_call: getstream_rtc.Call, call_id: str + ): + sfu_events = joined_call.sfu_events() + client_events = joined_call.client_events() + + client.video.call("default", call_id).end() + received = [event async for event in client_events] + + assert received[-1].state == getstream_rtc.CallingState.LEFT + # The SFU stream ends too, also when the SFU `call_ended` never came. + async for _ in sfu_events: + pass + + async def test_track_published( + self, + joined_call: getstream_rtc.Call, + joining_call: getstream_rtc.Call, + call_users: list[FullUserResponse], + ): + await joining_call.join(call_users[1].id) + events = joining_call.sfu_events() + + await joined_call.publish_audio(getstream_rtc.LocalAudioTrack()) + async for event in events: + if ( + isinstance(event, getstream_rtc.TrackPublished) + and event.user_id == call_users[0].id + ): + assert event.track_type == getstream_rtc.TrackType.AUDIO + assert event.participant.user_id == call_users[0].id + break + + async def test_coordinator_event( + self, + client: Stream, + joined_call: getstream_rtc.Call, + call_id: str, + call_users: list[FullUserResponse], + ): + events = joined_call.coordinator_events() + + client.video.call("default", call_id).send_call_event( + user_id=call_users[1].id, custom={"type": "test_event"} + ) + + async for event in events: + if event.name == "custom": + break + assert isinstance(event, getstream_rtc.CoordinatorEvent) + assert event.data["custom"]["type"] == "test_event" + + async def test_coordinator_event_keeps_the_custom_payload( + self, + client: Stream, + joined_call: getstream_rtc.Call, + call_id: str, + call_users: list[FullUserResponse], + ): + events = joined_call.coordinator_events() + custom = { + "type": "test_event", + # A float that a parser without correct rounding changes in the last bit. + "ratio": 0.9856906946328695, + "count": 3, + "enabled": True, + "items": [1, "a", None, {"nested": [False]}], + } + + client.video.call("default", call_id).send_call_event( + user_id=call_users[1].id, custom=custom + ) + + async for event in events: + if event.name == "custom": + break + assert event.data["custom"] == custom + + async def test_pins_changed( + self, + client: Stream, + joined_call: getstream_rtc.Call, + call_id: str, + call_users: list[FullUserResponse], + ): + events = joined_call.sfu_events() + session_id = await joined_call.session_id() + + client.video.call("default", call_id).video_pin( + session_id=session_id, user_id=call_users[0].id + ) + + async for event in events: + if isinstance(event, getstream_rtc.PinsChanged): + break + assert event.name == "pins_updated" + assert [(pin.user_id, pin.session_id) for pin in event.pins] == [ + (call_users[0].id, session_id) + ] + + async def test_call_grants_updated( + self, + client: Stream, + joined_call: getstream_rtc.Call, + call_id: str, + call_users: list[FullUserResponse], + ): + events = joined_call.sfu_events() + + client.video.call("default", call_id).update_user_permissions( + user_id=call_users[0].id, revoke_permissions=["send-audio"] + ) + + async for event in events: + if isinstance(event, getstream_rtc.CallGrantsUpdated): + break + assert event.current_grants.can_publish_audio is False + + +@pytest.mark.integration +class TestCallParticipants: + async def test_participants_include_joined_participant( + self, + joined_call: getstream_rtc.Call, + joining_call: getstream_rtc.Call, + call_users: list[FullUserResponse], + ): + events = joined_call.sfu_events() + await joining_call.join(call_users[1].id) + async for event in events: + if isinstance(event, getstream_rtc.ParticipantJoined): + break + + user_ids = {p.user_id for p in joined_call.participants()} + assert {call_users[0].id, call_users[1].id} <= user_ids + + async def test_call_state( + self, joined_call: getstream_rtc.Call, call_users: list[FullUserResponse] + ): + state = joined_call.call_state() + + assert call_users[0].id in {p.user_id for p in state.participants} + assert "send-audio" in state.own_capabilities + + +@pytest.mark.integration +class TestCallMedia: + async def test_tracks_end_when_call_is_left( + self, rust_client: getstream_rtc.Client, call_users: list[FullUserResponse] + ): + call = rust_client.call("default", str(uuid.uuid4())) + tracks = call.tracks() + + await call.join(call_users[0].id) + await call.leave() + + assert [track async for track in tracks] == [] + + async def test_receives_published_audio( + self, + published_audio: getstream_rtc.LocalAudioTrack, + joining_call: getstream_rtc.Call, + call_users: list[FullUserResponse], + ): + tracks = joining_call.tracks() + await joining_call.join(call_users[1].id) + await joining_call.update_subscriptions(AUDIO) + + async for track in tracks: + if track.track_type == getstream_rtc.TrackType.AUDIO: + break + assert track.participant.user_id == call_users[0].id + + while True: + frame = await track.next_pcm() + if np.abs(frame.samples).max() > 1000: + break + assert frame.sample_rate == SAMPLE_RATE + assert frame.samples.dtype == np.int16 + assert frame.pts is not None + + async def test_receives_published_video( + self, + published_video: getstream_rtc.LocalVideoTrack, + joining_call: getstream_rtc.Call, + call_users: list[FullUserResponse], + ): + tracks = joining_call.tracks() + await joining_call.join(call_users[1].id) + await joining_call.update_subscriptions(AUDIO_AND_VIDEO) + + async for track in tracks: + if track.track_type == getstream_rtc.TrackType.VIDEO: + break + assert track.participant.user_id == call_users[0].id + + frame = await anext(track.video_frames()) + assert (frame.width, frame.height) == (VIDEO_WIDTH, VIDEO_HEIGHT) + assert frame.data.dtype == np.uint8 + assert frame.data.size == VIDEO_WIDTH * VIDEO_HEIGHT * 3 // 2 + + async def test_frame_streams_of_one_track_get_the_same_frames( + self, remote_video: getstream_rtc.RemoteTrack + ): + first, second = remote_video.video_frames(), remote_video.video_frames() + + first_timestamps, second_timestamps = await asyncio.wait_for( + asyncio.gather(rtp_timestamps(first, 10), rtp_timestamps(second, 10)), + timeout=15, + ) + + assert first_timestamps == second_timestamps + + async def test_an_unread_frame_stream_gets_the_latest_frame( + self, remote_video: getstream_rtc.RemoteTrack + ): + read, unread = remote_video.video_frames(), remote_video.video_frames() + + timestamps = await asyncio.wait_for(rtp_timestamps(read, 10), timeout=15) + frame = await asyncio.wait_for(anext(unread), timeout=5) + + assert frame.rtp_timestamp >= timestamps[-1] + + async def test_a_cancelled_read_leaves_the_frame_stream_usable( + self, remote_video: getstream_rtc.RemoteTrack + ): + frames = remote_video.video_frames() + await asyncio.wait_for(anext(frames), timeout=10) + + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(anext(frames), timeout=0.001) + + await asyncio.wait_for(anext(frames), timeout=5) + + async def test_a_new_frame_stream_gets_frames_after_the_old_one_is_dropped( + self, remote_video: getstream_rtc.RemoteTrack + ): + frames = remote_video.video_frames() + await asyncio.wait_for(anext(frames), timeout=10) + del frames + # Lets the decoding stop before the new stream starts it again. + await asyncio.sleep(0.5) + + await asyncio.wait_for(anext(remote_video.video_frames()), timeout=10) + + async def test_a_frame_stream_keeps_the_track_subscribed( + self, + published_video: getstream_rtc.LocalVideoTrack, + joining_call: getstream_rtc.Call, + call_users: list[FullUserResponse], + ): + tracks = joining_call.tracks() + await joining_call.join(call_users[1].id) + await joining_call.update_subscriptions(AUDIO_AND_VIDEO) + frames = (await anext(tracks)).video_frames() + + # 3 s of video; a dropped track is unsubscribed well before that. + await asyncio.wait_for(rtp_timestamps(frames, 3 * VIDEO_FPS), timeout=15) + + async def test_frame_stream_ends_when_the_call_is_left( + self, remote_video: getstream_rtc.RemoteTrack, joining_call: getstream_rtc.Call + ): + frames = remote_video.video_frames() + await asyncio.wait_for(anext(frames), timeout=10) + + await joining_call.leave() + + await asyncio.wait_for(read_to_end(frames), timeout=10) + + async def test_track_dropped_while_muted_arrives_again_after_unmute( + self, + published_audio: getstream_rtc.LocalAudioTrack, + joined_call: getstream_rtc.Call, + joining_call: getstream_rtc.Call, + call_users: list[FullUserResponse], + ): + events = joining_call.sfu_events() + tracks = joining_call.tracks() + await joining_call.join(call_users[1].id) + await joining_call.update_subscriptions(AUDIO) + track = await anext(tracks) + + await joined_call.mute_track(getstream_rtc.TrackType.AUDIO) + async for event in events: + if ( + isinstance(event, getstream_rtc.TrackUnpublished) + and event.user_id == call_users[0].id + ): + break + del track + await joined_call.unmute_track(getstream_rtc.TrackType.AUDIO) + + track = await asyncio.wait_for(anext(tracks), timeout=15) + assert track.participant.user_id == call_users[0].id + while True: + frame = await asyncio.wait_for(track.next_pcm(), timeout=15) + if np.abs(frame.samples).max() > 1000: + break diff --git a/getstream-rtc/tests/test_client.py b/getstream-rtc/tests/test_client.py new file mode 100644 index 00000000..dd516bc7 --- /dev/null +++ b/getstream-rtc/tests/test_client.py @@ -0,0 +1,26 @@ +import getstream_rtc +import pytest + + +class TestClient: + def test_empty_api_key_raises(self): + with pytest.raises(getstream_rtc.ConfigError): + getstream_rtc.Client("", "secret") + + def test_secret_and_token_raise(self): + with pytest.raises(getstream_rtc.ConfigError): + getstream_rtc.Client("key", "secret", token="token") + + def test_no_secret_and_no_token_raise(self): + with pytest.raises(getstream_rtc.ConfigError): + getstream_rtc.Client("key") + + def test_zero_call_event_capacity_raises(self): + with pytest.raises(getstream_rtc.ConfigError): + getstream_rtc.Client("key", "secret", call_event_capacity=0) + + def test_non_websocket_ws_url_raises(self): + with pytest.raises(getstream_rtc.ConfigError): + getstream_rtc.Client( + "key", "secret", ws_url="https://video.stream-io-api.com/api/v2/connect" + ) diff --git a/getstream-rtc/tests/test_events.py b/getstream-rtc/tests/test_events.py new file mode 100644 index 00000000..23229f20 --- /dev/null +++ b/getstream-rtc/tests/test_events.py @@ -0,0 +1,11 @@ +import getstream_rtc + +from getstream.video.rtc.pb.stream.video.sfu.models import models_pb2 + + +class TestEvents: + def test_call_ended_made_in_python_has_no_reason(self): + event = getstream_rtc.CallEnded() + + assert event.name == "call_ended" + assert event.reason == models_pb2.CALL_ENDED_REASON_UNSPECIFIED diff --git a/getstream-rtc/tests/test_local_tracks.py b/getstream-rtc/tests/test_local_tracks.py new file mode 100644 index 00000000..824b01cc --- /dev/null +++ b/getstream-rtc/tests/test_local_tracks.py @@ -0,0 +1,51 @@ +import getstream_rtc +import numpy as np +import pytest + +SAMPLE_RATE = 48000 +VIDEO_FPS = 15 + + +class TestLocalTracks: + async def test_audio_queue_overflow_raises(self): + track = getstream_rtc.LocalAudioTrack() + samples = np.zeros(SAMPLE_RATE * 61, dtype=np.int16) + + with pytest.raises(getstream_rtc.PcmQueueOverflowError) as exc_info: + await track.write_pcm(samples, SAMPLE_RATE, 1) + + assert exc_info.value.capacity_samples == SAMPLE_RATE * 60 + assert exc_info.value.dropped_samples == SAMPLE_RATE + + async def test_pcm_queue_capacity_limits_the_queue(self): + track = getstream_rtc.LocalAudioTrack(pcm_queue_capacity=0.1) + samples = np.zeros(SAMPLE_RATE, dtype=np.int16) + + with pytest.raises(getstream_rtc.PcmQueueOverflowError) as exc_info: + await track.write_pcm(samples, SAMPLE_RATE, 1) + + assert exc_info.value.capacity_samples == SAMPLE_RATE // 10 + assert exc_info.value.dropped_samples == SAMPLE_RATE - SAMPLE_RATE // 10 + + async def test_audio_at_another_rate_than_the_first_frame_raises(self) -> None: + track = getstream_rtc.LocalAudioTrack() + await track.write_pcm(np.zeros(320, dtype=np.int16), 16000, 1) + + with pytest.raises(getstream_rtc.PcmRateMismatchError) as exc_info: + await track.write_pcm(np.zeros(480, dtype=np.int16), 24000, 1) + + assert (exc_info.value.expected, exc_info.value.actual) == (16000, 24000) + + async def test_misaligned_samples_raise(self): + track = getstream_rtc.LocalAudioTrack() + samples = np.frombuffer(memoryview(bytearray(1921))[1:], dtype=np.int16) + + with pytest.raises(ValueError): + await track.write_pcm(samples, SAMPLE_RATE, 1) + + async def test_odd_video_dimensions_raise(self): + track = getstream_rtc.LocalVideoTrack.vp9() + frame = np.zeros(321 * 240 * 3 // 2, dtype=np.uint8) + + with pytest.raises(getstream_rtc.MediaError): + await track.write_i420(frame, 321, 240, 1 / VIDEO_FPS) diff --git a/getstream-rtc/tests/test_logging.py b/getstream-rtc/tests/test_logging.py new file mode 100644 index 00000000..c616da6d --- /dev/null +++ b/getstream-rtc/tests/test_logging.py @@ -0,0 +1,411 @@ +import contextlib +import logging +import os +import sys +import threading +import time +import uuid +from typing import Iterator + +import getstream_rtc +import numpy as np +import pytest + +from getstream.models import FullUserResponse + +SAMPLE_RATE = 48000 + + +@pytest.fixture +def overflowing_samples() -> np.ndarray: + return np.zeros(SAMPLE_RATE * 61, dtype=np.int16) + + +@pytest.fixture +def long_switch_interval() -> Iterator[None]: + # A busy Python thread then keeps the GIL for up to one second. + interval = sys.getswitchinterval() + sys.setswitchinterval(1.0) + yield + sys.setswitchinterval(interval) + + +@pytest.fixture +def small_track() -> getstream_rtc.LocalAudioTrack: + return getstream_rtc.LocalAudioTrack(pcm_queue_capacity=0.05) + + +@pytest.fixture +def overflowing_chunk() -> np.ndarray: + # Twice the capacity of `small_track`: each write overflows and logs + # `stream.rtc.audio.pcm_queue_overflow` once. + return np.zeros(SAMPLE_RATE // 10, dtype=np.int16) + + +class BlockingHandler(logging.Handler): + """Blocks the thread that handles the first audio overflow record until + `release` is set.""" + + def __init__(self) -> None: + super().__init__() + self.entered = threading.Event() + self.release = threading.Event() + + def emit(self, record: logging.LogRecord) -> None: + if ( + record.getMessage() == "stream.rtc.audio.pcm_queue_overflow" + and not self.entered.is_set() + ): + self.entered.set() + self.release.wait() + + +@pytest.fixture +def blocking_handler() -> Iterator[BlockingHandler]: + logger = logging.getLogger("getstream") + handler = BlockingHandler() + logger.addHandler(handler) + yield handler + handler.release.set() + logger.removeHandler(handler) + + +class FlushingHandler(logging.Handler): + """Calls `_flush()` on the thread that handles the records.""" + + def __init__(self) -> None: + super().__init__() + self.flushed = threading.Event() + + def emit(self, record: logging.LogRecord) -> None: + getstream_rtc.logging._flush() + self.flushed.set() + + +@pytest.fixture +def flushing_handler() -> Iterator[FlushingHandler]: + logger = logging.getLogger("getstream") + handler = FlushingHandler() + logger.addHandler(handler) + yield handler + logger.removeHandler(handler) + + +class FailOnce(logging.Filter): + """Raises for the first record.""" + + def __init__(self) -> None: + super().__init__() + self.failed = False + + def filter(self, record: logging.LogRecord) -> bool: + if not self.failed: + self.failed = True + raise ValueError("a failing filter") + return True + + +@pytest.fixture +def failing_filter() -> Iterator[None]: + # `handle_record` calls `handle()` of the child logger, which applies only + # its own filters. + logger = logging.getLogger("getstream.rtc.tracks.local") + log_filter = FailOnce() + logger.addFilter(log_filter) + yield + logger.removeFilter(log_filter) + + +class ExitOnce(logging.Handler): + """Raises `SystemExit` for the first audio overflow record, which ends the + thread that handles it.""" + + def __init__(self) -> None: + super().__init__() + self.raised = threading.Event() + + def emit(self, record: logging.LogRecord) -> None: + if ( + record.getMessage() == "stream.rtc.audio.pcm_queue_overflow" + and not self.raised.is_set() + ): + self.raised.set() + raise SystemExit + + +@pytest.fixture +def exiting_handler() -> Iterator[ExitOnce]: + logger = logging.getLogger("getstream") + handler = ExitOnce() + logger.addHandler(handler) + yield handler + logger.removeHandler(handler) + + +class TestLogging: + async def test_sdk_events_reach_python_logging( + self, sdk_logs: pytest.LogCaptureFixture, overflowing_samples: np.ndarray + ): + track = getstream_rtc.LocalAudioTrack() + with pytest.raises(getstream_rtc.RtcError): + await track.write_pcm(overflowing_samples, SAMPLE_RATE, 1) + getstream_rtc.logging._flush() + + record = next( + r + for r in sdk_logs.records + if r.getMessage() == "stream.rtc.audio.pcm_queue_overflow" + ) + assert record.name == "getstream.rtc.tracks.local" + assert record.levelno == logging.DEBUG + assert record.rust_target == "getstream::rtc::tracks::local" + assert record.dropped_samples > 0 + assert record.capacity_samples == SAMPLE_RATE * 60 + + async def test_records_keep_the_time_of_the_rust_event( + self, + sdk_logs: pytest.LogCaptureFixture, + overflowing_samples: np.ndarray, + long_switch_interval: None, + ): + track = getstream_rtc.LocalAudioTrack() + write = track.write_pcm(overflowing_samples, SAMPLE_RATE, 1) + started = time.time() + # Holds the GIL, so the record cannot reach Python before this ends. + while time.time() - started < 0.3: + pass + with pytest.raises(getstream_rtc.RtcError): + await write + getstream_rtc.logging._flush() + + record = next( + r + for r in sdk_logs.records + if r.getMessage() == "stream.rtc.audio.pcm_queue_overflow" + ) + assert record.created < started + 0.1 + assert abs(record.msecs - (record.created % 1) * 1000) < 1 + + async def test_flush_during_logged_overflow_does_not_hang( + self, sdk_logs: pytest.LogCaptureFixture, overflowing_samples: np.ndarray + ): + track = getstream_rtc.LocalAudioTrack() + + for _ in range(20): + write = track.write_pcm(overflowing_samples, SAMPLE_RATE, 1) + track.flush() + with contextlib.suppress(getstream_rtc.RtcError): + await write + + async def test_no_records_after_logging_is_stopped( + self, caplog: pytest.LogCaptureFixture, overflowing_samples: np.ndarray + ): + caplog.set_level(logging.DEBUG, logger="getstream") + getstream_rtc.configure_logging(None, logging.NOTSET) + track = getstream_rtc.LocalAudioTrack() + + with pytest.raises(getstream_rtc.RtcError): + await track.write_pcm(overflowing_samples, SAMPLE_RATE, 1) + getstream_rtc.logging._flush() + + assert not [r for r in caplog.records if r.name.startswith("getstream.rtc")] + + async def test_records_come_from_the_logging_thread( + self, + sdk_logs: pytest.LogCaptureFixture, + small_track: getstream_rtc.LocalAudioTrack, + overflowing_chunk: np.ndarray, + ): + with contextlib.suppress(getstream_rtc.PcmQueueOverflowError): + await small_track.write_pcm(overflowing_chunk, SAMPLE_RATE, 1) + getstream_rtc.logging._flush() + + record = next( + r + for r in sdk_logs.records + if r.getMessage() == "stream.rtc.audio.pcm_queue_overflow" + ) + assert record.threadName == "getstream-logging" + + async def test_configure_logging_returns_while_a_handler_runs( + self, + sdk_logs: pytest.LogCaptureFixture, + blocking_handler: BlockingHandler, + small_track: getstream_rtc.LocalAudioTrack, + overflowing_chunk: np.ndarray, + ): + with contextlib.suppress(getstream_rtc.PcmQueueOverflowError): + await small_track.write_pcm(overflowing_chunk, SAMPLE_RATE, 1) + assert blocking_handler.entered.wait(timeout=5) + + configure = threading.Thread( + target=getstream_rtc.configure_logging, args=(None, logging.NOTSET) + ) + configure.start() + configure.join(timeout=1) + + assert not configure.is_alive() + + async def test_flush_returns_on_the_logging_thread( + self, + sdk_logs: pytest.LogCaptureFixture, + flushing_handler: FlushingHandler, + small_track: getstream_rtc.LocalAudioTrack, + overflowing_chunk: np.ndarray, + ): + with contextlib.suppress(getstream_rtc.PcmQueueOverflowError): + await small_track.write_pcm(overflowing_chunk, SAMPLE_RATE, 1) + + assert flushing_handler.flushed.wait(timeout=5) + + async def test_records_dropped_at_the_end_are_reported( + self, + sdk_logs: pytest.LogCaptureFixture, + blocking_handler: BlockingHandler, + small_track: getstream_rtc.LocalAudioTrack, + overflowing_chunk: np.ndarray, + ): + # While the handler blocks the logging thread, the queue (1024) and one + # batch (256) fill up, and the last records are dropped. + for _ in range(1500): + with contextlib.suppress(getstream_rtc.PcmQueueOverflowError): + await small_track.write_pcm(overflowing_chunk, SAMPLE_RATE, 1) + blocking_handler.release.set() + getstream_rtc.logging._flush() + + dropped = [ + r + for r in sdk_logs.records + if r.getMessage() == "stream.rust.log_records_dropped" + ] + assert dropped + assert dropped[0].dropped > 0 + + async def test_records_after_a_failing_filter_are_delivered( + self, + sdk_logs: pytest.LogCaptureFixture, + failing_filter: None, + small_track: getstream_rtc.LocalAudioTrack, + overflowing_chunk: np.ndarray, + ): + for _ in range(2): + with contextlib.suppress(getstream_rtc.PcmQueueOverflowError): + await small_track.write_pcm(overflowing_chunk, SAMPLE_RATE, 1) + getstream_rtc.logging._flush() + + assert sdk_logs.messages.count("stream.rtc.audio.pcm_queue_overflow") == 1 + + async def test_records_are_delivered_after_logging_is_enabled_again( + self, + sdk_logs: pytest.LogCaptureFixture, + small_track: getstream_rtc.LocalAudioTrack, + overflowing_chunk: np.ndarray, + ): + getstream_rtc.configure_logging(None, logging.NOTSET) + getstream_rtc.configure_logging(logging.getLogger("getstream"), logging.DEBUG) + + with contextlib.suppress(getstream_rtc.PcmQueueOverflowError): + await small_track.write_pcm(overflowing_chunk, SAMPLE_RATE, 1) + getstream_rtc.logging._flush() + + assert "stream.rtc.audio.pcm_queue_overflow" in sdk_logs.messages + + @pytest.mark.filterwarnings("ignore::pytest.PytestUnhandledThreadExceptionWarning") + async def test_records_are_delivered_after_the_logging_thread_ended( + self, + sdk_logs: pytest.LogCaptureFixture, + exiting_handler: ExitOnce, + small_track: getstream_rtc.LocalAudioTrack, + overflowing_chunk: np.ndarray, + ): + with contextlib.suppress(getstream_rtc.PcmQueueOverflowError): + await small_track.write_pcm(overflowing_chunk, SAMPLE_RATE, 1) + assert exiting_handler.raised.wait(timeout=5) + for thread in threading.enumerate(): + if thread.name == "getstream-logging": + thread.join(timeout=5) + getstream_rtc.configure_logging(logging.getLogger("getstream"), logging.DEBUG) + + with contextlib.suppress(getstream_rtc.PcmQueueOverflowError): + await small_track.write_pcm(overflowing_chunk, SAMPLE_RATE, 1) + getstream_rtc.logging._flush() + + assert "stream.rtc.audio.pcm_queue_overflow" in sdk_logs.messages + + async def test_a_failed_configure_logging_keeps_the_earlier_logger( + self, + sdk_logs: pytest.LogCaptureFixture, + small_track: getstream_rtc.LocalAudioTrack, + overflowing_chunk: np.ndarray, + ): + with pytest.raises(TypeError): + getstream_rtc.configure_logging( + logging.getLogger("other"), logging.INFO, "WARNING" + ) + + with contextlib.suppress(getstream_rtc.PcmQueueOverflowError): + await small_track.write_pcm(overflowing_chunk, SAMPLE_RATE, 1) + getstream_rtc.logging._flush() + + assert any(r.name == "getstream.rtc.tracks.local" for r in sdk_logs.records) + + @pytest.mark.integration + async def test_log_bodies_logs_request_bodies( + self, sdk_logs: pytest.LogCaptureFixture, call_users: list[FullUserResponse] + ): + client = getstream_rtc.Client( + os.environ["STREAM_API_KEY"], + os.environ["STREAM_API_SECRET"], + log_bodies=True, + ) + call = client.call("default", str(uuid.uuid4())) + await call.join(call_users[0].id) + await call.leave() + getstream_rtc.logging._flush() + + record = next( + r for r in sdk_logs.records if r.getMessage() == "stream.http.request_body" + ) + assert record.body + + @pytest.mark.integration + async def test_third_party_records_default_to_warning( + self, + sdk_logs: pytest.LogCaptureFixture, + rust_client: getstream_rtc.Client, + call_users: list[FullUserResponse], + ): + call = rust_client.call("default", str(uuid.uuid4())) + await call.join(call_users[0].id) + await call.leave() + getstream_rtc.logging._flush() + + assert not [ + r + for r in sdk_logs.records + if hasattr(r, "rust_target") + and not r.rust_target.startswith("getstream::") + and r.levelno < logging.WARNING + ] + + @pytest.mark.integration + async def test_third_party_level_can_be_lowered( + self, + sdk_logs: pytest.LogCaptureFixture, + rust_client: getstream_rtc.Client, + call_users: list[FullUserResponse], + ): + logger = logging.getLogger("getstream") + getstream_rtc.configure_logging(logger, logging.DEBUG, logging.DEBUG) + call = rust_client.call("default", str(uuid.uuid4())) + await call.join(call_users[0].id) + await call.leave() + getstream_rtc.logging._flush() + + assert [ + r + for r in sdk_logs.records + if hasattr(r, "rust_target") + and not r.rust_target.startswith("getstream::") + and r.levelno == logging.DEBUG + ] diff --git a/getstream-rtc/tests/test_resample.py b/getstream-rtc/tests/test_resample.py new file mode 100644 index 00000000..c439c5c2 --- /dev/null +++ b/getstream-rtc/tests/test_resample.py @@ -0,0 +1,68 @@ +import getstream_rtc +import numpy as np +import pytest + + +@pytest.fixture +def loud_end_16k() -> np.ndarray: + """20 ms of silence at 16 kHz whose last 2 ms carry a loud 1 kHz tone.""" + samples = np.zeros(320, dtype=np.int16) + time = np.arange(32) / 16000 + samples[-32:] = (20000 * np.sin(2 * np.pi * 1000 * time)).astype(np.int16) + return samples + + +def peak(frame: getstream_rtc.PcmFrame) -> int: + return int(np.abs(frame.samples.astype(np.int32)).max(initial=0)) + + +class TestStreamResampler: + def test_push_resamples_to_the_output_rate(self) -> None: + resampler = getstream_rtc.StreamResampler(48000, 1) + + frame = resampler.push(np.zeros(1600, dtype=np.int16), 16000, 1) + + assert (frame.sample_rate, frame.channels) == (48000, 1) + assert len(frame.samples) == pytest.approx(4800, abs=10) + + def test_flush_returns_the_audio_that_the_filter_holds( + self, loud_end_16k: np.ndarray + ) -> None: + resampler = getstream_rtc.StreamResampler(48000, 1) + + pushed = resampler.push(loud_end_16k, 16000, 1) + flushed = resampler.flush() + + assert peak(pushed) < 1000 + assert peak(flushed) > 15000 + + def test_audio_at_the_output_rate_is_copied(self) -> None: + resampler = getstream_rtc.StreamResampler(48000, 2) + samples = np.array([100, -200, 300, -400], dtype=np.int16) + + frame = resampler.push(samples, 48000, 2) + + assert np.array_equal(frame.samples, samples) + assert len(resampler.flush().samples) == 0 + + def test_another_rate_than_the_first_frame_raises(self) -> None: + resampler = getstream_rtc.StreamResampler(48000, 1) + resampler.push(np.zeros(320, dtype=np.int16), 16000, 1) + + with pytest.raises(getstream_rtc.PcmRateMismatchError) as exc_info: + resampler.push(np.zeros(480, dtype=np.int16), 24000, 1) + + assert (exc_info.value.expected, exc_info.value.actual) == (16000, 24000) + + def test_another_channel_count_raises(self) -> None: + resampler = getstream_rtc.StreamResampler(48000, 1) + + with pytest.raises(getstream_rtc.IllegalStateError): + resampler.push(np.zeros(640, dtype=np.int16), 16000, 2) + + def test_misaligned_samples_raise(self) -> None: + resampler = getstream_rtc.StreamResampler(48000, 1) + samples = np.frombuffer(memoryview(bytearray(641))[1:], dtype=np.int16) + + with pytest.raises(ValueError): + resampler.push(samples, 16000, 1) diff --git a/getstream/video/rtc/README.md b/getstream/video/rtc/README.md deleted file mode 100644 index 25c8b390..00000000 --- a/getstream/video/rtc/README.md +++ /dev/null @@ -1,78 +0,0 @@ -# v1-beta - -- [ ] Code generation for Twirp and Protobuf events -- [ ] Basic WS client -- [ ] Send audio/video from files -- [ ] Receive audio -- [ ] Receive video -- [ ] Send VP8 -- [ ] VP8 support -- [ ] h.264 support -- [ ] VAD -- [ ] STT example -- [ ] TTS example -- [ ] Computer vision example -- [ ] Speech-to-speech agent example -- [ ] Stable Agent API - -# v1 - -- [ ] WS connection monitoring and reconnection -- [ ] ICE Restart handling -- [ ] Session migrations -- [ ] pypi package -- [ ] Move ICE gather step to SFU (support without trickle) -- [ ] Remove peer connection bundling hack from Python and move to SFU -- [ ] Make connection.add_tracks wait correctly for the track to be ready - -# v2 - -- [ ] Simulcast support -- [ ] DTX -- [ ] Opus RED -- [ ] AV1 decode support -- [ ] VP9 decode support -- [ ] Send RTC stats to SFU - - -## Agents as recv/send classes - -## Pipeline spec - -```python -agent = pipeline.Pipeline() - -with call.join() as connection: - # media input gets all events from connection and ingest them into the pipeline - pipeline.add(media_input(connection)) - - # a VAD handles "audio" events from the connection and emits "audio" events that contain speech - vad = vad.Silero(option={}) - agent.add(vad) - - # a TTS step handles "speech.audio" events and emits "speech.text" events - tts = whisper.Whisper(options={}) - agent.add(tts) - - # a simple LLM gets a "speech.text" event and creates "speech.text" events - llm = gemini.LLM(call=call, tools=[]) - agent.add(llm) - - # a TTS gets a "speech.text" event and creates a "audio" event - tts = elevenlabs.TTS() - agent.add(tts) - - agent.add(audio_sink(connection)) - await pipeline.run() -``` - -each arg is a step in the pipeline. the way it works - -- each arg in connected to the previous step and will bind to a list of event types -- steps should use pyee for the on() and emit() methods -- steps should use async functions (async emitters) -- each step should expose a list of events that are emitted and handled - -the pipeline should expose a method that creates an SVG showing each step and the events that are connected - -when building the pipeline, you need to detect and throw if a step does not subscribe to any event produced by the previous step diff --git a/getstream/video/rtc/__init__.py b/getstream/video/rtc/__init__.py index 1e08938b..e94ec098 100644 --- a/getstream/video/rtc/__init__.py +++ b/getstream/video/rtc/__init__.py @@ -1,31 +1,45 @@ import logging from typing import Optional +from getstream_rtc import ( + AudioLevelChanged, + CallEnded, + CallEvent, + CallGrantsUpdated, + CallingState, + ChangePublishOptions, + ChangePublishQuality, + ConnectionQualityChanged, + DominantSpeakerChanged, + Error, + ICERestart, + InboundStateNotification, + ParticipantCountChanged, + ParticipantJoined, + ParticipantLeft, + ParticipantUpdated, + PinsChanged, + RemoteParticipant, + RemoteTrack, + TrackPublished, + TrackType, + TrackUnpublished, + VideoFrame, + VideoFrameStream, +) from getstream.video.async_call import Call from getstream.video.rtc.audio_track import AudioStreamTrack from getstream.video.rtc.connection_manager import ConnectionManager -from getstream.video.rtc.connection_utils import join_call_coordinator_request from getstream.video.rtc.g711 import ( G711Encoding, G711Mapping, ) -from getstream.video.rtc.location_discovery import ( - FALLBACK_LOCATION_NAME, - HEADER_CLOUDFRONT_POP, - STREAM_PROD_URL, - HTTPHintLocationDiscovery, -) -from getstream.video.rtc.models import ( - Credentials, - JoinCallRequest, - JoinCallResponse, - ServerCredentials, -) from getstream.video.rtc.track_util import ( AudioFormat, PcmData, Resampler, ) +from getstream.video.rtc.tracks import SubscriptionConfig logger = logging.getLogger(__name__) @@ -41,60 +55,65 @@ logger.debug(f"loaded aiortc {aiortc.__version__} correctly") -async def discover_location(): - """ - Discover the closest location based on CloudFront pop headers. - - Returns: - str: The 3-character location code (e.g. "IAD") - """ - logger.info("Discovering location") - discovery = HTTPHintLocationDiscovery(logger=logger) - # Even though discover is synchronous, we keep this function async for future compatibility - return discovery.discover() - - async def join( - call: Call, user_id: Optional[str] = None, create=True, **kwargs + call: Call, + user_id: str, + create=True, + subscription_config: Optional[SubscriptionConfig] = None, ) -> ConnectionManager: """ - Join a call. This method will: - - discover the best location - - join the call (or create it if needed) - - setup the peer connection - - connect to the SFU + Make a ConnectionManager for a call. Entering it (or `connect()`) joins the + call, or creates it if needed; the SDK chooses the SFU and connects. Args: call: The call to join user_id: The user id to join with create: Whether to create the call if it doesn't exist - **kwargs: Additional arguments to pass to the join call request + subscription_config: The remote tracks to subscribe to; without it, + only remote audio is received Returns: A ConnectionManager object that can be used as a context manager """ - # Return ConnectionManager instance that handles everything internally - # when used as an async context manager and async iterator - return ConnectionManager(call=call, user_id=user_id, create=create, **kwargs) + return ConnectionManager( + call=call, + user_id=user_id, + create=create, + subscription_config=subscription_config, + ) __all__ = [ - "HTTPHintLocationDiscovery", - "HEADER_CLOUDFRONT_POP", - "FALLBACK_LOCATION_NAME", - "STREAM_PROD_URL", "join", "ConnectionManager", - "JoinCallRequest", - "JoinCallResponse", - "ServerCredentials", - "Credentials", - "join_call_coordinator_request", - "discover_location", "PcmData", "Resampler", "AudioFormat", "G711Encoding", "G711Mapping", "AudioStreamTrack", + "CallingState", + "RemoteParticipant", + "RemoteTrack", + "TrackType", + "VideoFrame", + "VideoFrameStream", + "CallEvent", + "AudioLevelChanged", + "CallEnded", + "CallGrantsUpdated", + "ChangePublishOptions", + "ChangePublishQuality", + "ConnectionQualityChanged", + "DominantSpeakerChanged", + "Error", + "ICERestart", + "InboundStateNotification", + "ParticipantCountChanged", + "ParticipantJoined", + "ParticipantLeft", + "ParticipantUpdated", + "PinsChanged", + "TrackPublished", + "TrackUnpublished", ] diff --git a/getstream/video/rtc/audio_forwarder.py b/getstream/video/rtc/audio_forwarder.py new file mode 100644 index 00000000..2d7ab8bf --- /dev/null +++ b/getstream/video/rtc/audio_forwarder.py @@ -0,0 +1,72 @@ +import asyncio +import logging +from typing import cast + +import aiortc +import av +import getstream_rtc +import numpy as np +from numpy.typing import NDArray + +from getstream.video.rtc.audio_track import AudioStreamTrack + +logger = logging.getLogger(__name__) + + +class AudioForwarder: + """Copies the frames of an aiortc audio track to an SDK `LocalAudioTrack`. + + The SDK paces the frames, so the source need not be paced. A `flush()` of + an `AudioStreamTrack` source also clears the SDK queue. + """ + + def __init__( + self, source: aiortc.MediaStreamTrack, target: getstream_rtc.LocalAudioTrack + ) -> None: + self.source = source + self.target = target + + async def run(self) -> None: + """Copy frames until the source ends or the SDK stops the target.""" + flushes = ( + asyncio.create_task(self._forward_flushes(self.source)) + if isinstance(self.source, AudioStreamTrack) + else None + ) + try: + await self._forward_frames() + finally: + if flushes: + flushes.cancel() + + async def _forward_frames(self) -> None: + while True: + try: + # aiortc types recv() as a frame or a packet; an audio track + # gives audio frames. + frame = cast(av.AudioFrame, await self.source.recv()) + except aiortc.mediastreams.MediaStreamError: + return + # aiortc audio tracks give packed s16 frames: aiortc's own Opus + # encoder accepts only s16. + samples = cast(NDArray[np.int16], frame.to_ndarray()) + try: + await self.target.write_pcm( + samples.reshape(-1), + frame.sample_rate, + len(frame.layout.channels), + ) + except getstream_rtc.PcmQueueOverflowError as error: + # The SDK dropped the oldest audio and kept the newest. + logger.debug( + f"Audio buffer overflow: dropped {error.dropped_samples} samples" + ) + except getstream_rtc.IllegalStateError: + # The only cause is a stopped track: it was unpublished, or the + # call was left or ended. + return + + async def _forward_flushes(self, source: AudioStreamTrack) -> None: + while True: + await source.wait_for_flush() + self.target.flush() diff --git a/getstream/video/rtc/audio_track.py b/getstream/video/rtc/audio_track.py index 08f4d1e6..4aaf9d30 100644 --- a/getstream/video/rtc/audio_track.py +++ b/getstream/video/rtc/audio_track.py @@ -20,11 +20,16 @@ class AudioStreamTrack(aiortc.mediastreams.MediaStreamTrack): rate/layout and queues them (dropping the oldest once the queue exceeds audio_buffer_size_ms). - `recv()` paces the queue out in real time via _FramePacer, + With `pace=True`, `recv()` paces the queue out in real time via _FramePacer, handing back one frame per call with its pts stamped, and synthesizes silence when the queue is empty so the RTP timeline never stalls. - `flush()` clears the queue to support interruption/barge-in. + With `pace=False`, `recv()` hands back the next queued frame at once and waits + for the next `write()` when the queue is empty; the reader paces the frames. + + `flush()` clears the queue to support interruption/barge-in, and wakes the + callers of `wait_for_flush()`, so a reader that keeps its own queue can clear + it too. """ kind = "audio" @@ -35,6 +40,7 @@ def __init__( channels: int = 1, # output channel count (1=mono, 2=stereo) format: AudioFormatType = AudioFormat.S16, # output sample format; must be s16 (aiortc's Opus encoder requirement) audio_buffer_size_ms: int = 30000, # max audio to hold before dropping oldest + pace: bool = False, # True: recv() returns one frame per 20ms; False: at once ): super().__init__() if format != AudioFormat.S16: @@ -46,6 +52,13 @@ def __init__( self.channels = channels self.format = format self.audio_buffer_size_ms = audio_buffer_size_ms + self.pace = pace + # Set when a frame is queued or the track stops; wakes an unpaced recv(). + self._frame_queued = asyncio.Event() + # Replaced at each flush(); wait_for_flush() waits on the current one. + self._flushed = asyncio.Event() + # pts of the next unpaced frame, in samples. + self._next_pts = 0 self._frame_buffer: deque[av.AudioFrame] = deque() # Running per-channel sample total, to enforce the size cap cheaply. @@ -95,6 +108,7 @@ async def write(self, pcm: PcmData, final: bool = False) -> None: for frame in frames: self._frame_buffer.append(frame) self._buffered_samples += frame.samples + self._frame_queued.set() # Bound latency/memory: if the producer outran the consumer, drop the # oldest frames until back under the cap. @@ -109,13 +123,27 @@ async def flush(self) -> None: self._frame_buffer.clear() self._buffered_samples = 0 self._resampler.flush() + flushed, self._flushed = self._flushed, asyncio.Event() + flushed.set() + + async def wait_for_flush(self) -> None: + """Return at the next `flush()`.""" + await self._flushed.wait() + + def stop(self) -> None: + super().stop() + # Wakes an unpaced recv() that waits for a frame, so it can end. + self._frame_queued.set() async def recv(self) -> av.AudioFrame: - """Return the next 20ms frame, synthesizing silence when starved.""" + """Return the next 20ms frame; with pacing, synthesize silence when starved.""" # aiortc calls recv() in a loop; once the track is stopped, signal EOF. if self.readyState != "live": raise aiortc.mediastreams.MediaStreamError + if not self.pace: + return await self._recv_unpaced() + # Block until this frame is due, and get the pts to stamp on it. pts = await self._pacer.next_pts() @@ -131,6 +159,27 @@ async def recv(self) -> av.AudioFrame: self._silence, format="s16", layout=self._layout ) + return self._finish_frame(frame, pts) + + async def _recv_unpaced(self) -> av.AudioFrame: + """Return the next queued frame at once, waiting for a write when empty.""" + while True: + async with self._frame_lock: + if self._frame_buffer: + frame = self._frame_buffer.popleft() + self._buffered_samples -= frame.samples + break + # Cleared under the lock, so a write() cannot be missed. + self._frame_queued.clear() + await self._frame_queued.wait() + if self.readyState != "live": + raise aiortc.mediastreams.MediaStreamError + + pts = self._next_pts + self._next_pts += self._samples_per_frame + return self._finish_frame(frame, pts) + + def _finish_frame(self, frame: av.AudioFrame, pts: int) -> av.AudioFrame: # A flushed tail can be shorter than a full frame; pad it with trailing # silence so every emitted frame fills its fixed-rate pts slot. if frame.samples < self._samples_per_frame: diff --git a/getstream/video/rtc/connection_manager.py b/getstream/video/rtc/connection_manager.py index bb29f215..e22b1956 100644 --- a/getstream/video/rtc/connection_manager.py +++ b/getstream/video/rtc/connection_manager.py @@ -1,46 +1,27 @@ -import json import asyncio import logging -import uuid -import functools -from typing import Optional, Dict, Any +from typing import Any, Awaitable, Callable, Optional -import aioice import aiortc +import getstream_rtc from getstream.common import telemetry from getstream.utils import StreamAsyncIOEventEmitter -from getstream.video.rtc.coordinator.ws import StreamAPIWS -from getstream.video.rtc.pb.stream.video.sfu.event import events_pb2 from getstream.video.rtc.pb.stream.video.sfu.models import models_pb2 -from getstream.video.rtc.pb.stream.video.sfu.signal_rpc import signal_pb2 -from getstream.video.rtc.twirp_client_wrapper import SfuRpcError, SignalClient, Context from getstream.video.async_call import Call -from getstream.video.rtc.connection_utils import ( - ConnectionState, - SfuConnectionError, - SfuJoinError, - ConnectionOptions, - connect_websocket, - join_call, - watch_call, -) -from getstream.video.rtc.coordinator.backoff import exp_backoff +from getstream.video.rtc.audio_forwarder import AudioForwarder +from getstream.video.rtc.video_forwarder import VideoForwarder +from getstream.video.rtc.audio_track import AudioStreamTrack from getstream.video.rtc.track_util import ( - fix_sdp_msid_semantic, - fix_sdp_rtcp_fb, - parse_track_stream_mapping, + AudioFormat, + PcmData, ) -from getstream.video.rtc.network_monitor import NetworkMonitor -from getstream.video.rtc.recording import RecordingManager from getstream.video.rtc.participants import ParticipantsState -from getstream.video.rtc.tracks import SubscriptionConfig, SubscriptionManager -from getstream.video.rtc.reconnection import ReconnectionManager, ReconnectionStrategy -from getstream.video.rtc.peer_connection import PeerConnectionManager -from getstream.video.rtc.models import JoinCallResponse -from getstream.video.rtc.tracer import Tracer -from getstream.video.rtc.stats_reporter import SfuStatsReporter +from getstream.video.rtc.tracks import ( + SubscriptionConfig, + TrackSubscriptionConfig, +) logger = logging.getLogger(__name__) @@ -49,520 +30,258 @@ async def _log_event(event_type: str, data: Any): logger.debug(f"Received event {event_type}: {data}") +_AUDIO_TRACK_TYPES = ( + getstream_rtc.TrackType.AUDIO, + getstream_rtc.TrackType.SCREEN_SHARE_AUDIO, +) +# Events that each SDK event stream buffers while the event loop is blocked +# (SDK default 256); more makes a lag less likely and costs memory per call. +_CALL_EVENT_CAPACITY = 1024 +# The unit of `PcmFrame.pts`: the 48 kHz RTP clock of Opus. +_OPUS_TIME_BASE = 1 / 48000 +_TRACK_TYPES: dict[int, getstream_rtc.TrackType] = { + models_pb2.TRACK_TYPE_AUDIO: getstream_rtc.TrackType.AUDIO, + models_pb2.TRACK_TYPE_VIDEO: getstream_rtc.TrackType.VIDEO, + models_pb2.TRACK_TYPE_SCREEN_SHARE: getstream_rtc.TrackType.SCREEN_SHARE, + models_pb2.TRACK_TYPE_SCREEN_SHARE_AUDIO: getstream_rtc.TrackType.SCREEN_SHARE_AUDIO, +} + + +def _rust_subscription_config( + config: SubscriptionConfig, +) -> getstream_rtc.SubscriptionConfig: + return getstream_rtc.SubscriptionConfig( + default=_rust_track_subscription_config(config.default), + role_filters={ + role: _rust_track_subscription_config(rule) + for role, rule in config.role_filters.items() + }, + max_subscriptions=config.max_subscriptions, + ) + + +def _rust_track_subscription_config( + rule: TrackSubscriptionConfig, +) -> getstream_rtc.TrackSubscriptionConfig: + return getstream_rtc.TrackSubscriptionConfig( + # An unknown value matches no track, as in the aiortc version. + track_types=[ + _TRACK_TYPES[track_type] + for track_type in rule.track_types + if track_type in _TRACK_TYPES + ], + video_dimension=(rule.video_dimension.width, rule.video_dimension.height), + screenshare_dimension=( + rule.screenshare_dimension.width, + rule.screenshare_dimension.height, + ), + ) + + class ConnectionManager(StreamAsyncIOEventEmitter): """Main connection manager facade for video streaming.""" def __init__( self, call: Call, - user_id: Optional[str] = None, + user_id: str, create: bool = True, subscription_config: Optional[SubscriptionConfig] = None, - max_join_retries: int = 3, - drain_video_frames: bool = True, - **kwargs: Any, ): - """ - Args: - drain_video_frames: When True, attaches a MediaBlackhole to each - incoming video track so unconsumed frames are drained - automatically. This prevents unbounded queue growth in - RTCRtpReceiver when no subscriber is consuming the track. - The drain is stopped once a real subscriber is added via - add_track_subscriber. - """ super().__init__() + if call.id is None: + raise TypeError("the call has no id") # Public attributes self.call: Call = call - self.user_id: Optional[str] = user_id + self.user_id: str = user_id self.create: bool = create - self.kwargs: Dict[str, Any] = kwargs - self.running: bool = False - self.session_id: str = str(uuid.uuid4()) - self.join_response: Optional[JoinCallResponse] = None - self.local_sfu: bool = False # Local SFU flag for development - if max_join_retries < 0: - raise ValueError("max_join_retries must be >= 0") - self._max_join_retries: int = max_join_retries + + # Created before the join, so the tracks and events of the join are kept. + stream = call.client.stream + if stream.has_api_secret: + rust_client = getstream_rtc.Client( + stream.api_key, + stream.api_secret, + base_url=stream.base_url, + call_event_capacity=_CALL_EVENT_CAPACITY, + ) + else: + rust_client = getstream_rtc.Client( + stream.api_key, + token=stream.token, + base_url=stream.base_url, + call_event_capacity=_CALL_EVENT_CAPACITY, + ) + self._rust_call: getstream_rtc.Call = rust_client.call(call.call_type, call.id) + self._subscription_config = subscription_config + self._event_tasks: list[asyncio.Task] = [] + self._audio_tasks: set[asyncio.Task] = set() + # The forwarding task of the published track of each kind. + self._publish_tasks: dict[str, asyncio.Task] = {} + self._leaving: bool = False + self._call_ended_sent: bool = False # Private attributes - self._connection_state: ConnectionState = ConnectionState.IDLE self._stop_event: asyncio.Event = asyncio.Event() - self._connection_options: ConnectionOptions = ConnectionOptions() - self._ws_client = None - self._coordinator_ws_client = None # Initialize private managers self._participants_state: ParticipantsState = ParticipantsState() - self._recording_manager: RecordingManager = RecordingManager() - self._network_monitor: NetworkMonitor = NetworkMonitor(self) - self._reconnector: ReconnectionManager = ReconnectionManager(self) - self._subscription_manager: SubscriptionManager = SubscriptionManager( - self, subscription_config - ) - self._peer_manager: PeerConnectionManager = PeerConnectionManager( - self, drain_video_frames=drain_video_frames - ) - self.recording_manager = self._recording_manager self.participants_state = self._participants_state - self.reconnector = self._reconnector - - self.twirp_signaling_client = None - self.twirp_context: Optional[Context] = None - self._coordinator_task: Optional[asyncio.Task] = None - - # Stats tracing: generation counter (increments on reconnect), tracer, and reporter - self._sfu_client_tag: int = 0 # Generation counter, never resets during session - self._sfu_hostname: Optional[str] = None # Cached SFU hostname - self.tracer: Tracer = Tracer() - self.stats_reporter: Optional[SfuStatsReporter] = None @property - def connection_state(self) -> ConnectionState: + def connection_state(self) -> getstream_rtc.CallingState: """Get the current connection state.""" - return self._connection_state - - @connection_state.setter - def connection_state(self, state: ConnectionState): - """Set the connection state and emit state change event.""" - if state != self._connection_state: - old_state = self._connection_state - self._connection_state = state - # Schedule the emit as a background task since property setters cannot be async - self.emit("connection.state_changed", {"old": old_state, "new": state}) - - def pc_id(self, pc_type: str) -> str: - """Get PC ID for tracing. - - Args: - pc_type: "pub" for publisher or "sub" for subscriber - - Returns: - PC ID like "0-pub" or "0-sub" - """ - return f"{self._sfu_client_tag}-{pc_type}" - - def sfu_id(self) -> Optional[str]: - """Get SFU ID for tracing RPC calls and events. + return self._rust_call.calling_state - Returns: - SFU ID like "0-sfu-hostname.stream.com" or None if not set - """ - if self._sfu_hostname: - # Format: "{tag}-{edge_name}" where edge_name already includes "sfu-" prefix - return f"{self._sfu_client_tag}-{self._sfu_hostname}" - return None - - def _extract_sfu_hostname(self) -> Optional[str]: - """Extract SFU edge name from join response. - - Returns: - The SFU edge name (e.g., "sfu-dpk-london-...") or None if not available - """ - if self.join_response and self.join_response.credentials: - # Use edge_name directly - it already has the correct format like "sfu-dpk-london-..." - return self.join_response.credentials.server.edge_name - return None - - async def _on_ice_trickle(self, event): - """Handle ICE trickle from SFU.""" - logger.debug(f"Received ICE trickle for peer type {event.peer_type}") - - with telemetry.start_as_current_span("rtc.on_ice_trickle") as span: - try: - ice_candidate = json.loads(event.ice_candidate) - - candidate_sdp = ice_candidate.get("candidate") - span.set_attribute("candidate_sdp", candidate_sdp) - if not candidate_sdp: - return - - candidate = aiortc.rtcicetransport.candidate_from_aioice( - aioice.Candidate.from_sdp(candidate_sdp) - ) - candidate.sdpMid = ice_candidate.get("sdpMid") - candidate.sdpMLineIndex = ice_candidate.get("sdpMLineIndex") - - if ( - event.peer_type == models_pb2.PEER_TYPE_SUBSCRIBER - and self.subscriber_pc - ): - await self.subscriber_pc.addIceCandidate(candidate) - elif self.publisher_pc: - await self.publisher_pc.addIceCandidate(candidate) - except Exception as e: - logger.debug(f"Error handling ICE trickle: {e}") - - async def _on_subscriber_offer(self, event: events_pb2.SubscriberOffer): - logger.info("Subscriber offer received") - - # Offers can arrive after the subscriber peer connection has been - # torn down (slow asyncio loop under load, SFU sending a late - # renegotiation). `setRemoteDescription` would raise - # `InvalidStateError: Cannot handle offer in signaling state "closed"` - # and the exception propagates through the pyee error path, killing - # the session. Drop the offer instead — there is nothing to - # negotiate with a closed connection. - if self.subscriber_pc is None or self.subscriber_pc.signalingState == "closed": - logger.debug("Subscriber offer arrived after PC closed; dropping") - return - - with telemetry.start_as_current_span("rtc.on_subscriber_offer") as span: - await self.subscriber_negotiation_lock.acquire() - - try: - # Fix any invalid msid-semantic format in the SDP - fixed_sdp = fix_sdp_msid_semantic(event.sdp) - # Fix any invalid rtcp-fb lines - fixed_sdp = fix_sdp_rtcp_fb(fixed_sdp) - span.set_attribute("sdp", fixed_sdp) - # Parse SDP to create track_id to stream_id mapping - self.participants_state.set_track_stream_mapping( - parse_track_stream_mapping(fixed_sdp) - ) - # The SDP offer from the SFU might already contain candidates (trickled) - # or have a different structure. We set it as the remote description. - # The aiortc library handles merging and interpretation. - remote_description = aiortc.RTCSessionDescription( - type="offer", sdp=fixed_sdp - ) - logger.debug(f"""Setting remote description with SDP: - {remote_description.sdp}""") - span.set_attribute("remote_description.sdp", fixed_sdp) - - with telemetry.start_as_current_span( - "rtc.on_subscriber_offer.set_remote_description" - ): - await self.subscriber_pc.setRemoteDescription(remote_description) - - # Create the answer based on the remote offer (which includes our candidates) - with telemetry.start_as_current_span( - "rtc.on_subscriber_offer.create_answer" - ) as span: - answer = await self.subscriber_pc.createAnswer() - span.set_attribute("answer.sdp", answer.sdp) - - # Set the local description. aiortc will manage the SDP content. - with telemetry.start_as_current_span( - "rtc.on_subscriber_offer.set_local_description" - ) as span: - await self.subscriber_pc.setLocalDescription(answer) - - logger.debug( - f"""Sending answer with local description: - {self.subscriber_pc.localDescription.sdp}""" - ) - - try: - if self.twirp_signaling_client is None: - raise ValueError("twirp_signaling_client is not initialized") - await self.twirp_signaling_client.SendAnswer( - ctx=self.twirp_context, - request=signal_pb2.SendAnswerRequest( - peer_type=models_pb2.PEER_TYPE_SUBSCRIBER, - sdp=self.subscriber_pc.localDescription.sdp, - session_id=self.session_id, - ), - server_path_prefix="", # Note: Our wrapper doesn't need this, underlying client handles prefix - ) - logger.debug("Subscriber answer sent successfully.") - except SfuRpcError as e: - logger.error(f"Failed to send subscriber answer: {e}") - # Decide how to handle: maybe close connection, notify user, etc. - # For now, just log the error. - except Exception as e: - logger.error(f"Unexpected error sending subscriber answer: {e}") - finally: - self.subscriber_negotiation_lock.release() - - async def _on_signaling_connection_lost(self, reason: str) -> None: - """Reconnect when the signaling WebSocket drops unexpectedly. - - The WebSocketClient itself only logs the error and stops; it has - no reconnect of its own. This handler bridges that gap by routing - the loss into the existing `ReconnectionManager`, so a transient - TCP reset or a missed health check no longer means a dead session. - """ - if not self.running: - return - logger.warning(f"Signaling WS lost; triggering reconnect: {reason}") + def emit(self, event: str, *args: Any, **kwargs: Any) -> bool: + """Calls the handlers of `event`. A handler that raises is logged, so it + cannot stop the task that emits.""" try: - await self._reconnector.reconnect( - strategy=ReconnectionStrategy.FAST, - reason=f"signaling ws lost: {reason}", - ) + return super().emit(event, *args, **kwargs) except Exception: - logger.exception("Reconnect after signaling WS loss failed") - - async def _connect_coordinator_ws(self): - """ - Connects to the coordinator websocket and subscribes to events. - """ + logger.exception(f"A {event!r} handler failed") + return True - with telemetry.start_as_current_span( - "coordinator-setup", - ): - with telemetry.start_as_current_span( - "coordinator-ws-connect", - ): - stream = self.call.client.stream - self._coordinator_ws_client = StreamAPIWS( - call=self.call, - user_details={"id": self.user_id}, - user_token=None if stream.has_api_secret else stream.token, - ) - self._coordinator_ws_client.on_wildcard("*", _log_event) - self._coordinator_ws_client.on( - "custom", functools.partial(self.emit, "custom") - ) - await self._coordinator_ws_client.connect() - - with telemetry.start_as_current_span( - "watch-call", - ): - if self.user_id is None: - raise ValueError("user_id is required for watching a call") - if self._coordinator_ws_client._client_id is None: - raise ValueError("coordinator ws client_id is not set") - await watch_call( - self.call, self.user_id, self._coordinator_ws_client._client_id - ) - - async def _connect_internal( - self, - region: Optional[str] = None, - ws_url: Optional[str] = None, - token: Optional[str] = None, - session_id: Optional[str] = None, - migrating_from_list: Optional[list] = None, - ) -> None: + @telemetry.with_span("connect") + async def connect(self): """ - Internal connection method that handles the core connection logic. - - Args: - region: Optional region to connect to - ws_url: Optional WebSocket URL to connect to - token: Optional authentication token - session_id: Optional session ID + Join the call. The SDK retries the join and chooses the SFU. - Raises: - SfuConnectionError: If connection fails + Calls `getstream_rtc.configure_logging` with the "getstream" logger and + its effective level, which replaces an earlier configuration. To use + other settings, call `getstream_rtc.configure_logging` after this. """ - self.connection_state = ConnectionState.JOINING - - # Step 1: Determine region - # with telemetry.start_as_current_span( - # "location-discovery", - # ) as span: - # if not region: - # try: - # region = HTTPHintLocationDiscovery(logger=logger).discover() - # except Exception as e: - # logger.warning(f"Failed to discover location: {e}") - # location = "FRA" - # logger.debug(f"Using location: {region}") - # location = region - # span.set_attribute("location", location) - - # Step 2: Join call via coordinator - with telemetry.start_as_current_span( - "coordinator-join-call", - ) as span: - if not (ws_url or token): - if self.user_id is None: - raise ValueError("user_id is required for joining a call") - last_failed = migrating_from_list[-1] if migrating_from_list else None - join_response = await join_call( - self.call, - self.user_id, - "auto", - self.create, - self.local_sfu, - migrating_from=last_failed, - migrating_from_list=migrating_from_list, - **self.kwargs, - ) - ws_url = join_response.data.credentials.server.ws_endpoint - token = join_response.data.credentials.token - self.join_response = join_response.data - # Extract and cache SFU hostname for tracing - self._sfu_hostname = self._extract_sfu_hostname() - logger.debug(f"coordinator join response: {join_response.data}") - span.set_attribute( - "credentials", join_response.data.credentials.to_json() - ) - - # Use provided session_id or current one - current_session_id = session_id or self.session_id - - await self._peer_manager.setup_subscriber() - - # Step 3: Connect to WebSocket - if not token or not ws_url: - raise ValueError("token and ws_url are required for WebSocket connection") + logger.info("Joining the call") + # Process-wide. Rust drops records below this level before it formats + # them, so a later level change applies at the next connect(). + sdk_logger = logging.getLogger("getstream") + getstream_rtc.configure_logging(sdk_logger, sdk_logger.getEffectiveLevel()) + # The streams are created before the join, so the events and tracks of + # the join are kept. Both end when the call ends or is left. + call = self._rust_call + tasks = [ + asyncio.create_task(self._emit_sfu_events(call.sfu_events())), + asyncio.create_task( + self._emit_coordinator_events(call.coordinator_events()) + ), + asyncio.create_task(self._emit_client_events(call.client_events())), + asyncio.create_task(self._emit_tracks(call.tracks())), + ] try: - with telemetry.start_as_current_span( - "sfu-signaling-ws-connect", - ) as span: - self._ws_client, sfu_event = await connect_websocket( - token=token, - ws_url=ws_url, - session_id=current_session_id, - options=self._connection_options, - tracer=self.tracer, - sfu_id_fn=self.sfu_id, + await call.join(self.user_id, self.create) + # Includes this participant, which gets no `participant_joined`. + for participant in call.participants(): + self._participants_state._add_participant(participant) + # Without a config nothing is requested, as in the aiortc version; + # the SFU sends remote audio without a request. + if self._subscription_config is not None: + await call.update_subscriptions( + _rust_subscription_config(self._subscription_config) ) - - self._ws_client.on_wildcard("*", _log_event) - self._ws_client.on_event("ice_trickle", self._on_ice_trickle) - - # Connect track subscription events to subscription manager - self._ws_client.on_event( - "participant_joined", self.participants_state._on_participant_joined - ) - self._ws_client.on_event( - "participant_left", self.participants_state._on_participant_left - ) - self._ws_client.on_event( - "track_published", self._subscription_manager.handle_track_published - ) - self._ws_client.on_event( - "track_unpublished", self._subscription_manager.handle_track_unpublished - ) - - # Connect subscriber offer event to handle SDP negotiation - self._ws_client.on_event("subscriber_offer", self._on_subscriber_offer) - - # Drive reconnection when the signaling WS drops outside of an - # SFU-level error event (raw socket close, health-check timeout, - # transport-level exceptions). Without this handler the - # WebSocketClient just logs and stops; the session sits hanging - # until the frontend times out and tears it down. - self._ws_client.on_event( - "connection_lost", self._on_signaling_connection_lost - ) - - # Re-emit the events so they can be subscribed to on the ConnectionManager - self._ws_client.on_wildcard("*", self.emit) - - if hasattr(sfu_event, "join_response"): - logger.debug(f"sfu join response: {sfu_event.join_response}") - # Populate participants state with existing participants - if hasattr(sfu_event.join_response, "call_state"): - for participant in sfu_event.join_response.call_state.participants: - self._participants_state._add_participant(participant) - # Update reconnection config - if hasattr(sfu_event.join_response, "fast_reconnect_deadline_seconds"): - self._reconnector._fast_reconnect_deadline_seconds = ( - sfu_event.join_response.fast_reconnect_deadline_seconds - ) - else: - logger.exception(f"No join response from WebSocket: {sfu_event}") - - logger.debug(f"WebSocket connected successfully to {ws_url}") - except SfuJoinError: + except BaseException: + for task in tasks: + task.cancel() + # A failure after the SDK join must not keep this session in the call. + await call.leave() raise - except Exception as e: - logger.exception(f"Failed to connect WebSocket to {ws_url}: {e}") - raise SfuConnectionError(f"WebSocket connection failed: {e}") from e - - # Step 5: Create SFU signaling client with tracer - if self.join_response is None: - raise ValueError("join_response is not set") - twirp_server_url = self.join_response.credentials.server.url - self.twirp_signaling_client = SignalClient( - address=twirp_server_url, - tracer=self.tracer, - sfu_id_fn=self.sfu_id, - ) - self.twirp_context = Context(headers={"authorization": token}) - - # Start stats reporter - self.stats_reporter = SfuStatsReporter(self) - self.stats_reporter.start() - - # Mark as connected - self.running = True - self.connection_state = ConnectionState.JOINED - self._stop_event.clear() - - logger.info("Successfully connected to SFU") - - @telemetry.with_span("connect") - async def connect(self): - """ - Connect to SFU. - - This method automatically handles retry logic for transient errors - like "server is full" by requesting a different SFU from the - coordinator. - """ - logger.info("Connecting to SFU") - # Fire-and-forget the coordinator WS connection so we don't block here - if self._coordinator_task is None or self._coordinator_task.done(): - self._coordinator_task = asyncio.create_task( - self._connect_coordinator_ws(), name="coordinator-ws-connect" - ) - - def _on_coordinator_task_done(task: asyncio.Task): - try: - task.result() - except asyncio.CancelledError: - pass - except Exception: - logger.exception("Coordinator WS task failed") + self._event_tasks = tasks + + async def _emit_sfu_events(self, events: getstream_rtc.EventStream) -> None: + async for event in events: + if isinstance(event, getstream_rtc.EventsLagged): + # The lost events can include participant changes. + self._participants_state._replace_participants( + self._rust_call.participants() + ) + continue + if isinstance(event, getstream_rtc.ParticipantJoined): + await self._participants_state._on_participant_joined(event) + elif isinstance(event, getstream_rtc.ParticipantLeft): + await self._participants_state._on_participant_left(event) + if isinstance(event, getstream_rtc.CallEnded): + self._emit_call_ended(event) + else: + self.emit(event.name, event) + # The SDK leaves on the SFU `call_ended` and on the coordinator + # `call.ended`; after the latter the SFU `call_ended` may not come. The + # stream ends after its buffered events, so a SFU `call_ended` is + # emitted before this one. + if not self._leaving: + self._emit_call_ended(getstream_rtc.CallEnded()) + self._stop_event.set() - self._coordinator_task.add_done_callback(_on_coordinator_task_done) + async def _emit_coordinator_events(self, events: getstream_rtc.EventStream) -> None: + async for event in events: + # The stream gives only `CoordinatorEvent` and `EventsLagged`. + if not isinstance(event, getstream_rtc.CoordinatorEvent): + continue + # Of the coordinator events, only `custom` is emitted, as its dict. + if event.name == "custom": + self.emit("custom", event.data) + else: + await _log_event(event.name, event.data) - await self._connect_with_sfu_reassignment() + async def _emit_client_events(self, events: getstream_rtc.EventStream) -> None: + old = getstream_rtc.CallingState.IDLE + async for event in events: + # The stream gives only `CallingStateChanged` and `EventsLagged`. + if not isinstance(event, getstream_rtc.CallingStateChanged): + continue + # A reconnect can change the local session, and the SDK sends no + # events for the local sessions. + if ( + event.state == getstream_rtc.CallingState.JOINED + and old != getstream_rtc.CallingState.JOINING + ): + self._participants_state._replace_participants( + self._rust_call.participants() + ) + self.emit("connection.state_changed", {"old": old, "new": event.state}) + old = event.state - async def _connect_with_sfu_reassignment(self) -> None: - """Try connecting to SFU, reassigning to a different one on failure.""" - failed_sfus: list[str] = [] + def _emit_call_ended(self, event: getstream_rtc.CallEnded) -> None: + if not self._call_ended_sent: + self._call_ended_sent = True + self.emit("call_ended", event) - # First attempt without delay - attempt = 0 - try: - await self._connect_internal() - return - except SfuJoinError as e: - self._handle_join_failure(e, attempt, failed_sfus) - if self._max_join_retries == 0: - raise - - # Retries with exponential backoff, requesting a different SFU - async for delay in exp_backoff(max_retries=self._max_join_retries, base=0.5): - attempt += 1 - logger.info(f"Retrying in {delay}s with different SFU...") - await asyncio.sleep(delay) + async def _emit_tracks(self, tracks: getstream_rtc.TrackStream) -> None: + while True: try: - await self._connect_internal( - migrating_from_list=failed_sfus if failed_sfus else None, - ) + track = await anext(tracks) + except StopAsyncIteration: return - except SfuJoinError as e: - self._handle_join_failure(e, attempt, failed_sfus) - if attempt >= self._max_join_retries: - raise - - def _handle_join_failure( - self, error: SfuJoinError, attempt: int, failed_sfus: list[str] - ) -> None: - """Track a failed SFU and clean up partial connection state.""" - if self.join_response and self.join_response.credentials: - edge = self.join_response.credentials.server.edge_name - if edge and edge not in failed_sfus: - failed_sfus.append(edge) - logger.warning( - f"SFU join failed (attempt {attempt + 1}/{1 + self._max_join_retries}, " - f"code={error.error_code}). Failed SFUs: {failed_sfus}" - ) - if self._ws_client: - self._ws_client.close() - self._ws_client = None - self.connection_state = ConnectionState.IDLE + except getstream_rtc.RtcError as error: + # Raised once for each overflow of the track queue; the SDK + # unsubscribed from the dropped tracks. + logger.warning(str(error)) + continue + self.emit("track_added", track) + if track.track_type in _AUDIO_TRACK_TYPES: + task = asyncio.create_task(self._emit_audio(track)) + self._audio_tasks.add(task) + task.add_done_callback(self._audio_tasks.discard) + # Holds no reference: dropping the last one unsubscribes the track. + del track + + async def _emit_audio(self, track: getstream_rtc.RemoteTrack) -> None: + # Each read makes a new Python object. + participant = track.participant + # Reads every frame of the track, so no other reader may read it. + while (frame := await track.next_pcm()) is not None: + self.emit( + "audio", + PcmData( + sample_rate=frame.sample_rate, + format=AudioFormat.S16, + samples=frame.samples, + channels=frame.channels, + pts=frame.pts, + time_base=_OPUS_TIME_BASE, + participant=participant, + ), + ) async def wait(self): """ @@ -580,185 +299,111 @@ async def wait(self): async def leave(self): """Gracefully leave the call and close connections.""" logger.info("Leaving the call") - self.running = False + self._leaving = True self._stop_event.set() - - # Flush and stop stats reporter before cleaning up connections - if self.stats_reporter: - self.stats_reporter.flush() - await self.stats_reporter.stop() - self.stats_reporter = None - - await self._recording_manager.cleanup() - await self._network_monitor.stop_monitoring() - await self._peer_manager.close() - if self._ws_client: - self._ws_client.close() - self._ws_client = None - if self._coordinator_task and not self._coordinator_task.done(): - self._coordinator_task.cancel() - try: - await self._coordinator_task - except asyncio.CancelledError: - pass - finally: - self._coordinator_task = None - - if self._coordinator_ws_client: - await self._coordinator_ws_client.disconnect() - self._coordinator_ws_client = None - - self.connection_state = ConnectionState.LEFT - + try: + await self._rust_call.leave() + await asyncio.gather(*self._event_tasks) + finally: + media_tasks = [*self._audio_tasks, *self._publish_tasks.values()] + for task in media_tasks: + task.cancel() + if media_tasks: + await asyncio.wait(media_tasks) logger.info("Call left and connections closed") async def __aenter__(self): """Async context manager entry.""" - # Register network event handlers - self._network_monitor.register_event_handlers() - - # Connect with retry await self.connect() - - # Start network monitoring - await self._network_monitor.start_monitoring() return self async def __aexit__(self, exc_type, exc_val, exc_tb): """Async context manager exit.""" await self.leave() - async def add_tracks(self, audio=None, video=None): - """Add multiple audio and video tracks in a single negotiation.""" + async def add_tracks( + self, + audio: Optional[aiortc.MediaStreamTrack] = None, + video: Optional[aiortc.MediaStreamTrack] = None, + ) -> None: + """Publish `audio` and `video`, aiortc tracks. + + The SDK paces the audio and sends it as mono, so the audio track need not + be paced (`AudioStreamTrack(pace=False)`). The video track paces itself; + it is sent as VP9 at the size of its first frame. A track that ends is + unpublished. A new track replaces the published track of its kind. + + When the `send-audio` or `send-video` capability is removed, the SDK + unpublishes the track of that kind; when the SFU removes the grant + (`call_grants_updated`), it mutes the track. It does not publish or + unmute the track when the permission is given back: call `add_tracks()` + again. + """ with telemetry.start_as_current_span("rtc.add_tracks"): - await self._peer_manager.add_tracks(audio, video) - - async def start_recording( - self, recording_types, user_ids=None, output_dir="recordings" - ): - """Start recording.""" - logger.info("Starting recording") - await self._recording_manager.start_recording( - recording_types, user_ids, output_dir + if audio is not None: + await self._publish_audio(audio) + if video is not None: + await self._publish_video(video) + + async def _publish_audio(self, track: aiortc.MediaStreamTrack) -> None: + # The SDK queue holds the backlog, so it gets the track's buffer size. + capacity = ( + track.audio_buffer_size_ms / 1000 + if isinstance(track, AudioStreamTrack) + else None + ) + rust_track = getstream_rtc.LocalAudioTrack(pcm_queue_capacity=capacity) + # The SFU has one publish option for each kind of track. + await self._stop_forwarding("audio") + await self._rust_call.publish_audio(rust_track) + self._start_forwarding( + "audio", + AudioForwarder(track, rust_track), + self._rust_call.stop_publish_audio, ) - async def stop_recording(self, recording_types=None, user_ids=None): - """Stop recording.""" - logger.info("Stopping recording") - await self._recording_manager.stop_recording(recording_types, user_ids) - - @property - def is_recording(self) -> bool: - """Check if recording is active.""" - return self._recording_manager.is_recording - - def get_recording_status(self) -> dict: - """Get current recording status.""" - return self._recording_manager.get_recording_status() - - # WebSocket client helper - @property - def ws_client(self): - return self._ws_client - - @ws_client.setter - def ws_client(self, value): - self._ws_client = value - - # Publisher / Subscriber peer-connection shortcuts - @property - def publisher_pc(self): - return self._peer_manager.publisher_pc - - @publisher_pc.setter - def publisher_pc(self, value): - self._peer_manager.publisher_pc = value - - @property - def subscriber_pc(self): - return self._peer_manager.subscriber_pc - - @subscriber_pc.setter - def subscriber_pc(self, value): - self._peer_manager.subscriber_pc = value - - # Negotiation locks + async def _publish_video(self, track: aiortc.MediaStreamTrack) -> None: + # VP9 is the SFU's default publish option for camera video; no other + # codec is requested at join. + rust_track = getstream_rtc.LocalVideoTrack.vp9() + await self._stop_forwarding("video") + await self._rust_call.publish_video(rust_track) + self._start_forwarding( + "video", + VideoForwarder(track, rust_track), + self._rust_call.stop_publish_video, + ) - @property - def publisher_negotiation_lock(self): - return self._peer_manager.publisher_negotiation_lock + def _start_forwarding( + self, + kind: str, + forwarder: AudioForwarder | VideoForwarder, + stop_publish: Callable[[Any], Awaitable[None]], + ) -> None: + # leave() may have cancelled the publish tasks already, and the SDK + # leave stops the published track. + if self._leaving: + return + self._publish_tasks[kind] = asyncio.create_task( + self._forward(forwarder, stop_publish) + ) - @property - def subscriber_negotiation_lock(self): - return self._peer_manager.subscriber_negotiation_lock + async def _stop_forwarding(self, kind: str) -> None: + """Unpublish the published track of `kind`, if there is one.""" + task = self._publish_tasks.pop(kind, None) + if task is not None: + task.cancel() + await asyncio.wait([task]) - async def _cleanup_connections( - self, ws_client=None, publisher_pc=None, subscriber_pc=None - ): - """Close provided connections safely; used by ReconnectionManager.""" + async def _forward( + self, + forwarder: AudioForwarder | VideoForwarder, + stop_publish: Callable[[Any], Awaitable[None]], + ) -> None: try: - # Close peer connections (async) - tasks = [] - if publisher_pc: - tasks.append(publisher_pc.close()) - if subscriber_pc: - tasks.append(subscriber_pc.close()) - - if tasks: - await asyncio.gather(*tasks, return_exceptions=True) - - # Close WebSocket client (sync) - if ws_client: - try: - ws_client.close() - except Exception: - logger.debug("Error closing old WebSocket client", exc_info=True) + await forwarder.run() except Exception: - logger.debug("Error during _cleanup_connections", exc_info=True) - - async def _restore_published_tracks(self): - """Delegate restoration of previously published tracks to the peer manager.""" - try: - await self._peer_manager.restore_published_tracks() - except Exception as e: - logger.error("Failed to restore published tracks", exc_info=e) - - async def republish_tracks(self) -> None: - """ - Use the participants info from the SFU to re-emit the "track_published" - events for the already published tracks. - - It's needed because SFU does not send the events for the already present tracks when the - agent joins after the user. - """ - - if not self._ws_client: - return None - - participants = self.participants_state.get_participants() - - for participant in participants: - # Skip the tracks belonging to this connection - if participant.session_id == self.session_id: - continue - - for track_type_int in participant.published_tracks: - event = events_pb2.TrackPublished( - user_id=participant.user_id, - session_id=participant.session_id, - participant=participant, - type=track_type_int, - ) - try: - # Update track subscriptions first - await self._subscription_manager.handle_track_published(event) - # Emit the event downstream - self.emit("track_published", event) - except Exception: - logger.exception( - f"Failed to emit track_published event " - f"for the already published " - f"track {participant.user_id}:{participant.session_id}:{track_type_int}" - ) - - return None + logger.exception("The forwarding of a published track failed") + finally: + # The source track ended, or the forwarding was cancelled. + await stop_publish(forwarder.target) diff --git a/getstream/video/rtc/connection_utils.py b/getstream/video/rtc/connection_utils.py deleted file mode 100644 index 260ef7a8..00000000 --- a/getstream/video/rtc/connection_utils.py +++ /dev/null @@ -1,476 +0,0 @@ -""" -Connection utilities for video streaming. - -This module provides core connection-related functionality including: -- Connection state management -- Connection options -- Call coordination -- Track info preparation -- WebSocket URL handling -""" - -import asyncio -import logging -import platform -from dataclasses import dataclass -from enum import Enum -from typing import TYPE_CHECKING, Any, Callable, Optional, Tuple - -if TYPE_CHECKING: - from getstream.video.rtc.tracer import Tracer - -import aiortc - -from getstream.base import StreamResponse -from getstream.models import CallRequest -from getstream.utils import build_body_dict, build_query_param -from getstream.version import VERSION -from getstream.video.async_call import Call -from getstream.video.rtc.models import JoinCallResponse -from getstream.video.rtc.pb.stream.video.sfu.event import events_pb2 -from getstream.video.rtc.pb.stream.video.sfu.models.models_pb2 import ( - TRACK_TYPE_AUDIO, - TRACK_TYPE_VIDEO, - ClientDetails, - Device, - OS, - Sdk, - TrackInfo, - VideoDimension, - VideoLayer, -) -from getstream.video.rtc.signaling import SignalingError, WebSocketClient -from getstream.video.rtc.track_util import BufferedMediaTrack, detect_video_properties - -logger = logging.getLogger(__name__) - -# Public API - only these should be used outside this module -__all__ = [ - "ConnectionState", - "SfuConnectionError", - "ConnectionOptions", - "join_call", - "join_call_coordinator_request", - "create_join_request", - "prepare_video_track_info", - "create_audio_track_info", - "get_websocket_url", - "connect_websocket", -] - - -# Public classes and exceptions -class ConnectionState(Enum): - """Enumeration of possible connection states.""" - - IDLE = "idle" - JOINING = "joining" - JOINED = "joined" - RINGING = "ringing" - RECONNECTING = "reconnecting" - MIGRATING = "migrating" - OFFLINE = "offline" - RECONNECTING_FAILED = "reconnecting_failed" - LEFT = "left" - - -class SfuConnectionError(Exception): - """Exception raised when SFU connection fails.""" - - pass - - -class SfuJoinError(SfuConnectionError): - """Raised when SFU join fails with a retryable error code.""" - - def __init__(self, message: str, error_code: int = 0, should_retry: bool = False): - super().__init__(message) - self.error_code = error_code - self.should_retry = should_retry - - -_RETRYABLE_SFU_ERROR_CODES = { - 700, # ERROR_CODE_SFU_FULL - 600, # ERROR_CODE_SFU_SHUTTING_DOWN - 301, # ERROR_CODE_CALL_PARTICIPANT_LIMIT_REACHED -} - - -@dataclass -class ConnectionOptions: - """Options for the connection process.""" - - join_response: Optional[Any] = None - region: Optional[str] = None - fast_reconnect: bool = False - migrating_from: Optional[str] = None - previous_session_id: Optional[str] = None - - -async def watch_call(call: Call, user_id: str, connection_id: str): - stream = call.client.stream - token = ( - stream.create_token(user_id=user_id) if stream.has_api_secret else stream.token - ) - # Make the POST request to join the call - async with stream.clone_for_token(token) as client: - return await client.post( - "/api/v2/video/call/{type}/{id}", - JoinCallResponse, - path_params={ - "type": call.call_type, - "id": call.id, - }, - query_params=build_query_param(connection_id=connection_id), - ) - - -async def join_call( - call: Call, - user_id: str, - location: str, - create: bool, - local_sfu: bool, - **kwargs, -) -> StreamResponse[JoinCallResponse]: - """Join call via coordinator API.""" - try: - join_response = await join_call_coordinator_request( - call, - user_id, - location=location, - create=create, - **kwargs, - ) - if local_sfu: - join_response.data.credentials.server.url = "http://127.0.0.1:3031/twirp" - join_response.data.credentials.server.ws_endpoint = "ws://127.0.0.1:3031/ws" - - logger.debug( - f"Received SFU credentials: {join_response.data.credentials.server}" - ) - - return join_response - - except Exception as e: - logger.error(f"Failed to join call via coordinator: {e}") - raise SfuConnectionError(f"Failed to join call: {e}") - - -async def join_call_coordinator_request( - call: Call, - user_id: str, - create: bool = False, - data: Optional[CallRequest] = None, - ring: Optional[bool] = None, - notify: Optional[bool] = None, - video: Optional[bool] = None, - location: Optional[str] = None, - migrating_from: Optional[str] = None, - migrating_from_list: Optional[list] = None, -) -> StreamResponse[JoinCallResponse]: - """Make a request to join a call via the coordinator. - - Args: - call: The call to join - user_id: The user ID to join the call with - create: Whether to create the call if it doesn't exist - data: Additional call data if creating - ring: Whether to ring other users - notify: Whether to notify other users - video: Whether to enable video - location: The preferred location - - Returns: - A response containing the call information and credentials - """ - - # Prepare path parameters for the request - path_params = { - "type": call.call_type, - "id": call.id, - } - - # Build the request body - json_body = build_body_dict( - location=location, - create=create, - notify=notify, - ring=ring, - video=video, - data=data, - ) - if migrating_from: - json_body["migrating_from"] = migrating_from - if migrating_from_list: - json_body["migrating_from_list"] = migrating_from_list - - # Make the POST request to join the call - stream = call.client.stream - # Create a new client instance with the token for the given user_id - token = ( - stream.create_token(user_id=user_id) if stream.has_api_secret else stream.token - ) - client = stream.clone_for_token(token) - async with client: - return await client.post( - "/api/v2/video/call/{type}/{id}/join", - JoinCallResponse, - path_params=path_params, - json=json_body, - ) - - -async def create_join_request(token: str, session_id: str) -> events_pb2.JoinRequest: - """Create a JoinRequest protobuf message for the WebSocket connection. - - Args: - token: The token for the user - session_id: The session ID for this connection - - Returns: - A JoinRequest protobuf message configured with data - """ - - from getstream.video.rtc.pc import ( - subscribe_codec_preferences, - publish_codec_preferences, - ) - - # Create a JoinRequest - join_request = events_pb2.JoinRequest() - join_request.token = token - join_request.session_id = session_id - - # Add client details for SDK identification in dashboard - version_parts = VERSION.split(".") - major = version_parts[0] if len(version_parts) > 0 else "0" - minor = version_parts[1] if len(version_parts) > 1 else "0" - patch = version_parts[2] if len(version_parts) > 2 else "0" - - join_request.client_details.CopyFrom( - ClientDetails( - sdk=Sdk(major=major, minor=minor, patch=patch), - os=OS( - name=platform.system(), - version=platform.release(), - architecture=platform.machine(), - ), - device=Device(name="Python", version=platform.python_version()), - ) - ) - - # Create generic SDPs for send and recv - temp_pub_pc = aiortc.RTCPeerConnection() - temp_sub_pc = aiortc.RTCPeerConnection() - temp_pub_pc.addTransceiver(direction="sendonly", trackOrKind="video") - temp_pub_pc.addTransceiver(direction="sendonly", trackOrKind="audio") - temp_sub_pc.addTransceiver(direction="recvonly", trackOrKind="video") - temp_sub_pc.addTransceiver(direction="recvonly", trackOrKind="audio") - - for transceiver in temp_pub_pc.getTransceivers(): - if transceiver.kind == "video": - transceiver.setCodecPreferences(publish_codec_preferences()) - - for transceiver in temp_sub_pc.getTransceivers(): - if transceiver.kind == "video": - transceiver.setCodecPreferences(subscribe_codec_preferences()) - - pub_offer = await temp_pub_pc.createOffer() - sub_offer = await temp_sub_pc.createOffer() - join_request.publisher_sdp = pub_offer.sdp - join_request.subscriber_sdp = sub_offer.sdp - - for transceiver in temp_pub_pc.getTransceivers(): - await transceiver.stop() - for transceiver in temp_sub_pc.getTransceivers(): - await transceiver.stop() - - await temp_pub_pc.close() - await temp_sub_pc.close() - - return join_request - - -async def prepare_video_track_info( - video: aiortc.mediastreams.MediaStreamTrack, -) -> Tuple[TrackInfo, aiortc.mediastreams.MediaStreamTrack]: - """Prepare video track info by detecting its properties. - - Args: - video: A video MediaStreamTrack - - Returns: - A tuple of (TrackInfo, buffered_video_track) - - Raises: - Exception: If video property detection fails - """ - buffered_video = None - - try: - # Wrap the video track to buffer peeked frames - buffered_video = BufferedMediaTrack(video) - - # Detect video properties - with timeout to avoid hanging - video_props = await asyncio.wait_for( - detect_video_properties(buffered_video), timeout=3.0 - ) - - video_info = TrackInfo( - track_id=buffered_video.id, - track_type=TRACK_TYPE_VIDEO, - layers=[ - VideoLayer( - rid="f", - video_dimension=VideoDimension( - width=video_props["width"], - height=video_props["height"], - ), - bitrate=video_props["bitrate"], - fps=video_props["fps"], - ), - ], - ) - - # Return both the track info and the buffered track - return video_info, buffered_video - - except asyncio.TimeoutError: - logger.error("Timeout detecting video properties") - # Fall back to default properties - if buffered_video: - video_info = TrackInfo( - track_id=buffered_video.id, - track_type=TRACK_TYPE_VIDEO, - layers=[ - VideoLayer( - rid="f", - video_dimension=VideoDimension( - width=1280, - height=720, - ), - bitrate=1500, - fps=30, - ), - ], - ) - return video_info, buffered_video - raise - except Exception as e: - logger.error(f"Error preparing video track: {e}") - # Clean up on error - if buffered_video: - try: - buffered_video.stop() - except Exception as e: - logger.error(f"Error stopping buffered video: {e}") - # Re-raise the exception - raise - - -def create_audio_track_info(audio: aiortc.mediastreams.MediaStreamTrack) -> TrackInfo: - """Create track info for an audio track. - - Args: - audio: An audio MediaStreamTrack - - Returns: - A TrackInfo object for the audio track - """ - return TrackInfo( - track_id=audio.id, - track_type=TRACK_TYPE_AUDIO, - ) - - -def get_websocket_url(join_response, local_sfu: bool = False) -> str: - """Get the WebSocket URL from join response. - - Args: - join_response: The response from the coordinator join call - local_sfu: Whether to use local SFU URL for development - - Returns: - The WebSocket URL to connect to - """ - if local_sfu: - return "ws://127.0.0.1:3031/ws" - return join_response.data.credentials.server.ws_endpoint - - -async def connect_websocket( - token: str, - ws_url: str, - session_id: str, - options: ConnectionOptions, - tracer: Optional["Tracer"] = None, - sfu_id_fn: Optional[Callable[[], Optional[str]]] = None, -) -> Tuple[WebSocketClient, events_pb2.SfuEvent]: - """ - Connect to the WebSocket server. - - Args: - token: Authentication token - ws_url: WebSocket URL to connect to - session_id: Session ID for this connection - options: Connection options - tracer: Optional tracer for SFU event tracing - sfu_id_fn: Optional function that returns the current SFU ID for tracing - - Returns: - Tuple of (WebSocket client, initial SFU event) - - Raises: - SignalingError: If connection fails - """ - logger.info(f"Connecting to WebSocket at {ws_url}") - - ws_client = None - success = False - try: - # Create JoinRequest for WebSocket connection - join_request = await create_join_request(token, session_id) - - # Apply reconnect options if provided - if options.fast_reconnect: - join_request.fast_reconnect = True - if options.migrating_from: - join_request.reconnect_details.migrating_from = options.migrating_from # type: ignore[attr-defined] - if options.previous_session_id: - join_request.reconnect_details.previous_session_id = ( - options.previous_session_id - ) - - # Create and connect WebSocket client - ws_client = WebSocketClient( - ws_url, - join_request, - asyncio.get_running_loop(), - tracer=tracer, - sfu_id_fn=sfu_id_fn, - ) - sfu_event = await ws_client.connect() - - logger.debug("WebSocket connection established") - success = True - return ws_client, sfu_event - - except SignalingError as e: - if ( - e.error - and hasattr(e.error, "code") - and e.error.code in _RETRYABLE_SFU_ERROR_CODES - ): - raise SfuJoinError( - str(e), - error_code=e.error.code, - should_retry=True, - ) from e - raise - except Exception as e: - logger.error(f"Failed to connect WebSocket to {ws_url}: {e}") - raise SignalingError(f"WebSocket connection failed: {e}") - finally: - if ws_client and not success: - ws_client.close() diff --git a/getstream/video/rtc/coordinator/__init__.py b/getstream/video/rtc/coordinator/__init__.py deleted file mode 100644 index 60cb4858..00000000 --- a/getstream/video/rtc/coordinator/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -""" -WebSocket client for Stream Video Coordinator. - -This module provides the StreamAPIWS class for connecting to Stream's Video Coordinator -via WebSocket for real-time communication. -""" - -from .ws import StreamAPIWS # noqa: F401 diff --git a/getstream/video/rtc/coordinator/backoff.py b/getstream/video/rtc/coordinator/backoff.py deleted file mode 100644 index 9d990c6b..00000000 --- a/getstream/video/rtc/coordinator/backoff.py +++ /dev/null @@ -1,42 +0,0 @@ -""" -Exponential backoff implementation for WebSocket reconnection. - -This module provides utilities for implementing exponential backoff strategies -when reconnecting to failed WebSocket connections. -""" - -import logging -from typing import AsyncIterator - -logger = logging.getLogger(__name__) - - -async def exp_backoff( - max_retries: int, base: float = 1.0, factor: float = 2.0 -) -> AsyncIterator[float]: - """ - Generate exponential backoff delays for retry attempts. - - Args: - max_retries: Maximum number of retry attempts - base: Base delay in seconds for the first retry - factor: Multiplicative factor for each subsequent retry - - Yields: - float: Delay in seconds for each retry attempt - - Example: - >>> import asyncio - >>> async def example(): - ... delays = [] - ... async for delay in exp_backoff(max_retries=3, base=1.0, factor=2.0): - ... delays.append(delay) - ... return delays - >>> delays = asyncio.run(example()) - >>> delays - [1.0, 2.0, 4.0] - """ - for attempt in range(max_retries): - delay = base * (factor**attempt) - logger.debug(f"Backoff attempt {attempt + 1}/{max_retries}: {delay}s delay") - yield delay diff --git a/getstream/video/rtc/coordinator/errors.py b/getstream/video/rtc/coordinator/errors.py deleted file mode 100644 index 29294691..00000000 --- a/getstream/video/rtc/coordinator/errors.py +++ /dev/null @@ -1,55 +0,0 @@ -""" -Exception classes for WebSocket coordinator operations. - -This module defines the exception hierarchy for WebSocket-related errors -in the Stream Video Coordinator client. -""" - -import logging - -logger = logging.getLogger(__name__) - - -class StreamWSException(Exception): - """ - Base exception class for all WebSocket coordinator-related errors. - - This is the root exception from which all other WebSocket coordinator - exceptions inherit. - """ - - pass - - -class StreamWSAuthError(StreamWSException): - """ - Exception raised when authentication with the WebSocket server fails. - - This error is raised when the first frame received from the server - has type == "error", indicating authentication failure. This error - does not trigger retry logic. - """ - - pass - - -class StreamWSConnectionError(StreamWSException): - """ - Exception raised when there are transient connection issues. - - This error is raised for network-related failures, socket errors, - and other transient connection problems that may be resolved by retrying. - """ - - pass - - -class StreamWSMaxRetriesExceeded(StreamWSException): - """ - Exception raised when the maximum number of retry attempts is exceeded. - - This error is raised when all retry attempts have been exhausted and - the connection still cannot be established. - """ - - pass diff --git a/getstream/video/rtc/coordinator/ws.py b/getstream/video/rtc/coordinator/ws.py deleted file mode 100644 index 63570f34..00000000 --- a/getstream/video/rtc/coordinator/ws.py +++ /dev/null @@ -1,579 +0,0 @@ -""" -WebSocket client implementation for Stream Video Coordinator. - -This module contains the StreamAPIWS class that provides an asynchronous WebSocket client -for communicating with Stream's Video Coordinator. -""" - -import asyncio -import json -import logging -import time -from typing import Optional - -import websockets - -from websockets import ClientConnection - -from getstream.utils import StreamAsyncIOEventEmitter -from getstream.video.async_call import Call -from .errors import ( - StreamWSAuthError, - StreamWSConnectionError, - StreamWSMaxRetriesExceeded, -) -from getstream.utils import build_query_param - -from .backoff import exp_backoff - -logger = logging.getLogger(__name__) - -# in case you need to debug websockets, here's how to enable debug logs -# logging.getLogger("websockets.client").setLevel(logging.DEBUG) - -DEFAULT_WS_URI = "wss://video.stream-io-api.com/api/v2/connect" - - -class StreamAPIWS(StreamAsyncIOEventEmitter): - """ - Asynchronous WebSocket client for Stream Video Coordinator. - - This client handles authentication, event dispatching, and connection management - for communicating with Stream's Video Coordinator WebSocket API. - """ - - def __init__( - self, - call: Call, - user_details: Optional[dict] = None, - *, - uri: str = DEFAULT_WS_URI, - healthcheck_interval: float = 15.0, - healthcheck_timeout: float = 30.0, - max_retries: int = 5, - backoff_base: float = 1.0, - backoff_factor: float = 2.0, - logger: Optional[logging.Logger] = None, - user_token: Optional[str] = None, - ): - """ - Initialize the WebSocket client. - - Args: - api_key: Stream API key - token: JWT token for authentication - user_details: Optional user details dict for the connection - uri: WebSocket URI to connect to - healthcheck_interval: Interval in seconds between heartbeat messages - healthcheck_timeout: Timeout in seconds to wait for server messages - max_retries: Maximum number of reconnection attempts - backoff_base: Base delay for exponential backoff - backoff_factor: Factor for exponential backoff - logger: Optional logger instance - """ - super().__init__() - - self.call = call - self.user_details = user_details - self.user_token = user_token - self.uri = f"{uri}?api_key={self.call.client.api_key}&stream-auth-type=jwt" - self.healthcheck_interval = healthcheck_interval - self.healthcheck_timeout = healthcheck_timeout - self.max_retries = max_retries - self.backoff_base = backoff_base - self.backoff_factor = backoff_factor - - self._logger = logger or globals()["logger"] - - # Connection state - self._websocket: Optional[ClientConnection] = None - self._connected = False - self._client_id: Optional[str] = None - - # Background tasks - self._reader_task: Optional[asyncio.Task] = None - self._heartbeat_task: Optional[asyncio.Task] = None - - # Heartbeat tracking - self._last_received = 0.0 - self._reconnect_in_progress = False - self._initial_connection = True # Track if this is the first connection - - async def _build_auth_payload(self) -> dict: - """ - Build the authentication payload to send after connection. - - Returns: - Authentication payload as a dictionary - """ - if not self.user_token: - if not self.user_details: - raise ValueError( - "user_details is required when user_token is not provided" - ) - self.user_token = self.call.client.stream.create_token( - user_id=self.user_details["id"] - ) - payload = { - "token": self.user_token, - "products": ["video"], - } - - # Include user_details if available (both for initial connection and reconnections) - if self.user_details: - payload["user_details"] = self.user_details - - return payload - - async def _open_socket(self) -> dict: - """ - Open WebSocket connection and perform authentication. - - Returns: - The first message received from server - - Raises: - StreamWSAuthError: If authentication fails - StreamWSConnectionError: If connection fails - """ - self._logger.debug("Opening WebSocket connection", extra={"uri": self.uri}) - - # Open WebSocket connection with ping disabled (we handle heartbeats ourselves) - # Prepare auth payload in same time - self._websocket, auth_payload = await asyncio.gather( - websockets.connect( - self.uri, - ping_interval=None, - ping_timeout=None, - close_timeout=1.0, - ), - self._build_auth_payload(), - ) - self._logger.debug("WebSocket connection established") - assert self._websocket is not None - - # Send authentication payload immediately - self._logger.debug("Sending auth payload") - await self._websocket.send(json.dumps(auth_payload)) - - # Wait for the first server message - self._logger.debug("Waiting for first server message") - raw_message = await self._websocket.recv() - message = json.loads(raw_message) - - # Check if authentication failed - if message.get("type") in ("error", "connection.error"): - self._logger.error("Authentication failed", extra={"error": message}) - # Emit the error event before raising exception so wildcard handlers can catch it - event_type = message.get("type", "unknown") - self.emit(event_type, message) - await self._websocket.close() - self._websocket = None - raise StreamWSAuthError(f"Authentication failed: {message}") - - # Authentication successful - update last received time and emit event - self._last_received = time.time() - - # Extract and store the client_id (connection_id) from the response - self._client_id = message.get("connection_id") - if self._client_id: - self._logger.debug("Stored client_id", extra={"client_id": self._client_id}) - else: - self._logger.warning("No connection_id found in server response") - - self._logger.info("Successfully connected and authenticated") - - # Emit the event - event_type = message.get("type", "unknown") - self.emit(event_type, message) - - if not self._reconnect_in_progress: - return message - - # make sure we update connection_id to subscribe to events - if self.user_token is None: - raise ValueError("user_token is required") - - client = self.call.client.stream.clone_for_token(self.user_token) - if self.call.id is None: - raise ValueError("call.id is required") - path_params = { - "type": self.call.call_type, - "id": self.call.id, - } - query_params = build_query_param( - **{ - "connection_id": self._client_id, - } - ) - await client.post( - "/video/call/{type}/{id}", - path_params=path_params, - query_params=query_params, - ) - return message - - async def _reader_task_func(self) -> None: - """ - Background task that reads messages from the WebSocket and emits events. - """ - self._logger.debug("Starting reader task") - - try: - while self._connected and self._websocket: - try: - raw_message = await self._websocket.recv() - self._logger.debug(f"Received message {raw_message}") - - # Update last received timestamp - self._last_received = time.time() - - # Parse and emit message - try: - message = json.loads(raw_message) - event_type = message.get("type", "unknown") - self._logger.debug( - "Received message", extra={"type": event_type} - ) - self.emit(event_type, message) - except json.JSONDecodeError as e: - self._logger.warning( - "Failed to parse message as JSON", exc_info=e - ) - - except websockets.exceptions.ConnectionClosed as e: - # Extract close information safely - close_code = None - close_reason = None - try: - if hasattr(e, "rcvd") and e.rcvd: - close_code = getattr(e.rcvd, "code", None) - close_reason = getattr(e.rcvd, "reason", None) - except AttributeError: - pass - - self._logger.error( - f"WebSocket connection closed by server close_code: {close_code} close_reason: {close_reason}", - extra={ - "close_code": close_code, - "close_reason": close_reason, - }, - ) - if self._connected and not self._reconnect_in_progress: - await self._trigger_reconnect("ws connection closed by server") - break - except websockets.exceptions.WebSocketException as e: - self._logger.error( - "WebSocket protocol error in reader task", exc_info=e - ) - if self._connected and not self._reconnect_in_progress: - await self._trigger_reconnect(f"ws exception {e}") - break - except Exception as e: - self._logger.error("Unexpected error in reader task", exc_info=e) - if self._connected and not self._reconnect_in_progress: - await self._trigger_reconnect(f"ws read error {e}") - break - - except asyncio.CancelledError: - self._logger.debug("Reader task cancelled") - raise - finally: - self._logger.debug("Reader task ended") - - async def _heartbeat_task_func(self) -> None: - """ - Background task that sends heartbeat messages and monitors connection health. - """ - self._logger.debug("Starting heartbeat task") - - try: - while self._connected: - await asyncio.sleep(self.healthcheck_interval) - - if not self._connected: - break - - current_time = time.time() - - # Check if we haven't received anything for too long - if current_time - self._last_received > self.healthcheck_timeout: - self._logger.warning( - "No messages received within timeout period", - extra={ - "last_received": self._last_received, - "timeout": self.healthcheck_timeout, - "elapsed": current_time - self._last_received, - }, - ) - if not self._reconnect_in_progress: - await self._trigger_reconnect("healthcheck timeout") - break - - # Send heartbeat - if self._websocket and self._connected: - try: - heartbeat = {"type": "health.check"} - if self._client_id: - heartbeat["client_id"] = self._client_id - await self._websocket.send(json.dumps(heartbeat)) - self._logger.debug("Sent heartbeat") - except websockets.exceptions.ConnectionClosed as e: - # Extract close information safely - close_code = None - close_reason = None - try: - if hasattr(e, "rcvd") and e.rcvd: - close_code = getattr(e.rcvd, "code", None) - close_reason = getattr(e.rcvd, "reason", None) - except AttributeError: - pass - - self._logger.error( - "WebSocket connection closed while sending heartbeat", - extra={ - "close_code": close_code, - "close_reason": close_reason, - }, - ) - if not self._reconnect_in_progress: - await self._trigger_reconnect("ws connection closed") - break - except websockets.exceptions.WebSocketException as e: - self._logger.error( - "WebSocket protocol error while sending heartbeat", - exc_info=e, - ) - if not self._reconnect_in_progress: - await self._trigger_reconnect("healthcheck write error") - break - except Exception: - self._logger.exception( - "Unexpected error while sending heartbeat" - ) - if not self._reconnect_in_progress: - await self._trigger_reconnect("heartbeat unknown error") - break - - except asyncio.CancelledError: - self._logger.debug("Heartbeat task cancelled") - raise - finally: - self._logger.debug("Heartbeat task ended") - - async def _trigger_reconnect(self, reason) -> None: - """ - Trigger reconnection process. - """ - if self._reconnect_in_progress: - return - - self._logger.warning(f"Triggering reconnection reason: '{reason}'") - self._reconnect_in_progress = True - - try: - await self._reconnect() - except Exception as e: - self._logger.error("Reconnection failed completely", exc_info=e) - self._connected = False - finally: - self._reconnect_in_progress = False - - async def _reconnect(self) -> None: - """ - Attempt to reconnect using exponential backoff strategy. - - Raises: - StreamWSMaxRetriesExceeded: If all retry attempts fail - """ - self._logger.info("Starting reconnection process") - - # Ensure this is not treated as initial connection - self._initial_connection = False - - # Cancel existing tasks - await self._cancel_background_tasks() - - # Close existing websocket - if self._websocket: - try: - await self._websocket.close() - except Exception: - pass - self._websocket = None - - # Attempt reconnection with exponential backoff - attempt = 0 - async for delay in exp_backoff( - max_retries=self.max_retries, - base=self.backoff_base, - factor=self.backoff_factor, - ): - attempt += 1 - self._logger.info( - "Reconnection attempt", - extra={ - "attempt": attempt, - "max_retries": self.max_retries, - "delay": delay, - }, - ) - - await asyncio.sleep(delay) - - if not self._connected: - self._logger.debug( - "Connection was closed during backoff, aborting reconnection" - ) - return - - try: - await self._open_socket() - - # Restart background tasks - await self._start_background_tasks() - - self._logger.info("Reconnection successful") - return - - except (OSError, websockets.exceptions.WebSocketException) as e: - self._logger.warning( - "Reconnection attempt failed", - extra={"attempt": attempt, "error": str(e)}, - ) - continue - except StreamWSAuthError as e: - # Authentication errors should not trigger retry - self._logger.error( - "Authentication failed during reconnection", exc_info=e - ) - self._connected = False - raise - except Exception as e: - self._logger.error("Unexpected error during reconnection", exc_info=e) - continue - - # All retry attempts exhausted - self._logger.error("All reconnection attempts failed") - self._connected = False - raise StreamWSMaxRetriesExceeded( - "Maximum number of reconnection attempts exceeded" - ) - - async def _start_background_tasks(self) -> None: - """Start the background tasks for reading and heartbeat.""" - self._logger.debug("Starting background tasks") - - self._reader_task = asyncio.create_task(self._reader_task_func()) - self._heartbeat_task = asyncio.create_task(self._heartbeat_task_func()) - - async def _cancel_background_tasks(self) -> None: - """Cancel the background tasks.""" - self._logger.debug("Cancelling background tasks") - - if self._reader_task: - self._reader_task.cancel() - try: - await self._reader_task - except asyncio.CancelledError: - pass - self._reader_task = None - - if self._heartbeat_task: - self._heartbeat_task.cancel() - try: - await self._heartbeat_task - except asyncio.CancelledError: - pass - self._heartbeat_task = None - - async def connect(self) -> dict: - """ - Open the socket, authenticate, and wait for the first server frame. - - Returns: - The first event payload (usually {"type": "connection.ok"}) - - Raises: - StreamWSAuthError: If the first frame has type == "error" or authentication times out - StreamWSConnectionError: If there are connection issues - """ - self._logger.info("Connecting to coordinator", extra={"uri": self.uri}) - - if self._connected: - self._logger.warning("Already connected, disconnecting first") - await self.disconnect() - - # Mark this as initial connection - self._initial_connection = True - - try: - first_message = await self._open_socket() - - # Mark as connected and start background tasks - self._connected = True - await self._start_background_tasks() - - # After successful initial connection, subsequent connections are reconnections - self._initial_connection = False - - # Return the authentication response - return first_message - - except StreamWSAuthError: - # Re-raise authentication errors without modification - raise - except websockets.exceptions.WebSocketException as e: - self._logger.error("WebSocket connection failed", exc_info=e) - if self._websocket: - await self._websocket.close() - self._websocket = None - raise StreamWSConnectionError(f"WebSocket connection failed: {e}") from e - except json.JSONDecodeError as e: - self._logger.error("Failed to parse server message", exc_info=e) - if self._websocket: - await self._websocket.close() - self._websocket = None - raise StreamWSConnectionError(f"Invalid JSON from server: {e}") from e - except Exception as e: - self._logger.error("Unexpected error during connection", exc_info=e) - if self._websocket: - await self._websocket.close() - self._websocket = None - raise StreamWSConnectionError(f"Unexpected connection error: {e}") from e - - async def disconnect(self) -> None: - """ - Cancel internal tasks and close the WebSocket cleanly. - """ - self._logger.info("Disconnecting from coordinator") - - self._connected = False - self._reconnect_in_progress = False - self._client_id = None - self._initial_connection = True # Reset for next connection - - # Cancel background tasks - await self._cancel_background_tasks() - - # Close WebSocket - if self._websocket: - try: - await self._websocket.close(code=1000, reason="client disconnect") - self._logger.debug("WebSocket closed cleanly") - except Exception as e: - self._logger.warning("Error closing WebSocket", exc_info=e) - finally: - self._websocket = None - - self._logger.info("Disconnected from coordinator") - - @property - def connected(self) -> bool: - """ - Check if the WebSocket is currently connected. - - Returns: - True if connected, False otherwise - """ - return self._connected and self._websocket is not None diff --git a/getstream/video/rtc/encoders_patches.py b/getstream/video/rtc/encoders_patches.py deleted file mode 100644 index 7c93445f..00000000 --- a/getstream/video/rtc/encoders_patches.py +++ /dev/null @@ -1,158 +0,0 @@ -import fractions -import logging -import os -from typing import Optional - -import av -from aiortc import RTCRtpCodecParameters -from aiortc.codecs.h264 import MAX_FRAME_RATE as H264_MAX_FRAME_RATE -from aiortc.codecs.h264 import H264Encoder -from aiortc.codecs.vpx import Vp8Encoder -from aiortc.rtcrtpsender import RTCEncodedFrame, RTCRtpSender - -logger = logging.getLogger(__name__) - -# Stream video bitrate overrides (applied per-connection, not globally). -# These are higher than aiortc defaults to ensure acceptable video quality. -STREAM_VIDEO_DEFAULT_BITRATE = 2_500_000 # 2.5 Mbps -STREAM_VIDEO_MIN_BITRATE = 1_500_000 # 1.5 Mbps -STREAM_VIDEO_MAX_BITRATE = 3_000_000 # 3 Mbps - - -# Check if the Stream bitrate patching is disabled via environment variable -BITRATE_PATCH_DISABLED = os.getenv( - "STREAM_PATCH_AIORTC_BITRATES", "" -).lower().strip() in ( - "0", - "false", - "no", - "off", -) - -# libx264 preset for the StreamH264Encoder. Defaults to "ultrafast" for -# real-time CPU; can be raised (e.g. "veryfast", "medium") for better -# quality-per-bit at the cost of more CPU per frame. -STREAM_H264_PRESET = os.getenv("STREAM_PATCH_AIORTC_H264_PRESET", "ultrafast").strip() - - -try: - # Verify the name-mangled attributes we depend on still exist. - assert hasattr(Vp8Encoder(), "_Vp8Encoder__target_bitrate") - assert hasattr(H264Encoder(), "_H264Encoder__target_bitrate") - - class StreamVp8Encoder(Vp8Encoder): - """Vp8Encoder subclass with higher bitrate bounds for Stream calls.""" - - def __init__(self) -> None: - super().__init__() - self._Vp8Encoder__target_bitrate = STREAM_VIDEO_DEFAULT_BITRATE - - @property - def target_bitrate(self) -> int: - return self._Vp8Encoder__target_bitrate - - @target_bitrate.setter - def target_bitrate(self, bitrate: int) -> None: - bitrate = max( - STREAM_VIDEO_MIN_BITRATE, min(bitrate, STREAM_VIDEO_MAX_BITRATE) - ) - self._Vp8Encoder__target_bitrate = bitrate - - class StreamH264Encoder(H264Encoder): - """H264Encoder subclass with higher bitrate bounds and a real-time - libx264 preset for Stream calls.""" - - def __init__(self) -> None: - super().__init__() - self._H264Encoder__target_bitrate = STREAM_VIDEO_DEFAULT_BITRATE - self.preset = STREAM_H264_PRESET - - @property - def target_bitrate(self) -> int: - return self._H264Encoder__target_bitrate - - @target_bitrate.setter - def target_bitrate(self, bitrate: int) -> None: - bitrate = max( - STREAM_VIDEO_MIN_BITRATE, min(bitrate, STREAM_VIDEO_MAX_BITRATE) - ) - self._H264Encoder__target_bitrate = bitrate - - def _encode_frame(self, frame, force_keyframe): - # Mirror parent's invalidation policy so we own codec creation in - # both first-init AND recreation (resolution/bitrate change) paths; - # otherwise parent's `if self.codec is None` branch silently - # reverts our preset to libx264's default `medium`. - codec = self.codec - if ( - codec is not None - and codec.bit_rate - and ( - frame.width != codec.width - or frame.height != codec.height - or abs(self.target_bitrate - codec.bit_rate) / codec.bit_rate > 0.1 - ) - ): - self.buffer_data = b"" - self.buffer_pts = None - self.codec = None - - if self.codec is None: - self.codec = av.CodecContext.create("libx264", "w") - self.codec.width = frame.width - self.codec.height = frame.height - self.codec.bit_rate = self.target_bitrate - self.codec.pix_fmt = "yuv420p" - self.codec.framerate = fractions.Fraction(H264_MAX_FRAME_RATE, 1) - self.codec.time_base = fractions.Fraction(1, H264_MAX_FRAME_RATE) - self.codec.options = { - "level": "31", - "tune": "zerolatency", - "preset": self.preset, - } - self.codec.profile = "Baseline" - yield from super()._encode_frame(frame, force_keyframe) - -except Exception: - logger.warning( - "Failed to patch aiortc encoder subclasses with Stream values (aiortc internals may have changed), " - "falling back to default aiortc encoders. \n" - "Set STREAM_PATCH_AIORTC_BITRATES=0 to disable patching.", - exc_info=True, - ) - StreamVp8Encoder = None # type: ignore[assignment, misc] - StreamH264Encoder = None # type: ignore[assignment, misc] - - -def patch_sender_encoder(sender: RTCRtpSender) -> None: - """Patch a sender to use Stream's tuned encoders for the negotiated codec. - - Works for video (VP8/H264) senders. If anything - goes wrong (e.g. aiortc internals changed), the sender is left untouched - and will use the stock encoder via get_encoder(). - """ - if StreamVp8Encoder is None or StreamH264Encoder is None: - return - - try: - _orig_next = sender._next_encoded_frame - - async def _next_with_stream_encoder( - codec: RTCRtpCodecParameters, _orig_next=_orig_next - ) -> Optional[RTCEncodedFrame]: - if sender._RTCRtpSender__encoder is None: # type: ignore[attr-defined] - mime = codec.mimeType.lower() - if mime == "video/vp8": - sender._RTCRtpSender__encoder = StreamVp8Encoder() # type: ignore[attr-defined] - elif mime == "video/h264": - sender._RTCRtpSender__encoder = StreamH264Encoder() # type: ignore[attr-defined] - return await _orig_next(codec) - - sender._next_encoded_frame = _next_with_stream_encoder # type: ignore[method-assign] - except Exception: - logger.warning( - "Failed to patch aiortc encoder subclasses with Stream values (aiortc internals may have changed), " - "falling back to default aiortc encoders. \n" - "Set STREAM_PATCH_AIORTC_BITRATES=0 to disable patching.", - exc_info=True, - ) diff --git a/getstream/video/rtc/location_discovery.py b/getstream/video/rtc/location_discovery.py deleted file mode 100644 index bd268dd1..00000000 --- a/getstream/video/rtc/location_discovery.py +++ /dev/null @@ -1,147 +0,0 @@ -""" -Location discovery for video streaming. - -This module provides functionality to discover the optimal location for video streaming -connections based on CloudFront POP headers. -""" - -import functools -import http.client -import logging -from contextlib import contextmanager -from typing import ContextManager, Optional, Protocol - -# Constants matching the Go implementation -HEADER_CLOUDFRONT_POP = "X-Amz-Cf-Pop" -FALLBACK_LOCATION_NAME = "IAD" -STREAM_PROD_URL = "https://hint.stream-io-video.com/" - -# Create a logger for the location discovery module -logger = logging.getLogger(__name__) - - -class HTTPClient(Protocol): - """Protocol defining the HTTP client interface.""" - - def request(self, method: str, url: str, body=None, headers=None, **kwargs) -> None: - """Make an HTTP request.""" - ... - - def response(self) -> ContextManager[http.client.HTTPResponse]: - """Get the HTTP response.""" - ... - - -class HTTPHintLocationDiscovery: - """Implementation of location discovery using CloudFront headers.""" - - def __init__( - self, - url: str = STREAM_PROD_URL, - max_retries: int = 3, - client: Optional[HTTPClient] = None, - logger: Optional[logging.Logger] = None, - ): - """ - Initialize the location discovery service. - - Args: - url: The URL to use for discovery - max_retries: Maximum number of retries for discovery - client: HTTP client to use - logger: Logger instance - """ - self.url = url - self.max_retries = max_retries - self.client = client or create_default_http_client() - # Use the provided logger or fall back to the module-level logger - self.logger = logger or globals()["logger"] - - @functools.lru_cache(maxsize=1) - def discover(self, context=None) -> str: - """ - Discover the closest location based on CloudFront pop. - - Args: - context: Optional context (for compatibility with Go implementation) - - Returns: - The 3-character location code (e.g. "IAD", "FRA") - """ - # Basic validation to match previous behavior and provide fast-fail - parsed_url = self.url.split("://", 1) - if len(parsed_url) != 2: - self.logger.warning("Invalid URL format: %s", self.url) - return FALLBACK_LOCATION_NAME - - for i in range(self.max_retries): - self.logger.info("Discovering location, attempt %d", i + 1) - try: - # Use injected HTTP client (or default) for requests - self.client.request("HEAD", self.url) - with self.client.response() as response: - if response.status != 200: - self.logger.warning( - "Unexpected status code: %d", response.status - ) - continue - - pop_name = response.getheader(HEADER_CLOUDFRONT_POP, "") - - if len(pop_name) < 3: - self.logger.warning("Invalid pop name: %s", pop_name) - return FALLBACK_LOCATION_NAME - - location = pop_name[:3] - self.logger.info(f"Discovered location: {location}") - return location - - except Exception as e: - self.logger.warning("HEAD request failed: %s", str(e)) - continue - - self.logger.info( - "Failed to discover location after %d attempts, using fallback %s", - self.max_retries, - FALLBACK_LOCATION_NAME, - ) - return FALLBACK_LOCATION_NAME - - -def create_default_http_client(): - """ - Create a default HTTP client with appropriate timeouts. - - Returns: - A simple HTTP client - """ - - class SimpleHTTPClient: - def request(self, method, url, body=None, headers=None, **kwargs): - self.parsed_url = url.split("://", 1) - if len(self.parsed_url) != 2: - raise ValueError(f"Invalid URL format: {url}") - - self.protocol, host_path = self.parsed_url - self.host = host_path.split("/", 1)[0] - self.path = "/" + host_path.split("/", 1)[1] if "/" in host_path else "/" - self.method = method - self.body = body - self.headers = headers or {} - - @contextmanager - def response(self): - try: - if self.protocol.lower() == "https": - conn = http.client.HTTPSConnection(self.host, timeout=1) - else: - conn = http.client.HTTPConnection(self.host, timeout=1) - - conn.request(self.method, self.path, self.body, self.headers) - resp = conn.getresponse() - yield resp - resp.read() - finally: - conn.close() - - return SimpleHTTPClient() diff --git a/getstream/video/rtc/models.py b/getstream/video/rtc/models.py deleted file mode 100644 index a5d9c7b1..00000000 --- a/getstream/video/rtc/models.py +++ /dev/null @@ -1,52 +0,0 @@ -""" -Data models for the RTC module -""" - -from dataclasses import field as dc_field, dataclass -from typing import List, Optional, Dict, Any -from dataclasses_json import config as dc_config, DataClassJsonMixin - -from getstream.models import CallRequest, CallResponse, MemberResponse - - -@dataclass -class JoinCallRequest(DataClassJsonMixin): - create: Optional[bool] = dc_field( - default=False, metadata=dc_config(field_name="create") - ) - data: "Optional[CallRequest]" = dc_field( - default=None, metadata=dc_config(field_name="data") - ) - ring: Optional[bool] = dc_field(default=None, metadata=dc_config(field_name="ring")) - notify: Optional[bool] = dc_field( - default=None, metadata=dc_config(field_name="notify") - ) - video: Optional[bool] = dc_field( - default=None, metadata=dc_config(field_name="video") - ) - location: str = dc_field(default="", metadata=dc_config(field_name="location")) - - -@dataclass -class ServerCredentials(DataClassJsonMixin): - edge_name: str = dc_field(metadata=dc_config(field_name="edge_name")) - url: str = dc_field(metadata=dc_config(field_name="url")) - ws_endpoint: str = dc_field(metadata=dc_config(field_name="ws_endpoint")) - - -@dataclass -class Credentials(DataClassJsonMixin): - server: ServerCredentials = dc_field(metadata=dc_config(field_name="server")) - token: str = dc_field(metadata=dc_config(field_name="token")) - ice_servers: List[Dict[str, Any]] = dc_field( - metadata=dc_config(field_name="ice_servers") - ) - - -@dataclass -class JoinCallResponse(DataClassJsonMixin): - call: CallResponse = dc_field(metadata=dc_config(field_name="call")) - members: List[MemberResponse] = dc_field(metadata=dc_config(field_name="members")) - credentials: Credentials = dc_field(metadata=dc_config(field_name="credentials")) - stats_options: dict = dc_field(metadata=dc_config(field_name="stats_options")) - duration: str = dc_field(metadata=dc_config(field_name="duration")) diff --git a/getstream/video/rtc/network_monitor.py b/getstream/video/rtc/network_monitor.py deleted file mode 100644 index eef7b718..00000000 --- a/getstream/video/rtc/network_monitor.py +++ /dev/null @@ -1,200 +0,0 @@ -""" -Monitors network connectivity and manages network state. -""" - -import asyncio -import logging -import time -from typing import List, Optional - -import ping3 -from pyee.asyncio import AsyncIOEventEmitter - -from getstream.video.rtc.reconnection import ReconnectionStrategy - -logger = logging.getLogger(__name__) - - -class NetworkMonitor(AsyncIOEventEmitter): - """Monitors network connectivity and manages network state.""" - - def __init__( - self, - connection_manager, - connectivity_hosts: Optional[List[str]] = None, - connectivity_timeout: float = 3.0, - check_interval: float = 1.0, - required_successful_pings: int = 1, - ): - """ - Initialize network monitor. - - Args: - connection_manager: The ConnectionManager instance - connectivity_hosts: List of hosts to ping for connectivity checks - connectivity_timeout: Timeout for ping checks - check_interval: Seconds between connectivity checks - required_successful_pings: Number of successful pings needed to consider network online - """ - super().__init__() - - # Default reliable hosts for connectivity checking - self.connectivity_hosts = connectivity_hosts or [ - "8.8.8.8", # Google DNS - "1.1.1.1", # Cloudflare DNS - "208.67.222.222", # OpenDNS - ] - - self.connection_manager = connection_manager - self.connectivity_timeout = connectivity_timeout - self.check_interval = check_interval - self.required_successful_pings = required_successful_pings - self.is_online = True - self.monitor_task: Optional[asyncio.Task] = None - self._last_offline_timestamp: Optional[float] = None - self._fast_reconnect_deadline_seconds = 10 - self.logger = logger - - async def start_monitoring(self): - """Start network connectivity monitoring.""" - if self.monitor_task and not self.monitor_task.done(): - self.logger.warning("Network monitoring already started") - return - - self.logger.info("Starting network connectivity monitoring") - self.monitor_task = asyncio.create_task(self._monitor_loop()) - - async def stop_monitoring(self): - """Stop network connectivity monitoring.""" - if self.monitor_task: - self.logger.info("Stopping network connectivity monitoring") - self.monitor_task.cancel() - try: - await self.monitor_task - except asyncio.CancelledError: - pass - finally: - self.monitor_task = None - - async def _monitor_loop(self): - """Main monitoring loop to detect network changes.""" - while True: - try: - current_status = await self._check_connectivity() - if current_status != self.is_online: - self.is_online = current_status - await self._handle_network_change(current_status) - await asyncio.sleep(self.check_interval) - except asyncio.CancelledError: - self.logger.debug("Network monitoring loop cancelled") - raise - except Exception as e: - self.logger.error( - "Error in network monitoring", - exc_info=e, - extra={"check_interval": self.check_interval}, - ) - await asyncio.sleep(self.check_interval) - - async def _check_connectivity(self) -> bool: - """ - Check connectivity using ping3 package. - - Returns: - True if required number of pings succeed, False otherwise - """ - successful_pings = 0 - - for host in self.connectivity_hosts: - try: - # Run ping in executor to avoid blocking - response_time = await asyncio.get_event_loop().run_in_executor( - None, lambda: ping3.ping(host, timeout=self.connectivity_timeout) - ) - - if response_time is not None: - successful_pings += 1 - self.logger.debug( - f"Ping to {host} successful: {response_time:.2f}s" - ) - - # If we've reached the required number of successful pings, we're online - if successful_pings >= self.required_successful_pings: - return True - else: - self.logger.debug(f"Ping to {host} failed") - - except Exception as e: - self.logger.debug(f"Ping to {host} error: {e}") - - return False - - async def _handle_network_change(self, online: bool): - """ - Handle network status changes and emit events. - - Args: - online: True if network is now available, False if unavailable - """ - status = "online" if online else "offline" - self.logger.debug(f"Network status changed to {status}") - - # Emit network change event using AsyncIOEventEmitter - self.emit( - "network_changed", - {"online": online, "timestamp": asyncio.get_event_loop().time()}, - ) - - # Also emit specific online/offline events - if online: - self.emit("network_online", {"timestamp": asyncio.get_event_loop().time()}) - else: - self.emit("network_offline", {"timestamp": asyncio.get_event_loop().time()}) - - def register_event_handlers(self): - """Register network event handlers.""" - - @self.on("network_changed") - async def handle_network_change(event_data): - logger.info(f"Received network change: {event_data}") - online = event_data.get("online", True) - - if not online: - logger.debug("Going offline") - if self.connection_manager.connection_state not in ["JOINED"]: - return - - self._last_offline_timestamp = time.time() - network_event = asyncio.Event() - self.connection_manager.reconnector.set_network_event = network_event - self.connection_manager.connection_state = "OFFLINE" - else: - logger.debug("Going online") - - if self._last_offline_timestamp: - offline_duration = time.time() - self._last_offline_timestamp - strategy = ( - ReconnectionStrategy.FAST - if offline_duration <= self._fast_reconnect_deadline_seconds - else ReconnectionStrategy.REJOIN - ) - else: - strategy = ReconnectionStrategy.REJOIN - - if self.connection_manager.reconnector.set_network_event: - self.connection_manager.reconnector.set_network_event.set() - self.connection_manager.reconnector.set_network_event = None - - asyncio.create_task( - self.connection_manager.reconnector.reconnect( - strategy, "Going online" - ) - ) - - @self.on("network_online") - def handle_network_online(event_data): - logger.debug("Network came online") - - @self.on("network_offline") - def handle_network_offline(event_data): - logger.debug("Network went offline") diff --git a/getstream/video/rtc/participants.py b/getstream/video/rtc/participants.py index a694d110..624e051b 100644 --- a/getstream/video/rtc/participants.py +++ b/getstream/video/rtc/participants.py @@ -1,11 +1,10 @@ -from typing import Optional, Callable, List +from typing import Any, Callable, List import inspect import weakref from pyee.asyncio import AsyncIOEventEmitter -from getstream.video.rtc.pb.stream.video.sfu.event import events_pb2 -from getstream.video.rtc.pb.stream.video.sfu.models import models_pb2 +import getstream_rtc import logging @@ -13,53 +12,42 @@ class ParticipantsState(AsyncIOEventEmitter): - """Tracks participants and stream mapping received from the SFU.""" + """Tracks the participants of the call, this participant included.""" def __init__(self): super().__init__() - self._participant_by_prefix = {} - self._track_stream_mapping = {} + self._participant_by_session_id = {} self._map_handlers = [] # List of weak references to handler functions - def get_user_from_track_id(self, track_id: str) -> Optional[models_pb2.Participant]: - # Track IDs have format: participant_id:track_type:... - # We can extract the participant prefix directly from the track ID - if ":" in track_id: - # Extract the participant prefix from the track ID - prefix = track_id.split(":")[0] - user = self._participant_by_prefix.get(prefix) - if user: - return user - - # Fallback to the old mapping approach if it exists - stream_id = self._track_stream_mapping.get(track_id) - if stream_id: - prefix = stream_id.split(":")[0] - return self._participant_by_prefix.get(prefix) - - return None - - def get_stream_id_from_track_id(self, track_id: str) -> Optional[str]: - return self._track_stream_mapping.get(track_id) - - def set_track_stream_mapping(self, mapping: dict): - logger.debug(f"Setting track stream mapping: {mapping}") - self._track_stream_mapping = mapping - - def _add_participant(self, participant: models_pb2.Participant): - self._participant_by_prefix[participant.track_lookup_prefix] = participant + def emit(self, event: str, *args: Any, **kwargs: Any) -> bool: + """Calls the handlers of `event`. A handler that raises is logged, so it + cannot stop the task that emits.""" + try: + return super().emit(event, *args, **kwargs) + except Exception: + logger.exception(f"A {event!r} handler failed") + return True + + def _add_participant(self, participant: getstream_rtc.RemoteParticipant): + self._participant_by_session_id[participant.session_id] = participant self._notify_map_handlers() - def _remove_participant(self, participant: models_pb2.Participant): - if participant.track_lookup_prefix in self._participant_by_prefix: - del self._participant_by_prefix[participant.track_lookup_prefix] + def _remove_participant(self, participant: getstream_rtc.RemoteParticipant): + if participant.session_id in self._participant_by_session_id: + del self._participant_by_session_id[participant.session_id] self._notify_map_handlers() - def get_participants(self) -> List[models_pb2.Participant]: + def _replace_participants( + self, participants: List[getstream_rtc.RemoteParticipant] + ): + self._participant_by_session_id = {p.session_id: p for p in participants} + self._notify_map_handlers() + + def get_participants(self) -> List[getstream_rtc.RemoteParticipant]: """Get the current list of participants.""" - return list(self._participant_by_prefix.values()) + return list(self._participant_by_session_id.values()) - def map(self, handler: Callable[[List[models_pb2.Participant]], None]): + def map(self, handler: Callable[[List[getstream_rtc.RemoteParticipant]], None]): """ Subscribe to participant list changes. The handler is called immediately with the current list and whenever participants are added or removed. @@ -147,10 +135,10 @@ def _notify_map_handlers(self): # Update list to only include active handlers self._map_handlers[:] = active_handlers - async def _on_participant_joined(self, event: events_pb2.ParticipantJoined): + async def _on_participant_joined(self, event: getstream_rtc.ParticipantJoined): self._add_participant(event.participant) self.emit("participant_joined", event.participant) - async def _on_participant_left(self, event: events_pb2.ParticipantLeft): + async def _on_participant_left(self, event: getstream_rtc.ParticipantLeft): self._remove_participant(event.participant) self.emit("participant_left", event.participant) diff --git a/getstream/video/rtc/pc.py b/getstream/video/rtc/pc.py deleted file mode 100644 index 5b221b59..00000000 --- a/getstream/video/rtc/pc.py +++ /dev/null @@ -1,293 +0,0 @@ -import asyncio -import logging -from typing import Any, Optional - -import aiortc -from aiortc.contrib.media import MediaBlackhole, MediaRelay -from aiortc.mediastreams import MediaStreamTrack -from aiortc.rtcrtpparameters import RTCRtpCodecCapability -from aiortc.rtcrtpsender import RTCRtpSender -from pyee.asyncio import AsyncIOEventEmitter - -from getstream.common import telemetry -from getstream.video.rtc.encoders_patches import ( - BITRATE_PATCH_DISABLED, - patch_sender_encoder, -) -from getstream.video.rtc.track_util import AudioTrackHandler - -logger = logging.getLogger(__name__) - - -def parse_track_id(id: str) -> tuple[str, str]: - """ - Parse the webRTC media track and returns a tuple including: the id of the participant, the type of track - """ - participant_id, track_type, _ = id.split(":") - return participant_id, track_type - - -def publish_codec_preferences() -> list[RTCRtpCodecCapability]: - return [ - c - for c in RTCRtpSender.getCapabilities("video").codecs - if c.name.lower() != "vp8" - ] - - -def subscribe_codec_preferences() -> list[RTCRtpCodecCapability]: - return RTCRtpSender.getCapabilities("video").codecs - - -class PublisherPeerConnection(aiortc.RTCPeerConnection): - def __init__( - self, - manager: Any, - configuration: aiortc.RTCConfiguration, - ) -> None: - logger.info( - f"Creating publisher peer connection with configuration: {configuration}" - ) - super().__init__(configuration) - self.manager = manager - self._closed = False - self._connected_event = asyncio.Event() - - @self.on("icegatheringstatechange") - def on_icegatheringstatechange(): - logger.info( - f"Publisher ICE gathering state changed to {self.iceGatheringState}" - ) - if self.iceGatheringState == "complete": - logger.info("Publisher: All ICE candidates have been gathered.") - - @self.on("iceconnectionstatechange") - def on_iceconnectionstatechange(): - logger.info( - f"Publisher ICE connection state changed to {self.iceConnectionState}" - ) - - @self.on("connectionstatechange") - def on_connectionstatechange(): - logger.info(f"Publisher connection state changed to {self.connectionState}") - if self.connectionState == "connected": - self._connected_event.set() - - def addTrack(self, track: MediaStreamTrack) -> RTCRtpSender: - sender = super().addTrack(track) - if track.kind == "video": - for transceiver in self.getTransceivers(): - if transceiver.sender is sender: - transceiver.setCodecPreferences(publish_codec_preferences()) - break - if not BITRATE_PATCH_DISABLED: - patch_sender_encoder(sender) - return sender - - async def handle_answer(self, response): - """Handles the SDP answer received from the SFU for the publisher connection.""" - - logger.debug(f"Publisher received answer {response.sdp}") - - remote_description = aiortc.RTCSessionDescription( - type="answer", sdp=response.sdp - ) - - with telemetry.start_as_current_span("publisher.pc.handle_answer") as span: - span.set_attribute("remoteDescription", remote_description.sdp) - await self.setRemoteDescription(remote_description) - logger.debug( - f"Publisher remote description set successfully. {self.localDescription}" - ) - - async def wait_for_connected(self, timeout: float = 15.0): - # If already connected, return immediately - if self.connectionState == "connected": - logger.info("Publisher already connected, no need to wait") - return - - logger.info(f"Waiting for publisher connection with {timeout}s timeout") - try: - # Wait for the connected event with timeout - await asyncio.wait_for(self._connected_event.wait(), timeout=timeout) - logger.info("Publisher successfully connected") - except asyncio.TimeoutError: - logger.error(f"Publisher connection timed out after {timeout}s") - raise TimeoutError(f"Connection timed out after {timeout} seconds") - - async def close(self): - # Using self._closed guard here - # to avoid closing RTCPeerConnectionTwice by accident (it freezes on second time) - if self._closed: - return - self._closed = True - await super().close() - - async def restartIce(self): - """Restart ICE connection for reconnection scenarios.""" - logger.info("Restarting ICE connection for publisher") - try: - # Create new offer to restart ICE - offer = await self.createOffer() - await self.setLocalDescription(offer) - logger.debug("Publisher ICE restart initiated") - except Exception as e: - logger.error("Failed to restart publisher ICE", exc_info=e) - raise - - -class SubscriberPeerConnection(aiortc.RTCPeerConnection, AsyncIOEventEmitter): - def __init__( - self, - connection, - configuration: aiortc.RTCConfiguration, - drain_video_frames: bool = True, - ) -> None: - logger.info( - f"creating subscriber peer connection with configuration: {configuration}" - ) - super().__init__(configuration) - self.connection = connection - self._closed = False - self._drain_video_frames = drain_video_frames - - self.track_map = {} # track_id -> (MediaRelay, original_track) - self.video_frame_trackers = {} # track_id -> VideoFrameTracker - self._video_drains: dict[ - str, tuple[MediaBlackhole, asyncio.Task, MediaStreamTrack] - ] = {} - self._background_tasks: set[asyncio.Task] = set() - - @self.on("track") - async def on_track(track: aiortc.mediastreams.MediaStreamTrack): - logger.info(f"Track received: {track.id} : {track.kind}") - - # Try to get user from track ID first (original method) - user = self.connection.participants_state.get_user_from_track_id(track.id) - - # If that fails and it's an audio track, try to get the next expected audio user - if user is None and track.kind == "audio": - user = ( - self.connection._subscription_manager.get_next_expected_audio_user() - ) - - relay = MediaRelay() - tracked_track = track - - # For video tracks, wrap with VideoFrameTracker to capture frame metrics - if track.kind == "video": - from getstream.video.rtc.track_util import VideoFrameTracker - - tracked_track = VideoFrameTracker(track) - self.video_frame_trackers[track.id] = tracked_track - - # Drain unconsumed video frames to prevent unbounded queue growth - # in RTCRtpReceiver (aiortc issue #554) - if self._drain_video_frames: - drain_proxy = relay.subscribe(tracked_track) - blackhole = MediaBlackhole() - blackhole.addTrack(drain_proxy) - drain_task = asyncio.create_task(blackhole.start()) - self._video_drains[track.id] = (blackhole, drain_task, drain_proxy) - - self.track_map[track.id] = (relay, tracked_track) - - if track.kind == "audio": - from getstream.video.rtc import PcmData - - # Add a new subscriber for AudioTrackHandler and attach the participant to the pcm object - def _emit_pcm(pcm: PcmData): - pcm.participant = user - self.emit("audio", pcm) - - handler = AudioTrackHandler(relay.subscribe(tracked_track), _emit_pcm) - asyncio.create_task(handler.start()) - - proxy = relay.subscribe(tracked_track) - - self.emit("track_added", proxy, user) - - @self.on("icegatheringstatechange") - def on_icegatheringstatechange(): - logger.info(f"ICE gathering state changed to {self.iceGatheringState}") - if self.iceGatheringState == "complete": - logger.info("All ICE candidates have been gathered.") - - def add_track_subscriber( - self, track_id: str - ) -> Optional[aiortc.mediastreams.MediaStreamTrack]: - """Add a new subscriber to an existing track's MediaRelay.""" - track_data = self.track_map.get(track_id) - - video_drain = self._video_drains.pop(track_id, None) - - if video_drain is not None: - blackhole, drain_task, drain_proxy = video_drain - task = asyncio.create_task(blackhole.stop()) - drain_proxy.stop() - drain_task.cancel() - self._background_tasks.add(task) - task.add_done_callback(self._background_tasks.discard) - - if track_data: - relay, original_track = track_data - return relay.subscribe(original_track, buffered=False) - return None - - def handle_track_ended(self, track: aiortc.mediastreams.MediaStreamTrack) -> None: - logger.info(f"track ended: {track.id}") - - # Clean up stored references when track ends - if track.id in self.track_map: - del self.track_map[track.id] - if track.id in self.video_frame_trackers: - del self.video_frame_trackers[track.id] - self._video_drains.pop(track.id, None) - - def get_video_frame_tracker(self) -> Optional[Any]: - """Get a video frame tracker for stats collection. - - Note: Returns the first tracker by insertion order. When multiple video - tracks exist simultaneously (e.g., webcam + screenshare), this may not - match the track being actively consumed. Performance stats calculation - in StatsTracer mitigates this by selecting the highest-resolution track. - """ - if self.video_frame_trackers: - return next(iter(self.video_frame_trackers.values())) - return None - - async def close(self): - # Using self._closed guard here - # to avoid closing RTCPeerConnectionTwice by accident (it freezes on second time) - if self._closed: - return - - # Clean up video drains - for blackhole, drain_task, drain_proxy in list(self._video_drains.values()): - drain_task.cancel() - drain_proxy.stop() - await blackhole.stop() - self._video_drains.clear() - - # Cancel background tasks - for task in list(self._background_tasks): - task.cancel() - self._background_tasks.clear() - - # Clear track maps - self.track_map.clear() - self.video_frame_trackers.clear() - - self._closed = True - await super().close() - - async def restartIce(self): - """Restart ICE connection for reconnection scenarios.""" - logger.info("Restarting ICE connection for subscriber") - try: - # For subscriber, we typically wait for new offer from SFU - # This method serves as a placeholder for ICE restart coordination - logger.debug("Subscriber ICE restart initiated") - except Exception as e: - logger.error("Failed to restart subscriber ICE", exc_info=e) - raise diff --git a/getstream/video/rtc/peer_connection.py b/getstream/video/rtc/peer_connection.py deleted file mode 100644 index 685de269..00000000 --- a/getstream/video/rtc/peer_connection.py +++ /dev/null @@ -1,428 +0,0 @@ -""" -Manages WebRTC peer connections for video streaming. -""" - -import asyncio -import logging -from typing import Optional - -import aiortc -import aiortc.sdp -from aiortc.contrib.media import MediaRelay - -from getstream.common import telemetry -from getstream.video.rtc.connection_utils import ( - create_audio_track_info, - prepare_video_track_info, -) -from getstream.video.rtc.pb.stream.video.sfu.signal_rpc import signal_pb2 -from getstream.video.rtc.track_util import patch_sdp_offer -from getstream.video.rtc.twirp_client_wrapper import SfuRpcError -from getstream.video.rtc.pc import PublisherPeerConnection, SubscriberPeerConnection -from getstream.video.rtc.stats_reporter import DEFAULT_STATS_INTERVAL_MS -from getstream.video.rtc.stats_tracer import StatsTracer - -logger = logging.getLogger(__name__) - - -class PeerConnectionManager: - """Manages WebRTC peer connections for publishing and subscribing.""" - - def __init__(self, connection_manager, drain_video_frames: bool = True): - self.connection_manager = connection_manager - self._drain_video_frames = drain_video_frames - self.publisher_pc: Optional[PublisherPeerConnection] = None - self.subscriber_pc: Optional[SubscriberPeerConnection] = None - self.publisher_negotiation_lock = asyncio.Lock() - self.subscriber_negotiation_lock = asyncio.Lock() - # Stats tracers for getStats() delta compression - self.publisher_stats: Optional[StatsTracer] = None - self.subscriber_stats: Optional[StatsTracer] = None - - async def setup_subscriber(self): - """Setup subscriber peer connection.""" - if not self.subscriber_pc or self.subscriber_pc.connectionState in [ - "closed", - "failed", - ]: - self.subscriber_pc = SubscriberPeerConnection( - connection=self.connection_manager, - configuration=self._build_rtc_configuration(), - drain_video_frames=self._drain_video_frames, - ) - - # Trace create event - tracer = self.connection_manager.tracer - pc_id = self.connection_manager.pc_id("sub") - tracer.trace("create", pc_id, self._get_connection_config()) - - # Setup PC event tracing - self._setup_pc_tracing(self.subscriber_pc, pc_id) - - # Create stats tracer - self.subscriber_stats = StatsTracer( - self.subscriber_pc, "subscriber", DEFAULT_STATS_INTERVAL_MS / 1000 - ) - - @self.subscriber_pc.on("audio") - async def on_audio(pcm_data): - self.connection_manager.emit("audio", pcm_data) - - @self.subscriber_pc.on("track_added") - async def on_track_added(track, user): - """Handle track events from MediaRelay subscribers""" - await self.connection_manager.recording_manager.on_track_received( - track, user - ) - self.connection_manager.emit( - "track_added", track._source.id, track.kind, user - ) - - # Trace ontrack events for subscriber - @self.subscriber_pc.on("track") - def on_track_trace(track): - tracer.trace("ontrack", pc_id, f"{track.kind}:{track.id}") - - logger.debug("Created new subscriber peer connection") - else: - logger.debug("Reusing existing subscriber peer connection") - - async def add_tracks( - self, - audio: Optional[aiortc.mediastreams.MediaStreamTrack] = None, - video: Optional[aiortc.mediastreams.MediaStreamTrack] = None, - ): - """Add multiple audio and video tracks in a single negotiation.""" - if not self.connection_manager.running: - logger.error("Connection manager not running. Call connect() first.") - return - - if not audio and not video: - logger.warning("No tracks provided to add_tracks") - return - - relayed_audio = None - relayed_video = None - audio_info = None - video_info = None - relayed_audio = None - relayed_video = None - track_infos = [] - if audio: - audio_relay = MediaRelay() - relayed_audio = audio_relay.subscribe(audio) - audio_info = create_audio_track_info(relayed_audio) - if video: - video_relay = MediaRelay() - relayed_video = video_relay.subscribe(video) - video_info, relayed_video = await prepare_video_track_info(relayed_video) - - async with self.publisher_negotiation_lock: - logger.info(f"Adding tracks: {len(track_infos)} tracks") - - tracer = self.connection_manager.tracer - pc_id = self.connection_manager.pc_id("pub") - - if self.publisher_pc is None: - self.publisher_pc = PublisherPeerConnection( - manager=self.connection_manager, - configuration=self._build_rtc_configuration(), - ) - - # Trace create event - tracer.trace("create", pc_id, self._get_connection_config()) - - # Setup PC event tracing - self._setup_pc_tracing(self.publisher_pc, pc_id) - - # Create stats tracer - self.publisher_stats = StatsTracer( - self.publisher_pc, "publisher", DEFAULT_STATS_INTERVAL_MS / 1000 - ) - - if audio and relayed_audio: - self.publisher_pc.addTrack(relayed_audio) - logger.info(f"Added relayed audio track {relayed_audio.id}") - if video and relayed_video: - self.publisher_pc.addTrack(relayed_video) - logger.info(f"Added relayed video track {relayed_video.id}") - # Set frame tracker for video stats (BufferedMediaTrack has frame tracking) - if self.publisher_stats: - self.publisher_stats.set_frame_tracker(relayed_video) - - # Trace createOffer - tracer.trace("createOffer", pc_id, []) - with telemetry.start_as_current_span( - "rtc.publisher_pc.create_offer" - ) as span: - try: - offer = await self.publisher_pc.createOffer() - tracer.trace( - "createOfferOnSuccess", - pc_id, - {"type": "offer", "sdp": offer.sdp}, - ) - span.set_attribute("sdp", offer.sdp) - except Exception as e: - tracer.trace("createOfferOnFailure", pc_id, str(e)) - raise - - # Trace setLocalDescription - tracer.trace( - "setLocalDescription", pc_id, [{"type": offer.type, "sdp": offer.sdp}] - ) - with telemetry.start_as_current_span( - "rtc.publisher_pc.set_local_description" - ) as span: - span.set_attribute("sdp", offer.sdp) - try: - await self.publisher_pc.setLocalDescription(offer) - tracer.trace("setLocalDescriptionOnSuccess", pc_id, None) - except Exception as e: - tracer.trace("setLocalDescriptionOnFailure", pc_id, str(e)) - raise - - try: - patched_sdp = patch_sdp_offer(self.publisher_pc.localDescription.sdp) - parsed_sdp = aiortc.sdp.SessionDescription.parse(patched_sdp) - curr_mid = 0 - for media in parsed_sdp.media: - if ( - audio - and audio_info - and relayed_audio - and media.kind == "audio" - and relayed_audio.id == parsed_sdp.webrtc_track_id(media) - ): - audio_info.mid = str(curr_mid) - track_infos.append(audio_info) - curr_mid += 1 - if ( - video - and video_info - and relayed_video - and media.kind == "video" - and relayed_video.id == parsed_sdp.webrtc_track_id(media) - ): - video_info.mid = str(curr_mid) - track_infos.append(video_info) - logger.debug(f"Patched SDP offer: {patched_sdp}") - logger.debug(f"Tracks: {track_infos}") - response = ( - await self.connection_manager.twirp_signaling_client.SetPublisher( - ctx=self.connection_manager.twirp_context, - request=signal_pb2.SetPublisherRequest( - session_id=self.connection_manager.session_id, - sdp=patched_sdp, - tracks=track_infos, - ), - server_path_prefix="", - ) - ) - - # Trace setRemoteDescription - tracer.trace( - "setRemoteDescription", - pc_id, - [{"type": "answer", "sdp": response.sdp}], - ) - try: - await self.publisher_pc.handle_answer(response) - tracer.trace("setRemoteDescriptionOnSuccess", pc_id, None) - except Exception as e: - tracer.trace("setRemoteDescriptionOnFailure", pc_id, str(e)) - raise - with telemetry.start_as_current_span( - "rtc.publisher_pc.wait_for_connected" - ): - await self.publisher_pc.wait_for_connected() - except SfuRpcError as e: - logger.error(f"Failed to set publisher: {e}") - raise - - # Register ORIGINAL tracks and their MediaRelay instances for reconnection - track_info_index = 0 - if audio: - # Store original track info with its MediaRelay for reconnection - self.connection_manager.reconnector.reconnection_info.add_published_track( - audio.id, - audio, - track_infos[track_info_index], - relayed_audio, - ) - track_info_index += 1 - if video: - # Store original track info with its MediaRelay for reconnection - self.connection_manager.reconnector.reconnection_info.add_published_track( - video.id, - video, - track_infos[track_info_index], - relayed_video, - ) - - # Schedule one-off stats send after video track is published - if self.connection_manager.stats_reporter: - self.connection_manager.stats_reporter.schedule_one(3000) - - async def restore_published_tracks(self): - """Restore published tracks using their stored MediaRelay instances.""" - track_ids = list( - self.connection_manager.reconnector.reconnection_info.published_tracks.keys() - ) - logger.info( - f"Restoring {len(track_ids)} published tracks with MediaRelay - Track IDs: {track_ids}" - ) - - # Collect all tracks to restore - audio_tracks = [] - video_tracks = [] - - for ( - track_id, - track_info, - ) in self.connection_manager.reconnector.reconnection_info.published_tracks.items(): - original_track = track_info["track"] # Original track - - # Group tracks by type - if original_track.kind == "audio": - audio_tracks.append(original_track) - elif original_track.kind == "video": - video_tracks.append(original_track) - - # Restore tracks using the add_tracks method - # This ensures proper MediaRelay usage and peer connection management - try: - # Restore first audio and video track together if available - if audio_tracks or video_tracks: - await self.add_tracks( - audio=audio_tracks[0] if audio_tracks else None, - video=video_tracks[0] if video_tracks else None, - ) - logger.info("Restored primary audio/video tracks") - - # Restore additional audio tracks individually - for i, track in enumerate(audio_tracks[1:], 1): - await self.add_tracks(audio=track) - logger.info(f"Restored additional audio track {i}: {track.id}") - - # Restore additional video tracks individually - for i, track in enumerate(video_tracks[1:], 1): - await self.add_tracks(video=track) - logger.info(f"Restored additional video track {i}: {track.id}") - - logger.info( - f"Successfully restored all {len(track_ids)} tracks using stored MediaRelay instances" - ) - - except Exception as e: - logger.error("Failed to restore published tracks", exc_info=e) - raise - - async def close(self): - """Close all peer connections.""" - cleanup_tasks = [] - - if self.publisher_pc: - cleanup_tasks.append(self.publisher_pc.close()) - self.publisher_pc = None - if self.subscriber_pc: - cleanup_tasks.append(self.subscriber_pc.close()) - self.subscriber_pc = None - - # Clear stats tracers - self.publisher_stats = None - self.subscriber_stats = None - - # Run peer connection cleanup concurrently - if cleanup_tasks: - try: - await asyncio.gather(*cleanup_tasks, return_exceptions=True) - except Exception as e: - logger.debug(f"Error during peer connection cleanup: {e}") - - async def cleanup_connections(self, publisher_pc=None, subscriber_pc=None): - """Clean up specific peer connections.""" - cleanup_tasks = [] - - if publisher_pc: - cleanup_tasks.append(publisher_pc.close()) - if subscriber_pc: - cleanup_tasks.append(subscriber_pc.close()) - - # Run peer connection cleanup concurrently - if cleanup_tasks: - try: - await asyncio.gather(*cleanup_tasks, return_exceptions=True) - except Exception as e: - logger.debug(f"Error during peer connection cleanup: {e}") - - def _build_rtc_configuration(self) -> aiortc.RTCConfiguration: - """Build RTCConfiguration from coordinator credentials. - - Returns: - aiortc.RTCConfiguration with ICE servers from join response - """ - credentials = self.connection_manager.join_response.credentials - ice_servers = [ - aiortc.RTCIceServer( - urls=server.get("urls"), - username=server.get("username"), - credential=server.get("password") or server.get("credential"), - ) - for server in credentials.ice_servers - ] - return aiortc.RTCConfiguration( - iceServers=ice_servers, - bundlePolicy=aiortc.RTCBundlePolicy.MAX_BUNDLE, - ) - - def _get_connection_config(self) -> dict: - """Get the connection configuration for tracing. - - Returns: - Dict with ICE server configuration matching JS SDK format - """ - credentials = self.connection_manager.join_response.credentials - ice_servers = [ - { - "urls": server.get("urls"), - "username": server.get("username"), - "credential": server.get("password") or server.get("credential"), - } - for server in credentials.ice_servers - ] - return { - "url": credentials.server.edge_name, - "bundlePolicy": "max-bundle", - "iceServers": ice_servers, - } - - def _setup_pc_tracing(self, pc, pc_id: str) -> None: - """Attach event listeners that trace PC events. - - Args: - pc: The RTCPeerConnection to trace - pc_id: The PC ID for tracing (e.g., "0-pub", "0-sub") - """ - tracer = self.connection_manager.tracer - - @pc.on("signalingstatechange") - def on_signaling(): - tracer.trace("signalingstatechange", pc_id, pc.signalingState) - - @pc.on("iceconnectionstatechange") - def on_ice_conn(): - tracer.trace("iceconnectionstatechange", pc_id, pc.iceConnectionState) - - @pc.on("icegatheringstatechange") - def on_ice_gather(): - tracer.trace("icegatheringstatechange", pc_id, pc.iceGatheringState) - - @pc.on("connectionstatechange") - def on_conn(): - tracer.trace("connectionstatechange", pc_id, pc.connectionState) - - @pc.on("icecandidate") - def on_ice_candidate(candidate): - # Trace ICE candidate (may be None when gathering is complete) - tracer.trace("onicecandidate", pc_id, candidate) diff --git a/getstream/video/rtc/reconnection.py b/getstream/video/rtc/reconnection.py deleted file mode 100644 index 103f80d2..00000000 --- a/getstream/video/rtc/reconnection.py +++ /dev/null @@ -1,332 +0,0 @@ -""" -Handles reconnection logic for the video connection. -""" - -import asyncio -import logging -import time -from dataclasses import dataclass, field -from typing import Dict, Optional - -from getstream.video.rtc.connection_utils import ConnectionState - -logger = logging.getLogger(__name__) - -# Constants -_DISCONNECTION_TIMEOUT_SECONDS = 30 -_DEFAULT_MAX_ATTEMPTS = 3 -_DEFAULT_MIN_WAIT = 1 -_DEFAULT_MAX_WAIT = 5 - - -class ReconnectionStrategy: - """Reconnection strategies.""" - - UNSPECIFIED = "UNSPECIFIED" - FAST = "FAST" - REJOIN = "REJOIN" - MIGRATE = "MIGRATE" - DISCONNECT = "DISCONNECT" - - -@dataclass -class ReconnectionInfo: - """Stores information about the current reconnection attempt.""" - - strategy: str = ReconnectionStrategy.UNSPECIFIED - reason: str = "" - attempts: int = 0 - last_offline_timestamp: Optional[float] = None - published_tracks: Dict = field(default_factory=dict) - - def reset_state(self): - """Reset reconnection state.""" - self.strategy = ReconnectionStrategy.UNSPECIFIED - self.reason = "" - self.attempts = 0 - self.last_offline_timestamp = None - # Don't reset published_tracks as they're needed for restoration - - def add_published_track(self, track_id: str, track, track_info, media_relay): - """Store track info for reconnection.""" - self.published_tracks[track_id] = { - "track": track, - "track_info": track_info, - "media_relay": media_relay, - } - - -class ReconnectionManager: - """Manages reconnection logic and state for the ConnectionManager.""" - - def __init__(self, connection_manager): - self.connection_manager = connection_manager - self.reconnection_info = ReconnectionInfo() - self.set_network_event = None - self._reconnect_lock = asyncio.Lock() - self._fast_reconnect_deadline_seconds = 10 - - async def reconnect(self, strategy: str, reason: str): - """ - Main reconnection orchestrator. - - Args: - strategy: The reconnection strategy to use - reason: Human-readable reason for the reconnection - """ - async with self._reconnect_lock: - # Check if already in a reconnection state - if self.connection_manager.connection_state in [ - ConnectionState.RECONNECTING, - ConnectionState.MIGRATING, - ConnectionState.RECONNECTING_FAILED, - ]: - logger.debug( - "Reconnection already in progress", - extra={ - "current_state": self.connection_manager.connection_state.value - }, - ) - return - - reconnect_start_time = time.time() - self.reconnection_info.strategy = strategy - self.reconnection_info.reason = reason - - logger.info( - "Starting reconnection", extra={"strategy": strategy, "reason": reason} - ) - - try: - await self._execute_reconnection_loop(reconnect_start_time) - except Exception as e: - logger.error("Reconnection orchestrator failed", exc_info=e) - self.connection_manager.connection_state = ( - ConnectionState.RECONNECTING_FAILED - ) - self.connection_manager.emit("reconnection_failed", {"reason": str(e)}) - - async def _execute_reconnection_loop(self, reconnect_start_time: float): - """Execute the main reconnection retry loop.""" - while True: - # Check disconnection timeout - timeout = _DISCONNECTION_TIMEOUT_SECONDS - if 0 < timeout < (time.time() - reconnect_start_time): - logger.warning( - "Stopping reconnection attempts after reaching disconnection timeout" - ) - self.connection_manager.connection_state = ( - ConnectionState.RECONNECTING_FAILED - ) - self.connection_manager.emit( - "reconnection_failed", {"reason": "Disconnection timeout exceeded"} - ) - return - - # Increment attempts (except for FAST strategy) - if self.reconnection_info.strategy != ReconnectionStrategy.FAST: - self.reconnection_info.attempts += 1 - - try: - # Wait for network availability - if self.set_network_event: - logger.debug("Waiting for network availability") - await self.set_network_event.wait() - - logger.info( - f"Executing reconnection with strategy {self.reconnection_info.strategy}" - ) - - # Execute strategy-specific reconnection - await self._execute_reconnection_strategy() - - # If we reach here, reconnection was successful - duration = time.time() - reconnect_start_time - self.connection_manager.emit( - "reconnection_success", - {"strategy": self.reconnection_info.strategy, "duration": duration}, - ) - # Reset reconnection state after successful connection - self.reconnection_info.reset_state() - self.set_network_event = None - logger.debug("Reconnection state reset") - break - - except Exception as error: - if self.connection_manager.connection_state == ConnectionState.OFFLINE: - logger.debug("Can't reconnect while offline, stopping attempts") - break - - logger.warning( - f"Reconnection failed, attempting with REJOIN: {error}", - exc_info=error, - ) - await asyncio.sleep(0.5) # Brief delay before retry - self.reconnection_info.strategy = ReconnectionStrategy.REJOIN - - # Check if we should exit the loop - if self.connection_manager.connection_state in [ - ConnectionState.JOINED, - ConnectionState.RECONNECTING_FAILED, - ConnectionState.LEFT, - ]: - break - - logger.info("Reconnection flow finished") - - async def _execute_reconnection_strategy(self): - """Execute the strategy-specific reconnection logic.""" - strategy = self.reconnection_info.strategy - - if strategy == ReconnectionStrategy.FAST: - await self._reconnect_fast() - elif strategy == ReconnectionStrategy.REJOIN: - await self._reconnect_rejoin() - elif strategy == ReconnectionStrategy.MIGRATE: - await self._reconnect_migrate() - elif strategy == ReconnectionStrategy.DISCONNECT: - await self.connection_manager.leave() - return - else: - logger.debug(f"No-op strategy {strategy}") - - async def _reconnect_fast(self): - """Fast reconnection strategy - minimal disruption.""" - logger.info("Executing FAST reconnection strategy") - self.connection_manager.connection_state = ConnectionState.RECONNECTING - - try: - if ( - self.connection_manager.ws_client - and self.connection_manager.ws_client.running - ): - # Simple ICE restart if WebSocket is healthy - if self.connection_manager.publisher_pc: - await self.connection_manager.publisher_pc.restartIce() - logger.info("ICE restart completed for healthy WebSocket") - else: - # Full reconnection needed - self.connection_manager._connection_options.fast_reconnect = True - previous_ws_client = self.connection_manager.ws_client - - # Use _connect_internal with existing connection info. - # `self.join_response` is already the unwrapped data payload - # (see ConnectionManager._connect_internal: it stores - # `join_response.data`), so credentials live at the top level. - await self.connection_manager._connect_internal( - region=self.connection_manager._connection_options.region, - token=self.connection_manager.join_response.credentials.token - if self.connection_manager.join_response - else None, - ws_url=self.connection_manager.join_response.credentials.server.ws_endpoint - if self.connection_manager.join_response - else None, - session_id=self.connection_manager.session_id, - ) - - # Clean up old WebSocket after successful connection - if previous_ws_client: - previous_ws_client.close() - - # Restore published tracks with stored MediaRelay instances - await self.connection_manager._restore_published_tracks() - - self.connection_manager.connection_state = ConnectionState.JOINED - logger.info("FAST reconnection completed successfully") - except Exception as e: - logger.error("FAST reconnection failed", exc_info=e) - self.connection_manager.connection_state = ( - ConnectionState.RECONNECTING_FAILED - ) - raise - - async def _reconnect_rejoin(self): - """Rejoin reconnection strategy - full reconnection.""" - logger.info("Executing REJOIN reconnection strategy") - self.connection_manager.connection_state = ConnectionState.RECONNECTING - - # Increment generation counter BEFORE creating new PCs - self.connection_manager._sfu_client_tag += 1 - - # Store references to old connections for cleanup - old_publisher = self.connection_manager.publisher_pc - old_subscriber = self.connection_manager.subscriber_pc - old_ws_client = self.connection_manager.ws_client - - # Clear the old connections so new ones can be created - self.connection_manager.publisher_pc = None - self.connection_manager.subscriber_pc = None - self.connection_manager.ws_client = None - - try: - # Close old connections efficiently - await self.connection_manager._cleanup_connections( - old_ws_client, old_publisher, old_subscriber - ) - - # Use _connect_internal for fresh connection - await self.connection_manager._connect_internal() - - # Restore published tracks after successful reconnection - await self.connection_manager._restore_published_tracks() - - logger.info("REJOIN reconnection completed successfully") - except Exception as error: - logger.error("REJOIN reconnection failed", exc_info=error) - # Ensure connection state is properly set on failure - self.connection_manager.connection_state = ( - ConnectionState.RECONNECTING_FAILED - ) - raise - - async def _reconnect_migrate(self): - """Migration reconnection strategy - server-coordinated.""" - logger.info("Executing MIGRATE reconnection strategy") - - # Increment generation counter BEFORE creating new PCs - self.connection_manager._sfu_client_tag += 1 - - current_ws_client = self.connection_manager.ws_client - current_publisher = self.connection_manager.publisher_pc - current_subscriber = self.connection_manager.subscriber_pc - - # Clear old references so _connect_internal creates fresh PCs - self.connection_manager.publisher_pc = None - self.connection_manager.subscriber_pc = None - - self.connection_manager.connection_state = ConnectionState.MIGRATING - - if current_publisher and hasattr(current_publisher, "removeListener"): - current_publisher.removeListener("connectionstatechange") - if current_subscriber and hasattr(current_subscriber, "removeListener"): - current_subscriber.removeListener("connectionstatechange") - - try: - migrating_from = getattr(current_ws_client, "edge_name", None) - - # Set migration options for connection - if hasattr(self.connection_manager, "_connection_options"): - self.connection_manager._connection_options.fast_reconnect = False - self.connection_manager._connection_options.migrating_from = ( - migrating_from - ) - self.connection_manager._connection_options.previous_session_id = ( - self.connection_manager.session_id if migrating_from else None - ) - - # Use _connect_internal for migration - await self.connection_manager._connect_internal( - region=self.connection_manager._connection_options.region, - session_id=self.connection_manager.session_id, - ) - - await self.connection_manager._restore_published_tracks() - - self.connection_manager.connection_state = ConnectionState.JOINED - logger.info("MIGRATE reconnection completed successfully") - - finally: - # Clean up old connections - await self.connection_manager._cleanup_connections( - current_ws_client, current_publisher, current_subscriber - ) diff --git a/getstream/video/rtc/recording.py b/getstream/video/rtc/recording.py deleted file mode 100644 index 68cb9848..00000000 --- a/getstream/video/rtc/recording.py +++ /dev/null @@ -1,1341 +0,0 @@ -import asyncio -import fractions -import logging -import os -import time -from abc import ABC -from collections import defaultdict -from dataclasses import dataclass -from enum import Enum -from pathlib import Path -from typing import Any, Dict, List, Optional, Set, Union - -import numpy as np -from aiortc import MediaStreamTrack -from aiortc.contrib.media import MediaRecorder -from aiortc.mediastreams import AudioFrame as AiortcAudioFrame -from pyee.asyncio import AsyncIOEventEmitter - -logger = logging.getLogger(__name__) - - -class RecordingType(Enum): - TRACK = "track" - COMPOSITE = "composite" - - -class TrackType(Enum): - AUDIO = "audio" - VIDEO = "video" - - -@dataclass -class RecordingConfig: - """Configuration for recording.""" - - output_dir: str = "recordings" - max_queue_size: int = 10000 - frame_duration: float = 0.02 # 20ms - max_gap: float = 2.0 # 2 second - # Audio-specific config - audio_sample_rate: int = 48000 - audio_channels: int = 1 - audio_sample_width: int = 2 # 16-bit - # Video-specific config - video_width: int = 1280 - video_height: int = 720 - video_fps: int = 30 - video_bitrate: int = 2000000 # 2 Mbps - - -@dataclass -class Frame(ABC): - """Base class for all frame types.""" - - user_id: str - timestamp: float - data: bytes - metadata: Any = ( - None # Original source data (PcmData for audio, raw video data, etc.) - ) - - -@dataclass -class AudioFrame(Frame): - """Represents an audio frame with metadata.""" - - def __post_init__(self): - # Validate that this is audio data - if not isinstance(self.data, bytes): - raise ValueError("AudioFrame data must be bytes") - - -@dataclass -class VideoFrame(Frame): - """Represents a video frame with metadata.""" - - width: int = 0 - height: int = 0 - format: str = "unknown" - - -# Union type for all supported frame types -FrameType = Union[AudioFrame, VideoFrame] - - -class TrackRecorder(AsyncIOEventEmitter): - """Records a single track using MediaRecorder.""" - - def __init__( - self, - track_type: TrackType, - track: Optional[MediaStreamTrack] = None, - filename: Optional[str] = None, - recorder_id: Optional[str] = None, - config: Optional[RecordingConfig] = None, - ): - super().__init__() - self.track_type = track_type - self._track = track - self._filename = ( - filename or f"{track_type.value}_recording_{int(time.time() * 1000)}" - ) - self.recorder_id = ( - recorder_id or f"{track_type.value}_recorder_{int(time.time() * 1000)}" - ) - self.config = config or RecordingConfig() - self._recorder: Optional[MediaRecorder] = None - self._is_recording = False - self._start_time: Optional[float] = None - - @property - def is_recording(self) -> bool: - """Check if recording is active.""" - return self._is_recording - - @property - def filename(self) -> Optional[str]: - """Get the current recording filename.""" - return self._filename - - async def start_recording(self): - """Start recording using track and filename from constructor.""" - if self._is_recording: - logger.warning(f"TrackRecorder {self.recorder_id} is already recording") - return - - if not self._track: - raise ValueError(f"No track provided for recording {self.recorder_id}") - - try: - # Create output directory - file_format = "m4a" if self.track_type == TrackType.AUDIO else "mp4" - - # Check if filename already has an extension - if Path(self._filename).suffix: - filepath = Path(self.config.output_dir) / self._filename - else: - filepath = ( - Path(self.config.output_dir) / f"{self._filename}.{file_format}" - ) - - os.makedirs(filepath.parent, exist_ok=True) - - # Create MediaRecorder - self._recorder = MediaRecorder(str(filepath)) - logger.info( - f"TrackRecorder {self.recorder_id}: Created MediaRecorder for {filepath}" - ) - self._recorder.addTrack(self._track) - logger.info( - f"TrackRecorder {self.recorder_id}: Added track {type(self._track).__name__} to MediaRecorder" - ) - - # Start recording - await self._recorder.start() - logger.info( - f"TrackRecorder {self.recorder_id}: MediaRecorder started successfully" - ) - self._is_recording = True - self._start_time = time.time() - self._filename = str(filepath) - - self.emit( - "recording_started", - { - "recorder_id": self.recorder_id, - "track_type": self.track_type.value, - "filename": self._filename, - "timestamp": self._start_time, - }, - ) - - logger.info( - f"Started {self.track_type.value} recording {self.recorder_id}: {self._filename}" - ) - except Exception as e: - logger.error(f"Error starting recording for {self.recorder_id}: {e}") - self.emit( - "recording_error", - { - "recorder_id": self.recorder_id, - "track_type": self.track_type.value, - "error_type": "start_error", - "message": str(e), - "timestamp": time.time(), - }, - ) - raise - - async def stop_recording(self): - """Stop recording and close the file.""" - if not self._is_recording: - return - - try: - duration = time.time() - self._start_time if self._start_time else 0 - filename = self._filename - # Stop recording - if self._recorder: - await self._recorder.stop() - except Exception as e: - logger.error(f"Error stopping recording {self.recorder_id}: {e}") - finally: - # Clean up - self._recorder = None - self._is_recording = False - self.emit( - "recording_stopped", - { - "recorder_id": self.recorder_id, - "track_type": self.track_type.value, - "filename": filename, - "duration": duration, - "timestamp": time.time(), - }, - ) - - logger.info( - f"Stopped {self.track_type.value} recording {self.recorder_id} after {duration:.2f}s" - ) - - -class MixedAudioStreamTrack(MediaStreamTrack): - """Custom MediaStreamTrack that mixes multiple audio tracks using the existing recording.py logic.""" - - kind = "audio" - - def __init__(self, config: RecordingConfig, user_ids: Optional[Set[str]] = None): - super().__init__() - self.config = config - self.user_ids = user_ids - self._user_tracks: Dict[str, MediaStreamTrack] = {} - self._running = True - - # Mixing state (same as CompositeAudioRecorder) - self._frame_map: Dict[float, List[AudioFrame]] = defaultdict(list) - self._user_start_times: Dict[str, float] = {} - self._reference_time: Optional[float] = None - self._processed_time = 0.0 - self._frame_counters: Dict[str, int] = {} - - # Buffer for mixed frames - self._mixed_frame_buffer: asyncio.Queue = asyncio.Queue(maxsize=100) - self._mixing_task: Optional[asyncio.Task] = None - - # Debug counter - self._recv_call_count = 0 - - # Start the mixing task - self._start_mixing_task() - - def _start_mixing_task(self): - """Start the background mixing task.""" - if self._mixing_task is None or self._mixing_task.done(): - self._mixing_task = asyncio.create_task(self._mixing_loop()) - - async def _mixing_loop(self): - """Background task that continuously reads from tracks and produces mixed frames.""" - logger.debug("Started mixing loop for MixedAudioStreamTrack") - - while self._running: - try: - # Collect frames from all user tracks - frames_collected = [] - current_time = time.time() - - if not self._user_tracks: - await asyncio.sleep(0.01) # Wait longer if no tracks - continue - - for user_id, track in list(self._user_tracks.items()): - try: - # Try to receive frame with short timeout - aiortc_frame = await asyncio.wait_for( - track.recv(), timeout=0.001 - ) - - # Convert aiortc frame to our AudioFrame format - audio_bytes = self._aiortc_frame_to_bytes(aiortc_frame) # type: ignore[arg-type] - if audio_bytes: - frame = AudioFrame( - user_id=user_id, - data=audio_bytes, - timestamp=current_time, - metadata=aiortc_frame, - ) - frames_collected.append(frame) - - except (asyncio.TimeoutError, Exception): - # No frame available or error, continue - continue - - # Process collected frames using existing mixing logic - if frames_collected: - for frame in frames_collected: - mixed_audio = await self._process_frame(frame) - if mixed_audio: - # Convert mixed audio bytes back to aiortc AudioFrame - mixed_frame = self._bytes_to_aiortc_frame(mixed_audio) - if mixed_frame: - # Add to buffer (non-blocking, drop if full) - try: - self._mixed_frame_buffer.put_nowait(mixed_frame) - except asyncio.QueueFull: - # Drop oldest frame and add new one - try: - self._mixed_frame_buffer.get_nowait() - self._mixed_frame_buffer.put_nowait(mixed_frame) - except asyncio.QueueEmpty: - pass - - # Small delay to prevent busy waiting - await asyncio.sleep(0.005) # 5ms - - except Exception as e: - if self._running: - logger.error(f"Error in mixing loop: {e}") - await asyncio.sleep(0.01) - - logger.debug("Mixing loop stopped") - - async def recv(self) -> AiortcAudioFrame: - """Receive mixed audio frame from buffer.""" - self._recv_call_count += 1 - - if not self._running: - return self._create_silent_frame() - - # Wait for tracks to be available first - if not self._user_tracks: - # Wait up to 2 seconds for tracks to be added - for i in range(20): # 20 * 0.1s = 2s - if self._user_tracks: - logger.debug(f"Tracks now available after {i * 0.1:.1f}s") - break - await asyncio.sleep(0.1) - - if not self._user_tracks: - return self._create_silent_frame() - - # For the first few calls after tracks are available, wait longer for mixing to start - timeout = 3.0 if self._recv_call_count <= 5 else 0.1 - - try: - # Wait for mixed frame with timeout - mixed_frame = await asyncio.wait_for( - self._mixed_frame_buffer.get(), timeout=timeout - ) - return mixed_frame - except asyncio.TimeoutError: - # Return silent frame if no mixed audio available - return self._create_silent_frame() - except Exception as e: - logger.error(f"Error in recv(): {e}") - return self._create_silent_frame() - - def _create_silent_frame(self) -> AiortcAudioFrame: - """Create a silent audio frame.""" - samples_per_frame = int( - self.config.audio_sample_rate * self.config.frame_duration - ) - silent_samples = np.zeros( - (samples_per_frame, self.config.audio_channels), dtype=np.int16 - ) - - return AiortcAudioFrame( - format="s16", - layout="mono" if self.config.audio_channels == 1 else "stereo", - samples=silent_samples, - ) - - def _aiortc_frame_to_bytes(self, aiortc_frame: AiortcAudioFrame) -> Optional[bytes]: - """Convert aiortc AudioFrame to bytes, handling variable frame sizes.""" - try: - if hasattr(aiortc_frame, "to_ndarray"): - samples = aiortc_frame.to_ndarray() - - # Handle different array shapes - flatten to 1D - if samples.ndim > 1: - samples = samples.flatten() - - if samples.dtype != np.int16: - samples = samples.astype(np.int16) - - # Preserve full frame to maintain audio quality - return samples.tobytes() - else: - # Fallback: use planes - return aiortc_frame.planes[0].to_bytes() - except Exception as e: - logger.warning(f"Error converting aiortc frame to bytes: {e}") - return None - - def _bytes_to_aiortc_frame(self, audio_bytes: bytes) -> Optional[AiortcAudioFrame]: - """Convert bytes back to aiortc AudioFrame with normalized frame size.""" - try: - # Calculate expected frame size for consistent timing (20ms) - bytes_per_sample = 2 # 16-bit = 2 bytes per sample - expected_samples = int( - self.config.audio_sample_rate * self.config.frame_duration - ) - expected_bytes = ( - expected_samples * self.config.audio_channels * bytes_per_sample - ) - layout = "mono" if self.config.audio_channels == 1 else "stereo" - - # Normalize audio data to expected frame size to prevent stretched audio - normalized_bytes = self._normalize_audio_frame_size( - audio_bytes, expected_bytes - ) - - # Create AudioFrame with consistent frame size - frame = AiortcAudioFrame( - format="s16", layout=layout, samples=expected_samples - ) - - # Update the frame planes with our normalized audio data - for plane in frame.planes: - plane.update(normalized_bytes) - break # For audio, we typically only have one plane - - # Set additional frame properties - frame.sample_rate = self.config.audio_sample_rate - frame.time_base = fractions.Fraction(1, self.config.audio_sample_rate) - - return frame - except Exception as e: - logger.warning(f"Error converting bytes to aiortc frame: {e}") - return None - - def _normalize_audio_frame_size( - self, audio_bytes: bytes, target_bytes: int - ) -> bytes: - """Normalize audio frame size to target duration while preserving quality.""" - if len(audio_bytes) == target_bytes: - return audio_bytes - elif len(audio_bytes) > target_bytes: - # Input frame is longer than target - use resampling - samples = np.frombuffer(audio_bytes, dtype=np.int16) - target_samples = target_bytes // 2 # 2 bytes per sample - - if len(samples) > 0: - # Use linear interpolation to resample to target length - original_indices = np.linspace(0, len(samples) - 1, len(samples)) - target_indices = np.linspace(0, len(samples) - 1, target_samples) - - # Interpolate samples to new length - resampled = np.interp( - target_indices, original_indices, samples.astype(np.float32) - ) - resampled_int16 = np.clip(resampled, -32767, 32767).astype(np.int16) - - return resampled_int16.tobytes() - else: - return bytes(target_bytes) - else: - # Input frame is shorter than target - pad with silence - padding_bytes = target_bytes - len(audio_bytes) - return audio_bytes + bytes(padding_bytes) - - def add_track(self, user_id: str, track: MediaStreamTrack) -> None: - """Add a user track to the mix.""" - self._user_tracks[user_id] = track - logger.info( - f"Added user {user_id} track. Total tracks: {len(self._user_tracks)}" - ) - - # Signal that tracks are now available for mixing - if len(self._user_tracks) == 1: - logger.debug("First track added - mixing can now begin") - - def remove_track(self, user_id: str) -> None: - """Remove a user track from the mix.""" - if user_id in self._user_tracks: - del self._user_tracks[user_id] - # Clean up related state - if user_id in self._user_start_times: - del self._user_start_times[user_id] - if user_id in self._frame_counters: - del self._frame_counters[user_id] - logger.info(f"Removed user {user_id} track from mixed audio track") - - def has_tracks(self) -> bool: - """Check if there are any tracks in the mix.""" - return len(self._user_tracks) > 0 - - def stop(self) -> None: - """Stop the mixed track.""" - self._running = False - if self._mixing_task and not self._mixing_task.done(): - self._mixing_task.cancel() - - # Mixing logic methods (copied from CompositeAudioRecorder) - async def _process_frame(self, frame: AudioFrame) -> Optional[bytes]: - """Process composite frame with timestamp synchronization and mixing.""" - # Apply user filtering - if self.user_ids and frame.user_id not in self.user_ids: - return None - - # Extract timestamp - relative_timestamp = self._extract_timestamp(frame) - - # Update user timing - if frame.user_id not in self._user_start_times: - quantized_time = self._quantize_time(frame.timestamp) - self._user_start_times[frame.user_id] = quantized_time - logger.info(f"User {frame.user_id} started at {quantized_time:.3f}") - - if self._reference_time is None: - self._reference_time = self._user_start_times[frame.user_id] - logger.info(f"Reference time set to {self._reference_time:.3f}") - - # Calculate adjusted timestamp - user_absolute_time = self._user_start_times[frame.user_id] + relative_timestamp - adjusted_time = user_absolute_time - self._reference_time - quantized_time = self._quantize_time(adjusted_time) - - # Store frame in map - frame.timestamp = quantized_time # Update frame timestamp - self._frame_map[quantized_time].append(frame) - - # Process ready frames - return await self._process_ready_frames() - - async def _process_ready_frames(self) -> Optional[bytes]: - """Process and mix ready frames.""" - if not self._reference_time: - return None - - current_time = time.time() - current_recording_time = current_time - self._reference_time - quantized_current = self._quantize_time(current_recording_time) - max_process_time = quantized_current - self.config.frame_duration - - # Get timestamps to process - timestamps = [ - ts - for ts in sorted(self._frame_map.keys()) - if ts <= max_process_time and ts > self._processed_time - ] - - if not timestamps: - return None - - # Process the earliest timestamp - timestamp = timestamps[0] - frames = self._frame_map[timestamp] - - if frames: - # Mix frames (using exact same logic) - mixed_audio = self._mix_frames(frames) - del self._frame_map[timestamp] - self._processed_time = timestamp - - # Clean old timestamps - cutoff = quantized_current - self.config.max_gap - old_timestamps = [ts for ts in self._frame_map.keys() if ts < cutoff] - for ts in old_timestamps: - del self._frame_map[ts] - - return mixed_audio - - return None - - def _mix_frames(self, frames: List[AudioFrame]) -> Optional[bytes]: - """Mix multiple audio frames by proper audio mixing (addition with clipping).""" - if not frames: - return None - - # For single frame, return as-is to avoid unnecessary processing - if len(frames) == 1: - return frames[0].data - - # Convert audio data to samples - use int32 to prevent overflow during mixing - all_samples = [] - for frame in frames: - try: - # Keep as int16 initially, then convert to int32 for mixing - samples = np.frombuffer(frame.data, dtype=np.int16).astype(np.int32) - all_samples.append(samples) - except Exception as e: - logger.warning(f"Error converting frame audio data: {e}") - continue - - if not all_samples: - return None - - # Find common length - lengths = [samples.shape[0] for samples in all_samples] - target_length = max(set(lengths), key=lengths.count) if lengths else 0 - - if target_length == 0: - return None - - # Use int32 to prevent overflow during addition - mixed_samples = np.zeros(target_length, dtype=np.int32) - valid_count = 0 - - for i, samples in enumerate(all_samples): - try: - if samples.shape[0] == 0: - continue - - # Resize if needed - if samples.shape[0] != target_length: - if samples.shape[0] < target_length: - padded = np.zeros(target_length, dtype=np.int32) - padded[: samples.shape[0]] = samples - samples = padded - else: - samples = samples[:target_length] - - # ADD the samples (proper audio mixing) - mixed_samples += samples - valid_count += 1 - except Exception as e: - logger.warning(f"Error processing sample {i} in _mix_frames: {e}") - continue - - if valid_count > 0: - # Use averaging instead of volume scaling to prevent clipping - if valid_count > 1: - # Average the mixed samples by dividing by the number of valid tracks - mixed_samples = (mixed_samples / valid_count).astype(np.int32) - logger.debug(f"Averaged audio from {valid_count} tracks") - - # Clip to int16 range and convert back - int16_samples = np.clip(mixed_samples, -32767, 32767).astype(np.int16) - return int16_samples.tobytes() - - return None - - def _quantize_time(self, timestamp: float) -> float: - """Quantize timestamp to frame boundaries.""" - return ( - round(timestamp / self.config.frame_duration) * self.config.frame_duration - ) - - def _extract_timestamp(self, frame: AudioFrame) -> float: - """Extract or generate relative timestamp.""" - # Try to extract from metadata - if frame.metadata: - if ( - hasattr(frame.metadata, "pts_seconds") - and frame.metadata.pts_seconds is not None - ): - return float(frame.metadata.pts_seconds) - elif hasattr(frame.metadata, "pts") and hasattr( - frame.metadata, "time_base" - ): - if ( - frame.metadata.pts is not None - and frame.metadata.time_base is not None - ): - return float(frame.metadata.pts * frame.metadata.time_base) - - # Fallback: use frame counter - if frame.user_id not in self._frame_counters: - self._frame_counters[frame.user_id] = 0 - - timestamp = self._frame_counters[frame.user_id] * self.config.frame_duration - self._frame_counters[frame.user_id] += 1 - - return timestamp - - -class CompositeAudioRecorder(TrackRecorder): - """Records mixed audio from multiple tracks using MixedAudioStreamTrack.""" - - def __init__( - self, - user_ids: Optional[Set[str]] = None, - filename: Optional[str] = None, - config: Optional[RecordingConfig] = None, - ): - super().__init__( - TrackType.AUDIO, - None, - filename or "composite_audio", - "composite_audio", - config, - ) - self.user_ids = user_ids - self._mixed_track: Optional[MixedAudioStreamTrack] = None - - @property - def frame_count(self) -> int: - """Get frame count (placeholder for compatibility).""" - return 0 # MixedAudioStreamTrack doesn't expose this - - async def start_recording(self) -> None: - """Start composite recording using MixedAudioStreamTrack.""" - if self.is_recording: - logger.warning("Composite recording is already active") - return - - # Create mixed audio track - self._mixed_track = MixedAudioStreamTrack(self.config, self.user_ids) - - # Set the track in parent class - self._track = self._mixed_track - - # Start recording the mixed track using parent class - await super().start_recording() - - logger.info(f"Started composite audio recording: {self._filename}") - - async def stop_recording(self) -> None: - """Stop composite recording.""" - if not self.is_recording: - return - - # Stop the track recording - await super().stop_recording() - - # Clean up mixed track - if self._mixed_track: - self._mixed_track.stop() - self._mixed_track = None - - logger.info("Stopped composite audio recording") - - def add_user_track(self, user_id: str, track: MediaStreamTrack): - """Add a user track to the composite mix.""" - if self._mixed_track: - self._mixed_track.add_track(user_id, track) - - def remove_user_track(self, user_id: str): - """Remove a user track from the composite mix.""" - if self._mixed_track: - self._mixed_track.remove_track(user_id) - - def has_tracks(self) -> bool: - """Check if there are any tracks in the mix.""" - return self._mixed_track.has_tracks() if self._mixed_track else False - - -class RecordingManager(AsyncIOEventEmitter): - """ - Manages all audio and video recording operations. - - Events emitted: - - recording_started: {recording_types, user_ids, output_dir} - - recording_stopped: {recording_types, user_ids, duration} - - user_recording_started: {user_id, track_type, filename} - - user_recording_stopped: {user_id, track_type, filename, duration} - - composite_recording_started: {track_type, filename} - - composite_recording_stopped: {track_type, filename, duration} - - recording_error: {error_type, user_id, track_type, message} - """ - - def __init__(self, config: Optional[RecordingConfig] = None): - super().__init__() - - self.config = config or RecordingConfig() - - # Tracks that were received before recording started. - # Mapping of track_id -> (user_id, track) - self._pending_tracks: Dict[str, tuple[str, MediaStreamTrack]] = {} - - # Recording state - self._recording_types: Set[RecordingType] = set() - self._target_user_ids: Optional[Set[str]] = None - - # Track recorders organized by track type - self._audio_recorders: Dict[str, TrackRecorder] = {} - self._video_recorders: Dict[str, TrackRecorder] = {} - self._composite_audio_recorder: Optional[CompositeAudioRecorder] = None - - self._recording_start_time: Optional[float] = None - - @property - def is_recording(self) -> bool: - """Check if any recording is currently active.""" - return len(self._recording_types) > 0 - - @property - def recording_types(self) -> Set[RecordingType]: - """Get currently active recording types.""" - return self._recording_types.copy() - - async def start_recording( - self, - recording_types: List[RecordingType], - user_ids: Optional[List[str]] = None, - output_dir: Optional[str] = None, - ): - """Start recording with specified types and configuration.""" - if not recording_types: - logger.warning("No recording types specified") - return - - # Update config if output_dir provided - if output_dir: - self.config.output_dir = output_dir - - self._target_user_ids = set(user_ids) if user_ids else None - self._recording_start_time = time.time() - - # Create output directory - os.makedirs(self.config.output_dir, exist_ok=True) - - # Start recording types - for recording_type in recording_types: - await self._start_recording_type(recording_type) - self._recording_types.add(recording_type) - - # Set up event forwarding for new recorders - self._setup_recorder_events() - - # Process any tracks that were received before recording was enabled. - await self._process_pending_tracks() - - self.emit( - "recording_started", - { - "recording_types": [rt.value for rt in recording_types], - "user_ids": user_ids, - "output_dir": self.config.output_dir, - "timestamp": self._recording_start_time, - }, - ) - - logger.info(f"Started recording: {[rt.value for rt in recording_types]}") - - async def stop_recording( - self, - recording_types: Optional[List[RecordingType]] = None, - user_ids: Optional[List[str]] = None, - ): - """Stop recording for specified types and users.""" - if not self.is_recording: - logger.warning("No recording is currently active") - return - - # If no types specified, stop all - if recording_types is None: - recording_types = list(self._recording_types) - - # Stop each requested recording type - for recording_type in recording_types: - if recording_type not in self._recording_types: - logger.warning(f"{recording_type.value} recording is not active") - continue - - await self._stop_recording_type(recording_type, user_ids) - self._recording_types.discard(recording_type) - - # If all recording stopped, emit final event - if not self.is_recording: - duration = ( - time.time() - self._recording_start_time - if self._recording_start_time - else 0 - ) - - self.emit( - "recording_stopped", - { - "recording_types": [rt.value for rt in recording_types], - "user_ids": user_ids, - "duration": duration, - "timestamp": time.time(), - }, - ) - - logger.info("All recording stopped") - - async def on_user_left(self, user_id: str): - """Handle user leaving the call.""" - # Remove from composite recorder if it exists - if self._composite_audio_recorder: - self._composite_audio_recorder.remove_user_track(user_id) - - # Remove from pending tracks - for track_id in list(self._pending_tracks.keys()): - pending_user, _ = self._pending_tracks[track_id] - if pending_user == user_id: - del self._pending_tracks[track_id] - - # Stop track recording (both audio and video) - await self._stop_user_recording(user_id) - logger.info(f"Stopped recording for user {user_id} who left the call") - - async def on_track_removed(self, user_id: str, track_type: str): - """Handle track removal.""" - if track_type in ["audio", "video"]: - recorder_key = f"{user_id}_{track_type}" - await self._stop_user_recording_by_key(recorder_key, track_type) - logger.info( - f"Stopped {track_type} recording for user {user_id} whose {track_type} track was removed" - ) - - # Remove from pending - for track_id in list(self._pending_tracks.keys()): - pending_user, pending_track = self._pending_tracks[track_id] - if pending_user == user_id and pending_track.kind == track_type: - del self._pending_tracks[track_id] - - async def on_track_received(self, track, user): - """Handle new track received from track event.""" - if not track or track.kind not in ["audio", "video"]: - return - - user_id = getattr( - user, "user_id", getattr(user, "id", str(user) if user else "unknown_user") - ) - - # Apply user filtering - if self._target_user_ids and user_id not in self._target_user_ids: - return - - logger.info(f"Processing {track.kind} track for user {user_id}") - - # Handle audio tracks - if track.kind == "audio": - # Add to composite recorder if it exists - if self._composite_audio_recorder: - self._composite_audio_recorder.add_user_track(user_id, track) - - # Start track recording if enabled (both audio and video) - if RecordingType.TRACK in self._recording_types: - await self._start_user_track_recording(user_id, track) - - # Cache track so that we can start recording later if recording is not - # yet enabled. - if RecordingType.TRACK not in self._recording_types: - self._pending_tracks[track.id] = (user_id, track) - - async def _start_user_track_recording(self, user_id: str, track: MediaStreamTrack): - """Start recording a user track using MediaRecorder.""" - track_type = TrackType.AUDIO if track.kind == "audio" else TrackType.VIDEO - recorders_dict = ( - self._audio_recorders if track.kind == "audio" else self._video_recorders - ) - - # Check if user already has this type of track recording - recorder_key = f"{user_id}_{track.kind}" - if recorder_key in recorders_dict and recorders_dict[recorder_key].is_recording: - logger.debug(f"User {user_id} {track.kind} recording already active") - return - - try: - timestamp = int(time.time()) - filename = f"user_{user_id}_{track.kind}_{timestamp}" - - # Create track recorder with track and filename - track_recorder = TrackRecorder( - track_type, track, filename, f"user_{user_id}_{track.kind}", self.config - ) - - # Forward events - @track_recorder.on("recording_started") - def on_recording_started(data): - self.emit( - "user_recording_started", - { - "user_id": user_id, - "track_type": data["track_type"], - "filename": data["filename"], - "timestamp": data["timestamp"], - }, - ) - - @track_recorder.on("recording_stopped") - def on_recording_stopped(data): - self.emit( - "user_recording_stopped", - { - "user_id": user_id, - "track_type": data["track_type"], - "filename": data["filename"], - "duration": data["duration"], - "timestamp": data["timestamp"], - }, - ) - - @track_recorder.on("recording_error") - def on_recording_error(data): - self.emit( - "recording_error", - { - "error_type": data["error_type"], - "user_id": user_id, - "track_type": data["track_type"], - "message": data["message"], - }, - ) - - # Start recording (track and filename already provided in constructor) - await track_recorder.start_recording() - recorders_dict[recorder_key] = track_recorder - - except Exception as e: - logger.error( - f"Failed to start {track.kind} recording for user {user_id}: {e}" - ) - self.emit( - "recording_error", - { - "error_type": "start_user_recording", - "user_id": user_id, - "track_type": track.kind, - "message": str(e), - }, - ) - - async def _start_recording_type(self, recording_type: RecordingType) -> None: - """Start a specific recording type.""" - if recording_type == RecordingType.COMPOSITE: - # Generate filename - timestamp = int(time.time()) - - # Optimize filename generation for large user lists - if self._target_user_ids: - sorted_users = sorted(self._target_user_ids) - if len(sorted_users) > 5: # Limit filename length for many users - user_suffix = f"_users_{len(sorted_users)}_users" - else: - user_suffix = f"_users_{'_'.join(sorted_users)}" - else: - user_suffix = "" - - filename = f"composite_audio_{timestamp}{user_suffix}" - - # Create composite recorder with filename - self._composite_audio_recorder = CompositeAudioRecorder( - self._target_user_ids, filename, self.config - ) - self._setup_composite_recorder_events() - - # Start composite recording - await self._composite_audio_recorder.start_recording() - - async def _stop_recording_type( - self, recording_type: RecordingType, user_ids: Optional[List[str]] = None - ): - """Stop a specific recording type.""" - if recording_type == RecordingType.TRACK: - if user_ids: - for user_id in user_ids: - await self._stop_user_recording(user_id) - else: - # Stop all user recordings (both audio and video) - for recorder_key in list(self._audio_recorders.keys()): - await self._stop_user_recording_by_key(recorder_key, "audio") - for recorder_key in list(self._video_recorders.keys()): - await self._stop_user_recording_by_key(recorder_key, "video") - - elif ( - recording_type == RecordingType.COMPOSITE and self._composite_audio_recorder - ): - await self._composite_audio_recorder.stop_recording() - self._composite_audio_recorder = None - - async def _stop_user_recording(self, user_id: str): - """Stop all recordings for a specific user (both audio and video).""" - # Stop audio recording - audio_key = f"{user_id}_audio" - if audio_key in self._audio_recorders: - await self._stop_user_recording_by_key(audio_key, "audio") - - # Stop video recording - video_key = f"{user_id}_video" - if video_key in self._video_recorders: - await self._stop_user_recording_by_key(video_key, "video") - - async def _stop_user_recording_by_key(self, recorder_key: str, track_type: str): - """Stop recording for a specific recorder key.""" - recorders_dict = ( - self._audio_recorders if track_type == "audio" else self._video_recorders - ) - - if recorder_key in recorders_dict: - recorder = recorders_dict[recorder_key] - await recorder.stop_recording() - del recorders_dict[recorder_key] - - def _setup_recorder_events(self): - """Set up event forwarding for all current recorders.""" - # Set up events for existing audio recorders - for user_id, recorder in self._audio_recorders.items(): - self._setup_audio_recorder_events(recorder, user_id) - - # Set up events for composite recorder - if self._composite_audio_recorder: - self._setup_composite_recorder_events() - - def _create_event_forwarder( - self, source_event: str, target_event: str, transform_data=None - ): - """Create a generic event forwarder function.""" - - def forwarder(data): - if transform_data: - data = transform_data(data) - self.emit(target_event, data) - - return forwarder - - def _setup_audio_recorder_events(self, recorder: TrackRecorder, user_id: str): - """Set up event forwarding for an audio recorder.""" - - # Transform function to add user_id to the event data - def add_user_id(data): - return { - "user_id": user_id, - "track_type": data["track_type"], - "filename": data["filename"], - "timestamp": data["timestamp"], - **( - { - k: v - for k, v in data.items() - if k not in ["track_type", "filename", "timestamp"] - } - ), - } - - def add_user_id_error(data): - return { - "error_type": data["error_type"], - "user_id": user_id, - "track_type": data["track_type"], - "message": data["message"], - } - - recorder.on( - "recording_started", - self._create_event_forwarder( - "recording_started", "user_recording_started", add_user_id - ), - ) - recorder.on( - "recording_stopped", - self._create_event_forwarder( - "recording_stopped", "user_recording_stopped", add_user_id - ), - ) - recorder.on( - "recording_error", - self._create_event_forwarder( - "recording_error", "recording_error", add_user_id_error - ), - ) - - def _setup_composite_recorder_events(self): - """Set up event forwarding for the composite recorder.""" - if not self._composite_audio_recorder: - return - - # Transform function for composite events - def add_composite_context(data): - return { - "track_type": data["track_type"], - "filename": data["filename"], - "timestamp": data["timestamp"], - **( - { - k: v - for k, v in data.items() - if k not in ["track_type", "filename", "timestamp"] - } - ), - } - - def add_composite_error_context(data): - return { - "error_type": data["error_type"], - "user_id": "composite", - "track_type": data["track_type"], - "message": data["message"], - } - - self._composite_audio_recorder.on( - "recording_started", - self._create_event_forwarder( - "recording_started", - "composite_recording_started", - add_composite_context, - ), - ) - self._composite_audio_recorder.on( - "recording_stopped", - self._create_event_forwarder( - "recording_stopped", - "composite_recording_stopped", - add_composite_context, - ), - ) - self._composite_audio_recorder.on( - "recording_error", - self._create_event_forwarder( - "recording_error", "recording_error", add_composite_error_context - ), - ) - - def _to_bytes(self, pcm_data) -> bytes: - """Convert PCM data to bytes.""" - if isinstance(pcm_data, bytes): - return pcm_data - - if hasattr(pcm_data, "samples"): - samples = pcm_data.samples - if isinstance(samples, bytes): - return samples - elif isinstance(samples, np.ndarray): - return samples.astype(np.int16).tobytes() - else: - raise ValueError(f"Unsupported PcmData.samples type: {type(samples)}") - - if isinstance(pcm_data, np.ndarray): - return pcm_data.astype(np.int16).tobytes() - - if isinstance(pcm_data, memoryview): - return bytes(pcm_data) - - try: - return bytes(pcm_data) - except Exception as e: - raise ValueError(f"Cannot convert {type(pcm_data)} to bytes: {e}") - - def get_recording_status(self) -> dict: - """Get current recording status.""" - # Combine audio and video recorder keys - all_active_recordings = list(self._audio_recorders.keys()) + list( - self._video_recorders.keys() - ) - - return { - "recording_enabled": self.is_recording, - "recording_types": [rt.value for rt in self._recording_types], - "output_directory": str(self.config.output_dir), - "active_user_recordings": all_active_recordings, - "target_user_ids": list(self._target_user_ids) - if self._target_user_ids - else None, - "composite_active": RecordingType.COMPOSITE in self._recording_types, - "individual_audio_recorders": { - recorder_key: { - "filename": recorder.filename, - "frame_count": 0, # TrackRecorder doesn't track frame count - "is_recording": recorder.is_recording, - "track_type": recorder.track_type.value, - } - for recorder_key, recorder in self._audio_recorders.items() - }, - "individual_video_recorders": { - recorder_key: { - "filename": recorder.filename, - "frame_count": 0, # TrackRecorder doesn't track frame count - "is_recording": recorder.is_recording, - "track_type": recorder.track_type.value, - } - for recorder_key, recorder in self._video_recorders.items() - }, - "composite_audio_recorder": { - "filename": self._composite_audio_recorder.filename - if self._composite_audio_recorder - else None, - "frame_count": self._composite_audio_recorder.frame_count - if self._composite_audio_recorder - else 0, - "is_recording": self._composite_audio_recorder.is_recording - if self._composite_audio_recorder - else False, - "track_type": "audio", - } - if self._composite_audio_recorder - else None, - "config": { - "max_queue_size": self.config.max_queue_size, - "frame_duration": self.config.frame_duration, - "audio_sample_rate": self.config.audio_sample_rate, - "video_width": self.config.video_width, - "video_height": self.config.video_height, - "video_fps": self.config.video_fps, - "video_bitrate": self.config.video_bitrate, - }, - } - - def record_audio_data(self, pcm_data, user_id: str): - """ - Legacy method for recording audio data directly. - This is a no-op since the current implementation uses track-based recording. - """ - logger.debug( - f"record_audio_data called for user {user_id} - this is a legacy method and will be ignored" - ) - pass - - async def cleanup(self): - """Clean up all resources.""" - # Stop all recording - if self.is_recording: - await self.stop_recording() - - # Clean up all recorders - for recorder in self._audio_recorders.values(): - if hasattr(recorder, "stop_recording"): - await recorder.stop_recording() - self._audio_recorders.clear() - - for recorder in self._video_recorders.values(): - if hasattr(recorder, "stop_recording"): - await recorder.stop_recording() - self._video_recorders.clear() - - if self._composite_audio_recorder: - await self._composite_audio_recorder.stop_recording() - self._composite_audio_recorder = None - - logger.info("RecordingManager cleanup completed") - - async def _process_pending_tracks(self): - """Start recording for any tracks that were received before recording was enabled.""" - if not self._pending_tracks: - return - - for track_id, (user_id, track) in list(self._pending_tracks.items()): - try: - # Filter by requested user_ids if provided - if self._target_user_ids and user_id not in self._target_user_ids: - continue - - # Start individual track recording if requested - if RecordingType.TRACK in self._recording_types: - await self._start_user_track_recording(user_id, track) - - # Add to composite recorder if requested and audio track - if ( - RecordingType.COMPOSITE in self._recording_types - and track.kind == "audio" - and self._composite_audio_recorder is not None - ): - self._composite_audio_recorder.add_user_track(user_id, track) - - # Remove from pending once handled - del self._pending_tracks[track_id] - except Exception as e: - logger.warning(f"Error processing pending track {track_id}: {e}") diff --git a/getstream/video/rtc/signaling.py b/getstream/video/rtc/signaling.py deleted file mode 100644 index 81ad4c45..00000000 --- a/getstream/video/rtc/signaling.py +++ /dev/null @@ -1,370 +0,0 @@ -import asyncio -import logging -import threading -import time -from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional, Set - -import websocket - -from getstream.common import telemetry -from getstream.utils import StreamAsyncIOEventEmitter - -from .pb.stream.video.sfu.event import events_pb2 - -if TYPE_CHECKING: - from getstream.video.rtc.tracer import Tracer - -logger = logging.getLogger(__name__) - -# SFU events that should be traced for debugging -SFU_EVENTS_TO_TRACE: Set[str] = { - "change_publish_quality", - "go_away", - "error", - "call_ended", -} - - -class SignalingError(Exception): - """Exception raised for errors in the signaling process.""" - - def __init__(self, message: str, error=None): - super().__init__(message) - self.error = error - - -class WebSocketClient(StreamAsyncIOEventEmitter): - """ - WebSocket client for Stream Video signaling. - Handles WebSocket connection, message serialization/deserialization, and event handling. - """ - - def __init__( - self, - url: str, - join_request: events_pb2.JoinRequest, - main_loop: asyncio.AbstractEventLoop, - tracer: Optional["Tracer"] = None, - sfu_id_fn: Optional[Callable[[], Optional[str]]] = None, - ): - """ - Initialize a new WebSocket client. - - Args: - url: The WebSocket server URL - join_request: The JoinRequest protobuf message to send for authentication - main_loop: The main asyncio event loop to run callbacks on - tracer: Optional tracer for SFU event tracing - sfu_id_fn: Optional function that returns the current SFU ID for tracing - """ - super().__init__() - self.url = url - self.join_request = join_request - self.main_loop = main_loop - self.ws = None - self.first_message_event = threading.Event() - self.first_message = None - self.thread = None - self.running = False - self.closed = False - self._connection_lost_sent = False - # Set when a `call_ended` SFU event arrives. Read by `_on_close` to - # suppress `connection_lost`, since the close frame that follows carries - # no distinguishing status code or reason. - self._call_ended = False - - # For ping/health check mechanism - self.ping_thread = None - self.last_health_check_time = 0 - self.ping_interval = 10 # seconds - # Capture the current span so we can re-attach it in background threads - self.parent_span = telemetry.get_current_span() - - # Tracer for SFU event tracing - self._tracer = tracer - self._sfu_id_fn = sfu_id_fn - - async def connect(self): - """ - Establish WebSocket connection and authenticate. - - Returns: - The first SfuEvent received (JoinResponse if successful) - - Raises: - SignalingError: If the connection fails or the first message is an error - """ - if self.closed: - raise SignalingError("Cannot reconnect a closed WebSocket client") - - self.first_message_event.clear() - - # Create a new WebSocketApp - self.ws = websocket.WebSocketApp( - self.url, - on_open=self._on_open, - on_message=self._on_message, - on_error=self._on_error, - on_close=self._on_close, - ) - - # Start WebSocket in a separate thread - self.running = True - self.thread = threading.Thread(target=self._run_websocket, daemon=True) - self.thread.start() - - # Wait for first message - await asyncio.get_event_loop().run_in_executor( - None, self.first_message_event.wait - ) - - # Check if the first message is an error - if self.first_message and self.first_message.HasField("error"): - sfu_error = self.first_message.error.error - raise SignalingError( - f"Connection failed: {sfu_error.message}", error=sfu_error - ) - - # Check if we got join_response - if self.first_message and self.first_message.HasField("join_response"): - # Start the ping mechanism after successful connection - self._start_ping_handler() - return self.first_message - - raise SignalingError("Unexpected first message type") - - def _run_websocket(self): - """Run the WebSocket connection in the background.""" - if self.ws is None: - return - # Ensure handlers run under the same parent span (contextvars don't propagate across threads) - with telemetry.attach_span(self.parent_span): - self.ws.run_forever() - - def _on_open(self, ws): - """Handle WebSocket open event.""" - logger.debug("WebSocket connection established") - - if self._tracer is not None: - from google.protobuf.json_format import MessageToDict - - sfu_id = self._sfu_id_fn() if self._sfu_id_fn else None - self._tracer.trace("signal.ws.open", sfu_id, {"isTrusted": True}) - self._tracer.trace( - "joinRequest", - sfu_id, - { - "requestPayload": { - "oneofKind": "joinRequest", - "joinRequest": MessageToDict(self.join_request), - } - }, - ) - - request = events_pb2.SfuRequest(join_request=self.join_request) - ws.send(request.SerializeToString()) - - def _on_message(self, ws, message): - """Handle incoming WebSocket messages.""" - - event = events_pb2.SfuEvent() - event.ParseFromString(message) - event_type = event.WhichOneof("event_payload") - logger.debug(f"WebSocket message received {event_type}") - - if event_type and event_type == "call_ended": - self._call_ended = True - - if event.HasField("health_check_response"): - logger.debug("received health check response") - self.last_health_check_time = time.time() - - # Trace certain SFU events for debugging - if ( - event_type - and event_type in SFU_EVENTS_TO_TRACE - and self._tracer is not None - ): - sfu_id = self._sfu_id_fn() if self._sfu_id_fn else None - try: - from google.protobuf.json_format import MessageToDict - - payload = getattr(event, event_type) - # Use default camelCase field names - event_data = MessageToDict(payload) - self._tracer.trace(event_type, sfu_id, event_data) - except Exception: - pass - - # If this is the first message, set it and trigger the event - if not self.first_message_event.is_set(): - self.first_message = event - self.first_message_event.set() - - # Dispatch to event handlers on the main loop; re-attach the parent span inside the coroutine - asyncio.run_coroutine_threadsafe(self._dispatch_event(event), self.main_loop) - - def _on_error(self, ws, error): - """Handle WebSocket error.""" - is_close_frame = ( - isinstance(error, websocket.ABNF) - and error.opcode == websocket.ABNF.OPCODE_CLOSE - ) - if is_close_frame or not self.running: - # Close-frame-as-error or post-disconnect teardown noise. - logger.debug(f"WebSocket error after disconnect: {error}") - else: - logger.error(f"WebSocket error: {error}") - - if not self.first_message_event.is_set(): - # Mid-handshake failure: unblock `connect()` with a synthetic - # error event instead of leaving it parked on `first_message_event`. - error_event = events_pb2.SfuEvent() - error_event.error.error.message = str(error) - self.first_message = error_event - self.first_message_event.set() - elif not self.closed and not is_close_frame: - self._notify_connection_lost(f"error: {error}") - - def _on_close(self, ws, close_status_code, close_msg): - """Handle WebSocket close event.""" - logger.debug(f"WebSocket connection closed: {close_status_code} {close_msg}") - was_unexpected = not self.closed and not self._call_ended - self.running = False - if was_unexpected: - self._notify_connection_lost( - f"closed by remote (code={close_status_code} msg={close_msg})" - ) - - def _notify_connection_lost(self, reason: str) -> None: - """Schedule a ``connection_lost`` emit on the main loop. - - Idempotent per ``WebSocketClient`` instance — only the first call - per disconnect actually emits. Callers run on the WS worker thread - or ``_ping_loop`` thread; pyee schedules async listeners via - ``loop.create_task``, which is not thread-safe, hence the hop. - Same pattern as ``_on_message`` for SFU events. - """ - if not self._claim_connection_lost(): - return - try: - asyncio.run_coroutine_threadsafe( - self._emit_connection_lost(reason), - self.main_loop, - ) - except Exception: - logger.exception("Failed to schedule connection_lost emit") - - def _claim_connection_lost(self) -> bool: - """Return True if this is the first connection-lost notification.""" - if self._connection_lost_sent: - return False - self._connection_lost_sent = True - return True - - async def _emit_connection_lost(self, reason: str) -> None: - with telemetry.attach_span(self.parent_span): - self.emit("connection_lost", reason) - - def _start_ping_handler(self): - """Start the ping mechanism in a background thread.""" - if self.ping_thread is not None: - return # Already started - - # Set initial health check time - self.last_health_check_time = time.time() - - # Start thread for sending pings - self.ping_thread = threading.Thread(target=self._ping_loop, daemon=True) - self.ping_thread.start() - logger.debug("Started ping handler") - - def _ping_loop(self): - """Run periodic health checks to keep the connection alive.""" - logger.debug("Ping loop started") - - while self.running and not self.closed: - # Check if we haven't received a health check response in more than twice the ping interval - current_time = time.time() - if current_time - self.last_health_check_time > self.ping_interval * 2: - logger.warning("Health check failed, closing connection") - # Notify before close() so the owner can reconnect; close() - # itself sets `self.closed=True` and would suppress the - # notification in `_on_close`. - self._notify_connection_lost("health check timeout") - self.close() - return - - # Skip if the connection is closed - if not self.running or self.closed or self.ws is None: - return - - try: - # Create and send health check request - health_check_req = events_pb2.HealthCheckRequest() - sfu_request = events_pb2.SfuRequest( - health_check_request=health_check_req - ) - - # Send the serialized request - self.ws.send(sfu_request.SerializeToString()) - logger.debug("Sent health check request") - except Exception as e: - logger.error(f"Failed to send health check request: {e}") - - # Sleep for ping interval - time.sleep(self.ping_interval) - - async def _dispatch_event(self, event): - """ - Dispatch an event to the appropriate handlers using emit. - - Args: - event: The SfuEvent to dispatch - """ - # Get the oneof event type - event_type = event.WhichOneof("event_payload") - if event_type: - payload = getattr(event, event_type) - # Ensure callbacks run within the captured parent span context as well - with telemetry.attach_span(self.parent_span): - self.emit(event_type, payload) - - def on_event(self, event_type: str, callback: Callable[[Any], Awaitable[None]]): - """ - Register an event handler for a specific event type. - - Args: - event_type: The event type to listen for, or "*" for all events - callback: An async function to call when the event occurs - """ - # Use the parent class's on method for regular events - # For wildcard events, use on_wildcard method - if "*" in event_type: - self.on_wildcard(event_type, callback) - else: - self.on(event_type, callback) - - def close(self): - """Close the WebSocket connection.""" - if self.closed: - return - - self.closed = True - self.running = False - - if self.ws: - self.ws.close() - self.ws = None - - if self.thread: - self.thread.join(timeout=2.0) - self.thread = None - - if self.ping_thread: - self.ping_thread.join(timeout=2.0) - self.ping_thread = None - - # Clear event listeners from parent class - self.remove_all_listeners() - self.remove_all_wildcard_listeners() diff --git a/getstream/video/rtc/stats_reporter.py b/getstream/video/rtc/stats_reporter.py deleted file mode 100644 index 003409d4..00000000 --- a/getstream/video/rtc/stats_reporter.py +++ /dev/null @@ -1,277 +0,0 @@ -""" -Orchestrates stats collection and sending to SFU. -""" - -import asyncio -import json -import logging -from typing import TYPE_CHECKING, Any, List, Optional - -import aiortc - -from getstream.version import VERSION -from getstream.video.rtc.pb.stream.video.sfu.signal_rpc import signal_pb2 -from getstream.video.rtc.tracer import TraceSlice -from getstream.video.rtc.stats_tracer import _sanitize_value - -if TYPE_CHECKING: - from getstream.video.rtc.connection_manager import ConnectionManager - from getstream.video.rtc.stats_tracer import ComputedStats - -logger = logging.getLogger(__name__) - - -def _flatten_stats(report) -> List[Any]: - """Flatten RTCStatsReport to an array of stats objects. - - Matches the JS SDK's flatten() function which converts - RTCStatsReport (Map-like) to an array of RTCStats objects. - - Args: - report: The RTCStatsReport from getStats() - - Returns: - List of stats objects with sanitized values - """ - if report is None: - return [] - - stats = [] - if hasattr(report, "items"): - # Dict-like report (aiortc RTCStatsReport) - for stat_id, stat in report.items(): - stat_obj = {"id": stat_id} - if hasattr(stat, "__dict__"): - for key, value in vars(stat).items(): - stat_obj[key] = _sanitize_value(value) - elif isinstance(stat, dict): - for key, value in stat.items(): - stat_obj[key] = _sanitize_value(value) - stats.append(stat_obj) - elif hasattr(report, "__iter__"): - # List-like report - for stat in report: - if hasattr(stat, "__dict__"): - stat_obj = {k: _sanitize_value(v) for k, v in vars(stat).items()} - stats.append(stat_obj) - - return stats - - -# Default stats reporting interval in milliseconds -DEFAULT_STATS_INTERVAL_MS = 8000 - - -class SfuStatsReporter: - """Orchestrates stats collection and sending to SFU. - - Collects WebRTC stats from publisher and subscriber peer connections, - combines them with trace records, and sends to the SFU via SendStats RPC. - """ - - def __init__( - self, - connection_manager: "ConnectionManager", - interval_ms: int = DEFAULT_STATS_INTERVAL_MS, - ): - """Initialize the stats reporter. - - Args: - connection_manager: The connection manager instance - interval_ms: Interval between stats reports in milliseconds (default 8000) - """ - self._cm = connection_manager - self._interval_ms = interval_ms - self._running = False - self._task: Optional[asyncio.Task] = None - self._one_off_task: Optional[asyncio.Task] = None - - def start(self) -> None: - """Start the periodic stats reporting loop. - - Does nothing if interval is <= 0 or already running. - """ - if self._interval_ms <= 0 or self._running: - return - self._running = True - self._task = asyncio.create_task(self._run_loop()) - logger.info(f"SfuStatsReporter started (interval={self._interval_ms}ms)") - - async def stop(self) -> None: - """Stop the stats reporting loop and cancel pending tasks.""" - self._running = False - - # Cancel and await both tasks - for task in [self._task, self._one_off_task]: - if task and not task.done(): - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - - self._task = None - self._one_off_task = None - logger.info("SfuStatsReporter stopped") - - async def _run_loop(self) -> None: - """Main loop that periodically collects and sends stats.""" - while self._running: - await asyncio.sleep(self._interval_ms / 1000) - if not self._running: - break - try: - await self._run() - except asyncio.CancelledError: - break - except Exception as e: - logger.warning(f"Failed to send stats: {e}") - - async def _run(self) -> None: - """Collect and send stats once. - - Gets stats from publisher/subscriber peer connections, - traces the stats, takes a snapshot of traces, and sends to SFU. - """ - pm = self._cm._peer_manager - tracer = self._cm.tracer - - # Get stats from publisher/subscriber - pub_stats: Optional["ComputedStats"] = None - sub_stats: Optional["ComputedStats"] = None - - if pm.publisher_pc and pm.publisher_stats: - try: - pub_stats = await pm.publisher_stats.get() - tracer.trace("getstats", self._cm.pc_id("pub"), pub_stats.delta) - except Exception as e: - logger.debug(f"Failed to get publisher stats: {e}") - tracer.trace("getstatsOnFailure", self._cm.pc_id("pub"), str(e)) - - if pm.subscriber_pc and pm.subscriber_stats: - try: - sub_stats = await pm.subscriber_stats.get() - tracer.trace("getstats", self._cm.pc_id("sub"), sub_stats.delta) - except Exception as e: - logger.debug(f"Failed to get subscriber stats: {e}") - tracer.trace("getstatsOnFailure", self._cm.pc_id("sub"), str(e)) - - # Take trace buffer snapshot - trace_slice = tracer.take() - - try: - await self._send_stats(trace_slice, pub_stats, sub_stats) - except Exception as e: - # Rollback traces on failure so they can be sent on next attempt - trace_slice.rollback() - logger.debug(f"Stats send failed, traces rolled back: {e}") - raise - - async def _send_stats( - self, - trace_slice: TraceSlice, - pub_stats: Optional["ComputedStats"], - sub_stats: Optional["ComputedStats"], - ) -> None: - """Send stats to SFU via SendStats RPC. - - Args: - trace_slice: Snapshot of trace records - pub_stats: Publisher stats (if available) - sub_stats: Subscriber stats (if available) - """ - client = self._cm.twirp_signaling_client - ctx = self._cm.twirp_context - - if not client or not ctx: - logger.debug("Cannot send stats: signaling client not available") - return - - # Get performance stats - encode_stats: List[Any] = [] - decode_stats: List[Any] = [] - - if pub_stats and pub_stats.performance_stats: - encode_stats = pub_stats.performance_stats - if sub_stats and sub_stats.performance_stats: - decode_stats = sub_stats.performance_stats - - # Get aiortc version - webrtc_version = getattr(aiortc, "__version__", "unknown") - - # Serialize traces to JSON - this is the core tracing data - rtc_stats_json = json.dumps(trace_slice.snapshot, separators=(",", ":")) - - # Flatten raw stats to arrays (matching JS SDK format) - # JS SDK sends: subscriberStats: JSON.stringify(flatten(subscriberStats.stats)) - publisher_stats_json = json.dumps( - _flatten_stats(pub_stats.stats) if pub_stats else [], - separators=(",", ":"), - ) - subscriber_stats_json = json.dumps( - _flatten_stats(sub_stats.stats) if sub_stats else [], - separators=(",", ":"), - ) - - request = signal_pb2.SendStatsRequest( - session_id=self._cm.session_id, - sdk="stream-python", - sdk_version=VERSION, - webrtc_version=webrtc_version, - # Raw stats per peer connection (matching JS SDK) - publisher_stats=publisher_stats_json, - subscriber_stats=subscriber_stats_json, - # Core tracing fields per spec - rtc_stats=rtc_stats_json, - encode_stats=encode_stats, - decode_stats=decode_stats, - ) - - # Send stats (don't use the SignalClient wrapper to avoid tracing SendStats) - # We access the parent class method directly - from getstream.video.rtc.pb.stream.video.sfu.signal_rpc.signal_twirp import ( - AsyncSignalServerClient, - ) - - # Call the parent method directly to avoid the wrapper - await AsyncSignalServerClient.SendStats( - client, ctx=ctx, request=request, server_path_prefix="" - ) - logger.debug(f"SendStats: {len(trace_slice.snapshot)} traces") - - def schedule_one(self, delay_ms: int = 3000) -> None: - """Schedule a one-off stats send after delay. - - Useful for sending stats shortly after publishing a video track. - - Args: - delay_ms: Delay in milliseconds before sending stats (default 3000) - """ - # Cancel any existing one-off task - if self._one_off_task and not self._one_off_task.done(): - self._one_off_task.cancel() - - self._one_off_task = asyncio.create_task(self._delayed_run(delay_ms)) - - async def _delayed_run(self, delay_ms: int) -> None: - """Run stats collection after a delay. - - Args: - delay_ms: Delay in milliseconds - """ - try: - await asyncio.sleep(delay_ms / 1000) - await self._run() - except asyncio.CancelledError: - pass - except Exception as e: - logger.warning(f"Delayed stats send failed: {e}") - - def flush(self) -> None: - """Immediate flush (fire-and-forget). - - Triggers a stats send without waiting for the result. - """ - if not self._running: - return - asyncio.create_task(self._run()) diff --git a/getstream/video/rtc/stats_tracer.py b/getstream/video/rtc/stats_tracer.py deleted file mode 100644 index 4fa76757..00000000 --- a/getstream/video/rtc/stats_tracer.py +++ /dev/null @@ -1,674 +0,0 @@ -""" -Handles getStats() and delta compression for a PeerConnection. -""" - -import hashlib -import json -import logging -from dataclasses import dataclass, field -from datetime import datetime -from typing import Any, Dict, List, Optional - -from getstream.video.rtc.pb.stream.video.sfu.models import models_pb2 - -logger = logging.getLogger(__name__) - - -def _sanitize_value(value: Any) -> Any: - """Sanitize a value for JSON serialization.""" - if value is None: - return None - if isinstance(value, (str, int, float, bool)): - return value - if isinstance(value, datetime): - return int(value.timestamp() * 1000) - if isinstance(value, dict): - return {str(k): _sanitize_value(v) for k, v in value.items()} - if isinstance(value, (list, tuple)): - return [_sanitize_value(v) for v in value] - return str(value) - - -@dataclass -class ComputedStats: - """Computed stats with delta compression and performance metrics.""" - - stats: Any # Raw RTCStatsReport - delta: Dict[str, Any] # Delta-compressed stats - performance_stats: List[models_pb2.PerformanceStats] = field(default_factory=list) - - -class StatsTracer: - """Handles getStats() and delta compression for a PeerConnection. - - Provides: - - Delta compression to reduce size by ~90% - - Performance stats calculation (encode/decode metrics) - - Frame time and FPS history for averaging - - Integration with VideoFrameTracker/BufferedMediaTrack for frame metrics - """ - - def __init__(self, pc, peer_type: str, interval_s: float = 8.0): - """Initialize StatsTracer for a peer connection. - - Args: - pc: The RTCPeerConnection to collect stats from - peer_type: "publisher" or "subscriber" - interval_s: Interval between stats collections in seconds (for FPS calculation) - """ - self._pc = pc - self._peer_type = peer_type - self._interval_s = interval_s - self._previous_stats: Dict[str, Dict] = {} - self._frame_time_history: List[float] = [] - self._fps_history: List[float] = [] - self._frame_tracker: Optional[Any] = None - - def set_frame_tracker(self, tracker: Any) -> None: - """Set the frame tracker for publisher stats (video track wrapper).""" - self._frame_tracker = tracker - - async def get(self) -> ComputedStats: - """Get stats with delta compression and performance metrics. - - Returns: - ComputedStats with raw stats, delta-compressed stats, and performance metrics - """ - report = await self._pc.getStats() - current_stats = self._report_to_dict(report) - - performance_stats = ( - self._get_decode_stats(current_stats) - if self._peer_type == "subscriber" - else self._get_encode_stats(current_stats) - ) - - delta = self._delta_compress(self._previous_stats, current_stats) - self._previous_stats = current_stats - - self._frame_time_history = self._frame_time_history[-2:] - self._fps_history = self._fps_history[-2:] - - return ComputedStats( - stats=report, delta=delta, performance_stats=performance_stats - ) - - def _report_to_dict(self, report) -> Dict[str, Dict]: - """Convert RTCStatsReport to dict with sanitized values.""" - result = {} - - # Handle different report formats - if hasattr(report, "items"): - # Dict-like report (aiortc RTCStatsReport) - for stat_id, stat in report.items(): - stat_values = {} - if hasattr(stat, "__dict__"): - for key, value in vars(stat).items(): - stat_values[key] = _sanitize_value(value) - elif isinstance(stat, dict): - for key, value in stat.items(): - stat_values[key] = _sanitize_value(value) - else: - stat_values = {"value": _sanitize_value(stat)} - result[stat_id] = stat_values - elif hasattr(report, "__iter__"): - # List-like report - for stat in report: - if hasattr(stat, "id"): - stat_id = stat.id - stat_values = {} - if hasattr(stat, "__dict__"): - for key, value in vars(stat).items(): - stat_values[key] = _sanitize_value(value) - else: - stat_values = {"id": stat_id} - result[stat_id] = stat_values - - # Filter out spurious stats from aiortc (creates sender/receiver for all transceivers) - # Subscriber shouldn't have outbound-rtp, publisher shouldn't have inbound-rtp - if self._peer_type == "subscriber": - result = { - k: v for k, v in result.items() if v.get("type") != "outbound-rtp" - } - elif self._peer_type == "publisher": - result = {k: v for k, v in result.items() if v.get("type") != "inbound-rtp"} - - # Add codec stats and enhance RTP stats with mid/codecId/mediaType (not provided by aiortc) - self._add_codec_and_media_stats(result) - - # Add ICE candidate stats (not provided by aiortc getStats) - self._add_ice_candidate_stats(result) - - # Inject frame stats from tracker (not provided by aiortc) - self._inject_frame_stats(result) - - return result - - def _delta_compress( - self, old: Dict[str, Dict], new: Dict[str, Dict] - ) -> Dict[str, Any]: - """Apply delta compression - only include changed values.""" - try: - # Deep copy via JSON (values already sanitized by _report_to_dict) - compressed = json.loads(json.dumps(new)) - except (TypeError, ValueError) as e: - logger.debug(f"Error serializing stats: {e}") - compressed = {} - - for stat_id, report in list(compressed.items()): - if not isinstance(report, dict): - continue - - # Remove 'id' field as it's redundant (already the key) - report.pop("id", None) - - if stat_id not in old: - continue - - # Remove unchanged values - for name in list(report.keys()): - if old[stat_id].get(name) == report.get(name): - del report[name] - - # Normalize timestamps - set the max timestamp to 0 and store actual value at top level - max_ts = 0 - for report in compressed.values(): - if isinstance(report, dict): - ts = report.get("timestamp", 0) - if isinstance(ts, (int, float)) and ts > max_ts: - max_ts = ts - - for report in compressed.values(): - if isinstance(report, dict) and report.get("timestamp") == max_ts: - report["timestamp"] = 0 - - compressed["timestamp"] = max_ts - - return compressed - - def _get_encode_stats( - self, stats: Dict[str, Dict] - ) -> List[models_pb2.PerformanceStats]: - """Calculate encode performance for video outbound-rtp.""" - result = [] - - for stat_id, report in stats.items(): - if not isinstance(report, dict): - continue - - stat_type = report.get("type", "") - kind = report.get("kind", "") - - if stat_type != "outbound-rtp" or kind != "video": - continue - - # Skip if we don't have previous stats for this report - if stat_id not in self._previous_stats: - continue - - prev_report = self._previous_stats.get(stat_id, {}) - - frame_width = report.get("frameWidth", 0) - frame_height = report.get("frameHeight", 0) - - total_encode_time = report.get("totalEncodeTime", 0) - prev_total_encode_time = prev_report.get("totalEncodeTime", 0) - delta_total_encode_time = total_encode_time - prev_total_encode_time - - frames_sent = report.get("framesSent", 0) - prev_frames_sent = prev_report.get("framesSent", 0) - delta_frames_sent = frames_sent - prev_frames_sent - - frame_encode_time_ms = 0.0 - if delta_frames_sent > 0: - frame_encode_time_ms = ( - delta_total_encode_time / delta_frames_sent - ) * 1000 - self._frame_time_history.append(frame_encode_time_ms) - - frames_per_second = report.get("framesPerSecond", 0) - self._fps_history.append(frames_per_second) - - avg_frame_time_ms = ( - sum(self._frame_time_history) / len(self._frame_time_history) - if self._frame_time_history - else 0.0 - ) - avg_fps = ( - sum(self._fps_history) / len(self._fps_history) - if self._fps_history - else 0.0 - ) - - target_bitrate = report.get("targetBitrate", 0) - codec_id = report.get("codecId", "") - codec = self._get_codec_from_stats(stats, codec_id) - - perf_stats = models_pb2.PerformanceStats( - track_type=models_pb2.TRACK_TYPE_VIDEO, - codec=codec, - avg_frame_time_ms=avg_frame_time_ms, - avg_fps=avg_fps, - video_dimension=models_pb2.VideoDimension( - width=frame_width, height=frame_height - ), - target_bitrate=int(target_bitrate), - ) - result.append(perf_stats) - - return result - - def _get_decode_stats( - self, stats: Dict[str, Dict] - ) -> List[models_pb2.PerformanceStats]: - """Calculate decode performance for highest-res video inbound-rtp.""" - result = [] - - best_report = None - best_stat_id = None - best_resolution = 0 - - for stat_id, report in stats.items(): - if not isinstance(report, dict): - continue - if report.get("type") != "inbound-rtp" or report.get("kind") != "video": - continue - - resolution = report.get("frameWidth", 0) * report.get("frameHeight", 0) - if resolution > best_resolution: - best_resolution = resolution - best_report = report - best_stat_id = stat_id - - if best_report is None or best_stat_id not in self._previous_stats: - return result - - report = best_report - prev_report = self._previous_stats.get(best_stat_id, {}) - - frame_width = report.get("frameWidth", 0) - frame_height = report.get("frameHeight", 0) - - total_decode_time = report.get("totalDecodeTime", 0) - prev_total_decode_time = prev_report.get("totalDecodeTime", 0) - delta_total_decode_time = total_decode_time - prev_total_decode_time - - frames_decoded = report.get("framesDecoded", 0) - prev_frames_decoded = prev_report.get("framesDecoded", 0) - delta_frames_decoded = frames_decoded - prev_frames_decoded - - frame_decode_time_ms = 0.0 - if delta_frames_decoded > 0: - frame_decode_time_ms = ( - delta_total_decode_time / delta_frames_decoded - ) * 1000 - self._frame_time_history.append(frame_decode_time_ms) - - frames_per_second = report.get("framesPerSecond", 0) - self._fps_history.append(frames_per_second) - - avg_frame_time_ms = ( - sum(self._frame_time_history) / len(self._frame_time_history) - if self._frame_time_history - else 0.0 - ) - avg_fps = ( - sum(self._fps_history) / len(self._fps_history) - if self._fps_history - else 0.0 - ) - codec_id = report.get("codecId", "") - codec = self._get_codec_from_stats(stats, codec_id) - - perf_stats = models_pb2.PerformanceStats( - track_type=models_pb2.TRACK_TYPE_VIDEO, - codec=codec, - avg_frame_time_ms=avg_frame_time_ms, - avg_fps=avg_fps, - video_dimension=models_pb2.VideoDimension( - width=frame_width, height=frame_height - ), - target_bitrate=0, # Not applicable for decode - ) - result.append(perf_stats) - - return result - - def _get_codec_from_stats( - self, stats: Dict[str, Dict], codec_id: str - ) -> Optional[models_pb2.Codec]: - """Get codec info from stats, or None if not found.""" - if not codec_id: - return None - - codec_report = stats.get(codec_id) - if not codec_report or not isinstance(codec_report, dict): - return None - - return models_pb2.Codec( - payload_type=codec_report.get("payloadType", 0), - name=codec_report.get("mimeType", "").split("/")[-1], - clock_rate=codec_report.get("clockRate", 0), - ) - - def _candidate_id(self, candidate, transport_index: int) -> str: - """Generate stable 8-char hex ID for a candidate.""" - foundation = getattr(candidate, "foundation", "") - component = getattr(candidate, "component", 1) - ip = getattr(candidate, "ip", None) or getattr(candidate, "host", "") - port = getattr(candidate, "port", 0) - key = f"{foundation}:{component}:{ip}:{port}:{transport_index}" - return hashlib.md5(key.encode()).hexdigest()[:8] - - def _pair_state_to_string(self, state) -> str: - """Convert CandidatePair.State enum to JS SDK string.""" - from aioice.ice import CandidatePair - - mapping = { - CandidatePair.State.FROZEN: "frozen", - CandidatePair.State.WAITING: "waiting", - CandidatePair.State.IN_PROGRESS: "in-progress", - CandidatePair.State.SUCCEEDED: "succeeded", - CandidatePair.State.FAILED: "failed", - } - return mapping.get(state, "unknown") - - def _add_ice_candidate_stats(self, result: Dict[str, Dict]) -> None: - """Add ICE candidate stats not provided by aiortc getStats().""" - try: - from aioice.ice import CandidatePair - - ice_transports = getattr( - self._pc, "_RTCPeerConnection__iceTransports", set() - ) - - for transport_index, transport in enumerate(ice_transports): - transport_id = f"transport_{transport_index}" - - gatherer = getattr(transport, "iceGatherer", None) - if gatherer: - for candidate in gatherer.getLocalCandidates(): - cid = self._candidate_id(candidate, transport_index) - result[cid] = { - "type": "local-candidate", - "address": candidate.ip, - "candidateType": candidate.type, - "port": candidate.port, - "priority": candidate.priority, - "protocol": candidate.protocol.lower(), - "timestamp": 0, - } - - for candidate in transport.getRemoteCandidates(): - cid = self._candidate_id(candidate, transport_index) - ip = getattr(candidate, "ip", None) or getattr( - candidate, "host", "" - ) - protocol = getattr(candidate, "protocol", "") or getattr( - candidate, "transport", "udp" - ) - result[cid] = { - "type": "remote-candidate", - "address": ip, - "candidateType": candidate.type, - "port": candidate.port, - "priority": candidate.priority, - "protocol": protocol.lower(), - "timestamp": 0, - } - - connection = getattr(transport, "_connection", None) - if not connection: - continue - - check_list = getattr(connection, "_check_list", []) - nominated = getattr(connection, "_nominated", {}) - - for pair in check_list: - local_id = self._candidate_id(pair.local_candidate, transport_index) - remote_id = self._candidate_id( - pair.remote_candidate, transport_index - ) - - is_nominated = any(p is pair for p in nominated.values()) - is_selected = ( - is_nominated and pair.state == CandidatePair.State.SUCCEEDED - ) - is_connected = pair.state == CandidatePair.State.SUCCEEDED - - pair_key = f"{local_id}:{remote_id}:{transport_index}" - pair_id = hashlib.md5(pair_key.encode()).hexdigest()[:8] - - result[pair_id] = { - "type": "candidate-pair", - "localCandidateId": local_id, - "remoteCandidateId": remote_id, - "state": self._pair_state_to_string(pair.state), - "nominated": is_nominated, - "selected": is_selected, - "priority": min( - getattr(pair.local_candidate, "priority", 0), - getattr(pair.remote_candidate, "priority", 0), - ), - "transportId": transport_id, - "readable": is_connected, - "writable": is_connected, - "bytesReceived": 0, - "bytesSent": 0, - "currentRoundTripTime": 0, - "totalRoundTripTime": 0, - "lastPacketReceivedTimestamp": 0, - "lastPacketSentTimestamp": 0, - "responsesReceived": 0, - "timestamp": 0, - } - - except Exception as e: - logger.debug(f"Failed to extract ICE candidate stats: {e}") - - def _add_codec_and_media_stats(self, result: Dict[str, Dict]) -> None: - """Add codec stats and enhance RTP stats with mid/codecId/mediaType. - - aiortc doesn't include codec stats entries or mid/codecId fields in RTP stats. - This method adds them to match the JS SDK format by reading from transceivers. - """ - try: - transceivers = self._pc.getTransceivers() - - # Build mapping of SSRC -> transceiver info - ssrc_to_transceiver: Dict[int, Dict[str, Any]] = {} - codec_entries: Dict[str, Dict[str, Any]] = {} - - for transceiver in transceivers: - mid = transceiver.mid - kind = transceiver.kind - codecs = getattr(transceiver, "_codecs", []) - - # Get sender SSRC - sender = transceiver.sender - sender_ssrc = getattr(sender, "_ssrc", None) - if sender_ssrc: - ssrc_to_transceiver[sender_ssrc] = { - "mid": mid, - "kind": kind, - "codecs": codecs, - } - - # Get receiver SSRCs (may have multiple from remote streams) - receiver = transceiver.receiver - # aiortc stores remote SSRCs in __remote_streams dict - remote_streams = getattr( - receiver, "_RTCRtpReceiver__remote_streams", {} - ) - for recv_ssrc in remote_streams.keys(): - ssrc_to_transceiver[recv_ssrc] = { - "mid": mid, - "kind": kind, - "codecs": codecs, - } - - # Create codec stats entries for this transceiver - for codec in codecs: - codec_id = self._codec_id(codec, mid) - if codec_id not in codec_entries: - codec_type = ( - "encode" if self._peer_type == "publisher" else "decode" - ) - codec_entries[codec_id] = { - "type": "codec", - "payloadType": codec.payloadType, - "mimeType": codec.mimeType, - "clockRate": codec.clockRate, - "codecType": codec_type, - "timestamp": 0, - } - # Add channels for audio codecs - if kind == "audio": - channels = getattr(codec, "channels", None) - if channels: - codec_entries[codec_id]["channels"] = channels - # Add sdpFmtpLine if parameters exist - params = getattr(codec, "parameters", {}) - if params: - fmtp_parts = [f"{k}={v}" for k, v in params.items()] - if fmtp_parts: - codec_entries[codec_id]["sdpFmtpLine"] = ";".join( - fmtp_parts - ) - - # Enhance RTP stats with mid, codecId, and mediaType - for stat in result.values(): - if not isinstance(stat, dict): - continue - - stat_type = stat.get("type", "") - if stat_type not in ( - "outbound-rtp", - "inbound-rtp", - "remote-inbound-rtp", - "remote-outbound-rtp", - ): - continue - - ssrc = stat.get("ssrc") - if ssrc is None: - continue - - transceiver_info = ssrc_to_transceiver.get(ssrc) - if transceiver_info: - # Add mid if available - if transceiver_info["mid"] is not None: - stat["mid"] = transceiver_info["mid"] - - # Add mediaType (same as kind, but JS SDK includes both) - kind = transceiver_info["kind"] - if kind: - stat["mediaType"] = kind - - # Add codecId linking to first matching codec - codecs = transceiver_info["codecs"] - if codecs: - # Use first non-RTX codec - for codec in codecs: - if not getattr(codec, "mimeType", "").endswith("/rtx"): - codec_id = self._codec_id( - codec, transceiver_info["mid"] - ) - stat["codecId"] = codec_id - break - - # Add codec entries to result - result.update(codec_entries) - - except Exception as e: - logger.debug(f"Failed to add codec/media stats: {e}") - - def _codec_id(self, codec, mid: Optional[str]) -> str: - """Generate stable 8-char hex ID for a codec.""" - mime_type = getattr(codec, "mimeType", "") - payload_type = getattr(codec, "payloadType", 0) - clock_rate = getattr(codec, "clockRate", 0) - key = f"{mime_type}:{payload_type}:{clock_rate}:{mid}" - return hashlib.md5(key.encode()).hexdigest()[:8] - - def _inject_frame_stats(self, result: Dict[str, Dict]) -> None: - """Inject frame stats from trackers into RTP stats. - - aiortc doesn't provide frame metrics (dimensions, frame count, encode/decode time). - We inject these from our frame trackers into the appropriate RTP stats entries. - """ - if self._peer_type == "publisher": - self._inject_publisher_stats(result) - else: - self._inject_subscriber_stats(result) - - def _inject_publisher_stats(self, result: Dict[str, Dict]) -> None: - """Inject stats for publisher (outbound-rtp).""" - if not self._frame_tracker: - return - - try: - frame_stats = self._frame_tracker.get_frame_stats() - if frame_stats.get("framesSent", 0) == 0: - return - - for stat in result.values(): - if not isinstance(stat, dict): - continue - if stat.get("kind") != "video" or stat.get("type") != "outbound-rtp": - continue - - stat["framesSent"] = frame_stats["framesSent"] - stat["frameWidth"] = frame_stats["frameWidth"] - stat["frameHeight"] = frame_stats["frameHeight"] - stat["totalEncodeTime"] = frame_stats["totalEncodeTime"] - - if self._previous_stats: - prev = self._previous_stats.get(stat.get("id", ""), {}) - delta = frame_stats["framesSent"] - prev.get("framesSent", 0) - if delta > 0: - stat["framesPerSecond"] = delta / self._interval_s - - except Exception as e: - logger.debug(f"Failed to inject publisher stats: {e}") - - def _inject_subscriber_stats(self, result: Dict[str, Dict]) -> None: - """Inject frame stats for subscriber (inbound-rtp video). - - Note: When multiple video tracks exist (e.g., webcam + screenshare), - get_video_frame_tracker() returns the first by insertion order, which - may not match the actively consumed track. This is a known limitation; - _get_decode_stats() mitigates by selecting the highest-resolution track - for performance calculations. - """ - # Get video tracker from PC if not set - if not self._frame_tracker and hasattr(self._pc, "get_video_frame_tracker"): - self._frame_tracker = self._pc.get_video_frame_tracker() - - if not self._frame_tracker: - return - - try: - frame_stats = self._frame_tracker.get_frame_stats() - if frame_stats.get("framesDecoded", 0) == 0: - return - - for stat in result.values(): - if not isinstance(stat, dict): - continue - if stat.get("type") != "inbound-rtp" or stat.get("kind") != "video": - continue - - stat["framesDecoded"] = frame_stats["framesDecoded"] - stat["frameWidth"] = frame_stats["frameWidth"] - stat["frameHeight"] = frame_stats["frameHeight"] - stat["totalDecodeTime"] = frame_stats["totalDecodeTime"] - - if self._previous_stats: - prev = self._previous_stats.get(stat.get("id", ""), {}) - delta = frame_stats["framesDecoded"] - prev.get("framesDecoded", 0) - if delta > 0: - stat["framesPerSecond"] = delta / self._interval_s - - except Exception as e: - logger.debug(f"Failed to inject subscriber stats: {e}") diff --git a/getstream/video/rtc/tracer.py b/getstream/video/rtc/tracer.py deleted file mode 100644 index 0f031df9..00000000 --- a/getstream/video/rtc/tracer.py +++ /dev/null @@ -1,130 +0,0 @@ -""" -Central trace buffer for RTC events. -""" - -import time -from dataclasses import dataclass -from datetime import datetime -from typing import Any, Callable, List, Optional - -from google.protobuf.message import Message - -TraceRecord = List[Any] # [tag, id, data, timestamp_ms] - - -@dataclass -class TraceSlice: - """A snapshot of trace records with rollback capability.""" - - snapshot: List[TraceRecord] - rollback: Callable[[], None] - - -def timestamp_ms() -> int: - """Get current timestamp in milliseconds.""" - return int(time.time() * 1000) - - -def sanitize_value(value: Any) -> Any: - """Convert values to JSON-serializable format. - - Handles: - - datetime objects - - protobuf messages - - nested dicts and lists - - None values - """ - if value is None: - return None - - if isinstance(value, datetime): - # Convert datetime to milliseconds timestamp (matching JS SDK format) - return int(value.timestamp() * 1000) - - if isinstance(value, Message): - # Convert protobuf message to dict with camelCase field names - from google.protobuf.json_format import MessageToDict - - return MessageToDict(value) - - if isinstance(value, dict): - return {k: sanitize_value(v) for k, v in value.items()} - - if isinstance(value, (list, tuple)): - return [sanitize_value(item) for item in value] - - if isinstance(value, bytes): - # Convert bytes to hex string for JSON serialization - return value.hex() - - # For primitive types (str, int, float, bool), return as-is - if isinstance(value, (str, int, float, bool)): - return value - - # For any other type, try string conversion - try: - return str(value) - except Exception: - return repr(value) - - -class Tracer: - """Central trace buffer for RTC events. - - Collects trace records in the format [tag, pc_id, data, timestamp_ms] - for later sending to the SFU via SendStats RPC. - """ - - def __init__(self): - self._buffer: List[TraceRecord] = [] - self._enabled = True - - def trace(self, tag: str, pc_id: Optional[str], data: Any) -> None: - """Add trace record: [tag, id, data, timestamp_ms] - - Args: - tag: The trace event tag (e.g., "create", "signalingstatechange") - pc_id: The peer connection ID (e.g., "0-pub", "0-sub", "0-sfu-hostname") - data: The trace data (will be sanitized for JSON serialization) - """ - if not self._enabled: - return - self._buffer.append([tag, pc_id, sanitize_value(data), timestamp_ms()]) - - def take(self) -> TraceSlice: - """Return buffer snapshot with rollback capability. - - Returns a TraceSlice containing the current buffer contents - and a rollback function that can restore the buffer if sending fails. - """ - snapshot = self._buffer - self._buffer = [] - - def rollback(): - # Prepend the snapshot to any new traces added since take() - self._buffer = snapshot + self._buffer - - return TraceSlice(snapshot=snapshot, rollback=rollback) - - def set_enabled(self, enabled: bool) -> None: - """Enable or disable tracing. - - When disabled, existing traces are cleared. - """ - if self._enabled == enabled: - return - self._enabled = enabled - self._buffer = [] - - def dispose(self) -> None: - """Clear all trace records.""" - self._buffer = [] - - @property - def enabled(self) -> bool: - """Check if tracing is enabled.""" - return self._enabled - - def __len__(self) -> int: - """Return the number of trace records in the buffer.""" - return len(self._buffer) diff --git a/getstream/video/rtc/track_util.py b/getstream/video/rtc/track_util.py index 5bc0c523..fb9dbf41 100644 --- a/getstream/video/rtc/track_util.py +++ b/getstream/video/rtc/track_util.py @@ -1,18 +1,13 @@ -import asyncio import base64 import fractions import io import logging -import re -import time import wave from enum import Enum from fractions import Fraction from typing import ( Any, AsyncIterator, - Callable, - Dict, Iterator, Literal, Optional, @@ -20,12 +15,9 @@ cast, ) -import aiortc -import aiortc.sdp import av +import getstream_rtc import numpy as np -from aiortc import MediaStreamTrack -from aiortc.mediastreams import MediaStreamError from numpy.typing import NDArray from getstream.video.rtc.g711 import ( @@ -1681,166 +1673,6 @@ def empty(self) -> bool: return len(self.samples) == 0 -class PyAVResampler: - """ - A stateful audio resampler. - It acts as a thin wrapper around `pyav.AudioResampler`, and it is intended to be - created once for the audio track and re-used. - - - Key differences from the stateless implementation: - - - `pyav.AudioResampler` buffers samples internally, so the number of output samples doesn't always match the input. - - `PyAVResampler` keeps its own monotonic PTS clock, and it's meant to be used with a single audio stream only. - It ignores the PTS/DTS from `PcmData`. - PTS always starts from 0 for the first output. - - `pyav.AudioResampler` configures itself based on the first input frame. Feeding data in a different format - or sample rate will fail. - - `PyAVResampler` is not thread-safe. - - The source PCMs must have the same sample rate, format, and number of channels. - - Example: - - >>> import numpy as np - >>> resampler = PyAVResampler(format="s16", sample_rate=48000, channels=1) - >>> # Process 20ms chunks at 16kHz (320 samples each) - >>> samples = np.random.randint(-1000, 1000, 320, dtype=np.int16) - >>> pcm_16k = PcmData(samples=samples, sample_rate=16000, format="s16", channels=1) - >>> pcm_48k = resampler.resample(pcm_16k) # Returns 912 samples at 48kHz without flushing - >>> len(pcm_48k.samples) - 912 - >>> flushed_pcm = resampler.flush() - >>> len(flushed_pcm.samples) - 48 - """ - - def __init__( - self, - format: AudioFormatType, - sample_rate: int, - channels: int, - frame_size: int = 0, - ): - """ - Initialize a stateful resampler with target audio parameters. - - Args: - format: Target format ("s16" or "f32", also `AudioFormat.F32` or `AudioFormat.S16`). - sample_rate: Target sample rate (e.g., 48000, 16000, 8000) - channels: Target number of channels (1 for mono, 2 for stereo) - frame_size: how many samples per channel are produce in each output frame. - When set, the underlying resampler will buffer output the specified number of samples is accumulated, - and it will output frames of this exact size (except when ".resample(flush=True)"). - Default - `0` (each frame can be of a variable size). - """ - if isinstance(format, str): - AudioFormat.validate(format) - - self.format = AudioFormat(format) - self.sample_rate = sample_rate - self.channels = channels - self.frame_size = frame_size - # Determine PyAV format based on original format to preserve it - # f32 -> fltp (float32 planar), s16 -> s16p (int16 planar) - self._pyav_format = "fltp" if self.format == AudioFormat.F32 else "s16p" - self._pts = 0 - self._set_pyav_resampler() - - def __repr__(self) -> str: - return ( - f"{self.__class__.__name__}(format={self.format.name.lower()!r}, " - f"sample_rate={self.sample_rate}, channels={self.channels}, frame_size={self.frame_size})" - ) - - def _set_pyav_resampler(self): - # Create PyAV resampler with format matching the original - self._pyav_resampler = av.AudioResampler( - format=self._pyav_format, - layout="mono" if self.channels == 1 else "stereo", - rate=self.sample_rate, - frame_size=self.frame_size, - ) - - def _pyav_resample(self, frame: av.AudioFrame | None) -> list[av.AudioFrame]: - if frame is not None and not frame.samples: - # pyav resampler fails if audioframe has no samples - return [] - return self._pyav_resampler.resample(frame) - - def resample(self, pcm: PcmData, flush: bool = False) -> PcmData: - """ - Resample using PyAV (libav) for high-quality resampling and downmixing. - - Args: - pcm: Input PCM data to resample - flush: if True, get the remaining frames from underlying `av.AudioResampler` if there are any. - Default - `False`. - - Returns: - New PcmData object with resampled audio, potentially empty if the frame size is set and larger - than the input PCM. - """ - # Create AudioFrame from PcmData - frame = pcm.to_av_frame() - - # Convert each frame to PcmData using from_av_frame and concatenate them - # Start with an empty PcmData preserving the original format - result = PcmData( - sample_rate=self.sample_rate, - format=self.format, - channels=self.channels, - time_base=1 / self.sample_rate, - ) - - # Resample - # Keep the lock because resampler is stateful, and we want to keep PTS in order - resampled_frames = self._pyav_resample(frame) - if flush: - try: - resampled_frames.extend(self._pyav_resample(None)) - finally: - # Reset the resampler because it cannot be used after it's flushed, - self._set_pyav_resampler() - - for resampled_frame in resampled_frames: - self._pts += resampled_frame.samples - result = result.append(PcmData.from_av_frame(resampled_frame)) - - result.pts = self._pts - len(result.samples) - result.dts = result.pts - return result - - def flush(self) -> PcmData: - """ - Flush the underlying `av.AudioResampler` - - Returns: - New PcmData object with resampled audio, potentially empty. - """ - # Convert each frame to PcmData using from_av_frame and concatenate them - # Start with an empty PcmData preserving the original format - result = PcmData( - sample_rate=self.sample_rate, - format=self.format, - channels=self.channels, - ) - - try: - # Flush the resampler to get remaining buffered samples - resampled_frames = self._pyav_resample(None) - finally: - # Reset the resampler because it cannot be used after it's flushed, - self._set_pyav_resampler() - - # Convert frames to PcmData and update the PTS clock - for resampled_frame in resampled_frames: - self._pts += resampled_frame.samples - result = result.append(PcmData.from_av_frame(resampled_frame)) - result.pts = self._pts - len(result.samples) - return result - - class Resampler: """ Stateless audio resampler for converting between sample rates, formats, and channels. @@ -2044,570 +1876,13 @@ def __repr__(self) -> str: ) -def patch_sdp_offer(sdp: str) -> str: - """ - Patches an SDP offer to ensure consistent ICE and DTLS parameters across all media sections. - - This function: - 1. Ensures all media descriptions have the same ice-ufrag, ice-pwd, and fingerprint values - (using values from the first media section) - 2. Sets all media descriptions' ports to match the first media description's port - 3. Replaces all media descriptions' candidates with candidates from the first media description - - Args: - sdp: The original SDP string. - - Returns: - The modified SDP string with consistent parameters across all media sections. - """ - # Parse the SDP - session = aiortc.sdp.SessionDescription.parse(sdp) - - # If we have fewer than 2 media sections, nothing to patch - if len(session.media) < 2: - return sdp - - # Get the values from the first media section - first_media = session.media[0] - reference_port = first_media.port - reference_ice = first_media.ice - reference_fingerprints = first_media.dtls.fingerprints if first_media.dtls else [] - reference_candidates = first_media.ice_candidates - - # Apply to all other media sections - for media in session.media[1:]: - # Update port - media.port = reference_port - - # Update ICE parameters - if reference_ice and media.ice: - media.ice.usernameFragment = reference_ice.usernameFragment - media.ice.password = reference_ice.password - media.ice.iceLite = reference_ice.iceLite - - # Update DTLS fingerprints - if media.dtls and reference_fingerprints: - media.dtls.fingerprints = reference_fingerprints.copy() - - # Replace ICE candidates - media.ice_candidates = reference_candidates.copy() - if reference_candidates: - media.ice_candidates_complete = True - - # Convert back to string - return str(session) - - -def fix_sdp_msid_semantic(sdp: str) -> str: - """ - Fix SDP msid-semantic format by ensuring there is a space after "WMS". - - The WebRTC spec requires a space between "WMS" and any identifiers. - Some SDPs may incorrectly have "WMS*" instead of "WMS *" which can - cause connection issues. - - Args: - sdp: The SDP string to fix - - Returns: - The fixed SDP string - """ - return re.sub(r"a=msid-semantic:WMS\*", r"a=msid-semantic:WMS *", sdp) - - -def fix_sdp_rtcp_fb(sdp: str) -> str: - """ - Fix SDP rtcp-fb format - https://github.com/pion/webrtc/issues/3207 - """ - # Some generators (e.g. pion) emit a trailing space after feedback id when no - # parameter is present, e.g. "a=rtcp-fb:96 goog-remb ". This breaks some - # parsers. Strip only the trailing whitespace before end-of-line on rtcp-fb - # lines while preserving CRLF if present. - # - # Example fix: - # a=rtcp-fb:96 goog-remb \r\n -> a=rtcp-fb:96 goog-remb\r\n - return re.sub( - r"(?m)^(a=rtcp-fb:[^\r\n]*\S)[ \t]+(?=\r?$)", - r"\1", - sdp, - ) - - -def parse_track_stream_mapping(sdp: str) -> dict: - """Parse SDP to extract track_id to stream_id mapping from msid lines.""" - mapping = {} - for line in sdp.split("\n"): - if line.startswith("a=msid:"): - parts = line.strip().split(" ") - if len(parts) >= 2: - # Format: a=msid:stream_id track_id - track_id = parts[1] - stream_id = parts[0].replace("a=msid:", "") - mapping[track_id] = stream_id - return mapping - - -class BufferedMediaTrack(aiortc.mediastreams.MediaStreamTrack): - """A wrapper for MediaStreamTrack that buffers one peeked frame. - - Also tracks video frame statistics when kind is 'video': - - frames_processed: total frames that passed through recv() - - frame_width, frame_height: dimensions of the last frame - - total_processing_time_ms: cumulative time spent in recv() - """ - - def __init__(self, track): - super().__init__() - self._track = track - self._buffered_frames = [] # Store multiple frames - self._kind = track.kind - self._id = track.id - self._ended = False - - # Frame statistics (for video tracks) - self.frames_processed: int = 0 - self.frame_width: int = 0 - self.frame_height: int = 0 - self.total_processing_time_ms: float = 0.0 - - @property - def kind(self): - return self._kind - - @property - def id(self): - return self._id - - @property - def readyState(self): - return "ended" if self._ended else self._track.readyState - - def get_frame_stats(self) -> Dict[str, Any]: - """Get current frame statistics for StatsTracer injection.""" - return { - "framesSent": self.frames_processed, - "frameWidth": self.frame_width, - "frameHeight": self.frame_height, - "totalEncodeTime": self.total_processing_time_ms / 1000.0, - } - - def _update_frame_stats(self, frame, processing_time_ms: float) -> None: - """Update frame statistics from a video frame.""" - if ( - self._kind == "video" - and hasattr(frame, "width") - and hasattr(frame, "height") - ): - self.frames_processed += 1 - self.frame_width = frame.width - self.frame_height = frame.height - self.total_processing_time_ms += processing_time_ms - - async def recv(self): - """Returns the next buffered frame if available, otherwise gets a new frame from the track.""" - if self._ended: - raise MediaStreamError("Track is ended") - - if self._buffered_frames: - # Return the oldest buffered frame (FIFO order) - frame = self._buffered_frames.pop(0) - self._update_frame_stats(frame, 0.0) - return frame - - start_time = time.monotonic() - try: - frame = await self._track.recv() - elapsed_ms = (time.monotonic() - start_time) * 1000 - self._update_frame_stats(frame, elapsed_ms) - return frame - except Exception as e: - logger.error(f"Error receiving frame from track: {e}") - self._ended = True - raise MediaStreamError(f"Error receiving frame: {e}") from e - - async def peek(self): - """Peek at the next frame without removing it from the stream.""" - if self._ended: - raise MediaStreamError("Track is ended") - - if not self._buffered_frames: - try: - # Buffer a new frame - frame = await self._track.recv() - self._buffered_frames.append(frame) - except Exception as e: - logger.error(f"Error peeking at frame: {e}") - self._ended = True - raise MediaStreamError(f"Error peeking at frame: {e}") from e - - # Return the next frame that would be received, but don't remove it - if self._buffered_frames: - return self._buffered_frames[0] - return None - - def stop(self): - """Stop the track and clean up resources.""" - if not self._ended: - self._ended = True - self._buffered_frames = [] # Clear all buffered frames - # Stop the underlying track if it has a stop method - if hasattr(self._track, "stop"): - try: - self._track.stop() - except Exception as e: - logger.error(f"Error stopping track: {e}") - - -class VideoFrameTracker(aiortc.mediastreams.MediaStreamTrack): - """A transparent wrapper that tracks video frame statistics. - - Used for subscriber video tracks to capture frame metrics that aiortc - doesn't provide natively (dimensions, frame count, decode time). - """ - - kind = "video" - - def __init__(self, track: MediaStreamTrack): - super().__init__() - self._track = track - self._id = track.id - self._ended = False - - # Frame statistics - self.frames_processed: int = 0 - self.frame_width: int = 0 - self.frame_height: int = 0 - self.total_processing_time_ms: float = 0.0 - - @property - def id(self): - return self._id - - @property - def readyState(self): - return "ended" if self._ended else self._track.readyState - - def get_frame_stats(self) -> Dict[str, Any]: - """Get current frame statistics for StatsTracer injection.""" - return { - "framesDecoded": self.frames_processed, - "frameWidth": self.frame_width, - "frameHeight": self.frame_height, - "totalDecodeTime": self.total_processing_time_ms / 1000.0, - } - - async def recv(self): - """Receive a frame, tracking statistics.""" - if self._ended: - raise MediaStreamError("Track is ended") - - start_time = time.monotonic() - try: - frame = await self._track.recv() - elapsed_ms = (time.monotonic() - start_time) * 1000 - - # Update stats for video frames - if isinstance(frame, av.VideoFrame): - self.frames_processed += 1 - self.frame_width = frame.width - self.frame_height = frame.height - self.total_processing_time_ms += elapsed_ms - - return frame - except MediaStreamError: - self._ended = True - raise - except Exception as e: - logger.error(f"Error receiving frame: {e}") - self._ended = True - raise MediaStreamError(f"Error receiving frame: {e}") from e - - def stop(self): - """Stop the track.""" - if not self._ended: - self._ended = True - if hasattr(self._track, "stop"): - self._track.stop() - - -async def detect_video_properties( - video_track: aiortc.mediastreams.MediaStreamTrack, -) -> Dict[str, Any]: - """ - Detect video track properties by peeking at frames. - - Args: - video_track: A video MediaStreamTrack - - Returns: - Dict containing width (int), height (int), fps (int), and bitrate (int) in kbps - """ - logger.info("Detecting video track properties") - - # Default properties in case of failure - default_properties = {"width": 640, "height": 480, "fps": 30, "bitrate": 800} - - if not video_track or video_track.kind != "video": - logger.warning("No video track provided or track is not video") - return default_properties - - # Flag to indicate if we created our own buffered track - own_buffered_track = False - buffered_track = None - - try: - # Ensure we're using a buffered track - if isinstance(video_track, BufferedMediaTrack): - buffered_track = video_track - else: - buffered_track = BufferedMediaTrack(video_track) - own_buffered_track = True - - # Peek at a frame to get dimensions - frame1 = await asyncio.wait_for(buffered_track.peek(), timeout=2.0) - - if not frame1: - logger.warning("No frame received from video track") - return default_properties - - # Extract width and height - width = getattr(frame1, "width", default_properties["width"]) - height = getattr(frame1, "height", default_properties["height"]) - - # Calculate FPS based on time delta between consecutive frames - fps = 30 # Default value - try: - # Consume the first frame but store its pts and time_base - frame1 = await buffered_track.recv() - frame1_pts = getattr(frame1, "pts", None) - time_base = getattr(frame1, "time_base", None) - - # Get the second frame - frame2 = await asyncio.wait_for(buffered_track.recv(), timeout=2.0) - frame2_pts = getattr(frame2, "pts", None) - - # Calculate FPS if we have all the necessary information - if ( - frame1_pts is not None - and frame2_pts is not None - and time_base is not None - ): - delta_ticks = frame2_pts - frame1_pts - delta_seconds = delta_ticks * time_base - - if delta_seconds > 0: - estimated_fps = 1 / delta_seconds - fps = int(round(estimated_fps)) - logger.info(f"Calculated FPS: {fps} (delta: {delta_seconds}s)") - else: - logger.warning("Cannot calculate FPS: zero or negative time delta") - else: - logger.warning( - "Cannot calculate FPS: missing PTS or time_base information" - ) - except Exception as e: - logger.warning(f"Error calculating FPS: {e}, using default 30 fps") - - # Calculate a dynamic bitrate based on resolution and fps - # This formula accounts for both pixel count and frame rate - # Using bits-per-pixel approach for H.264 encoding - pixels_per_second = width * height * fps - - # Different quality factors based on resolution - if width >= 1920 and height >= 1080: # Full HD - # Higher quality for HD content: ~0.1 bits per pixel - bits_per_pixel = 0.1 - elif width >= 1280 and height >= 720: # HD - # Medium-high quality: ~0.08 bits per pixel - bits_per_pixel = 0.08 - elif width >= 854 and height >= 480: # SD - # Medium quality: ~0.06 bits per pixel - bits_per_pixel = 0.06 - else: # Lower quality - # Lower quality for smaller video: ~0.05 bits per pixel - bits_per_pixel = 0.05 - - # Calculate bitrate in kbps - estimated_bitrate = int(pixels_per_second * bits_per_pixel / 1000) - - # Set reasonable min/max boundaries - min_bitrate = 100 # Minimum acceptable bitrate - max_bitrate = 5000 # Maximum reasonable bitrate for WebRTC - - bitrate = max(min_bitrate, min(estimated_bitrate, max_bitrate)) - - logger.info(f"Detected video properties: {width}x{height} at {fps}fps") - logger.info( - f"Calculated bitrate: {bitrate} kbps (based on {bits_per_pixel} bits/pixel)" - ) - - return {"width": width, "height": height, "fps": fps, "bitrate": bitrate} - except asyncio.TimeoutError: - logger.error("Timeout while waiting for video frame") - return default_properties - except Exception as e: - logger.error(f"Error detecting video properties: {e}") - return default_properties - finally: - # Clean up only if we created our own buffered track - if own_buffered_track and buffered_track: - try: - # Clean up the buffered track but don't stop the original track - buffered_track._buffered_frames = [] - buffered_track._ended = True - except Exception as e: - logger.error(f"Error cleaning up buffered track: {e}") - - -def _normalize_audio_format( - pcm: PcmData, target_sample_rate: int, target_format: AudioFormatType -) -> PcmData: - """ - Helper function to normalize audio to target sample rate and format. - - Args: - pcm: Input audio data - target_sample_rate: Target sample rate - target_format: Target format (AudioFormat.S16 or AudioFormat.F32) - - Returns: - PcmData with target sample rate and format - """ - # Validate format - AudioFormat.validate(target_format) - # Resample if needed - if pcm.sample_rate != target_sample_rate: - pcm = pcm.resample(target_sample_rate) - - # Convert format if needed - if target_format == "f32" and pcm.format != "f32": - pcm = pcm.to_float32() - elif target_format == "s16" and pcm.format != "s16": - pcm = pcm.to_int16() - - return pcm - - -class AudioTrackHandler: - """ - A helper to receive raw PCM data from an aiortc AudioStreamTrack - and feed it into the provided callback. - """ - - def __init__( - self, track: MediaStreamTrack, on_audio_frame: Callable[[PcmData], Any] - ): - """ - :param track: The incoming audio track (from `pc.on("track")`). - :param on_audio_frame: A callback function that will receive - the PCM data as a NumPy array (int16). - """ - self.track = track - self._on_audio_frame = on_audio_frame - self._task = None - self._stopped = False - - async def start(self): - """ - Start reading frames from the track in a background task. - """ - if self._task is None: - self._task = asyncio.create_task(self._run_track()) - - async def stop(self): - """ - Stop reading frames and clean up. - """ - self._stopped = True - if self._task: - self._task.cancel() - try: - await self._task - except asyncio.CancelledError: - pass - self._task = None - - async def _run_track(self): - """ - Internal coroutine that continuously pulls frames from the track. - """ - - while not self._stopped: - try: - frame = await self.track.recv() - except asyncio.CancelledError: - # Task was cancelled, safe to exit - break - except MediaStreamError: - # Error with the media stream, possibly EOF - break - except Exception as e: - logger.error(f"Error receiving audio frame: {e}") - break - - if not isinstance(frame, av.AudioFrame): - raise TypeError("Audio frame not received") - - if frame.sample_rate != 48000: - raise TypeError("only 48000 sample rate supported") - - try: - pcm_ndarray = frame.to_ndarray() - except Exception as plane_error: - logger.error( - f"Error converting audio frame to ndarray: {plane_error}, dropping frame" - ) - break - - # Handle stereo to mono conversion - if len(frame.layout.channels) > 1: - try: - # Reshape to separate stereo channels: [L, R, L, R, ...] -> [[L, R], [L, R], ...] - # This assumes the data is interleaved stereo - audio_stereo = pcm_ndarray.reshape(-1, len(frame.layout.channels)) - # Take the mean across channels to convert to mono - pcm_ndarray = audio_stereo.mean(axis=1).astype(np.int16) - except ValueError as e: - logger.error( - f"Error reshaping stereo audio: {e}. " - f"Original shape: {pcm_ndarray.shape}, channels: {len(frame.layout.channels)}" - ) - break - - # Extract timestamp information from the frame - pts = getattr(frame, "pts", None) - dts = getattr(frame, "dts", None) - time_base = None - - # Convert time_base to float if available - if hasattr(frame, "time_base") and frame.time_base is not None: - try: - # time_base is typically a fractions.Fraction, convert to float - time_base = float(frame.time_base) - except (TypeError, ValueError): - logger.warning( - f"Could not convert time_base to float: {frame.time_base}" - ) - time_base = None - - self._on_audio_frame( - PcmData( - sample_rate=48_000, - format="s16", - samples=pcm_ndarray, - pts=pts, - dts=dts, - time_base=time_base, - ) - ) - - class FrameResampler: - """Wraps av.AudioResampler to emit fixed-size packed av.AudioFrames from PcmData. + """Resamples PcmData with the SDK's windowed-sinc `StreamResampler` and emits + fixed-size packed s16 av.AudioFrames. - A single av.AudioResampler locks onto its first input frame's rate/layout/format, - so this rebuilds the underlying resampler whenever the input signature changes. + A `StreamResampler` keeps the rate of its first input, so this rebuilds it + whenever the input rate changes. It does not convert channels or formats, so + the input is converted to s16 at the output channel count first. """ def __init__( @@ -2617,20 +1892,24 @@ def __init__( Args: rate: Target output sample rate in Hz. layout: Target channel layout, e.g. "mono" or "stereo". - format: Output sample format passed to av.AudioResampler (packed, e.g. "s16"). - frame_size: Samples per channel in each emitted frame; input is buffered + format: Output sample format; only packed "s16". + frame_size: Samples per channel in each emitted frame; output is buffered until a full frame_size can be emitted. 0 emits variable-size frames. The tail returned by flush() may be shorter than frame_size. """ + if format != AudioFormat.S16: + raise ValueError( + f"FrameResampler output format must be 's16', got {format!r}" + ) self._rate = rate self._layout = layout - self._format = format + self._channels = len(av.AudioLayout(layout).channels) self._frame_size = frame_size - self._resampler: Optional[av.AudioResampler] = None - # Input signature the current resampler was built for. - self._in_rate: Optional[int] = None - self._in_channels: Optional[int] = None - self._in_format: Optional[str] = None + self._resampler: getstream_rtc.StreamResampler | None = None + # Input rate the current resampler was built for. + self._in_rate: int | None = None + # Resampled interleaved samples that no emitted frame holds yet. + self._pending: NDArray[np.int16] = np.array([], dtype=np.int16) def resample(self, pcm: PcmData, flush: bool = False) -> list[av.AudioFrame]: """Resample pcm data and return finished av.AudioFrames. @@ -2638,61 +1917,77 @@ def resample(self, pcm: PcmData, flush: bool = False) -> list[av.AudioFrame]: Args: pcm: PcmData audio - flush: if True, also flush the tail and reset.""" - frames: list[av.AudioFrame] = [] + flush: if True, also flush the tail.""" # Empty input (e.g. the final marker) yields no frames but can still flush. if pcm.samples.size: - resampler, tail = self._reset_resampler(pcm) - # `tail` holds the old resampler's drained buffer when the input - # signature changed; emit it before this input's frames. - frames = tail + resampler.resample(pcm.to_av_frame()) + resampler = self._reset_resampler(pcm) + self._append( + resampler.push(self._interleave(pcm), pcm.sample_rate, self._channels) + ) if flush: - frames.extend(self.flush()) + return self.flush() - return frames + return self._take_frames(partial=False) def flush(self) -> list[av.AudioFrame]: """ - Flush the resampler's buffered tail and reset it. + Flush the resampler's buffered tail and the samples of a partial frame. """ - frames: list[av.AudioFrame] = [] - if self._resampler is not None: - # resample(None) flushes swr to EOF: it raises on any further input, so - # drop it here and _reset_resampler rebuilds on the next write. - frames = self._resampler.resample(None) - self._resampler = None - return frames + self._take_tail() + return self._take_frames(partial=True) - def _reset_resampler( - self, pcm: PcmData - ) -> tuple[av.AudioResampler, list[av.AudioFrame]]: - """Return the resampler for pcm's signature, rebuilding it on a change. + def _reset_resampler(self, pcm: PcmData) -> getstream_rtc.StreamResampler: + """Return the resampler for pcm's rate, rebuilding it on a change. - On a rebuild the old resampler's buffered tail is drained and returned so it - isn't dropped; the output signature is fixed, so the tail stays compatible. + On a rebuild the old resampler's held audio is kept so it isn't dropped; the + output rate and channels are fixed, so it stays compatible. """ resampler = self._resampler - if ( - resampler is None - or self._in_rate != pcm.sample_rate - or self._in_channels != pcm.channels - or self._in_format != pcm.format - ): - tail: list[av.AudioFrame] = [] - if resampler is not None: - # Drain the outgoing resampler before swapping it out. - tail = resampler.resample(None) - resampler = av.AudioResampler( - format=self._format, - layout=self._layout, - rate=self._rate, - # frame_size makes the resampler emit fixed frame_size-sample frames. - frame_size=self._frame_size, - ) + if resampler is None or self._in_rate != pcm.sample_rate: + self._take_tail() + resampler = getstream_rtc.StreamResampler(self._rate, self._channels) self._resampler = resampler self._in_rate = pcm.sample_rate - self._in_channels = pcm.channels - self._in_format = pcm.format - return resampler, tail - return resampler, [] + return resampler + + def _take_tail(self) -> None: + if self._resampler is not None: + self._append(self._resampler.flush()) + + def _append(self, frame: getstream_rtc.PcmFrame) -> None: + self._pending = np.concatenate([self._pending, frame.samples]) + + def _interleave(self, pcm: PcmData) -> NDArray[np.int16]: + """Return pcm as interleaved s16 samples at the output channel count.""" + samples = np.frombuffer(pcm.to_int16().to_bytes(), dtype=np.int16) + if pcm.channels == self._channels: + return samples + if self._channels == 1: + # Average the channels of each sample frame. + mono = samples.reshape(-1, pcm.channels).mean(axis=1) + return mono.round().astype(np.int16) + if pcm.channels == 1: + return np.repeat(samples, self._channels) + raise ValueError( + f"Unsupported channel conversion: {pcm.channels} -> {self._channels}" + ) + + def _take_frames(self, partial: bool) -> list[av.AudioFrame]: + """Cut the pending samples into frames; `partial` also emits the shorter rest.""" + pending = self._pending + size = self._frame_size * self._channels or len(pending) + if not size: + return [] + end = len(pending) if partial else len(pending) - len(pending) % size + self._pending = pending[end:] + frames = [] + for start in range(0, end, size): + frame = av.AudioFrame.from_ndarray( + pending[start : start + size].reshape(1, -1), + format="s16", + layout=self._layout, + ) + frame.sample_rate = self._rate + frames.append(frame) + return frames diff --git a/getstream/video/rtc/tracks.py b/getstream/video/rtc/tracks.py index ea7a4450..6584d096 100644 --- a/getstream/video/rtc/tracks.py +++ b/getstream/video/rtc/tracks.py @@ -1,24 +1,13 @@ """ -Consolidated track management module for video streaming. -Handles track publishing, subscription configuration, and subscription management. +Subscription configuration for remote tracks. """ -import asyncio -import logging from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional +from typing import Dict, List, Optional -from pyee.asyncio import AsyncIOEventEmitter - -from getstream.video.rtc.pb.stream.video.sfu.models import models_pb2 from getstream.video.rtc.pb.stream.video.sfu.models.models_pb2 import ( - TrackType, VideoDimension, ) -from getstream.video.rtc.pb.stream.video.sfu.signal_rpc import signal_pb2 -from getstream.video.rtc.twirp_client_wrapper import SfuRpcError - -logger = logging.getLogger(__name__) @dataclass @@ -56,253 +45,3 @@ class SubscriptionConfig: ) role_filters: Dict[str, TrackSubscriptionConfig] = field(default_factory=dict) max_subscriptions: Optional[int] = None - - -class SubscriptionManager(AsyncIOEventEmitter): - """Encapsulates remote track subscription policy & SFU UpdateSubscriptions plumbing.""" - - def __init__( - self, - connection_manager: "Any", - subscription_config: Optional[SubscriptionConfig] = None, - ) -> None: - super().__init__() - self.connection_manager = connection_manager - self._subscription_config = subscription_config or SubscriptionConfig() - self._subscribed_track_details: List[signal_pb2.TrackSubscriptionDetails] = [] - self._expected_tracks: Dict[ - str, models_pb2.Participant - ] = {} # track_type:user_id:session_id -> participant - self._received_track_order: List[ - str - ] = [] # Track the order of received WebRTC tracks - self._lock: asyncio.Lock = asyncio.Lock() - - def _get_role_config( - self, participant: Optional[models_pb2.Participant] - ) -> Optional[TrackSubscriptionConfig]: - """Determine which TrackSubscriptionConfig applies to the participant.""" - if not self._subscription_config: - return None - - roles = set(getattr(participant, "roles", [])) if participant else set() - - for role in roles: - if role in self._subscription_config.role_filters: - return self._subscription_config.role_filters[role] - - return self._subscription_config.default - - def _should_subscribe( - self, participant: Optional[models_pb2.Participant], track_type: int - ) -> bool: - """Check if a given track should be subscribed according to role config.""" - cfg = self._get_role_config(participant) - return bool(cfg and track_type in cfg.track_types) - - def _create_track_subscription_detail( - self, - participant: Optional[models_pb2.Participant], - track_type: int, - user_id: str, - session_id: str, - ) -> Optional[signal_pb2.TrackSubscriptionDetails]: - """Generate TrackSubscriptionDetails using role-based dimension preferences.""" - cfg = self._get_role_config(participant) - if not cfg: - return None - - try: - detail = signal_pb2.TrackSubscriptionDetails( - user_id=user_id, - session_id=session_id, - track_type=track_type, - ) - - if track_type == TrackType.TRACK_TYPE_VIDEO and cfg.video_dimension: - detail.dimension.CopyFrom(cfg.video_dimension) - elif ( - track_type == TrackType.TRACK_TYPE_SCREEN_SHARE - and cfg.screenshare_dimension - ): - detail.dimension.CopyFrom(cfg.screenshare_dimension) - - return detail - except Exception as e: - logger.error(f"Error creating TrackSubscriptionDetails: {e}") - return None - - def _generate_subscription_details_for_participants( - self, - ) -> List[signal_pb2.TrackSubscriptionDetails]: - """Generate subscription details for all current participants based on policy.""" - subscription_infos: List[signal_pb2.TrackSubscriptionDetails] = [] - - for ( - participant - ) in self.connection_manager.participants_state._participant_by_prefix.values(): - if participant.session_id == self.connection_manager.session_id: - continue # Skip our own tracks - - if not participant.published_tracks: - continue - - for track_type in participant.published_tracks: - if not self._should_subscribe(participant, track_type): - continue - - detail = self._create_track_subscription_detail( - participant, - track_type, - participant.user_id, - participant.session_id, - ) - - if detail is None: - continue - - subscription_infos.append(detail) - - # Respect max-subscription limit if set - if ( - self._subscription_config.max_subscriptions is not None - and len(subscription_infos) - >= self._subscription_config.max_subscriptions - ): - return subscription_infos - - return subscription_infos - - async def _update_subscriptions(self): - """Push the current subscription list to the SFU.""" - async with self._lock: - track_details = list(self._subscribed_track_details) - - if not track_details or not self.connection_manager.twirp_signaling_client: - return - - try: - response = await self.connection_manager.twirp_signaling_client.UpdateSubscriptions( - ctx=self.connection_manager.twirp_context, - request=signal_pb2.UpdateSubscriptionsRequest( - session_id=self.connection_manager.session_id, - tracks=track_details, - ), - server_path_prefix="", - ) - logger.debug(f"Updated subscriptions for req: {track_details} - {response}") - except SfuRpcError as e: - logger.error(f"Failed to update subscriptions SfuRpcError: {e}") - except Exception as e: - logger.error(f"Failed to update subscriptions: {e}") - - async def handle_track_published(self, event): - """Handle new remote track publications from the SFU.""" - logger.info( - f"Handling track published: {event.user_id} - {event.session_id} - {event.type}" - ) - try: - # Keep participants state up-to-date - if hasattr(event, "participant"): - self.connection_manager.participants_state._add_participant( - event.participant - ) - - # Register expected track for this user - track_type = getattr(event, "type", None) - if track_type == TrackType.TRACK_TYPE_AUDIO: - track_key = f"{track_type}:{event.user_id}:{event.session_id}" - self._expected_tracks[track_key] = event.participant - - track_type = getattr(event, "type", None) - if track_type is None: - logger.error(f"Track type is None: {event}") - return - - if not self._should_subscribe( - getattr(event, "participant", None), track_type - ): - logger.error(f"Not subscribing to track: {event}") - return - - # Check for duplicates & subscription limits - async with self._lock: - already_subscribed = any( - d.user_id == event.user_id - and d.session_id == event.session_id - and d.track_type == track_type - for d in self._subscribed_track_details - ) - if already_subscribed: - logger.error(f"Already subscribed to track: {event}") - return - - if ( - self._subscription_config.max_subscriptions is not None - and len(self._subscribed_track_details) - >= self._subscription_config.max_subscriptions - ): - logger.error( - "Max subscription limit reached, skipping new track subscription" - ) - return - - detail = self._create_track_subscription_detail( - event.participant, - track_type, - event.user_id, - event.session_id, - ) - if detail is None: - logger.error(f"Detail is None: {event}") - return - - self._subscribed_track_details.append(detail) - - # Push updated subscriptions to SFU outside the lock - await self._update_subscriptions() - - except Exception as e: - logger.error(f"Error handling track published: {e}") - - async def handle_track_unpublished(self, event): - """Handle remote track unpublication events.""" - try: - track_type = getattr(event, "type", None) - user_id = getattr(event, "user_id", None) - session_id = getattr(event, "session_id", None) - - if track_type is None: - return - - async with self._lock: - before = len(self._subscribed_track_details) - self._subscribed_track_details = [ - d - for d in self._subscribed_track_details - if not ( - d.user_id == user_id - and d.session_id == session_id - and d.track_type == track_type - ) - ] - after = len(self._subscribed_track_details) - - if before == after: - return # nothing changed - - await self._update_subscriptions() - - except Exception as e: - logger.error(f"Error handling track unpublished: {e}") - - def get_next_expected_audio_user(self) -> Optional[models_pb2.Participant]: - """Get the next expected user for an incoming audio track.""" - # Find the first audio track expectation - for track_key, participant in list(self._expected_tracks.items()): - if track_key.startswith(f"{TrackType.TRACK_TYPE_AUDIO}:"): - # Remove this expectation since we're using it - del self._expected_tracks[track_key] - return participant - - return None diff --git a/getstream/video/rtc/twirp_client_wrapper.py b/getstream/video/rtc/twirp_client_wrapper.py deleted file mode 100644 index 98cca788..00000000 --- a/getstream/video/rtc/twirp_client_wrapper.py +++ /dev/null @@ -1,246 +0,0 @@ -# -*- coding: utf-8 -*- -import asyncio # Import asyncio if not already present -import functools -import logging -from typing import TYPE_CHECKING, Callable, Optional - -import structlog -from structlog.stdlib import LoggerFactory, add_log_level -from structlog.types import BindableLogger -from twirp.context import Context as TwirpContext - -from getstream.common import telemetry -from getstream.video.rtc.pb.stream.video.sfu.models import models_pb2 -from getstream.video.rtc.pb.stream.video.sfu.signal_rpc.signal_twirp import ( - AsyncSignalServerClient, -) - -if TYPE_CHECKING: - from getstream.video.rtc.tracer import Tracer - -logger = logging.getLogger(__name__) - -# RPC methods to exclude from tracing (to avoid circular tracing) -RPC_TRACE_EXCLUSIONS = {"SendStats"} - -# RPC methods that should include response data in traces -RPC_RESPONSE_INCLUSIONS = {"SetPublisher"} - - -def _get_twirp_default_logger() -> BindableLogger: - """Re-generate the default structlog config used inside `twirp` without overriding - the root logger level. - A trimmed down copy of `twirp.logging.configure`""" - - structlog.configure( - logger_factory=LoggerFactory(), - processors=[ - add_log_level, - # Add timestamp - structlog.processors.TimeStamper("iso"), - # Add stack information - structlog.processors.StackInfoRenderer(), - # Set exception field using exec info - structlog.processors.format_exc_info, - # Render event_dict as JSON - structlog.processors.JSONRenderer(), - ], - ) - return structlog.get_logger() - - -_TWIRP_DEFAULT_LOGGER = _get_twirp_default_logger() - - -class Context(TwirpContext): - """A wrapper around `twirp.context.Context` to fix the logging issue. - Use it as a replacement. - """ - - def __init__(self, *args, headers=None): - # Passing our own logger here because twirp configures the root logger - # under the hood, making every other library noisy. - super().__init__(*args, logger=_TWIRP_DEFAULT_LOGGER, headers=headers) - - -# Define a custom exception for SFU RPC errors -class SfuRpcError(Exception): - """Exception raised when an SFU RPC call returns an error.""" - - def __init__(self, code, message, method_name): - self.code = code - self.message = message - self.method_name = method_name - super().__init__( - f"SFU RPC Error in {method_name} - Code: {code}, Message: {message}" - ) - - -# Helper function to check response error -def _check_response_for_error(response, method_name, span=None): - """Checks the response for an error and annotates the span. - - - If an error is present, adds error code/message to the span and raises. - - Otherwise, marks the call as ok on the span. - """ - try: - if span is not None: - # Always record which RPC method we hit - try: - span.set_attribute("twirp.method", method_name) - except Exception: - pass - - if hasattr(response, "HasField") and response.HasField("error"): - code = getattr(response.error, "code", models_pb2.ERROR_CODE_UNSPECIFIED) - message = getattr(response.error, "message", "") - if code != models_pb2.ERROR_CODE_UNSPECIFIED: - # Annotate span with error details - if span is not None: - try: - span.set_attribute("twirp.error", True) - span.set_attribute("twirp.error_code", int(code)) - if message: - span.set_attribute("twirp.error_message", message) - # Set error status when available - if hasattr(telemetry, "Status") and hasattr( - telemetry, "StatusCode" - ): - span.set_status( - telemetry.Status( - telemetry.StatusCode.ERROR, str(message) - ) - ) - except Exception: - pass - # Raise structured error - raise SfuRpcError(code=code, message=message, method_name=method_name) - - # No error present - if span is not None: - try: - span.set_attribute("twirp.error", False) - except Exception: - pass - return response - except Exception: - # Ensure we re-raise for caller handling - raise - - -# Inherit but use __getattribute__ for dynamic wrapping -class SignalClient(AsyncSignalServerClient): - """An async TwirpClient wrapper that inherits from AsyncSignalServerClient - (for IDE support) but uses __getattribute__ to dynamically wrap public - methods with error checking, avoiding manual synchronization. - - Also supports optional tracing of RPC calls for debugging. - """ - - def __init__( - self, - address: str, - tracer: Optional["Tracer"] = None, - sfu_id_fn: Optional[Callable[[], Optional[str]]] = None, - ): - """Initialize the SignalClient. - - Args: - address: The address of the signaling server - tracer: Optional tracer for RPC tracing - sfu_id_fn: Optional function that returns the current SFU ID for tracing - """ - super().__init__(address) - self._tracer = tracer - self._sfu_id_fn = sfu_id_fn - - def __getattribute__(self, name: str): - """Intercepts attribute access to dynamically wrap public async methods.""" - # Get the original attribute using the default mechanism (respects MRO) - # Use super() to avoid recursion within this method if accessing SignalClient's own attributes - original_attr = super().__getattribute__(name) - - # Wrap public, callable async methods (heuristic: callable, not starting with '_') - # Important: Check if it's an async function using asyncio.iscoroutinefunction - if ( - callable(original_attr) - and not name.startswith("_") - and asyncio.iscoroutinefunction(original_attr) - ): - # Get tracer and sfu_id_fn from instance (use object.__getattribute__ to avoid recursion) - tracer = object.__getattribute__(self, "_tracer") - sfu_id_fn = object.__getattribute__(self, "_sfu_id_fn") - - @functools.wraps(original_attr) - async def wrapped_method(*args, **kwargs): - # Trace RPC request (if not excluded) - should_trace = tracer and name not in RPC_TRACE_EXCLUSIONS - sfu_id = sfu_id_fn() if sfu_id_fn else None - - # Extract request data for tracing - request_data = None - if should_trace: - request = kwargs.get("request") - if request: - try: - from google.protobuf.json_format import MessageToDict - - # Use default camelCase field names (no preserving_proto_field_name) - request_data = MessageToDict(request) - except Exception: - request_data = str(request) - tracer.trace(name, sfu_id, request_data) - - with telemetry.start_as_current_span(f"signaling.twirp.{name}") as span: - try: - # Call the original async method retrieved earlier - response = await original_attr(*args, **kwargs) - - # Trace response for certain methods - if should_trace and name in RPC_RESPONSE_INCLUSIONS: - try: - from google.protobuf.json_format import MessageToDict - - # Use default camelCase field names - response_data = MessageToDict(response) - tracer.trace(f"{name}Response", sfu_id, response_data) - except Exception: - pass - - # Check response and annotate span - return _check_response_for_error(response, name, span=span) - except Exception as e: - # Trace failure - if should_trace: - tracer.trace( - f"{name}OnFailure", sfu_id, [str(e), request_data] - ) - raise - - # Return the dynamic wrapper - return wrapped_method - elif callable(original_attr) and not name.startswith("_"): - - @functools.wraps(original_attr) - async def wrapped_method(*args, **kwargs): - with telemetry.start_as_current_span(f"signaling.twirp.{name}") as span: - response = original_attr(*args, **kwargs) - return _check_response_for_error(response, name, span=span) - - # Return the dynamic wrapper - return wrapped_method - else: - # For non-callable attributes, or non-async methods, or private methods, - # return the original attribute directly. - return original_attr - - # No need to explicitly override each method like SetPublisher, SendAnswer etc. - # __getattribute__ handles them dynamically. - - -# Export necessary symbols -__all__ = [ - "SfuRpcError", - "SignalClient", - "Context", -] diff --git a/getstream/video/rtc/utils.py b/getstream/video/rtc/utils.py index e2c7d4ee..f59a45ab 100644 --- a/getstream/video/rtc/utils.py +++ b/getstream/video/rtc/utils.py @@ -17,7 +17,7 @@ def open_browser(api_key: str, token: str, call_id: str) -> str: Returns: The URL that was opened """ - base_url = "https://pronto.getstream.io/bare/join/" + base_url = "https://pronto.getstream.io/join/" params = {"api_key": api_key, "token": token, "skip_lobby": "true"} url = f"{base_url}{call_id}?{urlencode(params)}" diff --git a/getstream/video/rtc/video_forwarder.py b/getstream/video/rtc/video_forwarder.py new file mode 100644 index 00000000..28942ef7 --- /dev/null +++ b/getstream/video/rtc/video_forwarder.py @@ -0,0 +1,65 @@ +from typing import cast + +import aiortc +import av +import getstream_rtc +import numpy as np +from numpy.typing import NDArray + +# The duration of a frame whose source gives no timing, in seconds. +_DEFAULT_FRAME_DURATION = 1 / 30 + + +class VideoForwarder: + """Copies the frames of an aiortc video track to an SDK `LocalVideoTrack`. + + The source paces the frames. The SDK encoder needs one even size for the + whole track, so each frame is scaled to the size of the first frame, with an + odd width or height raised to the next even value. + """ + + def __init__( + self, source: aiortc.MediaStreamTrack, target: getstream_rtc.LocalVideoTrack + ) -> None: + self.source = source + self.target = target + + async def run(self) -> None: + """Copy frames until the source ends or the SDK stops the target.""" + size: tuple[int, int] | None = None + previous_time: float | None = None + while True: + try: + # aiortc types recv() as a frame or a packet; a video track + # gives video frames. + frame = cast(av.VideoFrame, await self.source.recv()) + except aiortc.mediastreams.MediaStreamError: + return + if size is None: + size = (_even(frame.width), _even(frame.height)) + # `time` is pts * time_base, or None when the frame has no pts. + time = frame.time + duration = ( + time - previous_time + if time is not None + and previous_time is not None + and time > previous_time + else _DEFAULT_FRAME_DURATION + ) + previous_time = time + width, height = size + frame = frame.reformat(width=width, height=height, format="yuv420p") + # A yuv420p frame gives uint8 planes. + pixels = cast(NDArray[np.uint8], frame.to_ndarray()) + try: + await self.target.write_i420( + pixels.reshape(-1), width, height, duration + ) + except getstream_rtc.IllegalStateError: + # The only cause is a stopped track: it was unpublished, or the + # call was left or ended. + return + + +def _even(value: int) -> int: + return value + value % 2 diff --git a/migration.md b/migration.md new file mode 100644 index 00000000..278b491d --- /dev/null +++ b/migration.md @@ -0,0 +1,626 @@ +# Migration Guide: aiortc transport → Rust Video SDK + +This guide lists the breaking changes in `getstream.video.rtc` when the WebRTC +transport moves from aiortc to the Rust Video SDK +([stream-video-rust](https://github.com/GetStream/stream-video-rust)). It +compares with stream-py 6.1.1. The work is not released yet; this guide is +updated with each change. + +## Overview + +`ConnectionManager` (from `rtc.join(...)`) no longer connects to the call +itself. The Rust SDK, compiled into the separate package `getstream-rtc` +(import name `getstream_rtc`), does the coordinator join, the SFU signaling, +the peer connections, the retries and the reconnects. `ConnectionManager` is a thin +layer on top of it. + +The main changes for consumers: + +- Event payloads are `getstream_rtc` classes (re-exported by + `getstream.video.rtc`), not SFU protobuf messages. +- `ConnectionManager` emits the stable event names that the SDK can supply. + The SFU transport events and the reconnection events are gone. +- Remote video is read from the track object of `track_added`. There is no + `add_track_subscriber()`. +- Join errors are the Rust SDK errors, not `SfuJoinError`. +- Local recording is removed; use the server recording. + +## Installation + +The `webrtc` extra now also installs `getstream-rtc` (import name +`getstream_rtc`), a separate package that contains the native extension. +`getstream` itself stays pure Python. Building `getstream-rtc` from source (a +git checkout, or its sdist on a platform without a wheel) needs the Rust +toolchain pinned in `getstream-rtc/rust-toolchain.toml`, a C compiler, `cmake`, +`pkg-config`, `libvpx` and libclang: + +- macOS: `brew install libvpx cmake pkg-config` (libclang comes with Xcode) +- Debian/Ubuntu: `apt install libvpx-dev libclang-dev cmake pkg-config build-essential` + +`getstream-rtc` needs Python 3.10 or later, as `getstream` does. + +The `webrtc` extra no longer installs `scipy`, `soundfile`, `websockets`, +`websocket-client`, `structlog`, `tenacity` and `ping3`; no stream-py code uses +them. Code that imports one of them must declare it itself. + +## Breaking Changes + +### Connection state + +| 6.1.1 | Now | Notes | +| --- | --- | --- | +| `connection.connection_state` returns `ConnectionState` | returns `CallingState` | Import it from `getstream.video.rtc`. | +| `ConnectionState.JOINED` | `CallingState.JOINED` | Values: `IDLE`, `JOINING`, `JOINED`, `RECONNECTING`, `MIGRATING`, `RECONNECTING_FAILED`, `LEFT`. | +| `ConnectionState.RINGING`, `ConnectionState.OFFLINE` | removed | stream-py never set these values. | +| `ConnectionState` is an `Enum` with string values (`"joined"`) | `CallingState` is not an `Enum` | The members have no `name` or `value`, cannot be hashed (as `dict` keys or in a `set`) and cannot be iterated. They compare equal to their `int` values. Compare with the members: `state == CallingState.JOINED`. | +| `connection.connection_state = ...` | read only | The SDK owns the state. | +| event `connection.state_changed` with `{"old": ConnectionState, "new": ConnectionState}` | the same event and dict, with `CallingState` values | The first event is `{"old": IDLE, "new": JOINING}`. | +| after a failed join, `connection.state_changed` with `{"old": JOINING, "new": IDLE}` | no `connection.state_changed` after `connect()` fails | `connect()` stops its event tasks and leaves the call before it raises. | + +### Event payloads + +Every SDK event is an instance of `CallEvent` and has `name`, the event name +it is emitted under. Import the event classes from `getstream.video.rtc`, +together with `RemoteTrack`, `RemoteParticipant`, `TrackType`, +`VideoFrameStream` and `VideoFrame`. The nested classes in the table below (for +example `AudioLevel`, `PublishOption` and `ErrorDetails`) are not exported; you +get them from the event attributes. Import `PcmFrame` and the errors from +`getstream_rtc`. + +| Event | 6.1.1 payload | Now | +| --- | --- | --- | +| `participant_joined` | `events_pb2.ParticipantJoined` | `ParticipantJoined`: `call_cid`, `participant` | +| `participant_left` | `events_pb2.ParticipantLeft` | `ParticipantLeft`: `call_cid`, `participant` | +| `track_published` | `events_pb2.TrackPublished` | `TrackPublished`: `user_id`, `session_id`, `track_type`, `participant` | +| `track_unpublished` | `events_pb2.TrackUnpublished` | `TrackUnpublished`: `user_id`, `session_id`, `track_type`, `cause`, `participant` | +| `call_ended` | `events_pb2.CallEnded` | `CallEnded`: `reason` | +| `participant_updated` | `events_pb2.ParticipantUpdated` | `ParticipantUpdated`: `call_cid`, `participant` | +| `dominant_speaker_changed` | `events_pb2.DominantSpeakerChanged` | `DominantSpeakerChanged`: `user_id`, `session_id` | +| `audio_level_changed` | `events_pb2.AudioLevelChanged` | `AudioLevelChanged`: `audio_levels` (`AudioLevel`: `user_id`, `session_id`, `level`, `is_speaking`) | +| `connection_quality_changed` | `events_pb2.ConnectionQualityChanged` | `ConnectionQualityChanged`: `connection_quality_updates` (`ConnectionQualityInfo`: `user_id`, `session_id`, `connection_quality`) | +| `pins_updated` | `events_pb2.PinsChanged` | `PinsChanged`: `pins` (`Pin`: `user_id`, `session_id`) | +| `inbound_state_notification` | `events_pb2.InboundStateNotification` | `InboundStateNotification`: `inbound_video_states` (`InboundVideoState`: `user_id`, `session_id`, `track_type`, `paused`) | +| `call_grants_updated` | `events_pb2.CallGrantsUpdated` | `CallGrantsUpdated`: `current_grants` (`CallGrants`: `can_publish_audio`, `can_publish_video`, `can_screenshare`), `message` | +| `ice_restart` | `events_pb2.ICERestart`, the SFU request | `ICERestart`: `peer_type`, sent after the SDK has handled the request | +| `participant_count_changed` | (not emitted) | new: `ParticipantCountChanged`: `total`, `anonymous`, when the SFU's count changes | +| `change_publish_options` | `events_pb2.ChangePublishOptions` | `ChangePublishOptions`: `publish_options` (`PublishOption`), `reason` | +| `change_publish_quality` | `events_pb2.ChangePublishQuality` | `ChangePublishQuality`: `audio_senders` (`AudioSender`), `video_senders` (`VideoSender` with `layers`: `VideoLayerSetting`) | +| `error` | `events_pb2.Error`: `error` (`models_pb2.Error`), `reconnect_strategy` | `Error`: `error` (`ErrorDetails`: `code`, `message`, `should_retry`), `reconnect_strategy` | +| `custom` | the coordinator JSON `dict` | unchanged | +| `audio` | `PcmData`, 48 kHz mono `s16`; `participant` is `models_pb2.Participant` | `PcmData`, 48 kHz mono `s16`; `participant` is a `RemoteParticipant`; `pts` (the RTP timestamp), `dts` (`None`) and `time_base` (`1/48000`) as in 6.1.1 | + +`ConnectionManager` reads every remote audio track (`AUDIO` and +`SCREEN_SHARE_AUDIO`) to emit `audio`. Do not also call `next_pcm()` on these +tracks: each frame goes to one reader only. + +Field changes: + +- `participant` is a `RemoteParticipant` with the fields of the protobuf + `Participant`, except `track_lookup_prefix`: use `session_id` to identify a + participant session. +- `participant.joined_at` is a `float`, the Unix time in seconds (or `None`), + not a protobuf `Timestamp`. Use + `datetime.fromtimestamp(joined_at, timezone.utc)` for a `datetime`. +- `participant.custom` is a `dict`, not a protobuf `Struct`; numbers are + `float`, as with `MessageToDict`. +- The protobuf field `type` of `TrackPublished` and `TrackUnpublished` is now + `track_type`. +- `track_type` and `published_tracks` use the `TrackType` enum (`AUDIO`, + `VIDEO`, `SCREEN_SHARE`, `SCREEN_SHARE_AUDIO`, `UNSPECIFIED`), not + `models_pb2.TrackType` integers. As with `CallingState`, the members are not + `Enum` members and cannot be hashed: `set(participant.published_tracks)` + raises `TypeError`. They compare equal to the `models_pb2.TrackType` + integers. +- `RemoteParticipant` compares by identity, and each read of a `participant` + attribute gives a new object, so `track.participant == track.participant` is + `False`. Compare `session_id`. In 6.1.1 the protobuf `Participant` compared + by value. +- The other protobuf enum fields keep their `int` values, as in 6.1.1: + `participant.connection_quality`, `participant.source`, + `TrackUnpublished.cause`, `CallEnded.reason`. A `connection_quality` or + `source` value that the SDK protocol does not know (from a newer SFU) + becomes 0, and an unknown track type is not in `published_tracks`. 6.1.1 + kept these `int` values. +- `TrackPublished.participant`, `TrackUnpublished.participant` and + `CallGrantsUpdated.current_grants` are `None` when the SFU does not send + them; in 6.1.1 they were always an object. +- `InboundVideoState.track_type`, `PublishOption.track_type`, + `AudioSender.track_type` and `VideoSender.track_type` use the `TrackType` + enum. +- `codec` (on `PublishOption`, `AudioSender`, `VideoSender`, + `VideoLayerSetting`) and `PublishOption.video_dimension` are `None` when the + SFU does not send them. +- `PublishOption` and `VideoSender` also have `degradation_preference` (`int`), + from a newer SFU protocol than 6.1.1. +- The nested error of the `Error` event is an `ErrorDetails`; the data path + (`event.error.code`) is the same as in 6.1.1. `Error` is an event class, not + an exception. +- `ICERestart` arrives after the SDK has handled the SFU's restart request; + in 6.1.1 it was the request itself. +- A `call_ended` after a coordinator end, or after the SDK leaves the call by + itself (see "Joining, waiting and leaving"), carries `reason` 0 + (`CALL_ENDED_REASON_UNSPECIFIED`). `call_ended` is emitted once, after the + SFU events that arrived before the end, so an SFU `call_ended` keeps its + `reason`. + +### Events that are no longer emitted + +| 6.1.1 event | Now | +| --- | --- | +| `subscriber_offer`, `publisher_answer`, `ice_trickle`, `join_response`, `health_check_response`, `go_away`, `participant_migration_complete` | Not emitted. The SDK handles these SFU transport events itself. For the participant count of `health_check_response`, use `participant_count_changed`. | +| `connection_lost`, `reconnection_success`, `reconnection_failed` | Not emitted. The SDK reconnects by itself; watch `connection.state_changed` (`RECONNECTING`, `MIGRATING`, `RECONNECTING_FAILED`). | +| coordinator events other than `custom` | Not emitted, as in 6.1.1. They are logged at DEBUG. | + +### Remote tracks + +| 6.1.1 | Now | Notes | +| --- | --- | --- | +| `track_added` with `(track_id, kind, user)` | `track_added` with a `RemoteTrack` | `track.participant`, `track.track_type`. | +| `connection.subscriber_pc.add_track_subscriber(track_id)` | the `RemoteTrack` itself | `await track.next_pcm()` gives a `PcmFrame` (48 kHz mono `int16` numpy samples), or `None` when the track ends. `track.video_frames()` gives a `VideoFrameStream`: `async for frame in track.video_frames()` gives each `VideoFrame` (I420 `uint8` numpy data) until the track ends. | +| each `add_track_subscriber()` call returns a new aiortc relay proxy, and each proxy gets the frames independently | each `video_frames()` call returns a new independent `VideoFrameStream` | Each stream gets its own copy of each frame. A stream that reads slower than the track skips to the latest frame. Two loops on one stream share its frames. The result is not an aiortc track. | +| `connection.republish_tracks()` | removed; not needed | `track_added` also arrives for the tracks of participants who joined before you. | +| `track_published` for tracks that existed before the join (from `republish_tracks()`) | not emitted | Use `track_added`. | + +Behavior of a track object: + +- `ConnectionManager` keeps a reference only to remote audio tracks (`AUDIO` + and `SCREEN_SHARE_AUDIO`): it reads them for the `audio` event until they + end. When the last reference to a video track and to its + `VideoFrameStream`s is dropped, the SDK unsubscribes from that track. +- After a remote mute and unmute (`track_unpublished`, then + `track_published`), no new `track_added` arrives. The same track object + delivers frames again. If you drop a video track object during the mute, a + new `track_added` with a new track object arrives when the media flows again + after the `track_published`. A video track that you drop while it is + published does not come back until it is published again. +- Remote H.264 video is not supported: the SDK decodes only VP8 and VP9. A + participant that publishes only H.264 (for example RTMP or SRT ingress, or an + app that chooses H.264 as its codec) gives no video track. 6.1.1 decoded + H.264 with aiortc. +- A track ends (`None`, or the end of its `VideoFrameStream`s) when its + participant leaves, when this client leaves, or when this client rejoins + with a new session; after a rejoin a new `track_added` arrives. + +### Joining, waiting and leaving + +| 6.1.1 | Now | Notes | +| --- | --- | --- | +| `participant_joined` only for participants who join after you | also for the participants already in the call at join | Handlers registered before `__aenter__()` get them. | +| `wait()` returns after `leave()` | also returns when the call ends or the SDK leaves the call | It does not return at `RECONNECTING_FAILED`; call `leave()`. | +| `call_ended` only from the SFU | also when the coordinator ends the call | Emitted once. After a coordinator end, the payload is a `CallEnded` that stream-py makes. | +| after an SFU `error` with the `DISCONNECT` strategy, only the `error` event; a failed reconnection gives `RECONNECTING_FAILED` and `reconnection_failed` | the SDK leaves the call: `connection.state_changed` gives `LEFT`, `call_ended` comes with `reason` 0, and `wait()` returns | The SDK also leaves when ICE never connects after several tries, when it reaches its rejoin rate limit, and after too many failed negotiations. Other reconnection failures give `RECONNECTING_FAILED`. | +| the join continues when the coordinator WebSocket cannot connect; the failure is logged | `connect()` raises, for example `CoordinatorError` | After the join, a closed coordinator WebSocket or a failed coordinator health check makes the SDK rejoin the call with a new session (see "Remote tracks"). 6.1.1 reconnected only the WebSocket. | +| `connect()` retries with `max_join_retries` and other SFUs | the SDK retries | The `max_join_retries` argument is removed. | +| `rtc.join(call, user_id, **kwargs)` passes `kwargs` to the join request | `rtc.join(call, user_id, create, subscription_config)` | Other keyword arguments raise `TypeError`. For the join request arguments `data`, `ring`, `notify` and `video`, call `await call.get_or_create(...)` with them before `rtc.join()`; it takes the same arguments. | +| `user_id=None` raises `ValueError` | raises `TypeError` | `user_id` of `rtc.join` and `ConnectionManager` has no default now. A call without an id raises `TypeError`. | +| `drain_video_frames` | removed | The SDK decodes a remote track only while it is read. | +| `connect()` again on the same `ConnectionManager` | not supported | After `leave()`, or after a `connect()` that failed or was cancelled, a new `connect()` joins the call, but no events and no tracks arrive, and `wait()` returns at once. Use a new `rtc.join()` for each join. | +| `add_tracks()` before `connect()` logs an error and returns | raises `IllegalStateError` | Call `add_tracks()` after the join. | + +### Publishing audio + +- `add_tracks(audio=track)` accepts aiortc audio tracks that give packed `s16` + frames, as `AudioStreamTrack` does. The SDK paces the frames and always + sends mono: stereo frames are mixed down. +- Frames in other formats are not converted. Float frames (`fltp`) stop the + forwarding and unpublish the track; planar stereo frames (`s16p`) are sent + garbled. In 6.1.1 the aiortc Opus encoder converted every format to `s16`. +- The SDK takes the sample rate of the first frame of a published track. A + later frame at another rate raises `PcmRateMismatchError` (`expected`, + `actual`), which stops the forwarding and unpublishes the track. + `AudioStreamTrack` always gives frames at its own `sample_rate`. +- `AudioStreamTrack` has a new setting `pace`, `False` by default: + - `pace=False`: `recv()` returns the next queued frame at once and waits for + the next `write()` when the queue is empty. This is the right setting for + `add_tracks`. + - `pace=True`: the 6.1.1 behavior, one frame per 20 ms and silence when the + queue is empty. Code that passes an `AudioStreamTrack` to its own aiortc + connection needs `pace=True`. +- `AudioStreamTrack.wait_for_flush()` is new: it returns at the next + `flush()`. `add_tracks` uses it, so `flush()` also drops the audio that is + already queued for sending and the audio of a write to the SDK that is in + progress. +- When more audio is queued than `audio_buffer_size_ms`, the oldest audio is + dropped, as in 6.1.1, with an "Audio buffer overflow" DEBUG record from + `getstream.video.rtc.audio_forwarder`. `add_tracks` raises `MediaError` for + an `audio_buffer_size_ms` below 20 (one frame) and `ValueError` for a + negative value. +- After `track.stop()`, the track is unpublished, so other participants get + `track_unpublished`. In 6.1.1 the packets only stopped. +- A new `add_tracks(audio=new_track)` replaces the published audio track: the + earlier track is unpublished first, also while it is still live. In 6.1.1 + each call added one more audio track. + +### Resampling + +`FrameResampler`, and so `AudioStreamTrack.write()`, resamples with the SDK's +windowed-sinc filter (`getstream_rtc.StreamResampler`), not with PyAV +(libswresample). The filter removes much more of the images and aliases: from +16 kHz to 48 kHz, the image of a 7 kHz tone is at −103 dB, with PyAV at +−38 dB. The differences from 6.1.1: + +- Audio at another rate than the output rate is delayed by 128 input frames: + 8 ms at 16 kHz, 5.3 ms at 24 kHz, 2.9 ms at 44.1 kHz. The delay is silence + at the start of the audio and again after each `flush()`. PyAV added no + silence. +- `flush()` and `write(pcm, final=True)` give the audio that the filter holds + and about 132 input frames of near-silence after it: 16 ms in all at + 16 kHz. PyAV gave 1 ms at 16 kHz. A change of the input rate also adds the + held audio and this near-silence. +- Mono input to a stereo `FrameResampler` is copied to each channel at full + level. PyAV lowered it by 3 dB. Stereo input to mono is averaged, as in + 6.1.1. +- The channel count changes only from mono or to mono; another change raises + `ValueError`. PyAV converted between all layouts. +- `FrameResampler` accepts only the output format `s16`; another `format` + raises `ValueError`. +- `PyAVResampler` is removed. No replacement works the same: + - `Resampler(format, sample_rate, channels).resample(pcm)` takes the same + arguments (without `frame_size`) and returns `PcmData`, but it + interpolates linearly and keeps no state between calls. + - `FrameResampler(rate, layout, format, frame_size)` keeps state between + calls, as `PyAVResampler` did, but it returns a `list` of `av.AudioFrame` + without `pts`, and its only output format is `s16`. + +### Publishing video + +- `add_tracks(video=track)` accepts any aiortc video track; the track paces + itself, as in 6.1.1. +- The video is sent as VP9, the SFU's default publish option for camera + video. 6.1.1 sent H264 (aiortc without VP8). +- The environment variables `STREAM_PATCH_AIORTC_BITRATES` and + `STREAM_PATCH_AIORTC_H264_PRESET` have no effect: the aiortc encoder patches + are removed. The SDK encoder starts at its default bitrate and follows the + SFU's publish quality settings (`change_publish_quality`). +- The whole track has the size of its first frame. Later frames of another + size are scaled to it, and an odd width or height is raised to the next + even value. In 6.1.1 each frame kept its own size. +- The frame duration comes from the `pts` difference; frames without `pts` + count as 1/30 s. +- After `track.stop()`, the track is unpublished, so other participants get + `track_unpublished`. +- A new `add_tracks(video=new_track)` replaces the published video track: the + earlier track is unpublished first, also while it is still live. Other + participants get the new frames on their existing `RemoteTrack`. In 6.1.1 + each call added one more video track. + +### Permissions to publish + +In 6.1.1, stream-py did not change the published tracks when the permissions +of the user changed. Now the SDK does: + +- When the `send-audio` or `send-video` capability is removed, the SDK + unpublishes the track of that kind. +- When the SFU removes the grant (`call_grants_updated`), the SDK mutes the + track. +- When the permission is given back, the SDK does not publish or unmute the + track. Call `add_tracks()` again. + +### Handlers, failures and lost events + +- A handler that raises does not stop the other events, `wait()` or + `leave()`. pyee passes its exception to the `error` event. Without an `error` + handler, the exception is logged at ERROR with its traceback, with the + message "A 'error' handler failed". With an `error` handler (for example for + the SFU `Error` event), that handler gets the exception, and nothing is + logged. This is true for `ConnectionManager` and `participants_state`. +- If `connect()` fails or is cancelled after the SDK join, it leaves the call + before it raises. +- If the forwarding of a track from `add_tracks` raises, for example for the + `fltp` frames or the rate change in "Publishing audio", the track is + unpublished, and the exception is logged at ERROR with its traceback, with + the message "The forwarding of a published track failed". +- If the event loop is blocked long enough that `ConnectionManager` falls + behind an SDK event stream (1024 buffered events per stream), the lost events are + skipped with a `stream.rtc.events.lagged` WARNING (field `skipped`), and + `participants_state` is filled again from the SDK's participant list. +- If the event loop is blocked long enough that more than 64 new remote tracks + wait to be read, the SDK unsubscribes from the tracks that do not fit, and + `ConnectionManager` logs a WARNING. These tracks give no `track_added` and no + `audio` until they are published again. The tracks that come after them + arrive as usual. + +### `participants_state` + +- `get_participants()` and the `map()` handlers give a list of + `RemoteParticipant`, not `models_pb2.Participant`. The list includes this + participant (the agent) and is keyed by `session_id`. To skip the agent, + compare `participant.user_id` with `connection.user_id`; this also skips the + other sessions of the same user. The `participant_joined` and + `participant_left` events of `participants_state` also give a + `RemoteParticipant`. +- After the join, and again after each reconnect, the list is filled from the + SDK's participants; then `participant_joined` and `participant_left` update + it. `participant_updated` does not change it, as in 6.1.1. +- `track_published` does not change the list. In 6.1.1 it replaced the + participant (with its new `published_tracks`) and called the `map()` + handlers. Now `published_tracks` in the list keeps the value from the join + or from `participant_joined`. +- `get_user_from_track_id()`, `get_stream_id_from_track_id()` and + `set_track_stream_mapping()` are removed: they mapped aiortc track ids. A + `RemoteTrack` carries its `participant`. + +### Subscriptions + +`subscription_config` of `rtc.join` has the same fields as in 6.1.1 +(`default`, `role_filters`, `max_subscriptions`, separate video and screen +share sizes). The SDK applies it again on each participant and track event. +The differences: + +- `role_filters` also applies to the tracks that are published after the + join. In 6.1.1 these tracks got the `default` rule, except in large calls: + the SFU sends the participant of `track_published` only in large calls. +- The SDK makes the list of matching tracks again on each event and keeps the + first `max_subscriptions` of them, so a new track can take the place of a + track that is already subscribed. In 6.1.1 the tracks that were subscribed + first stayed, and a track over the limit was skipped. +- A participant with two roles that both have a rule in `role_filters` gets + the rule of the first role in its `roles` list. In 6.1.1 the choice was not + defined. +- When no track matches any more, the SDK sends an empty subscription list to + the SFU. The first list after a join or a reconnect is never empty. 6.1.1 + never sent an empty list. +- The SFU sends remote audio without a request, also without a + `subscription_config`, as in 6.1.1. It is not verified whether a config + without `TRACK_TYPE_AUDIO` stops it. + +### Errors + +| 6.1.1 | Now | +| --- | --- | +| `SfuJoinError`, `SfuConnectionError` | Errors from `getstream_rtc`, all subclasses of `RustError`: | +| | `ApiError` (coordinator HTTP error: `code`, `status_code`, `message`, `unrecoverable`) | +| | `CoordinatorError` (coordinator connection or authentication failed) | +| | `PermissionDeniedError` (`capability`), `IllegalStateError`, `MediaError`, `PcmQueueOverflowError`, `PcmRateMismatchError` (`expected`, `actual`) | +| | `RtcError` (other RTC failures), `ConfigError` (invalid client configuration) | + +`rtc.join()` and `ConnectionManager()` create the SDK client. For an invalid +client configuration they raise `ConfigError`, and for other client errors +`RustError` itself, before `connect()`. + +`add_tracks()` raised `SfuRpcError` when the SFU rejected the publish and +`TimeoutError` when the publisher did not connect in 15 s. Now it raises: + +- `PermissionDeniedError` when the user does not have the `send-audio` or + `send-video` capability. +- `IllegalStateError` before the join. +- `MediaError` when the SFU has no unused publish option for the codec of the + track. +- `RtcError` for other publish failures. + +### `ConnectionManager` attributes + +These attributes and methods of the aiortc transport are removed: + +- `join_response`, `session_id`, `running`, `local_sfu`, `kwargs`. +- `ws_client`, `publisher_pc`, `subscriber_pc`, `publisher_negotiation_lock`, + `subscriber_negotiation_lock`, `twirp_signaling_client`, `twirp_context`, + `stats_reporter`, `tracer`, `reconnector`. +- `pc_id()`, `sfu_id()`, `republish_tracks()`. + +`call`, `user_id`, `create`, `participants_state` and `connection_state` stay. +For the call data of `join_response`, use `await call.get()`. `session_id` has +no replacement. + +### Removed modules and names + +The SDK does the coordinator join, chooses the SFU and owns the peer +connections, so the aiortc transport code is removed: + +- From `getstream.video.rtc`: `join_call_coordinator_request`, + `JoinCallRequest`, `JoinCallResponse`, `ServerCredentials`, `Credentials`, + `discover_location`, `HTTPHintLocationDiscovery`, `HEADER_CLOUDFRONT_POP`, + `FALLBACK_LOCATION_NAME`, `STREAM_PROD_URL`. +- The modules `peer_connection`, `pc`, `encoders_patches`, `signaling`, + `twirp_client_wrapper`, `connection_utils` (with `SfuJoinError`, + `SfuConnectionError` and its `ConnectionState`), `models`, `reconnection`, + `network_monitor`, `stats_reporter`, `stats_tracer`, `tracer`, + `coordinator` and `location_discovery` of `getstream.video.rtc`. +- `getstream.video.rtc.tracks.SubscriptionManager`, and the name `TrackType` + that `tracks` imported from `models_pb2`. `SubscriptionConfig` and + `TrackSubscriptionConfig` stay. For `TrackSubscriptionConfig.track_types`, + use the `models_pb2` constants (for example `models_pb2.TRACK_TYPE_VIDEO`). +- From `getstream.video.rtc.track_util`: `patch_sdp_offer`, + `fix_sdp_msid_semantic`, `fix_sdp_rtcp_fb`, `parse_track_stream_mapping`, + `BufferedMediaTrack`, `VideoFrameTracker`, `detect_video_properties`, + `AudioTrackHandler`, `PyAVResampler` (see "Resampling"). `PcmData`, + `AudioFormat`, `Resampler` and `FrameResampler` stay. + +### Logging + +- The transport logs come from the SDK through the `getstream` logger and its + children (for example `getstream.rtc.join.lifecycle`), not from the `aiortc` + and `aioice` loggers. Records of other Rust crates (for example + `getstream.webrtc_ice`) are forwarded at `WARNING` and above. +- `connect()` reads the effective level of the `getstream` logger. A later + level change takes effect at the next `connect()`. +- `rtc.join()` starts the threads of the SDK runtime, and the first + `connect()` (or `getstream_rtc.configure_logging()`) starts the thread + `getstream-logging`, which hands the Rust records to `logging`. An import + starts no thread. `fork()` copies only the calling thread, so a process + forked after that cannot use the SDK. In a process forked after `connect()`, + `connect()` raises `RuntimeError`. In a process forked after `rtc.join()` + only, SDK calls never complete, and `connect()` never returns. Start the + worker processes before the first `rtc.join()`, as + `gunicorn --preload` and the Celery prefork pool do, or use the `spawn` or + `forkserver` start method of `multiprocessing`. + +### Telemetry + +- Only the OpenTelemetry spans `connect`, `leave` and `rtc.add_tracks` stay. + The spans of the aiortc transport steps are removed, for example + `coordinator-setup`, `coordinator-join-call`, `sfu-signaling-ws-connect`, + `rtc.on_subscriber_offer`, `rtc.publisher_pc.create_offer` and + `signaling.twirp.`. + +### Network + +- TURN works only over UDP. Networks that allow only TCP or TLS (for example + port 443 only) cannot connect; aiortc supported TURN over TCP and TLS. +- When the `Stream` client has the API secret, the SDK creates a new user + token for each join, rejoin and migration. Each token is valid for 10 + minutes. +- When the `Stream` client has only a user token, the SDK cannot refresh it. + If that token has an `expiration` and expires during the call, each later + rejoin or migration fails. Tokens from `create_token()` without + `expiration` do not expire. + +### Local recording + +Local recording is removed. stream-video-js, the Go SDK and the Rust SDK do +not have local call recording either. + +| 6.1.1 | Now | +| --- | --- | +| `connection.start_recording()`, `stop_recording()`, `is_recording`, `get_recording_status()` | removed | +| `connection.recording_manager` and the module `getstream.video.rtc.recording` (`RecordingManager`, `RecordingType`, `RecordingConfig` and the recorder classes) | removed | +| the `recording_manager` events `recording_started`, `recording_stopped`, `user_recording_started`, `user_recording_stopped`, `composite_recording_started`, `composite_recording_stopped`, `recording_error` | removed | + +To record a call, use the server recording: +`call.start_recording(recording_type)` and +`call.stop_recording(recording_type)`. The coordinator event +`call.recording_ready` tells when a file is ready; `ConnectionManager` does not +emit it, so receive it with a webhook. To write files locally, read the `audio` +event (`PcmData` with `participant`, `pts` and `time_base`) and +`track.video_frames()`. + +## Other bugs + +The code review of this branch found these issues in addition to the changes +that the sections above describe. They are not addressed yet. Each item gives +its location, its severity, and whether the review confirmed it in the code or +in a test run ("confirmed") or found only its cause ("not confirmed"). + +### Rust bindings + +- `getstream-rtc/rustsrc/tracks.rs:87-91` (medium, confirmed): when the call + ends, the `TrackStream` stops, but the unread tracks stay in the + `TrackQueue`, which lives as long as the `Call`. After a new join on the same + `Call`, a new `TrackStream` first returns the dead tracks of the old session. + Until they are read, these tracks keep their decoders. +- `getstream-rtc/rustsrc/tracks.rs:93` (low, confirmed): if Python cancels + the awaitable after the Rust future is complete (for example + `asyncio.wait_for(anext(tracks), timeout)`), pyo3-async-runtimes discards + the result (`generic.rs:348`). The `RemoteTrack` is dropped and the SDK + unsubscribes from it. The track is lost until it is published again. + `EventStream` and `next_pcm()` can lose one item in the same way. +- Python calls from Rust that AGENTS.md does not document (low, confirmed): + `PyObject_Repr` in each `__repr__` (`repr.rs:7`) and `import_exception!` of + `getstream_rtc.errors` (`errors.rs:4-13`). + +### Python and type stubs + +- `getstream-rtc/getstream_rtc/_bindings.pyi:26,35` (medium, confirmed): the + stub declares `CallingState(Enum)` and `TrackType(Enum)`. Type checkers accept + `hash()`, `.name`, `.value` and iteration, which fail at runtime (see + "Connection state"). +- `getstream/video/rtc/connection_manager.py:391-409` (low, not confirmed): + `_stop_forwarding()` can cancel a task that is already in its + `finally: await stop_publish(...)` because its source ended. The SDK stop is + then dropped. If this occurs during the mute request to the SFU, the old + track stays in the SDK with a pending stop and keeps the publish option of + its kind, so the publish of the new track raises `MediaError`. Two + concurrent `add_tracks(audio=...)` calls do not leave a forwarder that runs: + the second publish raises `MediaError`, because the first track has the + only audio publish option. +- `getstream/video/rtc/video_forwarder.py:41-48` (low, confirmed): the SDK + uses `duration` as the length of the frame and adds it to the RTP + timestamp after that frame. The forwarder gives the gap since the previous + frame, so each value is one frame late. The RTP timestamps are wrong only + when the frame rate changes. +- `getstream-rtc/getstream_rtc/__init__.py:15-53` (low, confirmed): 14 classes + of `_bindings` are not exported at the package root: `AudioBitrate`, + `AudioLevel`, `AudioSender`, `CallGrants`, `CallStateSnapshot`, `Codec`, + `ConnectionQualityInfo`, `ErrorDetails`, `InboundVideoState`, `Pin`, + `PublishOption`, `VideoDimension`, `VideoLayerSetting`, `VideoSender`. + AGENTS.md says that the root exports every class. +- `getstream-rtc/getstream_rtc/_bindings.pyi:475-478` (low, confirmed): the + docstring says only that a capacity below 0.02 s raises `MediaError`. A + negative or NaN value raises `ValueError`. +- `getstream/video/rtc/audio_forwarder.py:50-51` (low, confirmed): the comment + says that the aiortc Opus encoder accepts only `s16`. It converts every + format to `s16` (see "Publishing audio"). +- `getstream/video/rtc/connection_manager.py:67,177` (low, confirmed): the + comments "as in the aiortc version" describe the migration, not the code. + +### Tests + +- `getstream-rtc/tests/test_logging.py:200-211` (medium, confirmed): + `test_no_records_after_logging_is_stopped` cannot fail. The autouse fixture + `forward_sdk_logs` forwards at INFO, and the `pcm_queue_overflow` record is + DEBUG, so Rust filters it also while forwarding is on. Without the + `configure_logging(None, logging.NOTSET)` call, the test still passes. +- `getstream-rtc/tests/test_call.py:214-223` (low, not confirmed): + `test_cancelled_join_allows_new_join` cancels the join after 0.1 s. If the + join completes faster, `pytest.raises(CancelledError)` fails. +- `async for ... break` loops without `else: pytest.fail(...)` (low, not + confirmed): `getstream-rtc/tests/test_call.py:295, 317, 337, 346, 372, 397, + 415, 479, 501, 528, 547, 568, 584, 738` and + `tests/rtc/test_connection_manager.py:664, 673`. If the stream ends before the + expected event, `test_calling_state_changed_to_joined` (397) and + `test_track_published` (479) pass with no assertion. The other tests assert + on the last event. +- Writer tasks that are cancelled and never awaited (low, not confirmed): + `getstream-rtc/tests/test_call.py:79-81, 97-99, 367/370, 382/385` and + `tests/rtc/test_connection_manager.py:88-90`. If `write_pcm` or + `write_i420` raises, the receiving test waits for the 120 s timeout and does + not show the cause. +- Type annotations (low, confirmed): the test methods in + `getstream-rtc/tests/test_call.py`, `getstream-rtc/tests/test_logging.py` + and `tests/rtc/test_connection_manager.py`, and in + `tests/rtc/test_audio_forwarder.py:36,60` and + `tests/rtc/test_video_forwarder.py:29`, have no `-> None`. The fixture + `client()` in `tests/rtc/test_connection_manager.py:33` has no return type. +- Issues that existed before this branch (low, confirmed): + - `tests/rtc/test_connection_manager.py:24` calls `load_dotenv()` at module + level, not in a fixture. + - `tests/test_audio_stream_track.py:284` requests `monkeypatch` and does not + use it. + - `tests/rtc/coordinator/test_custom_events.py:26,40` has a test outside a + class and a fixture without annotations. + - `tests/assets/` has 4 files over 256 KB that no test uses: + `speech_48k.wav`, `speech_16k.wav`, `test_tone_48k.wav` and + `formant_speech_48k.wav`. + +### CI and release + +- `.github/workflows/release.yml:222` (medium, confirmed): the `publish` job + calls `python-uv-setup` without `build-extension: "false"`, so it compiles + getstream-rtc, which `uv build` does not use. An apt, rustup or Rust build + failure stops the PyPI release of `getstream`. The default of + `build-extension` is `"true"`, so each new job that uses the action compiles + getstream-rtc unless it sets the input. +- `.github/workflows/release.yml:225-229` (medium, confirmed): no workflow + builds or publishes getstream-rtc wheels or an sdist. The `webrtc` extra + requires `getstream-rtc>=0.1.0,<0.2`, so `pip install "getstream[webrtc]"` + fails until the package is on PyPI. +- `.github/workflows/run_tests.yml:51` (low, confirmed): the `rust` job has no + `timeout-minutes`. The test jobs use 30. + +### AGENTS.md + +- Line 7 (low, confirmed): "exports every class at its root" is false (see the + `__init__.py` item above). +- Line 4 (low, confirmed): `uv sync` without `--all-extras` does not remove + `numpy`, because `getstream-rtc` depends on it. It removes `aiortc`, `av` + and `aiohttp`. +- Lines 79-81 (low, confirmed): records of other crates use the less verbose + of `level` and `third_party_level` (`logging.rs:299-300`), not only + `third_party_level`. +- Line 7 (low, confirmed): the version text does not mention the range + `getstream-rtc>=0.1.0,<0.2` in the root `pyproject.toml:38`. +- Line 7 (low, confirmed): on Debian/Ubuntu, `cargo test --lib` also needs + `python3-dev`. CI installs it in `run_tests.yml:54-70`. +- Style (low): item 5 (line 7) has sentences of 40 to 70 words. "for no + reason" (line 30) and "Keep both halves impossible" (line 43) are not clear. + +### Not verified + +The review did not check these points: + +- The event names that the SDK emits. +- Whether the SFU sends an `Error` with the `DISCONNECT` strategy when it + kicks or blocks a participant. The SDK has no other handling for a kick or a + block. +- The lock behavior inside tokio, and how pyo3 drops a `Py` without the + GIL. diff --git a/pyproject.toml b/pyproject.toml index 20cc2ca4..49cec768 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,27 +35,17 @@ openai-realtime = [ "openai[realtime]>=1.93.0", ] webrtc = [ + "getstream-rtc>=0.1.0,<0.2", "aiortc>=1.14.0, <1.15.0", "av>=14.2.0, <17", "numpy>=2.2.6,<2.3 ; python_version < '3.14'", "numpy>=2.3.2,<2.4 ; python_version >= '3.14'", - "soundfile>=0.13.1", - "scipy>=1.15.3,<1.16 ; python_version < '3.14'", - "scipy>=1.16.1,<1.17 ; python_version >= '3.14'", "twirp", "protobuf>=4.25.1", - "websockets>=15.0.1,<16", - "websocket-client>=1.8.0", "python-dateutil>=2.8.2", - "structlog<24", "numpy>=2.2.6", - "tenacity>=9.1.2", - "scipy>=1.15.3", "aiohttp>=3.13.2,<4", - "ping3>=4.0.8", "protobuf>=6.31.1", - "websockets>=15.0.1", - "websocket-client>=1.8.0", ] telemetry = [ "opentelemetry-api>=1.26.0", @@ -69,7 +59,7 @@ dev = [ "python-dotenv>=1.1.1,<2", "pytest>=8.4.1", "pytest-timeout>=2.4.0", - "ruff>=0.12.1", + "ruff>=0.12.1,<0.16", # Pinned: 0.16 enables more default rules. Review the rule set before you bump the version. "pre-commit>=4.2.0", "pytest-asyncio>=1.0.0", "grpcio-tools==1.76.0", # Pinned: controls protoc version for code generation @@ -85,9 +75,13 @@ dev = [ "pytest-httpserver>=1.1.5", ] +[tool.uv.sources] +getstream-rtc = { workspace = true } + [tool.uv.workspace] members = [ "examples/test", + "getstream-rtc", ] exclude = [ "**/__pycache__", @@ -116,6 +110,7 @@ exclude = [ [tool.hatch.build.targets.sdist] exclude = [ "examples/**", + "getstream-rtc/**", "protocol/**", "scripts/**", "tests/**", diff --git a/pytest.ini b/pytest.ini index fab4b06a..ddb15cb4 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,6 +1,6 @@ [pytest] addopts = --doctest-modules -s --timeout=120 --import-mode=importlib -testpaths = tests getstream +testpaths = tests getstream getstream-rtc/tests asyncio_mode = auto diff --git a/release-please-config.json b/release-please-config.json index 78421d99..a32856a9 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -5,6 +5,7 @@ ".": { "release-type": "python", "include-component-in-tag": false, + "exclude-paths": ["getstream-rtc"], "extra-files": [ { "type": "toml", @@ -12,6 +13,12 @@ "jsonpath": "$.package[?(@.name.value=='getstream')].version" } ] + }, + "getstream-rtc": { + "release-type": "python", + "component": "getstream-rtc", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true } } } diff --git a/scripts/test_sfu_connect.py b/scripts/test_sfu_connect.py deleted file mode 100644 index cff1f690..00000000 --- a/scripts/test_sfu_connect.py +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env python3 -""" -Utility script for testing SFU connection and retry behavior. - -Connects to a call as a given user and logs each step of the connection -process — useful for verifying SFU assignment, retry on transient errors -(e.g. SFU_FULL), and reassignment via the coordinator. - -Environment variables ---------------------- -STREAM_API_KEY — Stream API key (required) -STREAM_API_SECRET — Stream API secret (required) -STREAM_BASE_URL — Coordinator URL (default: Stream cloud). - Set to http://127.0.0.1:3030 for a local coordinator. -USER_ID — User ID to join as (default: "test-user"). -CALL_TYPE — Call type (default: "default"). -CALL_ID — Call ID. If not set, a random UUID is generated. - -Usage ------ - # Connect via cloud coordinator - STREAM_API_KEY=... STREAM_API_SECRET=... \\ - uv run --extra webrtc python scripts/test_sfu_connect.py - - # Connect via local coordinator - STREAM_BASE_URL=http://127.0.0.1:3030 \\ - uv run --extra webrtc python scripts/test_sfu_connect.py -""" - -import asyncio -import logging -import os -import uuid - -from dotenv import load_dotenv - -from getstream import AsyncStream -from getstream.models import CallRequest -from getstream.video.rtc import ConnectionManager - -load_dotenv() - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) - - -async def run(): - base_url = os.getenv("STREAM_BASE_URL") - user_id = os.getenv("USER_ID", "test-user") - call_type = os.getenv("CALL_TYPE", "default") - call_id = os.getenv("CALL_ID", str(uuid.uuid4())) - - logger.info("Configuration:") - logger.info(f" Coordinator: {base_url or 'cloud (default)'}") - logger.info(f" User: {user_id}") - logger.info(f" Call: {call_type}:{call_id}") - - client_kwargs = {} - if base_url: - client_kwargs["base_url"] = base_url - - client = AsyncStream(timeout=10.0, **client_kwargs) - - call = client.video.call(call_type, call_id) - logger.info("Creating call...") - await call.get_or_create(data=CallRequest(created_by_id=user_id)) - logger.info("Call created") - - cm = ConnectionManager( - call=call, - user_id=user_id, - create=False, - ) - - logger.info("Connecting to SFU...") - - async with cm: - join = cm.join_response - if join and join.credentials: - logger.info(f"Connected to SFU: {join.credentials.server.edge_name}") - logger.info(f" WS endpoint: {join.credentials.server.ws_endpoint}") - logger.info(f" Session ID: {cm.session_id}") - - logger.info("Holding connection for 3s...") - await asyncio.sleep(3) - - logger.info("Leaving call") - - logger.info("Done") - - -if __name__ == "__main__": - asyncio.run(run()) diff --git a/tests/conftest.py b/tests/conftest.py index bd037013..64748274 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ -import functools import uuid +from typing import Iterator + import pytest import os from dotenv import load_dotenv @@ -14,7 +15,7 @@ ) from getstream import Stream -from getstream.models import UserRequest, ChannelInput +from getstream.models import ChannelInput, FullUserResponse, UserRequest __all__ = [ "client", @@ -28,6 +29,7 @@ "random_user", "random_users", "server_user", + "call_users", ] @@ -81,6 +83,19 @@ def server_user(client: Stream): pass +@pytest.fixture(scope="session") +def call_users() -> Iterator[list[FullUserResponse]]: + client = Stream(timeout=10.0) + user_ids = [str(uuid.uuid4()) for _ in range(2)] + response = client.update_users( + users={user_id: UserRequest(id=user_id, name=user_id) for user_id in user_ids} + ) + yield [response.data.users[user_id] for user_id in user_ids] + client.delete_users( + user_ids=user_ids, user="hard", conversations="hard", messages="hard" + ) + + @pytest.fixture def channel(client: Stream, random_user): channel_id = str(uuid.uuid4()) @@ -121,19 +136,3 @@ def pytest_runtest_setup(item): skip_in_ci_marker = item.get_closest_marker("skip_in_ci") if skip_in_ci_marker is not None: pytest.skip("Test skipped in CI environment") - - -def skip_on_rate_limit(func): - """Skip test if it fails due to rate limiting.""" - from getstream.video.rtc.coordinator.errors import StreamWSConnectionError - - @functools.wraps(func) - async def wrapper(*args, **kwargs): - try: - return await func(*args, **kwargs) - except StreamWSConnectionError as e: - if "did not receive a valid http response" in str(e).lower(): - pytest.skip(f"Skipped due to rate limiting: {e}") - raise - - return wrapper diff --git a/tests/rtc/conftest.py b/tests/rtc/conftest.py new file mode 100644 index 00000000..db17cf5a --- /dev/null +++ b/tests/rtc/conftest.py @@ -0,0 +1,32 @@ +import logging +import uuid +from typing import AsyncIterator, Iterator + +import getstream_rtc +import pytest + +from getstream import Stream +from getstream.models import FullUserResponse + + +@pytest.fixture(autouse=True) +def forward_sdk_logs() -> Iterator[None]: + logger = logging.getLogger("getstream") + getstream_rtc.configure_logging(logger, logger.getEffectiveLevel()) + yield + # The records of this test must not reach the next test. + getstream_rtc.configure_logging(None, logging.NOTSET) + getstream_rtc.logging._flush() + + +@pytest.fixture +async def joined_call( + client: Stream, call_users: list[FullUserResponse] +) -> AsyncIterator[getstream_rtc.Call]: + """A new call that the first call user has joined through the bindings.""" + call = getstream_rtc.Client(client.api_key, client.api_secret).call( + "default", str(uuid.uuid4()) + ) + await call.join(call_users[0].id) + yield call + await call.leave() diff --git a/tests/rtc/coordinator/test_backoff.py b/tests/rtc/coordinator/test_backoff.py deleted file mode 100644 index 985f03b5..00000000 --- a/tests/rtc/coordinator/test_backoff.py +++ /dev/null @@ -1,86 +0,0 @@ -""" -Tests for exponential backoff utilities. -""" - -import pytest -from getstream.video.rtc.coordinator.backoff import exp_backoff - - -@pytest.mark.asyncio -async def test_exp_backoff_default_parameters(): - """Test exponential backoff with default parameters (base=1.0, factor=2.0).""" - max_retries = 5 - expected_delays = [1.0, 2.0, 4.0, 8.0, 16.0] - - actual_delays = [] - async for delay in exp_backoff(max_retries=max_retries): - actual_delays.append(delay) - - assert actual_delays == expected_delays, ( - f"Expected delays {expected_delays}, but got {actual_delays}" - ) - - -@pytest.mark.asyncio -async def test_exp_backoff_custom_parameters(): - """Test exponential backoff with custom parameters.""" - max_retries = 3 - base = 0.5 - factor = 3.0 - expected_delays = [0.5, 1.5, 4.5] # 0.5 * 3^0, 0.5 * 3^1, 0.5 * 3^2 - - actual_delays = [] - async for delay in exp_backoff(max_retries=max_retries, base=base, factor=factor): - actual_delays.append(delay) - - assert actual_delays == expected_delays, ( - f"Expected delays {expected_delays}, but got {actual_delays}" - ) - - -@pytest.mark.asyncio -async def test_exp_backoff_zero_retries(): - """Test exponential backoff with zero retries.""" - max_retries = 0 - expected_delays = [] - - actual_delays = [] - async for delay in exp_backoff(max_retries=max_retries): - actual_delays.append(delay) - - assert actual_delays == expected_delays, ( - f"Expected no delays for zero retries, but got {actual_delays}" - ) - - -@pytest.mark.asyncio -async def test_exp_backoff_single_retry(): - """Test exponential backoff with a single retry.""" - max_retries = 1 - base = 2.0 - expected_delays = [2.0] - - actual_delays = [] - async for delay in exp_backoff(max_retries=max_retries, base=base): - actual_delays.append(delay) - - assert actual_delays == expected_delays, ( - f"Expected delays {expected_delays}, but got {actual_delays}" - ) - - -@pytest.mark.asyncio -async def test_exp_backoff_fractional_factor(): - """Test exponential backoff with a fractional factor (decreasing delays).""" - max_retries = 3 - base = 8.0 - factor = 0.5 - expected_delays = [8.0, 4.0, 2.0] # 8.0 * 0.5^0, 8.0 * 0.5^1, 8.0 * 0.5^2 - - actual_delays = [] - async for delay in exp_backoff(max_retries=max_retries, base=base, factor=factor): - actual_delays.append(delay) - - assert actual_delays == expected_delays, ( - f"Expected delays {expected_delays}, but got {actual_delays}" - ) diff --git a/tests/rtc/coordinator/test_connect.py b/tests/rtc/coordinator/test_connect.py deleted file mode 100644 index 461f1110..00000000 --- a/tests/rtc/coordinator/test_connect.py +++ /dev/null @@ -1,411 +0,0 @@ -""" -Tests for StreamAPIWS connect and disconnect functionality. -""" - -import asyncio -import json - -import pytest -import websockets - -from getstream.video.rtc.coordinator.ws import StreamAPIWS, Call -from getstream.video.rtc.coordinator.errors import ( - StreamWSAuthError, - StreamWSConnectionError, -) -from getstream.stream import Stream -from tests.conftest import skip_on_rate_limit - - -pytestmark = pytest.mark.integration - - -@pytest.mark.asyncio -async def test_simple_connection_debug(): - """Simple test to debug websocket server handler signature.""" - - # Very simple handler that just echoes back - async def echo_handler(websocket): - try: - while True: - message = await websocket.recv() - await websocket.send(message) - except websockets.exceptions.ConnectionClosed: - pass - - # Start server without path in handler - server = await websockets.serve(echo_handler, "localhost", 0) - port = server.sockets[0].getsockname()[1] - server_uri = f"ws://localhost:{port}" - - try: - # Test basic websocket functionality - websocket = await websockets.connect(server_uri) - await websocket.send("test") - response = await websocket.recv() - assert response == "test" - await websocket.close() - - finally: - server.close() - await server.wait_closed() - - -@pytest.mark.asyncio -async def test_successful_connection(client: Stream): - """Test successful connection and authentication.""" - - # Mock server that sends connection.ok - async def mock_server(websocket): - try: - # Wait for auth payload - auth_message = await websocket.recv() - auth_data = json.loads(auth_message) - - # Verify auth payload structure - assert "token" in auth_data - assert "products" in auth_data - assert "user_details" in auth_data - assert auth_data["products"] == ["video"] - - # Send successful connection response - response = {"type": "connection.ok", "message": "Authentication successful"} - await websocket.send(json.dumps(response)) - - except websockets.exceptions.ConnectionClosed: - pass - - # Start mock server - server = await websockets.serve(mock_server, "localhost", 0) - port = server.sockets[0].getsockname()[1] - server_uri = f"ws://localhost:{port}" - call = Call(client.video, "default") - - try: - # Create client and connect - client = StreamAPIWS( - call, - user_details={"id": "test_user"}, - uri=server_uri, - ) - - # Test connection - result = await client.connect() - - # Verify result - assert result["type"] == "connection.ok" - assert result["message"] == "Authentication successful" - assert client.connected is True - - # Disconnect - await client.disconnect() - assert client.connected is False - - finally: - server.close() - await server.wait_closed() - - -@pytest.mark.asyncio -async def test_authentication_error(client: Stream): - """Test authentication failure handling.""" - - # Mock server that sends error response - async def mock_server(websocket): - try: - # Wait for auth payload - _ = await websocket.recv() - - # Send error response - error_response = { - "type": "error", - "error": "invalid_token", - "message": "Authentication failed", - } - await websocket.send(json.dumps(error_response)) - - except websockets.exceptions.ConnectionClosed: - pass - - # Start mock server - server = await websockets.serve(mock_server, "localhost", 0) - port = server.sockets[0].getsockname()[1] - server_uri = f"ws://localhost:{port}" - call = Call(client.video, "default") - - try: - # Create client - client = StreamAPIWS( - call, - user_details={"id": "test_user"}, - uri=server_uri, - ) - - # Test that authentication error is raised - with pytest.raises(StreamWSAuthError) as exc_info: - await client.connect() - - # Verify error message contains authentication failure info - assert "Authentication failed" in str(exc_info.value) - assert client.connected is False - - finally: - server.close() - await server.wait_closed() - - -@pytest.mark.asyncio -async def test_connection_error(client: Stream): - """Test connection failure handling.""" - # Use an invalid URI to trigger connection error - call = Call(client.video, "default") - client = StreamAPIWS( - call, - user_details={"id": "test_user"}, - uri="ws://localhost:99999", # Invalid port - ) - - # Test that connection error is raised - with pytest.raises(StreamWSConnectionError): - await client.connect() - - assert client.connected is False - - -@pytest.mark.asyncio -async def test_invalid_json_response(client: Stream): - """Test handling of invalid JSON from server.""" - - # Mock server that sends invalid JSON - async def mock_server(websocket): - try: - # Wait for auth payload - await websocket.recv() - - # Send invalid JSON - await websocket.send("invalid json response") - - except websockets.exceptions.ConnectionClosed: - pass - - # Start mock server - server = await websockets.serve(mock_server, "localhost", 0) - port = server.sockets[0].getsockname()[1] - server_uri = f"ws://localhost:{port}" - call = Call(client.video, "default") - - try: - # Create client - client = StreamAPIWS( - call, - user_details={"id": "test_user"}, - uri=server_uri, - ) - - # Test that connection error is raised for invalid JSON - with pytest.raises(StreamWSConnectionError) as exc_info: - await client.connect() - - assert "Invalid JSON from server" in str(exc_info.value) - assert client.connected is False - - finally: - server.close() - await server.wait_closed() - - -@pytest.mark.asyncio -async def test_event_emission(client: Stream): - """Test that events are properly emitted.""" - - # Mock server that sends connection.ok - async def mock_server(websocket): - try: - # Wait for auth payload - await websocket.recv() - - # Send successful connection response - response = {"type": "connection.ok", "data": {"session_id": "test_session"}} - await websocket.send(json.dumps(response)) - - except websockets.exceptions.ConnectionClosed: - pass - - # Start mock server - server = await websockets.serve(mock_server, "localhost", 0) - port = server.sockets[0].getsockname()[1] - server_uri = f"ws://localhost:{port}" - call = Call(client.video, "default") - - try: - # Create client - client = StreamAPIWS( - call, - user_details={"id": "test_user"}, - uri=server_uri, - ) - - # Set up event listener - events_received = [] - - @client.on("connection.ok") - def on_connection_ok(data): - events_received.append(("connection.ok", data)) - - # Connect and verify event emission - result = await client.connect() - - # Verify event was emitted - assert len(events_received) == 1 - assert events_received[0][0] == "connection.ok" - assert events_received[0][1] == result - - await client.disconnect() - - finally: - server.close() - await server.wait_closed() - - -@pytest.mark.asyncio -async def test_auth_payload_structure(client: Stream): - """Test that authentication payload has correct structure.""" - - # Mock server that captures and validates auth payload - captured_auth = None - - async def mock_server(websocket): - nonlocal captured_auth - try: - # Capture auth payload - auth_message = await websocket.recv() - captured_auth = json.loads(auth_message) - - # Send successful response - response = {"type": "connection.ok"} - await websocket.send(json.dumps(response)) - - except websockets.exceptions.ConnectionClosed: - pass - - # Start mock server - server = await websockets.serve(mock_server, "localhost", 0) - port = server.sockets[0].getsockname()[1] - server_uri = f"ws://localhost:{port}" - call = Call(client.video, "default") - - try: - # Create client - client = StreamAPIWS( - call, - user_details={"id": "user_456", "name": "Test User"}, - uri=server_uri, - ) - - # Connect - await client.connect() - - # Verify auth payload structure - assert captured_auth is not None - assert captured_auth["token"] == client.user_token - assert captured_auth["products"] == ["video"] - assert captured_auth["user_details"]["id"] == "user_456" - assert captured_auth["user_details"]["name"] == "Test User" - - await client.disconnect() - - finally: - server.close() - await server.wait_closed() - - -@pytest.mark.asyncio -async def test_disconnect_without_connect(client: Stream): - """Test that disconnect works even if never connected.""" - call = Call(client.video, "default") - client = StreamAPIWS(call, user_details={"id": "test_user"}) - - # Should not raise an exception - await client.disconnect() - assert client.connected is False - - -@pytest.mark.asyncio -async def test_integration_test_simple(client: Stream): - call = Call(client.video, "default") - ws_client = StreamAPIWS(call, user_details={"id": "user_id"}) - response = await ws_client.connect() - assert response["type"] == "connection.ok" - await ws_client.disconnect() - - -@pytest.mark.asyncio -async def test_integration_test_bad_auth_raises(client: Stream): - call = Call(Stream(client.api_key, "api_sec").video, "default") - ws_client = StreamAPIWS(call, user_details={"id": "xxx"}) - - # Test that authentication error is raised with invalid token - with pytest.raises(StreamWSAuthError) as exc_info: - await ws_client.connect() - - # Verify error message contains authentication failure info - assert "Authentication failed" in str(exc_info.value) - assert "connection.error" in str(exc_info.value) - assert ws_client.connected is False - - -@pytest.mark.asyncio -async def test_integration_test_simple_healthcheck(client: Stream): - call = Call(client.video, "default") - ws_client = StreamAPIWS( - call, - user_details={"id": "xx"}, - healthcheck_interval=2.0, - ) - response = await ws_client.connect() - assert response["type"] == "connection.ok" - - # Wait for a few heartbeat intervals to ensure the connection stays alive - # The heartbeat mechanism should prevent disconnection - await asyncio.sleep(5.0) - - # Verify connection is still alive - assert ws_client.connected is True - - await ws_client.disconnect() - - -@pytest.mark.asyncio -@skip_on_rate_limit -async def test_integration_test_user_details_in_response(client: Stream): - """Test that user details are returned in the 'me' field of the connection response.""" - user_details = { - "id": "test_user_123", - "name": "Test User yo", - } - - call = Call(client.video, "default") - ws_client = StreamAPIWS( - call, - user_details=user_details, - healthcheck_interval=30.0, # Long interval to avoid interference - ) - - response = await ws_client.connect() - assert response["type"] == "connection.ok" - - # Verify that user information is returned in the "me" field - assert "me" in response, "Expected 'me' field in connection response" - me_field = response["me"] - - # Verify user details are present in the me field - assert me_field["id"] == "test_user_123" - assert me_field["name"] == "Test User yo" - - # Verify that server added additional fields - assert "created_at" in me_field - assert "updated_at" in me_field - assert "online" in me_field - assert me_field["online"] is True # Should be online since we just connected - - await ws_client.disconnect() diff --git a/tests/rtc/coordinator/test_custom_events.py b/tests/rtc/coordinator/test_custom_events.py index 5aceac09..ea19e048 100644 --- a/tests/rtc/coordinator/test_custom_events.py +++ b/tests/rtc/coordinator/test_custom_events.py @@ -17,8 +17,7 @@ from getstream import AsyncStream from getstream.models import CallRequest, UserRequest from getstream.video import rtc -from getstream.video.rtc.connection_utils import ConnectionState -from tests.conftest import skip_on_rate_limit +from getstream.video.rtc import CallingState logger = logging.getLogger(__name__) @@ -38,7 +37,6 @@ async def test_users(async_client: AsyncStream): @pytest.mark.asyncio @pytest.mark.integration -@skip_on_rate_limit async def test_custom_event_round_trip(async_client: AsyncStream, test_users: list): """Send a custom event via REST and verify it arrives on ConnectionManager.""" sender, receiver = test_users @@ -47,7 +45,7 @@ async def test_custom_event_round_trip(async_client: AsyncStream, test_users: li await call.get_or_create(data=CallRequest(created_by_id=sender)) async with await rtc.join(call, receiver) as connection: - assert connection.connection_state == ConnectionState.JOINED + assert connection.connection_state == CallingState.JOINED received_event = None event_received = asyncio.Event() @@ -58,9 +56,6 @@ def on_custom(event): received_event = event event_received.set() - # join() returns before the coordinator watches the call, and an event sent earlier never arrives. - await asyncio.wait_for(connection._coordinator_task, timeout=10.0) - await call.send_call_event( user_id=sender, custom={"type": "test_event", "payload": "hello from sender"}, diff --git a/tests/rtc/coordinator/test_errors.py b/tests/rtc/coordinator/test_errors.py deleted file mode 100644 index 4554c521..00000000 --- a/tests/rtc/coordinator/test_errors.py +++ /dev/null @@ -1,106 +0,0 @@ -""" -Tests for WebSocket coordinator exception hierarchy. -""" - -import pytest -from getstream.video.rtc.coordinator.errors import ( - StreamWSException, - StreamWSAuthError, - StreamWSConnectionError, - StreamWSMaxRetriesExceeded, -) - - -def test_base_exception_inheritance(): - """Test that StreamWSException inherits from Exception.""" - assert issubclass(StreamWSException, Exception) - - -def test_auth_error_inheritance(): - """Test that StreamWSAuthError inherits from StreamWSException.""" - assert issubclass(StreamWSAuthError, StreamWSException) - assert issubclass(StreamWSAuthError, Exception) - - -def test_connection_error_inheritance(): - """Test that StreamWSConnectionError inherits from StreamWSException.""" - assert issubclass(StreamWSConnectionError, StreamWSException) - assert issubclass(StreamWSConnectionError, Exception) - - -def test_max_retries_error_inheritance(): - """Test that StreamWSMaxRetriesExceeded inherits from StreamWSException.""" - assert issubclass(StreamWSMaxRetriesExceeded, StreamWSException) - assert issubclass(StreamWSMaxRetriesExceeded, Exception) - - -def test_exception_instantiation(): - """Test that all exception classes can be instantiated.""" - # Test base exception - base_exc = StreamWSException("Base error") - assert str(base_exc) == "Base error" - assert isinstance(base_exc, Exception) - - # Test auth error - auth_exc = StreamWSAuthError("Authentication failed") - assert str(auth_exc) == "Authentication failed" - assert isinstance(auth_exc, StreamWSException) - assert isinstance(auth_exc, Exception) - - # Test connection error - conn_exc = StreamWSConnectionError("Connection failed") - assert str(conn_exc) == "Connection failed" - assert isinstance(conn_exc, StreamWSException) - assert isinstance(conn_exc, Exception) - - # Test max retries error - retries_exc = StreamWSMaxRetriesExceeded("Max retries exceeded") - assert str(retries_exc) == "Max retries exceeded" - assert isinstance(retries_exc, StreamWSException) - assert isinstance(retries_exc, Exception) - - -def test_exception_raising_and_catching(): - """Test that exceptions can be raised and caught properly.""" - # Test raising and catching specific exceptions - with pytest.raises(StreamWSAuthError): - raise StreamWSAuthError("Auth failed") - - with pytest.raises(StreamWSConnectionError): - raise StreamWSConnectionError("Connection failed") - - with pytest.raises(StreamWSMaxRetriesExceeded): - raise StreamWSMaxRetriesExceeded("Max retries exceeded") - - # Test catching with base exception - with pytest.raises(StreamWSException): - raise StreamWSAuthError("Should be caught by base exception") - - with pytest.raises(StreamWSException): - raise StreamWSConnectionError("Should be caught by base exception") - - with pytest.raises(StreamWSException): - raise StreamWSMaxRetriesExceeded("Should be caught by base exception") - - -def test_exception_hierarchy_distinctness(): - """Test that specific exception classes are distinct from each other.""" - # Auth error should not be instance of connection error - auth_exc = StreamWSAuthError("Auth error") - assert not isinstance(auth_exc, StreamWSConnectionError) - assert not isinstance(auth_exc, StreamWSMaxRetriesExceeded) - - # Connection error should not be instance of auth error - conn_exc = StreamWSConnectionError("Connection error") - assert not isinstance(conn_exc, StreamWSAuthError) - assert not isinstance(conn_exc, StreamWSMaxRetriesExceeded) - - # Max retries error should not be instance of other specific errors - retries_exc = StreamWSMaxRetriesExceeded("Retries error") - assert not isinstance(retries_exc, StreamWSAuthError) - assert not isinstance(retries_exc, StreamWSConnectionError) - - # But all should be instances of the base exception - assert isinstance(auth_exc, StreamWSException) - assert isinstance(conn_exc, StreamWSException) - assert isinstance(retries_exc, StreamWSException) diff --git a/tests/rtc/coordinator/test_heartbeat.py b/tests/rtc/coordinator/test_heartbeat.py deleted file mode 100644 index 0d6ec481..00000000 --- a/tests/rtc/coordinator/test_heartbeat.py +++ /dev/null @@ -1,490 +0,0 @@ -""" -Tests for StreamAPIWS heartbeat and reconnection functionality. -""" - -import asyncio -import json -import pytest -import time -from unittest.mock import patch -import websockets - -from getstream.video.rtc.coordinator.ws import StreamAPIWS, Call -from getstream.video.rtc.coordinator.errors import ( - StreamWSConnectionError, - StreamWSMaxRetriesExceeded, -) -from getstream import Stream - - -pytestmark = pytest.mark.integration - - -@pytest.mark.asyncio -async def test_heartbeat_sent_periodically(client: Stream): - """Test that heartbeat messages are sent at regular intervals.""" - heartbeats_received = [] - - async def mock_server(websocket): - try: - # Wait for auth payload - await websocket.recv() - - # Send connection.ok - response = {"type": "connection.ok"} - await websocket.send(json.dumps(response)) - - # Collect heartbeat messages - while True: - message = await websocket.recv() - data = json.loads(message) - if data.get("type") == "health.check": - heartbeats_received.append(time.time()) - - except websockets.exceptions.ConnectionClosed: - pass - - server = await websockets.serve(mock_server, "localhost", 0) - port = server.sockets[0].getsockname()[1] - server_uri = f"ws://localhost:{port}" - call = Call(client.video, "default") - try: - # Create client with short heartbeat interval for testing - client = StreamAPIWS( - call, - user_details={"id": "test_user"}, - uri=server_uri, - healthcheck_interval=0.1, # 100ms for fast testing - ) - - # Connect and wait for a few heartbeats - await client.connect() - await asyncio.sleep(0.35) # Wait for ~3 heartbeats - await client.disconnect() - - # Verify heartbeats were sent - assert len(heartbeats_received) >= 2, ( - f"Expected at least 2 heartbeats, got {len(heartbeats_received)}" - ) - - # Verify timing between heartbeats - if len(heartbeats_received) >= 2: - interval = heartbeats_received[1] - heartbeats_received[0] - assert 0.08 <= interval <= 0.15, ( - f"Heartbeat interval should be ~0.1s, got {interval}" - ) - - finally: - server.close() - await server.wait_closed() - - -@pytest.mark.asyncio -async def test_reconnect_on_heartbeat_timeout(client: Stream): - """Test that reconnection is triggered when no messages are received within timeout.""" - connection_attempts = [] - - async def mock_server(websocket): - try: - connection_attempts.append(time.time()) - - # Wait for auth payload - await websocket.recv() - - # Send connection.ok - response = {"type": "connection.ok"} - await websocket.send(json.dumps(response)) - - # For first connection, just ignore heartbeats (simulate server silence) - if len(connection_attempts) == 1: - # Don't respond to anything, let it timeout - await asyncio.sleep(2) # Wait longer than the timeout - else: - # For reconnection, respond normally - while True: - await websocket.recv() # Ignore incoming messages - - except websockets.exceptions.ConnectionClosed: - pass - - server = await websockets.serve(mock_server, "localhost", 0) - port = server.sockets[0].getsockname()[1] - server_uri = f"ws://localhost:{port}" - call = Call(client.video, "default") - - try: - # Create client with short timeout for testing - client = StreamAPIWS( - call, - user_details={"id": "test_user"}, - uri=server_uri, - healthcheck_interval=0.1, # 100ms heartbeat interval - healthcheck_timeout=0.2, # 200ms timeout - backoff_base=0.1, # Fast reconnection for testing - max_retries=3, - ) - - # Connect and wait for timeout + reconnection - await client.connect() - await asyncio.sleep(1) # Wait for timeout and reconnection - await client.disconnect() - - # Verify reconnection occurred - assert len(connection_attempts) >= 2, ( - f"Expected at least 2 connection attempts, got {len(connection_attempts)}" - ) - - finally: - server.close() - await server.wait_closed() - - -@pytest.mark.asyncio -async def test_reconnect_on_connection_closed(client: Stream): - """Test that reconnection is triggered when server closes the connection.""" - connection_attempts = [] - - async def mock_server(websocket): - try: - connection_attempts.append(time.time()) - - # Wait for auth payload - await websocket.recv() - - # Send connection.ok - response = {"type": "connection.ok"} - await websocket.send(json.dumps(response)) - - if len(connection_attempts) == 1: - # Close connection after short delay to trigger reconnection - await asyncio.sleep(0.1) - await websocket.close() - else: - # For reconnection, stay connected - while True: - await websocket.recv() - - except websockets.exceptions.ConnectionClosed: - pass - - server = await websockets.serve(mock_server, "localhost", 0) - port = server.sockets[0].getsockname()[1] - server_uri = f"ws://localhost:{port}" - call = Call(client.video, "default") - - try: - client = StreamAPIWS( - call, - user_details={"id": "test_user"}, - uri=server_uri, - backoff_base=0.1, # Fast reconnection for testing - max_retries=3, - ) - - # Connect and wait for close + reconnection - await client.connect() - await asyncio.sleep(0.5) # Wait for close and reconnection - await client.disconnect() - - # Verify reconnection occurred - assert len(connection_attempts) >= 2, ( - f"Expected at least 2 connection attempts, got {len(connection_attempts)}" - ) - - finally: - server.close() - await server.wait_closed() - - -@pytest.mark.asyncio -async def test_max_retries_exceeded(client: Stream): - """Test that StreamWSMaxRetriesExceeded is raised when all retries fail.""" - - # Server that always closes immediately - async def failing_server(websocket): - await websocket.close() - - server = await websockets.serve(failing_server, "localhost", 0) - port = server.sockets[0].getsockname()[1] - server_uri = f"ws://localhost:{port}" - call = Call(client.video, "default") - - try: - # Mock asyncio.sleep to make test run faster - with patch("asyncio.sleep", side_effect=lambda x: asyncio.sleep(0.01)): - client = StreamAPIWS( - call, - user_details={"id": "test_user"}, - uri=server_uri, - healthcheck_interval=0.1, - healthcheck_timeout=0.2, - backoff_base=0.1, - max_retries=2, # Small number for testing - ) - - # Connect should eventually fail with max retries exceeded - with pytest.raises((StreamWSConnectionError, StreamWSMaxRetriesExceeded)): - await client.connect() - # Wait a bit to let reconnection attempts happen - await asyncio.sleep(0.5) - - assert not client.connected - - finally: - server.close() - await server.wait_closed() - - -@pytest.mark.asyncio -async def test_message_reception_updates_last_received(client: Stream): - """Test that receiving messages updates the last received timestamp.""" - - async def mock_server(websocket): - try: - # Wait for auth payload - await websocket.recv() - - # Send connection.ok - response = {"type": "connection.ok"} - await websocket.send(json.dumps(response)) - - # Send additional messages - await asyncio.sleep(0.1) - await websocket.send(json.dumps({"type": "test.message", "data": "test"})) - - await asyncio.sleep(0.1) - await websocket.send( - json.dumps({"type": "another.message", "data": "test2"}) - ) - - # Keep server alive - await asyncio.sleep(1) - - except websockets.exceptions.ConnectionClosed: - pass - - server = await websockets.serve(mock_server, "localhost", 0) - port = server.sockets[0].getsockname()[1] - server_uri = f"ws://localhost:{port}" - call = Call(client.video, "default") - - try: - client = StreamAPIWS( - call, - user_details={"id": "test_user"}, - uri=server_uri, - healthcheck_interval=1.0, # Long interval so heartbeats don't interfere - healthcheck_timeout=2.0, - ) - - messages_received = [] - - @client.on("test.message") - def on_test_message(data): - messages_received.append(("test.message", time.time())) - - @client.on("another.message") - def on_another_message(data): - messages_received.append(("another.message", time.time())) - - # Connect and wait for messages - initial_time = time.time() - await client.connect() - await asyncio.sleep(0.5) # Wait for messages to arrive - - # Verify messages were received - assert len(messages_received) >= 2, ( - f"Expected at least 2 messages, got {len(messages_received)}" - ) - - # Verify last_received was updated (should be more recent than initial connection) - assert client._last_received > initial_time - - await client.disconnect() - - finally: - server.close() - await server.wait_closed() - - -@pytest.mark.asyncio -async def test_background_tasks_cancelled_on_disconnect(client: Stream): - """Test that background tasks are properly cancelled when disconnecting.""" - - async def mock_server(websocket): - try: - await websocket.recv() # auth - await websocket.send(json.dumps({"type": "connection.ok"})) - await asyncio.sleep(1) # Keep server alive - except websockets.exceptions.ConnectionClosed: - pass - - server = await websockets.serve(mock_server, "localhost", 0) - port = server.sockets[0].getsockname()[1] - server_uri = f"ws://localhost:{port}" - call = Call(client.video, "default") - - try: - client = StreamAPIWS( - call, - user_details={"id": "test_user"}, - uri=server_uri, - ) - - # Connect - await client.connect() - - # Verify tasks are running - assert client._reader_task is not None - assert client._heartbeat_task is not None - assert not client._reader_task.done() - assert not client._heartbeat_task.done() - - # Disconnect - await client.disconnect() - - # Verify tasks are cancelled - assert client._reader_task is None - assert client._heartbeat_task is None - - finally: - server.close() - await server.wait_closed() - - -@pytest.mark.asyncio -async def test_heartbeat_includes_client_id(client: Stream): - """Test that heartbeats include client_id when available.""" - - # Track sent messages - sent_messages = [] - - # Mock server that includes connection_id in response - async def mock_server(websocket): - try: - # Wait for auth payload - await websocket.recv() - - # Send successful connection response with connection_id - response = { - "type": "connection.ok", - "connection_id": "test-connection-123", - "message": "Authentication successful", - } - await websocket.send(json.dumps(response)) - - # Receive and track heartbeat messages - while True: - try: - message = await websocket.recv() - sent_messages.append(json.loads(message)) - except websockets.exceptions.ConnectionClosed: - break - - except websockets.exceptions.ConnectionClosed: - pass - - # Start mock server - server = await websockets.serve(mock_server, "localhost", 0) - port = server.sockets[0].getsockname()[1] - server_uri = f"ws://localhost:{port}" - call = Call(client.video, "default") - - try: - # Create client with short heartbeat interval - client = StreamAPIWS( - call, - user_details={"id": "test_user"}, - uri=server_uri, - healthcheck_interval=0.1, # 100ms - ) - - # Connect and wait for a heartbeat - await client.connect() - await asyncio.sleep(0.2) # Wait for at least one heartbeat - - # Verify that heartbeats were sent and include client_id - assert len(sent_messages) > 0 - - # Check that heartbeat messages include client_id - heartbeat_messages = [ - msg for msg in sent_messages if msg.get("type") == "health.check" - ] - assert len(heartbeat_messages) > 0 - - for heartbeat in heartbeat_messages: - assert heartbeat["type"] == "health.check" - assert heartbeat["client_id"] == "test-connection-123" - - await client.disconnect() - - finally: - server.close() - await server.wait_closed() - - -@pytest.mark.asyncio -async def test_heartbeat_without_client_id(client: Stream): - """Test that heartbeats work when no client_id is available.""" - - # Track sent messages - sent_messages = [] - - # Mock server that doesn't include connection_id in response - async def mock_server(websocket): - try: - # Wait for auth payload - await websocket.recv() - - # Send successful connection response without connection_id - response = {"type": "connection.ok", "message": "Authentication successful"} - await websocket.send(json.dumps(response)) - - # Receive and track heartbeat messages - while True: - try: - message = await websocket.recv() - sent_messages.append(json.loads(message)) - except websockets.exceptions.ConnectionClosed: - break - - except websockets.exceptions.ConnectionClosed: - pass - - # Start mock server - server = await websockets.serve(mock_server, "localhost", 0) - port = server.sockets[0].getsockname()[1] - server_uri = f"ws://localhost:{port}" - call = Call(client.video, "default") - - try: - # Create client with short heartbeat interval - client = StreamAPIWS( - call, - user_details={"id": "test_user"}, - uri=server_uri, - healthcheck_interval=0.1, # 100ms - ) - - # Connect and wait for a heartbeat - await client.connect() - await asyncio.sleep(0.2) # Wait for at least one heartbeat - - # Verify that heartbeats were sent but don't include client_id - assert len(sent_messages) > 0 - - # Check that heartbeat messages don't include client_id - heartbeat_messages = [ - msg for msg in sent_messages if msg.get("type") == "health.check" - ] - assert len(heartbeat_messages) > 0 - - for heartbeat in heartbeat_messages: - assert heartbeat["type"] == "health.check" - assert "client_id" not in heartbeat - - await client.disconnect() - - finally: - server.close() - await server.wait_closed() diff --git a/tests/rtc/test_audio_forwarder.py b/tests/rtc/test_audio_forwarder.py new file mode 100644 index 00000000..70108c34 --- /dev/null +++ b/tests/rtc/test_audio_forwarder.py @@ -0,0 +1,73 @@ +import asyncio +import logging + +import getstream_rtc +import numpy as np +import pytest + +from getstream.video.rtc import AudioStreamTrack, PcmData +from getstream.video.rtc.audio_forwarder import AudioForwarder + +SAMPLE_RATE = 48000 + + +@pytest.fixture +def one_second() -> PcmData: + return PcmData( + samples=np.zeros(SAMPLE_RATE, dtype=np.int16), + sample_rate=SAMPLE_RATE, + format="s16", + channels=1, + ) + + +class TestAudioForwarder: + async def test_run_returns_when_the_source_stops(self): + source = AudioStreamTrack() + run = asyncio.create_task( + AudioForwarder(source, getstream_rtc.LocalAudioTrack()).run() + ) + await asyncio.sleep(0.01) + + source.stop() + + await asyncio.wait_for(run, timeout=1) + + async def test_overflow_is_logged_and_the_copy_continues( + self, caplog: pytest.LogCaptureFixture, one_second: PcmData + ): + source = AudioStreamTrack() + # Not published, so nothing drains the 100 ms queue. + target = getstream_rtc.LocalAudioTrack(pcm_queue_capacity=0.1) + run = asyncio.create_task(AudioForwarder(source, target).run()) + + logger = "getstream.video.rtc.audio_forwarder" + with caplog.at_level(logging.DEBUG, logger=logger): + await source.write(one_second) + await asyncio.sleep(0.2) + + assert any( + record.getMessage().startswith("Audio buffer overflow") + for record in caplog.records + ) + assert not run.done() + source.stop() + await asyncio.wait_for(run, timeout=1) + + +@pytest.mark.integration +class TestAudioForwarderOnACall: + async def test_run_returns_when_the_call_is_left( + self, joined_call: getstream_rtc.Call, one_second: PcmData + ): + source = AudioStreamTrack() + target = getstream_rtc.LocalAudioTrack() + await joined_call.publish_audio(target) + run = asyncio.create_task(AudioForwarder(source, target).run()) + await source.write(one_second) + + # Leaving stops the published track. + await joined_call.leave() + await source.write(one_second) + + await asyncio.wait_for(run, timeout=5) diff --git a/tests/rtc/test_connection_manager.py b/tests/rtc/test_connection_manager.py index 6a8ea153..b6d19950 100644 --- a/tests/rtc/test_connection_manager.py +++ b/tests/rtc/test_connection_manager.py @@ -1,66 +1,93 @@ import asyncio -import contextlib +import logging +import sys +import time import uuid -from unittest.mock import AsyncMock, MagicMock, patch +from typing import AsyncIterator, Iterator +import aiortc +import av +import getstream_rtc +import numpy as np import pytest from dotenv import load_dotenv from getstream import AsyncStream -from getstream.models import CallRequest, UserRequest +from getstream.models import CallRequest, FullUserResponse, UserRequest from getstream.video import rtc +from getstream.video.rtc import AudioStreamTrack, CallingState, PcmData from getstream.video.rtc.connection_manager import ConnectionManager -from getstream.video.rtc.connection_utils import ( - ConnectionState, - SfuConnectionError, - SfuJoinError, -) from getstream.video.rtc.pb.stream.video.sfu.models import models_pb2 -from getstream.video.rtc.reconnection import ReconnectionStrategy +from getstream.video.rtc.tracks import SubscriptionConfig, TrackSubscriptionConfig +from tests.rtc.video_source import FrameSource load_dotenv() +SAMPLE_RATE = 48000 +VIDEO_WIDTH = 320 +VIDEO_HEIGHT = 240 +VIDEO_FPS = 15 -@contextlib.contextmanager -def patched_dependencies(): - """Patch heavy ConnectionManager dependencies for unit testing.""" - with ( - patch("getstream.video.rtc.connection_manager.PeerConnectionManager"), - patch("getstream.video.rtc.connection_manager.NetworkMonitor"), - patch("getstream.video.rtc.connection_manager.ReconnectionManager"), - patch("getstream.video.rtc.connection_manager.RecordingManager"), - patch("getstream.video.rtc.connection_manager.SubscriptionManager"), - patch("getstream.video.rtc.connection_manager.ParticipantsState"), - patch("getstream.video.rtc.connection_manager.Tracer"), - patch( - "getstream.video.rtc.connection_manager.asyncio.sleep", - new_callable=AsyncMock, - ), - ): - yield + +@pytest.fixture +def client(): + return AsyncStream(timeout=10.0) @pytest.fixture -def connection_manager(request): - """Create a ConnectionManager with mocked heavy dependencies. - - Accepts max_join_retries via indirect parametrize, defaults to 3. - """ - max_join_retries = getattr(request, "param", 3) - with patched_dependencies(): - mock_call = MagicMock() - mock_call.call_type = "default" - mock_call.id = "test_call" - cm = ConnectionManager( - call=mock_call, user_id="user1", max_join_retries=max_join_retries - ) - cm._connect_coordinator_ws = AsyncMock() - yield cm +def call_id() -> str: + return str(uuid.uuid4()) @pytest.fixture -def client(): - return AsyncStream(timeout=10.0) +async def connection( + client: AsyncStream, call_id: str, call_users: list[FullUserResponse] +) -> AsyncIterator[ConnectionManager]: + call = client.video.call("default", call_id) + async with await rtc.join(call, call_users[0].id) as connection: + yield connection + + +@pytest.fixture +async def peer_call( + client: AsyncStream, call_id: str +) -> AsyncIterator[getstream_rtc.Call]: + call = getstream_rtc.Client(client.api_key, client.api_secret).call( + "default", call_id + ) + yield call + await call.leave() + + +@pytest.fixture +def tone() -> np.ndarray: + t = np.arange(SAMPLE_RATE) / SAMPLE_RATE + return (np.sin(2 * np.pi * 440 * t) * 10000).astype(np.int16) + + +@pytest.fixture +async def peer_video( + client: AsyncStream, + call_id: str, + call_users: list[FullUserResponse], + peer_call: getstream_rtc.Call, +) -> AsyncIterator[None]: + """The second user publishes VP9 video before the test joins.""" + call = client.video.call("default", call_id) + await call.get_or_create(data=CallRequest(created_by_id=call_users[0].id)) + await peer_call.join(call_users[1].id, create=False) + video = getstream_rtc.LocalVideoTrack.vp9() + await peer_call.publish_video(video) + frame = np.full(VIDEO_WIDTH * VIDEO_HEIGHT * 3 // 2, 128, dtype=np.uint8) + + async def write_forever() -> None: + while True: + await video.write_i420(frame, VIDEO_WIDTH, VIDEO_HEIGHT, 1 / VIDEO_FPS) + await asyncio.sleep(1 / VIDEO_FPS) + + task = asyncio.create_task(write_forever()) + yield + task.cancel() class TestConnectionManager: @@ -72,16 +99,16 @@ async def test_leave_twice_does_not_hang(self, client: AsyncStream): call = client.video.call("default", call_id) async with await rtc.join(call, "test-user") as connection: - assert connection.connection_state == ConnectionState.JOINED + assert connection.connection_state == CallingState.JOINED await asyncio.sleep(2) await asyncio.wait_for(connection.leave(), timeout=10.0) - assert connection.connection_state == ConnectionState.LEFT + assert connection.connection_state == CallingState.LEFT # Second leave must not hang await asyncio.wait_for(connection.leave(), timeout=10.0) - assert connection.connection_state == ConnectionState.LEFT + assert connection.connection_state == CallingState.LEFT @pytest.mark.asyncio @pytest.mark.integration @@ -109,150 +136,626 @@ async def test_token_only_client_can_join_call(self, client: AsyncStream): token_call = token_client.video.call("default", call_id) async with await rtc.join(token_call, user_id) as connection: - assert connection.connection_state == ConnectionState.JOINED + assert connection.connection_state == CallingState.JOINED await asyncio.sleep(2) await asyncio.wait_for(connection.leave(), timeout=10.0) - assert connection.connection_state == ConnectionState.LEFT + assert connection.connection_state == CallingState.LEFT - @pytest.mark.asyncio - @pytest.mark.parametrize("connection_manager", [2], indirect=True) - async def test_retries_on_sfu_join_error_and_passes_failed_sfus( - self, connection_manager + +@pytest.mark.integration +class TestConnectionManagerEvents: + async def test_participant_joined( + self, + connection: ConnectionManager, + peer_call: getstream_rtc.Call, + call_users: list[FullUserResponse], + ): + events: asyncio.Queue[getstream_rtc.ParticipantJoined] = asyncio.Queue() + connection.on("participant_joined", events.put_nowait) + + await peer_call.join(call_users[1].id) + + event = await asyncio.wait_for(events.get(), timeout=10) + assert event.participant.user_id == call_users[1].id + + async def test_track_added( + self, + connection: ConnectionManager, + peer_call: getstream_rtc.Call, + call_users: list[FullUserResponse], + tone: np.ndarray, + ): + tracks: asyncio.Queue[getstream_rtc.RemoteTrack] = asyncio.Queue() + connection.on("track_added", tracks.put_nowait) + await peer_call.join(call_users[1].id) + audio = getstream_rtc.LocalAudioTrack() + await peer_call.publish_audio(audio) + + await audio.write_pcm(tone, SAMPLE_RATE, 1) + + track = await asyncio.wait_for(tracks.get(), timeout=10) + assert track.participant.user_id == call_users[1].id + assert track.track_type == getstream_rtc.TrackType.AUDIO + + async def test_audio( + self, + connection: ConnectionManager, + peer_call: getstream_rtc.Call, + call_users: list[FullUserResponse], + tone: np.ndarray, + ): + frames: asyncio.Queue[PcmData] = asyncio.Queue() + connection.on("audio", frames.put_nowait) + await peer_call.join(call_users[1].id) + audio = getstream_rtc.LocalAudioTrack() + await peer_call.publish_audio(audio) + + await audio.write_pcm(tone, SAMPLE_RATE, 1) + + while True: + pcm = await asyncio.wait_for(frames.get(), timeout=10) + if np.abs(pcm.samples).max() > 1000: + break + following = await asyncio.wait_for(frames.get(), timeout=10) + assert pcm.participant.user_id == call_users[1].id + assert pcm.sample_rate == SAMPLE_RATE + assert pcm.time_base == 1 / SAMPLE_RATE + assert (following.pts - pcm.pts) % 2**32 == len(pcm.samples) + + async def test_participants_state( + self, + connection: ConnectionManager, + peer_call: getstream_rtc.Call, + call_users: list[FullUserResponse], + ): + lists: asyncio.Queue[list[getstream_rtc.RemoteParticipant]] = asyncio.Queue() + # The subscription keeps the weakly referenced handler alive. + subscription = connection.participants_state.map(lists.put_nowait) + + user_ids = {p.user_id for p in await lists.get()} + assert call_users[0].id in user_ids + + await peer_call.join(call_users[1].id) + while call_users[1].id not in user_ids: + user_ids = {p.user_id for p in await asyncio.wait_for(lists.get(), 10)} + await peer_call.leave() + while call_users[1].id in user_ids: + user_ids = {p.user_id for p in await asyncio.wait_for(lists.get(), 10)} + subscription.unsubscribe() + + async def test_failing_handler_does_not_stop_the_events( + self, + connection: ConnectionManager, + peer_call: getstream_rtc.Call, + call_users: list[FullUserResponse], + ): + left: asyncio.Queue[getstream_rtc.ParticipantLeft] = asyncio.Queue() + + def fail(event: getstream_rtc.ParticipantJoined) -> None: + raise RuntimeError("handler failed") + + connection.on("participant_joined", fail) + connection.on("participant_left", left.put_nowait) + + await peer_call.join(call_users[1].id) + await peer_call.leave() + + event = await asyncio.wait_for(left.get(), timeout=10) + assert event.participant.user_id == call_users[1].id + + async def test_failing_state_handler_does_not_stop_wait( + self, client: AsyncStream, call_id: str, call_users: list[FullUserResponse] + ): + def fail(change: dict[str, CallingState]) -> None: + raise RuntimeError("handler failed") + + call = client.video.call("default", call_id) + connection = await rtc.join(call, call_users[0].id) + connection.on("connection.state_changed", fail) + + async with connection: + await call.end() + await asyncio.wait_for(connection.wait(), timeout=10) + + async def test_cancelled_connect_leaves_the_call( + self, client: AsyncStream, call_id: str, call_users: list[FullUserResponse] + ): + config = SubscriptionConfig( + default=TrackSubscriptionConfig(track_types=[models_pb2.TRACK_TYPE_AUDIO]) + ) + call = client.video.call("default", call_id) + connection = await rtc.join(call, call_users[0].id, subscription_config=config) + connect = asyncio.create_task(connection.connect()) + + def cancel_after_join( + participants: list[getstream_rtc.RemoteParticipant], + ) -> None: + # Called while connect() fills the list, after the SDK join. + if participants: + connect.cancel() + + subscription = connection.participants_state.map(cancel_after_join) + with pytest.raises(asyncio.CancelledError): + await connect + subscription.unsubscribe() + + assert connection.connection_state == CallingState.LEFT + + async def test_unknown_track_type_in_subscription_config_is_ignored( + self, client: AsyncStream, call_id: str, call_users: list[FullUserResponse] ): - """When SFU is full, connect() should retry with migrating_from_list.""" - cm = connection_manager - call_count = 0 - received_migrating_from_list = [] - - async def mock_connect_internal(migrating_from_list=None, **kwargs): - nonlocal call_count - call_count += 1 - received_migrating_from_list.append( - list(migrating_from_list) if migrating_from_list else None + config = SubscriptionConfig( + default=TrackSubscriptionConfig( + track_types=[ + models_pb2.TRACK_TYPE_UNSPECIFIED, + models_pb2.TRACK_TYPE_AUDIO, + ] ) + ) + call = client.video.call("default", call_id) - if call_count <= 2: - mock_join_response = MagicMock() - mock_join_response.credentials.server.edge_name = ( - f"sfu-node-{call_count}" - ) - cm.join_response = mock_join_response - raise SfuJoinError( - "server is full", - error_code=models_pb2.ERROR_CODE_SFU_FULL, - should_retry=True, - ) - cm.running = True + async with await rtc.join( + call, call_users[0].id, subscription_config=config + ) as connection: + assert connection.connection_state == CallingState.JOINED + + async def test_wait_returns_when_call_ends( + self, client: AsyncStream, connection: ConnectionManager, call_id: str + ): + await client.video.call("default", call_id).end() + + await asyncio.wait_for(connection.wait(), timeout=10) - cm._connect_internal = mock_connect_internal + async def test_connection_state_changed( + self, client: AsyncStream, call_id: str, call_users: list[FullUserResponse] + ): + changes: list[dict[str, CallingState]] = [] + call = client.video.call("default", call_id) + connection = await rtc.join(call, call_users[0].id) + connection.on("connection.state_changed", changes.append) - await cm.connect() + async with connection: + pass - assert call_count == 3 - assert received_migrating_from_list[0] is None - assert received_migrating_from_list[1] == ["sfu-node-1"] - assert received_migrating_from_list[2] == ["sfu-node-1", "sfu-node-2"] + assert changes == [ + {"old": CallingState.IDLE, "new": CallingState.JOINING}, + {"old": CallingState.JOINING, "new": CallingState.JOINED}, + {"old": CallingState.JOINED, "new": CallingState.LEFT}, + ] - @pytest.mark.asyncio - @pytest.mark.parametrize("connection_manager", [1], indirect=True) - async def test_raises_after_all_retries_exhausted(self, connection_manager): - """When all retries are exhausted, connect() should raise SfuJoinError.""" - cm = connection_manager - call_count = 0 - - async def always_fail(migrating_from_list=None, **kwargs): - nonlocal call_count - call_count += 1 - mock_join_response = MagicMock() - mock_join_response.credentials.server.edge_name = "sfu-node-1" - cm.join_response = mock_join_response - raise SfuJoinError( - "server is full", - error_code=models_pb2.ERROR_CODE_SFU_FULL, - should_retry=True, + async def test_call_ended_is_emitted_once_when_call_ends( + self, client: AsyncStream, connection: ConnectionManager, call_id: str + ): + ended: list[getstream_rtc.CallEnded] = [] + connection.on("call_ended", ended.append) + + await client.video.call("default", call_id).end() + await asyncio.wait_for(connection.wait(), timeout=10) + await connection.leave() + + assert len(ended) == 1 + assert isinstance(ended[0], getstream_rtc.CallEnded) + + +@pytest.fixture +def overflowing_samples() -> np.ndarray: + return np.zeros(SAMPLE_RATE * 61, dtype=np.int16) + + +@pytest.fixture +def long_switch_interval() -> Iterator[None]: + # A busy Python thread then keeps the GIL for up to one second. + interval = sys.getswitchinterval() + sys.setswitchinterval(1.0) + yield + sys.setswitchinterval(interval) + + +@pytest.fixture +async def overflow_handler_waits_for_the_loop() -> AsyncIterator[None]: + """A `getstream` handler that runs a coroutine on the test's event loop + for each audio overflow record.""" + loop = asyncio.get_running_loop() + + class WaitForLoop(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + if record.getMessage() == "stream.rtc.audio.pcm_queue_overflow": + asyncio.run_coroutine_threadsafe(asyncio.sleep(0), loop).result() + + logger = logging.getLogger("getstream") + handler = WaitForLoop() + logger.addHandler(handler) + yield + logger.removeHandler(handler) + + +@pytest.mark.integration +class TestConnectionManagerLogging: + async def test_connect_does_not_block_the_loop_while_records_are_delivered( + self, + client: AsyncStream, + call_id: str, + call_users: list[FullUserResponse], + caplog: pytest.LogCaptureFixture, + overflow_handler_waits_for_the_loop: None, + overflowing_samples: np.ndarray, + long_switch_interval: None, + ) -> None: + caplog.set_level(logging.DEBUG, logger="getstream") + getstream_rtc.configure_logging(logging.getLogger("getstream"), logging.DEBUG) + call = client.video.call("default", call_id) + connection = await rtc.join(call, call_users[0].id) + track = getstream_rtc.LocalAudioTrack() + write = track.write_pcm(overflowing_samples, SAMPLE_RATE, 1) + started = time.time() + # Holds the GIL, so the overflow record is still queued when connect() + # configures the logging. + while time.time() - started < 0.3: + pass + + async with connection: + pass + + with pytest.raises(getstream_rtc.RtcError): + await write + # The handler of `overflow_handler_waits_for_the_loop` needs the loop. + await asyncio.to_thread(getstream_rtc.logging._flush) + assert "stream.rtc.audio.pcm_queue_overflow" in caplog.messages + + async def test_sdk_logs_reach_python_logging( + self, + client: AsyncStream, + call_id: str, + call_users: list[FullUserResponse], + caplog: pytest.LogCaptureFixture, + ): + # Stops the forwarding that the autouse fixture set up. + getstream_rtc.configure_logging(None, logging.NOTSET) + caplog.set_level(logging.DEBUG, logger="getstream") + + call = client.video.call("default", call_id) + async with await rtc.join(call, call_users[0].id): + pass + getstream_rtc.logging._flush() + + assert any( + record.name.startswith("getstream.rtc.") for record in caplog.records + ) + + +@pytest.fixture +async def agent_audio( + connection: ConnectionManager, + peer_call: getstream_rtc.Call, + call_users: list[FullUserResponse], +) -> AsyncIterator[tuple[AudioStreamTrack, getstream_rtc.TrackStream]]: + """The agent publishes an AudioStreamTrack; the peer receives audio.""" + tracks = peer_call.tracks() + await peer_call.join(call_users[1].id) + await peer_call.update_subscriptions( + getstream_rtc.SubscriptionConfig( + default=getstream_rtc.TrackSubscriptionConfig( + track_types=[getstream_rtc.TrackType.AUDIO] ) + ) + ) + audio = AudioStreamTrack() + await connection.add_tracks(audio=audio) + yield audio, tracks - cm._connect_internal = always_fail - with pytest.raises(SfuJoinError): - await cm.connect() +async def next_loud_frame(track: getstream_rtc.RemoteTrack) -> getstream_rtc.PcmFrame: + while True: + frame = await track.next_pcm() + if np.abs(frame.samples).max() > 1000: + return frame - assert call_count == 2 # 1 initial + 1 retry - @pytest.mark.asyncio - async def test_non_retryable_error_propagates_immediately(self, connection_manager): - """Non-retryable errors should not trigger retry.""" - cm = connection_manager - call_count = 0 +async def next_silent_frame(track: getstream_rtc.RemoteTrack) -> getstream_rtc.PcmFrame: + while True: + frame = await track.next_pcm() + if np.abs(frame.samples).max() < 100: + return frame - async def fail_with_generic_error(migrating_from_list=None, **kwargs): - nonlocal call_count - call_count += 1 - raise SfuConnectionError("something went wrong") - cm._connect_internal = fail_with_generic_error +async def next_frame_of_size( + frames: getstream_rtc.VideoFrameStream, size: tuple[int, int] +) -> getstream_rtc.VideoFrame: + async for frame in frames: + if (frame.width, frame.height) == size: + return frame + raise AssertionError(f"the stream ended before a {size} frame") - with pytest.raises(SfuConnectionError): - await cm.connect() - assert call_count == 1 +async def next_error_record( + caplog: pytest.LogCaptureFixture, logger: str +) -> logging.LogRecord: + while True: + for record in caplog.records: + if record.name == logger and record.levelno == logging.ERROR: + return record + await asyncio.sleep(0.01) - @pytest.mark.asyncio - @pytest.mark.parametrize("connection_manager", [1], indirect=True) - async def test_cleans_up_ws_client_between_retries(self, connection_manager): - """Partial WS state should be cleaned up before retry.""" - cm = connection_manager - call_count = 0 - - first_ws_client = MagicMock() - - async def mock_connect_internal(migrating_from_list=None, **kwargs): - nonlocal call_count - call_count += 1 - if call_count == 1: - cm._ws_client = first_ws_client - mock_join_response = MagicMock() - mock_join_response.credentials.server.edge_name = "sfu-node-1" - cm.join_response = mock_join_response - raise SfuJoinError( - "server is full", - error_code=models_pb2.ERROR_CODE_SFU_FULL, - should_retry=True, + +@pytest.fixture +def float_audio() -> aiortc.MediaStreamTrack: + """An aiortc audio track that gives silent 20 ms `fltp` frames.""" + + class FloatAudio(aiortc.MediaStreamTrack): + kind = "audio" + + async def recv(self) -> av.AudioFrame: + await asyncio.sleep(0.02) + frame = av.AudioFrame.from_ndarray( + np.zeros((1, SAMPLE_RATE // 50), dtype=np.float32), + format="fltp", + layout="mono", + ) + frame.sample_rate = SAMPLE_RATE + return frame + + return FloatAudio() + + +class IdleAudio(aiortc.MediaStreamTrack): + """An aiortc audio track that gives no frames. `read_stopped` is set when a + `recv()` stops waiting.""" + + kind = "audio" + + def __init__(self) -> None: + super().__init__() + self.read_stopped = asyncio.Event() + + async def recv(self) -> av.AudioFrame: + try: + return await asyncio.get_running_loop().create_future() + finally: + self.read_stopped.set() + + +@pytest.fixture +def idle_audio() -> IdleAudio: + return IdleAudio() + + +@pytest.fixture +async def peer_video_tracks( + peer_call: getstream_rtc.Call, call_users: list[FullUserResponse] +) -> getstream_rtc.TrackStream: + """The peer joins and subscribes to video.""" + tracks = peer_call.tracks() + await peer_call.join(call_users[1].id) + await peer_call.update_subscriptions( + getstream_rtc.SubscriptionConfig( + default=getstream_rtc.TrackSubscriptionConfig( + track_types=[getstream_rtc.TrackType.VIDEO] + ) + ) + ) + return tracks + + +@pytest.mark.integration +class TestConnectionManagerPublishing: + async def test_add_tracks_publishes_audio( + self, + agent_audio: tuple[AudioStreamTrack, getstream_rtc.TrackStream], + call_users: list[FullUserResponse], + tone: np.ndarray, + ): + audio, tracks = agent_audio + pcm = PcmData(samples=tone, sample_rate=SAMPLE_RATE, format="s16", channels=1) + + for _ in range(5): + await audio.write(pcm) + + track = await asyncio.wait_for(anext(tracks), timeout=15) + assert track.participant.user_id == call_users[0].id + await asyncio.wait_for(next_loud_frame(track), timeout=10) + + async def test_flush_drops_the_queued_audio( + self, + agent_audio: tuple[AudioStreamTrack, getstream_rtc.TrackStream], + tone: np.ndarray, + ): + audio, tracks = agent_audio + pcm = PcmData(samples=tone, sample_rate=SAMPLE_RATE, format="s16", channels=1) + for _ in range(20): + await audio.write(pcm) + track = await asyncio.wait_for(anext(tracks), timeout=15) + await asyncio.wait_for(next_loud_frame(track), timeout=10) + + await audio.flush() + + # Without the flush, the tone would play for about 20 s. + await asyncio.wait_for(next_silent_frame(track), timeout=5) + + async def test_audio_added_while_audio_is_published_replaces_it( + self, + agent_audio: tuple[AudioStreamTrack, getstream_rtc.TrackStream], + connection: ConnectionManager, + tone: np.ndarray, + ): + audio, tracks = agent_audio + pcm = PcmData(samples=tone, sample_rate=SAMPLE_RATE, format="s16", channels=1) + await audio.write(pcm) + track = await asyncio.wait_for(anext(tracks), timeout=15) + await asyncio.wait_for(next_loud_frame(track), timeout=10) + await asyncio.wait_for(next_silent_frame(track), timeout=10) + + second = AudioStreamTrack() + await connection.add_tracks(audio=second) + await second.write(pcm) + + await asyncio.wait_for(next_loud_frame(track), timeout=10) + + async def test_failed_forwarding_is_logged( + self, + connection: ConnectionManager, + float_audio: aiortc.MediaStreamTrack, + caplog: pytest.LogCaptureFixture, + ) -> None: + # The SDK takes only s16 samples. + await connection.add_tracks(audio=float_audio) + + record = await asyncio.wait_for( + next_error_record(caplog, "getstream.video.rtc.connection_manager"), + timeout=5, + ) + assert record.exc_info is not None + assert record.exc_info[0] is TypeError + + async def test_cancelled_leave_stops_the_forwarding( + self, connection: ConnectionManager, idle_audio: IdleAudio + ) -> None: + await connection.add_tracks(audio=idle_audio) + leave = asyncio.create_task(connection.leave()) + # The forwarder starts to read, and leave() waits for the SDK leave. + await asyncio.sleep(0) + + leave.cancel() + + with pytest.raises(asyncio.CancelledError): + await leave + await asyncio.wait_for(idle_audio.read_stopped.wait(), timeout=5) + + +@pytest.mark.integration +class TestConnectionManagerVideoPublishing: + async def test_video_keeps_the_first_size( + self, + connection: ConnectionManager, + peer_call: getstream_rtc.Call, + call_users: list[FullUserResponse], + ): + tracks = peer_call.tracks() + await peer_call.join(call_users[1].id) + await peer_call.update_subscriptions( + getstream_rtc.SubscriptionConfig( + default=getstream_rtc.TrackSubscriptionConfig( + track_types=[getstream_rtc.TrackType.VIDEO] ) - cm.running = True + ) + ) + # 4 s at the first size, then a size that the SDK encoder rejects + # unless the forwarder scales it. + source = FrameSource([(320, 240)] * 60 + [(640, 480)] * 300) + await connection.add_tracks(video=source) + + track = await asyncio.wait_for(anext(tracks), timeout=15) + assert track.participant.user_id == call_users[0].id + frames = track.video_frames() + # 90 received frames end well after the size change. + for _ in range(90): + frame = await asyncio.wait_for(anext(frames), timeout=10) + assert (frame.width, frame.height) == (320, 240) + + async def test_stopped_video_track_is_unpublished( + self, + connection: ConnectionManager, + peer_call: getstream_rtc.Call, + call_users: list[FullUserResponse], + ): + events = peer_call.sfu_events() + await peer_call.join(call_users[1].id) + source = FrameSource([(320, 240)] * 300) + await connection.add_tracks(video=source) + async for event in events: + if ( + isinstance(event, getstream_rtc.TrackPublished) + and event.user_id == call_users[0].id + ): + break + + source.stop() + + async for event in events: + if ( + isinstance(event, getstream_rtc.TrackUnpublished) + and event.user_id == call_users[0].id + ): + break + assert event.track_type == getstream_rtc.TrackType.VIDEO + + async def test_video_added_after_stop_replaces_the_stopped_video( + self, + connection: ConnectionManager, + peer_video_tracks: getstream_rtc.TrackStream, + ): + first = FrameSource([(320, 240)] * 300) + await connection.add_tracks(video=first) + track = await asyncio.wait_for(anext(peer_video_tracks), timeout=15) + frames = track.video_frames() + await asyncio.wait_for(anext(frames), timeout=10) + + first.stop() + await connection.add_tracks(video=FrameSource([(640, 480)] * 300)) - cm._connect_internal = mock_connect_internal + await asyncio.wait_for(next_frame_of_size(frames, (640, 480)), timeout=15) - await cm.connect() + async def test_video_added_while_video_is_published_replaces_it( + self, + connection: ConnectionManager, + peer_video_tracks: getstream_rtc.TrackStream, + ): + await connection.add_tracks(video=FrameSource([(320, 240)] * 300)) + track = await asyncio.wait_for(anext(peer_video_tracks), timeout=15) + frames = track.video_frames() + await asyncio.wait_for(anext(frames), timeout=10) - assert call_count == 2 - first_ws_client.close.assert_called_once() - assert cm._ws_client is None + await connection.add_tracks(video=FrameSource([(640, 480)] * 300)) - def test_rejects_negative_max_join_retries(self): - """max_join_retries must be >= 0.""" - with ( - patched_dependencies(), - pytest.raises(ValueError, match="max_join_retries must be >= 0"), - ): - ConnectionManager(call=MagicMock(), user_id="user1", max_join_retries=-1) + await asyncio.wait_for(next_frame_of_size(frames, (640, 480)), timeout=15) - @pytest.mark.asyncio - async def test_signaling_connection_lost_triggers_fast_reconnect( - self, connection_manager + +@pytest.mark.integration +class TestConnectionManagerSubscriptions: + async def test_default_rule_subscribes_to_video( + self, + client: AsyncStream, + call_id: str, + call_users: list[FullUserResponse], + peer_video: None, ): - """A signaling-WS `connection_lost` event drives a FAST reconnect. + config = SubscriptionConfig( + default=TrackSubscriptionConfig(track_types=[models_pb2.TRACK_TYPE_VIDEO]) + ) + tracks: asyncio.Queue[getstream_rtc.RemoteTrack] = asyncio.Queue() + call = client.video.call("default", call_id) + connection = await rtc.join( + call, call_users[0].id, create=False, subscription_config=config + ) + connection.on("track_added", tracks.put_nowait) - Without this handler the session would sit hanging on a transient - socket drop until the frontend tears it down. - """ - cm = connection_manager - cm.running = True - cm._reconnector.reconnect = AsyncMock() + async with connection: + track = await asyncio.wait_for(tracks.get(), timeout=15) + + assert track.track_type == getstream_rtc.TrackType.VIDEO + assert track.participant.user_id == call_users[1].id + + async def test_role_rule_subscribes_to_video( + self, + client: AsyncStream, + call_id: str, + call_users: list[FullUserResponse], + peer_video: None, + ): + config = SubscriptionConfig( + role_filters={ + "user": TrackSubscriptionConfig( + track_types=[models_pb2.TRACK_TYPE_VIDEO] + ) + } + ) + tracks: asyncio.Queue[getstream_rtc.RemoteTrack] = asyncio.Queue() + call = client.video.call("default", call_id) + connection = await rtc.join( + call, call_users[0].id, create=False, subscription_config=config + ) + connection.on("track_added", tracks.put_nowait) - await cm._on_signaling_connection_lost("health check timeout") + async with connection: + track = await asyncio.wait_for(tracks.get(), timeout=15) - cm._reconnector.reconnect.assert_called_once() - kwargs = cm._reconnector.reconnect.call_args.kwargs - assert kwargs["strategy"] == ReconnectionStrategy.FAST - assert "health check timeout" in kwargs["reason"] + assert track.track_type == getstream_rtc.TrackType.VIDEO diff --git a/tests/rtc/test_encoders_patches.py b/tests/rtc/test_encoders_patches.py deleted file mode 100644 index 80807f3b..00000000 --- a/tests/rtc/test_encoders_patches.py +++ /dev/null @@ -1,230 +0,0 @@ -import importlib -from unittest.mock import MagicMock - -import pytest -from aiortc.codecs.h264 import H264Encoder -from aiortc.codecs.vpx import Vp8Encoder - -from getstream.video.rtc.encoders_patches import ( - STREAM_VIDEO_DEFAULT_BITRATE, - STREAM_VIDEO_MAX_BITRATE, - STREAM_VIDEO_MIN_BITRATE, - StreamH264Encoder, - StreamVp8Encoder, - patch_sender_encoder, -) - -# Original upstream values -ORIGINAL_VPX = { - "DEFAULT_BITRATE": 500_000, - "MIN_BITRATE": 250_000, - "MAX_BITRATE": 1_500_000, -} -ORIGINAL_H264 = { - "DEFAULT_BITRATE": 1_000_000, - "MIN_BITRATE": 500_000, -} - - -class TestStreamVp8Encoder: - def test_default_bitrate(self): - enc = StreamVp8Encoder() - assert enc.target_bitrate == STREAM_VIDEO_DEFAULT_BITRATE - - def test_clamps_above_max(self): - enc = StreamVp8Encoder() - enc.target_bitrate = 999_999_999 - assert enc.target_bitrate == STREAM_VIDEO_MAX_BITRATE - - def test_clamps_below_min(self): - enc = StreamVp8Encoder() - enc.target_bitrate = 1 - assert enc.target_bitrate == STREAM_VIDEO_MIN_BITRATE - - def test_accepts_in_range(self): - enc = StreamVp8Encoder() - enc.target_bitrate = 2_000_000 - assert enc.target_bitrate == 2_000_000 - - -class TestStreamH264Encoder: - def test_default_bitrate(self): - enc = StreamH264Encoder() - assert enc.target_bitrate == STREAM_VIDEO_DEFAULT_BITRATE - - def test_clamps_above_max(self): - enc = StreamH264Encoder() - enc.target_bitrate = 999_999_999 - assert enc.target_bitrate == STREAM_VIDEO_MAX_BITRATE - - def test_clamps_below_min(self): - enc = StreamH264Encoder() - enc.target_bitrate = 1 - assert enc.target_bitrate == STREAM_VIDEO_MIN_BITRATE - - def test_accepts_in_range(self): - enc = StreamH264Encoder() - enc.target_bitrate = 2_000_000 - assert enc.target_bitrate == 2_000_000 - - -class TestBitratePatchDisabled: - @pytest.mark.parametrize("env_val", [None, "", "1", "true", "yes", "on"]) - def test_enabled_by_default(self, monkeypatch, env_val): - """BITRATE_PATCH_DISABLED is False when env var is unset or truthy.""" - if env_val is None: - monkeypatch.delenv("STREAM_PATCH_AIORTC_BITRATES", raising=False) - else: - monkeypatch.setenv("STREAM_PATCH_AIORTC_BITRATES", env_val) - - import getstream.video.rtc.encoders_patches as mod - - importlib.reload(mod) - assert mod.BITRATE_PATCH_DISABLED is False - - @pytest.mark.parametrize("env_val", ["0", "false", "no", "off"]) - def test_disabled_via_env(self, monkeypatch, env_val): - """BITRATE_PATCH_DISABLED is True when env var is a falsy value.""" - monkeypatch.setenv("STREAM_PATCH_AIORTC_BITRATES", env_val) - - import getstream.video.rtc.encoders_patches as mod - - importlib.reload(mod) - assert mod.BITRATE_PATCH_DISABLED is True - - -class TestStreamH264Preset: - def test_default_is_ultrafast(self, monkeypatch): - """STREAM_H264_PRESET defaults to 'ultrafast' when env var is unset.""" - monkeypatch.delenv("STREAM_PATCH_AIORTC_H264_PRESET", raising=False) - - import getstream.video.rtc.encoders_patches as mod - - importlib.reload(mod) - assert mod.STREAM_H264_PRESET == "ultrafast" - assert mod.StreamH264Encoder().preset == "ultrafast" - - @pytest.mark.parametrize("env_val", ["medium", "veryfast", "superfast"]) - def test_overridden_via_env(self, monkeypatch, env_val): - """STREAM_H264_PRESET reflects the env var and is stored on the instance.""" - monkeypatch.setenv("STREAM_PATCH_AIORTC_H264_PRESET", env_val) - - import getstream.video.rtc.encoders_patches as mod - - importlib.reload(mod) - assert mod.STREAM_H264_PRESET == env_val - assert mod.StreamH264Encoder().preset == env_val - - def test_whitespace_is_stripped(self, monkeypatch): - """Leading/trailing whitespace in the env value is stripped.""" - monkeypatch.setenv("STREAM_PATCH_AIORTC_H264_PRESET", " medium ") - - import getstream.video.rtc.encoders_patches as mod - - importlib.reload(mod) - assert mod.STREAM_H264_PRESET == "medium" - assert mod.StreamH264Encoder().preset == "medium" - - -class TestPatchSenderEncoder: - @pytest.mark.asyncio - async def test_installs_vp8_encoder(self): - sender = MagicMock() - sender._RTCRtpSender__encoder = None - - async def _orig_coro(codec): - return None - - sender._next_encoded_frame = _orig_coro - patch_sender_encoder(sender) - - codec = MagicMock() - codec.mimeType = "video/VP8" - await sender._next_encoded_frame(codec) - - from getstream.video.rtc.encoders_patches import StreamVp8Encoder as Vp8Cls - - assert isinstance(sender._RTCRtpSender__encoder, Vp8Cls) - - @pytest.mark.asyncio - async def test_installs_h264_encoder(self): - sender = MagicMock() - sender._RTCRtpSender__encoder = None - - async def _orig_coro(codec): - return None - - sender._next_encoded_frame = _orig_coro - patch_sender_encoder(sender) - - codec = MagicMock() - codec.mimeType = "video/H264" - await sender._next_encoded_frame(codec) - - from getstream.video.rtc.encoders_patches import StreamH264Encoder as H264Cls - - assert isinstance(sender._RTCRtpSender__encoder, H264Cls) - - @pytest.mark.asyncio - async def test_does_not_replace_existing_encoder(self): - """Already-set encoder is not replaced.""" - sender = MagicMock() - existing_encoder = MagicMock() - sender._RTCRtpSender__encoder = existing_encoder - - async def _orig_coro(codec): - return None - - sender._next_encoded_frame = _orig_coro - patch_sender_encoder(sender) - - codec = MagicMock() - codec.mimeType = "video/H264" - await sender._next_encoded_frame(codec) - assert sender._RTCRtpSender__encoder is existing_encoder - - def test_noop_when_encoders_none(self, monkeypatch): - """patch_sender_encoder is a no-op when encoder classes failed to load.""" - import getstream.video.rtc.encoders_patches as mod - - monkeypatch.setattr(mod, "StreamVp8Encoder", None) - sender = MagicMock() - orig = sender._next_encoded_frame - mod.patch_sender_encoder(sender) - assert sender._next_encoded_frame is orig - - -class TestUpstreamAssumptions: - def test_upstream_assumptions_hold(self): - """Canary: fails if aiortc changes its internal API. - - Our per-sender patching relies on name-mangled attributes and - the _next_encoded_frame method. If this breaks after an aiortc - upgrade, review the upstream code and update accordingly. - """ - vp8 = Vp8Encoder() - assert hasattr(vp8, "target_bitrate"), "Vp8Encoder lost target_bitrate" - - h264 = H264Encoder() - assert hasattr(h264, "target_bitrate"), "H264Encoder lost target_bitrate" - - # Name-mangled __target_bitrate is accessible from subclasses - assert hasattr(vp8, "_Vp8Encoder__target_bitrate"), ( - "Vp8Encoder name-mangled __target_bitrate changed" - ) - assert hasattr(h264, "_H264Encoder__target_bitrate"), ( - "H264Encoder name-mangled __target_bitrate changed" - ) - - # RTCRtpSender has _next_encoded_frame and uses __encoder - import inspect - - from aiortc.rtcrtpsender import RTCRtpSender - - assert hasattr(RTCRtpSender, "_next_encoded_frame"), ( - "RTCRtpSender lost _next_encoded_frame" - ) - source = inspect.getsource(RTCRtpSender._next_encoded_frame) - assert "self.__encoder" in source, ( - "RTCRtpSender._next_encoded_frame no longer uses self.__encoder" - ) diff --git a/tests/rtc/test_frame_tracking.py b/tests/rtc/test_frame_tracking.py deleted file mode 100644 index b148872e..00000000 --- a/tests/rtc/test_frame_tracking.py +++ /dev/null @@ -1,173 +0,0 @@ -"""Tests for video frame tracking and stats injection.""" - -import asyncio -import pytest -import av -from unittest.mock import Mock -from aiortc import RTCPeerConnection, RTCConfiguration, RTCIceServer -from aiortc.mediastreams import MediaStreamError - - -def create_video_frame(width: int = 320, height: int = 240) -> av.VideoFrame: - """Create a real av.VideoFrame for testing.""" - return av.VideoFrame(width, height, "rgb24") - - -class MockVideoTrack: - """Mock video track for testing frame trackers.""" - - kind = "video" - - def __init__(self, frames: list, delay_ms: float = 0): - self.frames = frames - self.frame_index = 0 - self.delay_ms = delay_ms - self.id = "mock-track" - self._ended = False - - @property - def readyState(self): - return "ended" if self._ended else "live" - - async def recv(self): - if self.frame_index >= len(self.frames): - self._ended = True - raise MediaStreamError("Track ended") - if self.delay_ms > 0: - await asyncio.sleep(self.delay_ms / 1000) - frame = self.frames[self.frame_index] - self.frame_index += 1 - return frame - - def stop(self): - self._ended = True - - -class TestVideoFrameTracker: - """Tests for VideoFrameTracker (subscriber video track wrapper).""" - - @pytest.mark.asyncio - async def test_tracks_frames_and_dimensions(self): - """Should count frames, capture dimensions, and accumulate decode time.""" - from getstream.video.rtc.track_util import VideoFrameTracker - - frames = [ - create_video_frame(320, 240), - create_video_frame(320, 240), - create_video_frame(160, 120), # Resolution change - ] - tracker = VideoFrameTracker(MockVideoTrack(frames, delay_ms=5)) - - for _ in range(3): - await tracker.recv() - - stats = tracker.get_frame_stats() - assert stats["framesDecoded"] == 3 - assert stats["frameWidth"] == 160 # Last frame - assert stats["frameHeight"] == 120 - assert stats["totalDecodeTime"] > 0.01 # At least 15ms accumulated - - @pytest.mark.asyncio - async def test_handles_track_end(self): - """Should raise MediaStreamError when track ends.""" - from getstream.video.rtc.track_util import VideoFrameTracker - - tracker = VideoFrameTracker(MockVideoTrack([create_video_frame()])) - await tracker.recv() - - with pytest.raises(MediaStreamError): - await tracker.recv() - - # Stats still valid after track ends - assert tracker.get_frame_stats()["framesDecoded"] == 1 - - -class TestBufferedMediaTrackFrameTracking: - """Tests for BufferedMediaTrack frame tracking (publisher video).""" - - @pytest.mark.asyncio - async def test_tracks_frames_through_recv(self): - """Should track video frames passing through recv().""" - from getstream.video.rtc.track_util import BufferedMediaTrack - - frames = [create_video_frame(320, 240), create_video_frame(160, 120)] - buffered = BufferedMediaTrack(MockVideoTrack(frames)) - - await buffered.recv() - await buffered.recv() - - stats = buffered.get_frame_stats() - assert stats["framesSent"] == 2 - assert stats["frameWidth"] == 160 - assert stats["frameHeight"] == 120 - - -class TestStatsTracerFrameInjection: - """Tests for StatsTracer frame stats injection.""" - - @pytest.mark.asyncio - async def test_injects_publisher_frame_stats(self): - """Frame stats from tracker should be injected into outbound-rtp.""" - from getstream.video.rtc.stats_tracer import StatsTracer - - pc = RTCPeerConnection( - RTCConfiguration( - iceServers=[RTCIceServer(urls=["stun:stun.l.google.com:19302"])] - ) - ) - - try: - pc.addTransceiver("video", direction="sendonly") - await pc.setLocalDescription(await pc.createOffer()) - await asyncio.sleep(0.2) - - tracer = StatsTracer(pc, "publisher") - tracer.set_frame_tracker( - Mock( - get_frame_stats=lambda: { - "framesSent": 100, - "frameWidth": 320, - "frameHeight": 240, - "totalEncodeTime": 0.5, - } - ) - ) - - # First get() populates stats with frame data - result = await tracer.get() - - # Find video outbound-rtp in delta (first call has all fields) - video_stats = [ - v - for v in result.delta.values() - if isinstance(v, dict) and v.get("kind") == "video" - ] - assert len(video_stats) > 0 - video_rtp = video_stats[0] - assert video_rtp["framesSent"] == 100 - assert video_rtp["frameWidth"] == 320 - assert video_rtp["frameHeight"] == 240 - - finally: - await pc.close() - - @pytest.mark.asyncio - async def test_works_without_frame_tracker(self): - """Stats collection should work without frame tracker set.""" - from getstream.video.rtc.stats_tracer import StatsTracer - - pc = RTCPeerConnection() - - try: - pc.addTransceiver("video", direction="sendonly") - await pc.setLocalDescription(await pc.createOffer()) - - tracer = StatsTracer(pc, "publisher") - # No frame tracker set - - result = await tracer.get() - assert result.delta is not None - assert "timestamp" in result.delta - - finally: - await pc.close() diff --git a/tests/rtc/test_join.py b/tests/rtc/test_join.py deleted file mode 100644 index 2ba03c60..00000000 --- a/tests/rtc/test_join.py +++ /dev/null @@ -1,411 +0,0 @@ -import pytest -from getstream.stream import Stream -from getstream.video import rtc -from getstream.video.rtc import audio_track -import os # Import os for path manipulation -import asyncio -import uuid -from multiprocessing import Process, Value -import time -from dotenv import load_dotenv -import logging -from typing import Callable, Any - -from getstream.video.rtc.track_util import PcmData - -pytest.skip("Skipping RTC join tests during regular test runs", allow_module_level=True) - - -CALL_ID = os.getenv("CALL_ID") - - -# Shared function for process setup and error handling - -pytestmark = pytest.mark.integration - - -def run_process_with_stream_client( - process_type: str, - call_id: str, - file_path: str, - test_timeout: int, - task_func: Callable[[Stream, Any, str, int, logging.Logger], Any], - *args, -): - """ - Common function to handle process setup for Stream client processes. - - Args: - process_type: String identifier for the process ('SENDER' or 'RECEIVER') - call_id: The call ID to join - file_path: Path to the media file - test_timeout: Timeout in seconds - task_func: The specific task function to run in the process - *args: Additional arguments to pass to the task function - """ - - async def _async_runner(): - import logging - import sys - from getstream.stream import Stream - - # Configure logging in this process - logging.basicConfig( - level=logging.INFO, - format=f"[{process_type}] %(asctime)s - %(levelname)s - %(message)s", - force=True, - ) - logger = logging.getLogger(f"{process_type.lower()}_process") - - # Ensure output is immediately visible - sys.stdout.flush() - sys.stderr.flush() - - # Load environment variables in this process - load_dotenv() - - # Initialize client in this process - use the same env var names as in fixtures.py - api_key = os.environ.get("STREAM_API_KEY") - api_secret = os.environ.get("STREAM_API_SECRET") - - if not api_key or not api_secret: - print( - f"[{process_type}] Error: API credentials not found in environment variables", - flush=True, - ) - print( - f"[{process_type}] Environment vars: {list(os.environ.keys())}", - flush=True, - ) - raise ValueError( - "API credentials not found. Make sure STREAM_API_KEY and STREAM_API_SECRET are set." - ) - - client = Stream(api_key=api_key, api_secret=api_secret) - - logger.info(f"Starting {process_type.lower()} process") - try: - # Run the specific task function - await task_func(client, call_id, file_path, test_timeout, logger, *args) - except Exception as e: - logger.error(f"Error in {process_type.lower()} process: {e}") - import traceback - - logger.error(traceback.format_exc()) - - # Run the async function in this process - asyncio.run(_async_runner()) - - -# Specific task for the receiver process -async def receiver_task( - client: Stream, - call_id: str, - file_path: str, - test_timeout: int, - logger: logging.Logger, - received_flag: Value, # type: ignore -): - """Task function for the receiver process.""" - call = client.video.call("default", call_id) - async with await rtc.join(call, "test-user") as connection: - logger.info("Connected to call") - - @connection.on("audio") - async def audio_handler(pcm: PcmData, participant): - logger.info( - f"Audio event received: {len(pcm.samples)} bytes of PCM data from {participant}" - ) - # Set the shared flag to indicate audio was received - received_flag.value = 1 - # Leave the call - await connection.leave() - - # Wait for audio event or timeout - try: - await asyncio.wait_for(connection.wait(), timeout=test_timeout) - logger.info("Connection ended normally") - except asyncio.TimeoutError: - logger.info("Timed out waiting for audio") - finally: - if not connection._stop_event.is_set(): - await connection.leave() - logger.info("Receiver process complete") - - -# Specific task for the sender process -async def sender_task( - client: Stream, - call_id: str, - file_path: str, - test_timeout: int, - logger: logging.Logger, -): - """Task function for the sender process.""" - from aiortc.contrib.media import MediaPlayer - - # Create media player - player = MediaPlayer(file_path) - - call = client.video.call("default", call_id) - async with await rtc.join(call, "test-user") as connection: - logger.info("Connected to call, adding audio track") - await connection.add_tracks(audio=player.audio) - - # Wait for a while to ensure audio is sent - try: - await asyncio.sleep(test_timeout) - logger.info("Sender finished waiting") - finally: - await connection.leave() - logger.info("Sender process complete") - - -# Function to run the receiver in a separate process - defined at module level -def run_receiver(call_id, received_flag, file_path, test_timeout): - run_process_with_stream_client( - "RECEIVER", call_id, file_path, test_timeout, receiver_task, received_flag - ) - - -# Function to run the sender in a separate process - defined at module level -def run_sender(call_id, file_path, test_timeout): - run_process_with_stream_client( - "SENDER", call_id, file_path, test_timeout, sender_task - ) - - -@pytest.mark.asyncio -async def test_join_and_leave(client: Stream): - call = client.video.call("default", "test-join") - - async with await rtc.join(call, "test-user") as connection: - await connection.leave() - await connection.wait() - - -@pytest.mark.skip_in_ci -@pytest.mark.asyncio -async def test_join_two_users(client: Stream): - """Test end-to-end audio transmission between two users in the same call. - - Uses separate processes for sender and receiver to ensure complete isolation. - """ - - # Configure logging in the main process - logger = logging.getLogger("test_join_two_users") - - # Create a unique call ID for this test - call_id = f"test-audio-{uuid.uuid4()}" - logger.info(f"Using call ID: {call_id}") - - # Path to the test media file - file_path = "/Users/tommaso/src/data-samples/video/SampleVideo_1280x720_30mb.mp4" - - # Check if file exists before running test, skip if not - if not os.path.exists(file_path): - pytest.skip(f"Test media file not found at {file_path}") - - # Shared variable to track audio reception across processes - received_audio = Value("i", 0) - - # Set timeout for the entire test (seconds) - test_timeout = 20 - - # Create and start the receiver process first, explicitly connecting to parent's stdout/stderr - receiver_process = Process( - target=run_receiver, - args=(call_id, received_audio, file_path, test_timeout), - daemon=True, # Ensure process is killed if parent exits - ) - receiver_process.start() - logger.info(f"Started receiver process (PID: {receiver_process.pid})") - - # Wait briefly to ensure receiver is connected - await asyncio.sleep(3) - - # Create and start the sender process, explicitly connecting to parent's stdout/stderr - sender_process = Process( - target=run_sender, - args=(call_id, file_path, test_timeout), - daemon=True, # Ensure process is killed if parent exits - ) - sender_process.start() - logger.info(f"Started sender process (PID: {sender_process.pid})") - - # Wait for both processes to complete or timeout - start_time = time.time() - while time.time() - start_time < test_timeout: - # If audio was received, we can exit early - if received_audio.value == 1: - logger.info("Audio received, ending test") - break - - # Check if processes are still running - if not receiver_process.is_alive() and not sender_process.is_alive(): - logger.info("Both processes completed") - break - - await asyncio.sleep(0.5) - - # Clean up processes - for process in [receiver_process, sender_process]: - if process.is_alive(): - logger.info(f"Terminating process {process.pid}") - process.terminate() - process.join(timeout=2) - - # Assert that audio was received - assert received_audio.value == 1, "No audio event was received" - - -@pytest.mark.asyncio -async def test_detect_video_properties(client: Stream): - from aiortc.contrib.media import MediaPlayer - from getstream.video.rtc.track_util import ( - detect_video_properties, - BufferedMediaTrack, - ) - - file_path = "/Users/tommaso/src/data-samples/video/SampleVideo_1280x720_30mb.mp4" - - # Check if file exists before running test, skip if not - if not os.path.exists(file_path): - pytest.skip(f"Test media file not found at {file_path}") - - try: - # Create a media player from the file - player = MediaPlayer(file_path) - - # Ensure we have a video track - assert player.video is not None, "No video track found in test file" - - # Create a buffered track to safely inspect properties - buffered_track = BufferedMediaTrack(player.video) - - # Detect video properties before adding to the connection - video_props = await detect_video_properties(buffered_track) - print(f"Detected video properties: {video_props}") - - # Verify detected properties match expectations - assert "width" in video_props, "Width not detected" - assert "height" in video_props, "Height not detected" - assert video_props["width"] == 1280, ( - f"Incorrect width detected: {video_props['width']}" - ) - assert video_props["height"] == 720, ( - f"Incorrect height detected: {video_props['height']}" - ) - assert video_props["fps"] == 25, "Invalid FPS value" - assert 1000 <= video_props["bitrate"] <= 2000, ( - f"Unexpected bitrate: {video_props['bitrate']}" - ) - - finally: - # Ensure player is properly closed - if "player" in locals() and hasattr(player, "video") and player.video: - buffered_track.stop() # Stop the buffered track - - # Give a moment for cleanup - await asyncio.sleep(0.1) - - -@pytest.mark.asyncio -async def test_play_file(client: Stream): - from aiortc.contrib.media import MediaPlayer - - call = client.video.call("default", CALL_ID) - - file_path = ( - "/Users/vivek/stream/tools/samples/videos/bbb_sunflower_1080p_60fps_normal.mp4" - ) - - # Check if file exists before running test, skip if not - if not os.path.exists(file_path): - pytest.skip(f"Test media file not found at {file_path}") - - player = MediaPlayer(file_path) - - async with await rtc.join(call, "vivek33") as connection: - # Use add_tracks which now automatically detects properties - await connection.add_tracks( - video=player.video, - audio=player.audio, - ) - - await connection.wait() - - -@pytest.mark.asyncio -async def test_play_audio_track_from_text(client: Stream): - from getstream.plugins.gs_elevenlabs import ElevenLabsTTS - - audio = audio_track.AudioStreamTrack(framerate=16000) - tts_instance = ElevenLabsTTS(voice_id="JBFqnCBsd6RMkjVDRZzb") - tts_instance.set_output_track(audio) - call = client.video.call("default", CALL_ID) - - async with await rtc.join(call, "test-user") as connection: - # TODO: make connection.add_tracks block until the track is ready - await connection.add_tracks( - audio=audio, - ) - - await tts_instance.send("hey how is it going?") - - await tts_instance.send("do you think Stream is awesome so far?") - - await asyncio.sleep(1) - - -@pytest.mark.asyncio -async def test_full_echo(client: Stream): - from getstream.plugins.silero import SileroVAD - from getstream.plugins.deepgram import DeepgramSTT - from getstream.plugins.gs_elevenlabs import ElevenLabsTTS - - audio = audio_track.AudioStreamTrack(framerate=16000) - vad = SileroVAD() - stt = DeepgramSTT() - call = client.video.call("default", "mQbx3HG7wtTj") - tts_instance = ElevenLabsTTS(voice_id="zOImbcIGBTxd0yXmwgRi") - tts_instance.set_output_track(audio) - - async with await rtc.join(call, "test-user") as connection: - await connection.add_tracks( - audio=audio, - ) - - await tts_instance.send("welcome my friend, how's it going?") - - @connection.on("audio") - async def on_audio(pcm: PcmData, user): - await vad.process_audio(pcm, user) - - @vad.on("audio") - async def on_speech_detected(pcm: PcmData, user): - print( - f"{time.time()} Speech detected from user: {user} duration {pcm.duration}" - ) - await stt.process_audio(pcm, None) - - @stt.on("transcript") - async def on_transcript(text: str, user: Any, metadata: dict[str, Any]): - print( - f"{time.time()} got text from audio, will echo back the transcript {text}" - ) - await tts_instance.send(text) - - await asyncio.sleep(30) - - -@pytest.mark.asyncio -async def test_simple_capture(client: Stream): - call = client.video.call("default", CALL_ID) - async with await rtc.join(call, "test-user") as connection: - - @connection.on("audio") - async def on_audio(pcm: PcmData, user): - print("got audio") - - await connection.wait() diff --git a/tests/rtc/test_location_discovery.py b/tests/rtc/test_location_discovery.py deleted file mode 100644 index bcfa69d0..00000000 --- a/tests/rtc/test_location_discovery.py +++ /dev/null @@ -1,204 +0,0 @@ -import unittest -from unittest.mock import MagicMock, patch - -import pytest - -from getstream.video.rtc.location_discovery import ( - HTTPHintLocationDiscovery, - HEADER_CLOUDFRONT_POP, - FALLBACK_LOCATION_NAME, - STREAM_PROD_URL, -) - - -class TestLocationDiscovery(unittest.TestCase): - """Unit tests for the HTTPHintLocationDiscovery class.""" - - def setUp(self): - self.logger_mock = MagicMock() - self.client_mock = MagicMock() - self.discovery = HTTPHintLocationDiscovery( - url=STREAM_PROD_URL, - max_retries=3, - logger=self.logger_mock, - ) - - def test_init_default_values(self): - """Test that default values are set correctly.""" - discovery = HTTPHintLocationDiscovery() - self.assertEqual(discovery.url, STREAM_PROD_URL) - self.assertEqual(discovery.max_retries, 3) - self.assertIsNotNone(discovery.client) - self.assertIsNotNone(discovery.logger) - - def test_init_custom_values(self): - """Test that custom values are set correctly.""" - custom_url = "https://custom.example.com/" - custom_max_retries = 5 - discovery = HTTPHintLocationDiscovery( - url=custom_url, - max_retries=custom_max_retries, - client=self.client_mock, - logger=self.logger_mock, - ) - self.assertEqual(discovery.url, custom_url) - self.assertEqual(discovery.max_retries, custom_max_retries) - self.assertEqual(discovery.client, self.client_mock) - self.assertEqual(discovery.logger, self.logger_mock) - - @patch("http.client.HTTPSConnection") - def test_discover_success(self, mock_https_connection): - """Test successful location discovery.""" - # Setup the mock response - mock_response = MagicMock() - mock_response.status = 200 - mock_response.getheader.return_value = "IAD12" - - # Setup the mock connection - mock_conn = MagicMock() - mock_conn.getresponse.return_value = mock_response - mock_https_connection.return_value = mock_conn - - # Call the discover method - location = self.discovery.discover() - - # Verify the result - self.assertEqual(location, "IAD") - - # Verify that the correct HTTP request was made - mock_conn.request.assert_called_once_with("HEAD", "/", None, {}) - mock_response.getheader.assert_called_once_with(HEADER_CLOUDFRONT_POP, "") - mock_response.read.assert_called_once() - mock_conn.close.assert_called_once() - - @patch("http.client.HTTPSConnection") - def test_discover_invalid_pop_name(self, mock_https_connection): - """Test discovering with an invalid POP name.""" - # Setup the mock response - mock_response = MagicMock() - mock_response.status = 200 - mock_response.getheader.return_value = "AB" # Too short - - # Setup the mock connection - mock_conn = MagicMock() - mock_conn.getresponse.return_value = mock_response - mock_https_connection.return_value = mock_conn - - # Call the discover method - location = self.discovery.discover() - - # Verify the result - self.assertEqual(location, FALLBACK_LOCATION_NAME) - - # Verify that the warning was logged - self.logger_mock.warning.assert_called_with("Invalid pop name: %s", "AB") - - @patch("http.client.HTTPSConnection") - def test_discover_non_200_status(self, mock_https_connection): - """Test discovering with a non-200 status code.""" - # Setup the mock response - mock_response = MagicMock() - mock_response.status = 404 # Not found - - # Setup the mock connection - mock_conn = MagicMock() - mock_conn.getresponse.return_value = mock_response - mock_https_connection.return_value = mock_conn - - # Call the discover method - location = self.discovery.discover() - - # Verify the result - self.assertEqual(location, FALLBACK_LOCATION_NAME) - - # Verify that the warning was logged - self.logger_mock.warning.assert_called_with("Unexpected status code: %d", 404) - - @patch("http.client.HTTPSConnection") - def test_discover_exception(self, mock_https_connection): - """Test discovering with an exception during the request.""" - # Setup the mock connection to raise an exception - mock_conn = MagicMock() - mock_conn.request.side_effect = Exception("Connection error") - mock_https_connection.return_value = mock_conn - - # Call the discover method - location = self.discovery.discover() - - # Verify the result - self.assertEqual(location, FALLBACK_LOCATION_NAME) - - # Verify that the warning was logged - self.logger_mock.warning.assert_called_with( - "HEAD request failed: %s", "Connection error" - ) - - @patch("http.client.HTTPSConnection") - def test_discover_max_retries(self, mock_https_connection): - """Test that discovery respects the max_retries parameter.""" - # Setup the mock connection to raise an exception every time - mock_conn = MagicMock() - mock_conn.request.side_effect = Exception("Connection error") - mock_https_connection.return_value = mock_conn - - # Set a custom max retries value - self.discovery.max_retries = 2 - - # Call the discover method - location = self.discovery.discover() - - # Verify the result - self.assertEqual(location, FALLBACK_LOCATION_NAME) - - # Verify that the request was attempted the correct number of times - self.assertEqual(mock_conn.request.call_count, 2) - - def test_discover_invalid_url(self): - """Test discovering with an invalid URL.""" - # Set an invalid URL - self.discovery.url = "invalid-url" - - # Call the discover method - location = self.discovery.discover() - - # Verify the result - self.assertEqual(location, FALLBACK_LOCATION_NAME) - - # Verify that the warning was logged - self.logger_mock.warning.assert_called_with( - "Invalid URL format: %s", "invalid-url" - ) - - def test_discover_caching(self): - """Test that discover results are cached.""" - # Create a mock for the HTTP connection - with patch("http.client.HTTPSConnection") as mock_https_connection: - # Setup the mock response - mock_response = MagicMock() - mock_response.status = 200 - mock_response.getheader.return_value = "IAD12" - - # Setup the mock connection - mock_conn = MagicMock() - mock_conn.getresponse.return_value = mock_response - mock_https_connection.return_value = mock_conn - - # Call the discover method twice - location1 = self.discovery.discover() - location2 = self.discovery.discover() - - # Verify the results - self.assertEqual(location1, "IAD") - self.assertEqual(location2, "IAD") - - # Verify that the request was only made once - mock_conn.request.assert_called_once() - - -@pytest.mark.integration -def test_real_discovery(): - """Integration test that uses the actual network to discover a location.""" - discovery = HTTPHintLocationDiscovery() - location = discovery.discover() - assert location is not None - assert len(location) == 3 diff --git a/tests/rtc/test_location_discovery_integration.py b/tests/rtc/test_location_discovery_integration.py deleted file mode 100644 index b641002c..00000000 --- a/tests/rtc/test_location_discovery_integration.py +++ /dev/null @@ -1,68 +0,0 @@ -import pytest -import logging - -from getstream.video.rtc.location_discovery import ( - HTTPHintLocationDiscovery, - FALLBACK_LOCATION_NAME, -) - - -@pytest.mark.integration -def test_real_discovery(): - """ - Integration test that connects to the real Stream hint URL. - - This test requires network access and will make a real HTTP request. - It's marked with integration to make it easy to exclude from regular test runs. - """ - logging.basicConfig(level=logging.INFO) - logger = logging.getLogger("test_location_discovery_integration") - - discovery = HTTPHintLocationDiscovery(logger=logger) - location = discovery.discover() - - logger.info(f"Discovered location: {location}") - - assert location is not None - assert len(location) == 3 # Should be a 3-character code - - -@pytest.mark.integration -def test_cached_discovery(): - """ - Test that the discovery is cached and only makes one HTTP request. - - This test requires network access and will make a real HTTP request. - """ - logging.basicConfig(level=logging.INFO) - logger = logging.getLogger("test_cached_discovery") - - discovery = HTTPHintLocationDiscovery(logger=logger) - - # First call - should make an HTTP request - location1 = discovery.discover() - assert location1 is not None - assert len(location1) == 3 - - # Second call - should use the cached value - location2 = discovery.discover() - assert location2 is not None - assert len(location2) == 3 - - # Both calls should return the same location - assert location1 == location2 - - logger.info(f"Cached discovery result: {location1}") - - -@pytest.mark.integration -def test_fallback_with_invalid_url(): - """ - Test that discovery falls back to the default location with an invalid URL. - - This test doesn't require network access since it should fail immediately. - """ - discovery = HTTPHintLocationDiscovery(url="invalid://url.com") - location = discovery.discover() - - assert location == FALLBACK_LOCATION_NAME diff --git a/tests/rtc/test_participants.py b/tests/rtc/test_participants.py index bfed5d4a..df85f054 100644 --- a/tests/rtc/test_participants.py +++ b/tests/rtc/test_participants.py @@ -21,14 +21,14 @@ def test_add_participant(self): p1 = models_pb2.Participant() p1.user_id = "user1" - p1.track_lookup_prefix = "prefix1" + p1.session_id = "prefix1" state._add_participant(p1) participants = state.get_participants() assert len(participants) == 1 assert participants[0].user_id == "user1" - assert participants[0].track_lookup_prefix == "prefix1" + assert participants[0].session_id == "prefix1" def test_add_multiple_participants(self): """Test adding multiple participants.""" @@ -36,15 +36,15 @@ def test_add_multiple_participants(self): p1 = models_pb2.Participant() p1.user_id = "user1" - p1.track_lookup_prefix = "prefix1" + p1.session_id = "prefix1" p2 = models_pb2.Participant() p2.user_id = "user2" - p2.track_lookup_prefix = "prefix2" + p2.session_id = "prefix2" p3 = models_pb2.Participant() p3.user_id = "user3" - p3.track_lookup_prefix = "prefix3" + p3.session_id = "prefix3" state._add_participant(p1) state._add_participant(p2) @@ -62,11 +62,11 @@ def test_remove_participant(self): p1 = models_pb2.Participant() p1.user_id = "user1" - p1.track_lookup_prefix = "prefix1" + p1.session_id = "prefix1" p2 = models_pb2.Participant() p2.user_id = "user2" - p2.track_lookup_prefix = "prefix2" + p2.session_id = "prefix2" state._add_participant(p1) state._add_participant(p2) @@ -83,12 +83,34 @@ def test_remove_nonexistent_participant(self): p1 = models_pb2.Participant() p1.user_id = "user1" - p1.track_lookup_prefix = "prefix1" + p1.session_id = "prefix1" # Should not raise an error state._remove_participant(p1) assert len(state.get_participants()) == 0 + def test_replace_participants(self): + """Replacing the list notifies the map handlers with the new list.""" + state = ParticipantsState() + + p1 = models_pb2.Participant() + p1.user_id = "user1" + p1.session_id = "session1" + p2 = models_pb2.Participant() + p2.user_id = "user2" + p2.session_id = "session2" + state._add_participant(p1) + results = [] + + def handler(participants): + results.append([p.user_id for p in participants]) + + _subscription = state.map(handler) + state._replace_participants([p2]) + + assert results[-1] == ["user2"] + assert [p.user_id for p in state.get_participants()] == ["user2"] + def test_map_called_immediately(self): """Test that map handler is called immediately with current participant list.""" state = ParticipantsState() @@ -120,14 +142,14 @@ def handler(participants): p1 = models_pb2.Participant() p1.user_id = "user1" - p1.track_lookup_prefix = "prefix1" + p1.session_id = "prefix1" state._add_participant(p1) assert results == [0, 1] p2 = models_pb2.Participant() p2.user_id = "user2" - p2.track_lookup_prefix = "prefix2" + p2.session_id = "prefix2" state._add_participant(p2) assert results == [0, 1, 2] @@ -138,7 +160,7 @@ def test_map_called_on_remove_participant(self): p1 = models_pb2.Participant() p1.user_id = "user1" - p1.track_lookup_prefix = "prefix1" + p1.session_id = "prefix1" state._add_participant(p1) results = [] @@ -165,7 +187,7 @@ def test_map_with_lambda(self): p1 = models_pb2.Participant() p1.user_id = "user1" - p1.track_lookup_prefix = "prefix1" + p1.session_id = "prefix1" state._add_participant(p1) assert results == [0, 1] @@ -188,7 +210,7 @@ def test_map_multiple_handlers(self): p1 = models_pb2.Participant() p1.user_id = "user1" - p1.track_lookup_prefix = "prefix1" + p1.session_id = "prefix1" state._add_participant(p1) assert results1 == [0, 1] @@ -209,7 +231,7 @@ def handler(participants): p1 = models_pb2.Participant() p1.user_id = "user1" - p1.track_lookup_prefix = "prefix1" + p1.session_id = "prefix1" state._add_participant(p1) assert results == [0, 1] @@ -219,7 +241,7 @@ def handler(participants): # Add another participant - handler should NOT be called p2 = models_pb2.Participant() p2.user_id = "user2" - p2.track_lookup_prefix = "prefix2" + p2.session_id = "prefix2" state._add_participant(p2) assert results == [0, 1] # Should not have changed @@ -245,7 +267,7 @@ def handler(participants): # Handler should be cleaned up p1 = models_pb2.Participant() p1.user_id = "user1" - p1.track_lookup_prefix = "prefix1" + p1.session_id = "prefix1" state._add_participant(p1) # Results should not have changed (handler was garbage collected) @@ -269,7 +291,7 @@ def test_weak_reference_cleanup_with_lambda(self): # Handler should be cleaned up p1 = models_pb2.Participant() p1.user_id = "user1" - p1.track_lookup_prefix = "prefix1" + p1.session_id = "prefix1" state._add_participant(p1) # Results should not have changed (handler was garbage collected) @@ -297,7 +319,7 @@ def test_selective_cleanup_multiple_subscriptions(self): # Add a participant p1 = models_pb2.Participant() p1.user_id = "user1" - p1.track_lookup_prefix = "prefix1" + p1.session_id = "prefix1" state._add_participant(p1) # Only sub1 and sub3 should have been called @@ -321,16 +343,16 @@ def handler(participants): p1 = models_pb2.Participant() p1.user_id = "alice" - p1.track_lookup_prefix = "alice_123" + p1.session_id = "alice_123" state._add_participant(p1) assert len(received_participants) == 1 assert received_participants[0].user_id == "alice" - assert received_participants[0].track_lookup_prefix == "alice_123" + assert received_participants[0].session_id == "alice_123" p2 = models_pb2.Participant() p2.user_id = "bob" - p2.track_lookup_prefix = "bob_456" + p2.session_id = "bob_456" state._add_participant(p2) assert len(received_participants) == 2 @@ -344,11 +366,11 @@ def test_map_with_existing_participants(self): # Add participants before subscribing p1 = models_pb2.Participant() p1.user_id = "user1" - p1.track_lookup_prefix = "prefix1" + p1.session_id = "prefix1" p2 = models_pb2.Participant() p2.user_id = "user2" - p2.track_lookup_prefix = "prefix2" + p2.session_id = "prefix2" state._add_participant(p1) state._add_participant(p2) @@ -364,46 +386,6 @@ def handler(participants): # Handler should be called immediately with existing 2 participants assert results == [2] - def test_get_user_from_track_id_with_prefix(self): - """Test get_user_from_track_id using track prefix.""" - state = ParticipantsState() - - p1 = models_pb2.Participant() - p1.user_id = "user1" - p1.track_lookup_prefix = "prefix1" - state._add_participant(p1) - - # Track ID format: participant_id:track_type:... - user = state.get_user_from_track_id("prefix1:audio:123") - assert user is not None - assert user.user_id == "user1" - - def test_get_user_from_track_id_not_found(self): - """Test get_user_from_track_id returns None for unknown track.""" - state = ParticipantsState() - - user = state.get_user_from_track_id("unknown:audio:123") - assert user is None - - def test_get_stream_id_from_track_id(self): - """Test get_stream_id_from_track_id.""" - state = ParticipantsState() - - mapping = { - "track1": "stream1", - "track2": "stream2", - } - state.set_track_stream_mapping(mapping) - - stream_id = state.get_stream_id_from_track_id("track1") - assert stream_id == "stream1" - - stream_id = state.get_stream_id_from_track_id("track2") - assert stream_id == "stream2" - - stream_id = state.get_stream_id_from_track_id("track3") - assert stream_id is None - def test_map_with_bound_method(self): """Test that map works with bound methods.""" state = ParticipantsState() @@ -426,7 +408,7 @@ def on_participants(self, participants): # Add a participant p1 = models_pb2.Participant() p1.user_id = "user1" - p1.track_lookup_prefix = "prefix1" + p1.session_id = "prefix1" state._add_participant(p1) # Handler should be called @@ -435,7 +417,7 @@ def on_participants(self, participants): # Add another participant p2 = models_pb2.Participant() p2.user_id = "user2" - p2.track_lookup_prefix = "prefix2" + p2.session_id = "prefix2" state._add_participant(p2) # Handler should be called again @@ -466,7 +448,7 @@ def on_participants(self, participants): # Handler should be cleaned up p1 = models_pb2.Participant() p1.user_id = "user1" - p1.track_lookup_prefix = "prefix1" + p1.session_id = "prefix1" state._add_participant(p1) # No handlers should remain diff --git a/tests/rtc/test_pcm_data.py b/tests/rtc/test_pcm_data.py index 79581ccb..f7670935 100644 --- a/tests/rtc/test_pcm_data.py +++ b/tests/rtc/test_pcm_data.py @@ -8,7 +8,6 @@ AudioFormat, PcmData, Resampler, - PyAVResampler, ) @@ -1974,353 +1973,3 @@ def test_g711_integration(tmp_path): # Check that decoded audio isn't all zeros assert np.any(pcm_decoded_mulaw.samples != 0) assert np.any(pcm_decoded_alaw.samples != 0) - - -class TestPyAVResampler: - def test_resample_upsample(self): - """Test basic resampling functionality.""" - # Create a resampler for 48kHz mono s16 - resampler = PyAVResampler(format="s16", sample_rate=48000, channels=1) - - # Create 20ms of 16kHz audio (320 samples) - samples = np.random.randint(-1000, 1000, 320, dtype=np.int16) - pcm_16k = PcmData(samples=samples, sample_rate=16000, format="s16", channels=1) - - # Resample to 48kHz - pcm_48k = resampler.resample(pcm_16k) - - assert pcm_48k.sample_rate == 48000 - assert pcm_48k.format == "s16" - assert pcm_48k.channels == 1 - - # 16kHz to 48kHz is 3x upsampling: 320 * 3 = 960 - # Stateful resampler keeps some frames in the buffer - assert len(pcm_48k.samples) < 960 - # We need to flush the resampler to get the remaining ones - pcm_flushed = resampler.flush() - assert len(pcm_flushed.samples) + len(pcm_48k.samples) == 960 - - # Calling resample with "flush=True" returns all samples at once - assert len(resampler.resample(pcm_16k, flush=True).samples) == 960 - - def test_resample_downsample(self): - """Test downsampling from 48kHz to 16kHz.""" - resampler = PyAVResampler(format="s16", sample_rate=16000, channels=1) - - # Create 48kHz audio (960 samples = 20ms) - samples = np.random.randint(-1000, 1000, 960, dtype=np.int16) - pcm_48k = PcmData(samples=samples, sample_rate=48000, format="s16", channels=1) - - # Downsample to 16kHz - pcm_16k = resampler.resample(pcm_48k) - - assert pcm_16k.sample_rate == 16000 - # 48kHz to 16kHz is 1/3x: 960 / 3 = 320 - # Stateful resampler keeps some frames in the buffer - assert len(pcm_16k.samples) < 320 - # We need to flush the resampler to get the remaining ones - pcm_flushed = resampler.flush() - assert len(pcm_flushed.samples) + len(pcm_16k.samples) == 320 - - # Calling resample with "flush=True" returns all samples at once - assert len(resampler.resample(pcm_48k, flush=True).samples) == 320 - - def test_resample_no_change(self): - """Test that resampler returns same data when no resampling needed.""" - resampler = PyAVResampler(format="s16", sample_rate=16000, channels=1) - - samples = np.array([1, 2, 3, 4], dtype=np.int16) - pcm = PcmData(samples=samples, sample_rate=16000, format="s16", channels=1) - - result = resampler.resample(pcm) - - assert result.sample_rate == 16000 - assert result.format == "s16" - assert result.channels == 1 - np.testing.assert_array_equal(result.samples, samples) - - def test_resample_different_sample_rates_fails(self): - # Create a resampler for 48kHz mono s16 - resampler = PyAVResampler(format="s16", sample_rate=48000, channels=1) - - samples = np.random.randint(-1000, 1000, 320, dtype=np.int16) - # Create 20ms of 16kHz audio (320 samples) - pcm_16k = PcmData(samples=samples, sample_rate=16000, format="s16", channels=1) - # Create 40ms of 16kHz audio (320 samples) - pcm_8k = PcmData(samples=samples, sample_rate=8000, format="s16", channels=1) - - resampler.resample(pcm_16k) - # Feeding a different sample rate fails - with pytest.raises( - ValueError, match="Frame does not match AudioResampler setup" - ): - resampler.resample(pcm_8k) - - def test_resample_different_formats_fails(self): - # Create a resampler for 48kHz mono s16 - resampler = PyAVResampler(format="s16", sample_rate=48000, channels=1) - - # Create 20ms of 16kHz audio (320 samples) - pcm_16k_s16 = PcmData( - samples=np.linspace(-1000, 1000, 320, dtype=np.int16), - sample_rate=16000, - format="s16", - channels=1, - ) - pcm_16k_f32 = PcmData( - samples=np.linspace(-1000, 1000, 320, dtype=np.float32), - sample_rate=16000, - format="f32", - channels=1, - ) - - resampler.resample(pcm_16k_s16) - # Feeding a different format fails - with pytest.raises( - ValueError, match="Frame does not match AudioResampler setup" - ): - resampler.resample(pcm_16k_f32) - - def test_resample_upsample_frame_size_set(self): - """Test that resampler accumulates samples before returning if frame size is set.""" - # Create a resampler for 8kHz mono s16 - # with a frame size 160 (20ms) - resampler = PyAVResampler( - format="s16", - sample_rate=8000, - channels=1, - frame_size=160, - ) - - # Create 100 samples of 16kHz audio - samples = np.random.randint(-1000, 1000, 100, dtype=np.int16) - pcm_16k = PcmData(samples=samples, sample_rate=16000, format="s16", channels=1) - - # Resample to 8kHz - pcm_8k = resampler.resample(pcm_16k) - - assert pcm_8k.sample_rate == 8000 - assert pcm_8k.format == "s16" - assert pcm_8k.channels == 1 - - # 100 samples are not enough for a frame of 160 8khz - assert len(pcm_8k.samples) == 0 - # Feed it 3 more times to get some output - resampler.resample(pcm_16k) - resampler.resample(pcm_16k) - pcm_8k = resampler.resample(pcm_16k) - assert len(pcm_8k.samples) == 160 - - # Flush resampler to get the last 40 samples - assert len(resampler.flush().samples) == 40 - - def test_resample_mono_to_stereo(self): - """Test resampling with channel conversion from mono to stereo.""" - resampler = PyAVResampler(format="s16", sample_rate=48000, channels=2) - - # Create 64 samples of mono 16kHz audio - mono_samples = np.linspace(-100, 100, num=64, dtype=np.int16) - pcm_mono = PcmData( - samples=mono_samples, sample_rate=16000, format="s16", channels=1 - ) - - # Resample to stereo 48kHz - pcm_stereo = resampler.resample(pcm_mono, flush=True) - - assert pcm_stereo.sample_rate == 48000 - assert pcm_stereo.channels == 2 - assert pcm_stereo.samples.shape[0] == 2 # 2 channels - # Both channels should have the same data (duplicated from mono) - np.testing.assert_array_equal(pcm_stereo.samples[0], pcm_stereo.samples[1]) - - def test_resample_stereo_to_mono(self): - """Test resampling with channel conversion from stereo to mono.""" - resampler = PyAVResampler(format="s16", sample_rate=48000, channels=1) - - # Create stereo 16kHz audio - left_channel = np.linspace(-100, 100, num=64, dtype=np.int16) - right_channel = np.linspace(-100, 100, num=64, dtype=np.int16) - stereo_samples = np.vstack([left_channel, right_channel]) - pcm_stereo = PcmData( - samples=stereo_samples, sample_rate=16000, format="s16", channels=2 - ) - - # Resample to mono 48kHz - pcm_mono = resampler.resample(pcm_stereo) - - assert pcm_mono.sample_rate == 48000 - assert pcm_mono.channels == 1 - assert pcm_mono.samples.ndim == 1 # 1D array for mono - - def test_resample_format_conversion_to_f32(self): - """Test format conversion from s16 to f32.""" - resampler = PyAVResampler(format="f32", sample_rate=16000, channels=1) - - # Create s16 audio - samples = np.array([0, 16384, -16384, 32767, -32768], dtype=np.int16) - pcm_s16 = PcmData(samples=samples, sample_rate=16000, format="s16", channels=1) - - # Convert to f32 - pcm_f32 = resampler.resample(pcm_s16) - - assert pcm_f32.format == "f32" - assert pcm_f32.samples.dtype == np.float32 - # Check value ranges are properly scaled to [-1, 1] - assert -1.0 <= pcm_f32.samples.min() <= 1.0 - assert -1.0 <= pcm_f32.samples.max() <= 1.0 - - def test_resample_format_conversion_to_s16(self): - """Test format conversion from f32 to s16.""" - resampler = PyAVResampler(format="s16", sample_rate=16000, channels=1) - - # Create f32 audio - samples = np.array([0.0, 0.5, -0.5, 1.0, -1.0], dtype=np.float32) - pcm_f32 = PcmData(samples=samples, sample_rate=16000, format="f32", channels=1) - - # Convert to s16 - pcm_s16 = resampler.resample(pcm_f32) - - assert pcm_s16.format == "s16" - assert pcm_s16.samples.dtype == np.int16 - # Check values are in int16 range - assert -32768 <= pcm_s16.samples.min() <= 32767 - assert -32768 <= pcm_s16.samples.max() <= 32767 - - def test_resample_20ms_chunks(self): - """Test resampling of consecutive 20ms chunks (simulating real-time streaming).""" - resampler = PyAVResampler(format="s16", sample_rate=48000, channels=1) - - # Simulate 5 consecutive 20ms chunks at 16kHz - chunks = [] - for i in range(5): - # 20ms at 16kHz = 320 samples - samples = ( - np.sin(2 * np.pi * 440 * (np.arange(320) + i * 320) / 16000) * 10000 - ) - samples = samples.astype(np.int16) - pcm = PcmData(samples=samples, sample_rate=16000, format="s16", channels=1) - chunks.append(pcm) - - # Resample each chunk independently (simulating real-time processing) - resampled_chunks = [] - for chunk in chunks: - resampled = resampler.resample(chunk, flush=True) - resampled_chunks.append(resampled) - # Each 20ms chunk at 48kHz should be 960 samples - assert len(resampled.samples) == 960 - assert resampled.sample_rate == 48000 - - # Verify no state is maintained between chunks by checking each is processed identically - # Two identical input chunks should produce identical outputs - identical_chunk = PcmData( - samples=chunks[0].samples, sample_rate=16000, format="s16", channels=1 - ) - resampled1 = resampler.resample(chunks[0], flush=True) - resampled2 = resampler.resample(identical_chunk, flush=True) - np.testing.assert_array_equal(resampled1.samples, resampled2.samples) - - def test_resample_time_handling(self): - """ - Test that PTS/DTS timestamps increase monotonically after resampling. - """ - resampler = PyAVResampler(format="s16", sample_rate=48000, channels=1) - - samples = np.linspace(-100, 100, num=320, dtype=np.int16) - pcm = PcmData( - samples=samples, - sample_rate=16000, - format="s16", - channels=1, - pts=1234, - dts=1230, - time_base=0.001, - ) - - resampled = resampler.resample(pcm, flush=True) - - # Stateful resampler keeps track of the time on its own. - # It resets pts when getting the first sample, and it tracks the new pts value - # based on the number of output samples. - # The values from PcmData objects are ignored. - assert resampled.pts == 0 - assert resampled.dts == 0 - assert resampled.time_base == 1 / resampler.sample_rate - - resampled = resampler.resample(pcm, flush=True) - assert resampled.pts == 960 - assert resampled.dts == 960 - - def test_repr(self): - """Test string representation of Resampler.""" - resampler = PyAVResampler( - format="f32", sample_rate=44100, channels=2, frame_size=1024 - ) - repr_str = repr(resampler) - - assert "format='f32'" in repr_str - assert "sample_rate=44100" in repr_str - assert "channels=2" in repr_str - assert "frame_size=1024" in repr_str - - def test_resample_empty_audio(self): - """Test edge cases like empty audio, single sample, etc.""" - resampler = PyAVResampler(format="s16", sample_rate=48000, channels=1) - - # Empty audio - empty_pcm = PcmData( - samples=np.array([], dtype=np.int16), - sample_rate=16000, - format="s16", - channels=1, - ) - resampled_empty = resampler.resample(empty_pcm) - assert len(resampled_empty.samples) == 0 - - def test_flush_empty(self): - """Test flushing the resampler before processing any samples.""" - resampler = PyAVResampler(format="s16", sample_rate=48000, channels=1) - - # Empty audio - resampled_empty = resampler.flush() - assert len(resampled_empty.samples) == 0 - - def test_resample_consistency_across_chunks(self): - """Test that splitting audio and processing in chunks gives consistent results.""" - resampler = PyAVResampler(format="s16", sample_rate=48000, channels=1) - - # Create a longer audio signal - total_samples = 1600 # 100ms at 16kHz - samples = np.sin(2 * np.pi * 440 * np.arange(total_samples) / 16000) * 10000 - samples = samples.astype(np.int16) - - # Process as one chunk and flush immediately - pcm_full = PcmData(samples=samples, sample_rate=16000, format="s16", channels=1) - resampled_full = resampler.resample(pcm_full, flush=True) - - # Process as multiple 20ms chunks - chunk_size = 320 # 20ms at 16kHz - resampled_chunks = [] - for i in range(0, total_samples, chunk_size): - chunk_samples = samples[i : i + chunk_size] - pcm_chunk = PcmData( - samples=chunk_samples, sample_rate=16000, format="s16", channels=1 - ) - resampled_chunk = resampler.resample(pcm_chunk) - resampled_chunks.append(resampled_chunk.samples) - - # Flush the resampler after processing all the chunks - resampled_chunks.append(resampler.flush().samples) - - # Concatenate chunks - resampled_concatenated = np.concatenate(resampled_chunks) - - # The results should be similar, though with some differences due to - # independent chunk processing. The stateless resampler uses endpoint - # mapping for each chunk, which prevents out-of-bounds access but creates - # phase differences compared to processing as one continuous signal. - assert len(resampled_full.samples) == len(resampled_concatenated) - # Check that the difference is reasonable for independent chunk processing - diff = np.abs(resampled_full.samples - resampled_concatenated) - assert ( - np.mean(diff) < 250 - ) # Allow for phase differences in stateless processing diff --git a/tests/rtc/test_peer_connection_config.py b/tests/rtc/test_peer_connection_config.py deleted file mode 100644 index d320b4cf..00000000 --- a/tests/rtc/test_peer_connection_config.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Tests for PeerConnectionManager ICE configuration.""" - -import aiortc -from unittest.mock import Mock - -from getstream.video.rtc.peer_connection import PeerConnectionManager - - -class TestPeerConnectionConfig: - """Tests for ICE server configuration conversion.""" - - def _create_manager_with_credentials( - self, ice_servers: list - ) -> PeerConnectionManager: - """Create a PeerConnectionManager with mocked credentials.""" - mock_connection_manager = Mock() - mock_connection_manager.join_response.credentials.ice_servers = ice_servers - mock_connection_manager.join_response.credentials.server.edge_name = ( - "sfu-dpk-london-test.stream-io-video.com" - ) - return PeerConnectionManager(mock_connection_manager) - - def test_build_rtc_configuration_with_turn_servers(self): - """Should convert coordinator ICE servers to aiortc RTCConfiguration.""" - ice_servers = [ - { - "urls": ["turn:sfu-test.stream-io-video.com:3478"], - "username": "user123", - "password": "secret456", - }, - { - "urls": ["turn:sfu-test.stream-io-video.com:3478?transport=tcp"], - "username": "user123", - "password": "secret456", - }, - ] - - manager = self._create_manager_with_credentials(ice_servers) - config = manager._build_rtc_configuration() - - assert isinstance(config, aiortc.RTCConfiguration) - assert config.iceServers is not None - assert len(config.iceServers) == 2 - assert config.bundlePolicy == aiortc.RTCBundlePolicy.MAX_BUNDLE - - # Check first server - server = config.iceServers[0] - assert server.urls == ["turn:sfu-test.stream-io-video.com:3478"] - assert server.username == "user123" - assert server.credential == "secret456" # password -> credential - - def test_build_rtc_configuration_handles_credential_field(self): - """Should accept 'credential' field as alternative to 'password'.""" - ice_servers = [ - { - "urls": ["stun:stun.example.com:19302"], - "username": "user", - "credential": "cred", # Using 'credential' instead of 'password' - }, - ] - - manager = self._create_manager_with_credentials(ice_servers) - config = manager._build_rtc_configuration() - - assert config.iceServers is not None - assert config.iceServers[0].credential == "cred" - - def test_get_connection_config_format(self): - """Should return dict matching JS SDK trace format.""" - ice_servers = [ - { - "urls": ["turn:sfu-test.stream-io-video.com:3478"], - "username": "user123", - "password": "secret456", - }, - ] - - manager = self._create_manager_with_credentials(ice_servers) - config = manager._get_connection_config() - - # Should have url, bundlePolicy, and iceServers (matching JS SDK format) - assert config["url"] == "sfu-dpk-london-test.stream-io-video.com" - assert config["bundlePolicy"] == "max-bundle" - assert "iceServers" in config - - # ICE servers should use 'credential' not 'password' - server = config["iceServers"][0] - assert server["urls"] == ["turn:sfu-test.stream-io-video.com:3478"] - assert server["username"] == "user123" - assert server["credential"] == "secret456" - assert "password" not in server - - def test_get_connection_config_multiple_servers(self): - """Should include all ICE servers in trace config.""" - ice_servers = [ - {"urls": ["turn:server1.com:3478"], "username": "u1", "password": "p1"}, - {"urls": ["turn:server2.com:3478"], "username": "u2", "password": "p2"}, - {"urls": ["turns:server3.com:443"], "username": "u3", "password": "p3"}, - ] - - manager = self._create_manager_with_credentials(ice_servers) - config = manager._get_connection_config() - - assert len(config["iceServers"]) == 3 diff --git a/tests/rtc/test_stats_tracer.py b/tests/rtc/test_stats_tracer.py deleted file mode 100644 index a7d9cd50..00000000 --- a/tests/rtc/test_stats_tracer.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Tests for StatsTracer class.""" - -import pytest -from aiortc import RTCPeerConnection, RTCConfiguration, RTCIceServer - - -class TestStatsTracer: - """Integration tests for StatsTracer using real aiortc peer connections.""" - - @pytest.mark.asyncio - async def test_publisher_stats_collection(self): - """Publisher stats should include RTP, codec, and ICE candidate stats with enhancements.""" - from getstream.video.rtc.stats_tracer import StatsTracer - - config = RTCConfiguration( - iceServers=[RTCIceServer(urls=["stun:stun.l.google.com:19302"])] - ) - pc = RTCPeerConnection(configuration=config) - - try: - # Add audio and video transceivers - pc.addTransceiver("audio", direction="sendonly") - pc.addTransceiver("video", direction="sendonly") - offer = await pc.createOffer() - await pc.setLocalDescription(offer) - - # Brief wait for ICE gathering - import asyncio - - await asyncio.sleep(0.3) - - tracer = StatsTracer(pc, "publisher") - result = await tracer.get() - - # Collect stats by type - stats_by_type = {} - for k, v in result.delta.items(): - if isinstance(v, dict) and "type" in v: - stat_type = v["type"] - stats_by_type.setdefault(stat_type, []).append(v) - - # Should have outbound-rtp for audio and video - assert "outbound-rtp" in stats_by_type - outbound = stats_by_type["outbound-rtp"] - kinds = {s.get("kind") for s in outbound} - assert "audio" in kinds - assert "video" in kinds - - # Publisher should NOT have inbound-rtp (filtered) - assert "inbound-rtp" not in stats_by_type - - # RTP stats should have mid, mediaType, codecId - for rtp in outbound: - assert "mid" in rtp - assert rtp.get("mediaType") == rtp.get("kind") - assert "codecId" in rtp - # codecId should reference an existing codec entry - assert rtp["codecId"] in result.delta - - # Should have codec entries - assert "codec" in stats_by_type - codec = stats_by_type["codec"][0] - assert "payloadType" in codec - assert "mimeType" in codec - assert "clockRate" in codec - assert codec.get("codecType") == "encode" - - # Should have local ICE candidates - assert "local-candidate" in stats_by_type - candidate = stats_by_type["local-candidate"][0] - assert "address" in candidate - assert "port" in candidate - assert "protocol" in candidate - - finally: - await pc.close() - - @pytest.mark.asyncio - async def test_subscriber_filters_outbound_rtp(self): - """Subscriber stats should filter out outbound-rtp but include codec stats.""" - from getstream.video.rtc.stats_tracer import StatsTracer - - pc = RTCPeerConnection() - - try: - pc.addTransceiver("audio", direction="recvonly") - offer = await pc.createOffer() - await pc.setLocalDescription(offer) - - tracer = StatsTracer(pc, "subscriber") - result = await tracer.get() - - stat_types = { - v.get("type") for v in result.delta.values() if isinstance(v, dict) - } - # Subscriber shouldn't have outbound-rtp - assert "outbound-rtp" not in stat_types - # But should have codec stats (with decode type) - assert "codec" in stat_types - - codec = next( - v - for v in result.delta.values() - if isinstance(v, dict) and v.get("type") == "codec" - ) - assert codec.get("codecType") == "decode" - - finally: - await pc.close() diff --git a/tests/rtc/test_subscriber_drain.py b/tests/rtc/test_subscriber_drain.py deleted file mode 100644 index 44db03b5..00000000 --- a/tests/rtc/test_subscriber_drain.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Tests for SubscriberPeerConnection video drain behavior.""" - -from unittest.mock import AsyncMock, Mock - -import pytest -from aiortc.contrib.media import MediaRelay - -from getstream.video.rtc.pc import SubscriberPeerConnection - - -@pytest.fixture -def subscriber_pc(): - """Create a SubscriberPeerConnection bypassing heavy parent inits.""" - pc = SubscriberPeerConnection.__new__(SubscriberPeerConnection) - pc.connection = Mock() - pc._closed = False - pc._drain_video_frames = True - pc.track_map = {} - pc.video_frame_trackers = {} - pc._video_drains = {} - pc._background_tasks = set() - pc._listeners = {} - return pc - - -class TestAddTrackSubscriberStopsDrain: - @pytest.mark.asyncio - async def test_blackhole_stopped_when_subscriber_added(self, subscriber_pc): - track_id = "user123:video:0" - relay = MediaRelay() - original_track = Mock() - subscriber_pc.track_map[track_id] = (relay, original_track) - - blackhole = Mock() - blackhole.stop = AsyncMock() - drain_proxy = Mock() - subscriber_pc._video_drains[track_id] = (blackhole, Mock(), drain_proxy) - - subscriber_pc.add_track_subscriber(track_id) - - blackhole.stop.assert_called_once() - drain_proxy.stop.assert_called_once() - assert track_id not in subscriber_pc._video_drains - - def test_no_error_when_no_drain_exists(self, subscriber_pc): - track_id = "user123:video:0" - relay = MediaRelay() - original_track = Mock() - subscriber_pc.track_map[track_id] = (relay, original_track) - - result = subscriber_pc.add_track_subscriber(track_id) - assert result is not None diff --git a/tests/rtc/test_tracer.py b/tests/rtc/test_tracer.py deleted file mode 100644 index 0b826cfd..00000000 --- a/tests/rtc/test_tracer.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Tests for Tracer class and sanitize_value function.""" - -from datetime import datetime - - -from getstream.video.rtc.tracer import Tracer, sanitize_value - - -class TestSanitizeValue: - """Tests for sanitize_value function.""" - - def test_primitives_unchanged(self): - assert sanitize_value("hello") == "hello" - assert sanitize_value(42) == 42 - assert sanitize_value(3.14) == 3.14 - assert sanitize_value(True) is True - assert sanitize_value(None) is None - - def test_datetime_to_milliseconds(self): - dt = datetime(2024, 1, 15, 12, 30, 45, 500000) # 500ms - result = sanitize_value(dt) - assert isinstance(result, int) - # Should be milliseconds timestamp - assert result == int(dt.timestamp() * 1000) - - def test_protobuf_message_to_dict(self): - from getstream.video.rtc.pb.stream.video.sfu.models import models_pb2 - - participant = models_pb2.Participant() - participant.user_id = "test-user" - participant.session_id = "session-123" - - result = sanitize_value(participant) - assert isinstance(result, dict) - assert result["userId"] == "test-user" # camelCase from protobuf - assert result["sessionId"] == "session-123" - - def test_nested_dict(self): - data = { - "outer": { - "inner": datetime(2024, 1, 1, 0, 0, 0), - "value": 123, - } - } - result = sanitize_value(data) - assert isinstance(result["outer"]["inner"], int) - assert result["outer"]["value"] == 123 - - def test_list(self): - data = [datetime(2024, 1, 1), "string", 42] - result = sanitize_value(data) - assert isinstance(result[0], int) - assert result[1] == "string" - assert result[2] == 42 - - def test_bytes_to_hex(self): - data = b"\xde\xad\xbe\xef" - result = sanitize_value(data) - assert result == "deadbeef" - - -class TestTracer: - """Tests for Tracer class.""" - - def test_trace_adds_record_with_correct_format(self): - tracer = Tracer() - tracer.trace("test-tag", "0-pub", {"key": "value"}) - - assert len(tracer) == 1 - snapshot = tracer.take() - record = snapshot.snapshot[0] - - assert record[0] == "test-tag" # tag - assert record[1] == "0-pub" # id - assert record[2] == {"key": "value"} # data - assert isinstance(record[3], int) # timestamp_ms - - def test_take_clears_buffer_and_returns_snapshot(self): - tracer = Tracer() - tracer.trace("event1", "id1", {}) - tracer.trace("event2", "id2", {}) - - assert len(tracer) == 2 - - snapshot = tracer.take() - assert len(snapshot.snapshot) == 2 - assert len(tracer) == 0 # Buffer cleared - - def test_rollback_restores_buffer(self): - tracer = Tracer() - tracer.trace("event1", "id1", {}) - - snapshot = tracer.take() - assert len(tracer) == 0 - - # Add new trace after take - tracer.trace("event2", "id2", {}) - assert len(tracer) == 1 - - # Rollback prepends original traces - snapshot.rollback() - assert len(tracer) == 2 - - # Original trace should be first - new_snapshot = tracer.take() - assert new_snapshot.snapshot[0][0] == "event1" - assert new_snapshot.snapshot[1][0] == "event2" - - def test_disabled_tracer_ignores_traces(self): - tracer = Tracer() - tracer.trace("event1", "id1", {}) - assert len(tracer) == 1 - - tracer.set_enabled(False) - tracer.trace("event2", "id2", {}) - assert len(tracer) == 0 # Cleared when disabled - - tracer.set_enabled(True) - tracer.trace("event3", "id3", {}) - assert len(tracer) == 1 - - def test_dispose_clears_buffer(self): - tracer = Tracer() - tracer.trace("event1", "id1", {}) - tracer.trace("event2", "id2", {}) - assert len(tracer) == 2 - - tracer.dispose() - assert len(tracer) == 0 diff --git a/tests/rtc/test_track_util.py b/tests/rtc/test_track_util.py index 40854a54..bf83fcaa 100644 --- a/tests/rtc/test_track_util.py +++ b/tests/rtc/test_track_util.py @@ -1,350 +1,17 @@ -import asyncio import pytest import numpy as np -from unittest.mock import Mock import logging from getstream.video.rtc.track_util import ( - AudioTrackHandler, PcmData, AudioFormat, FrameResampler, ) -import getstream.video.rtc.track_util as track_util logger = logging.getLogger(__name__) -class MockAudioFrame: - """Mock audio frame that simulates aiortc AudioFrame behavior.""" - - def __init__(self, sample_rate=48000, samples=None, channels=1): - self.sample_rate = sample_rate - self.channels = channels - - # Create mock layout with channels - self.layout = Mock() - self.layout.channels = [Mock() for _ in range(channels)] - - # If no samples provided, create 20ms worth of samples - if samples is None: - num_samples = int(sample_rate * 0.02) # 20ms - if channels == 1: - samples = np.random.randint(-1000, 1000, num_samples, dtype=np.int16) - else: - samples = np.random.randint( - -1000, 1000, (num_samples, channels), dtype=np.int16 - ) - - self._samples = samples - - def to_ndarray(self, format=AudioFormat.S16): - """Mock the to_ndarray method that's causing issues in newer PyAV.""" - if format == "s16": - return self._samples - else: - raise ValueError(f"Unsupported format: {format}") - - -class MockAudioTrack: - """Mock audio track that simulates aiortc MediaStreamTrack behavior.""" - - def __init__(self, frames=None, should_raise=None): - self.frames = frames or [] - self.frame_index = 0 - self.should_raise = should_raise - self.kind = "audio" - - async def recv(self): - """Mock recv method that returns frames or raises exceptions.""" - if self.should_raise: - if isinstance(self.should_raise, Exception): - raise self.should_raise - elif self.should_raise == "cancelled": - raise asyncio.CancelledError() - - if self.frame_index >= len(self.frames): - # Simulate end of stream - raise Exception("End of stream") - - frame = self.frames[self.frame_index] - self.frame_index += 1 - return frame - - -@pytest.fixture -def _monkeypatch_audio_frame(monkeypatch): - """Monkey-patch `track_util.av.AudioFrame` so `isinstance(..., av.AudioFrame)` - succeeds for the real PyAV class *and* any duck-typed test double that - exposes the minimal interface we rely on (``sample_rate``, ``layout``, - and ``to_ndarray``). This covers both ``MockAudioFrame`` and ad-hoc - classes like ``BrokenAudioFrame`` defined inside individual tests. - - Note: This fixture is NOT auto-used because some tests (like resampling tests) - need the real PyAV AudioFrame.from_ndarray method. - """ - - real_audio_frame_cls = track_util.av.AudioFrame - - class _AudioFrameMeta(type): - def __instancecheck__(cls, instance): # type: ignore[override] - # Real PyAV frames pass straight through. - if isinstance(instance, real_audio_frame_cls): - return True - - # Duck-typed fallback used by tests. - return ( - hasattr(instance, "sample_rate") - and hasattr(instance, "layout") - and callable(getattr(instance, "to_ndarray", None)) - ) - - class _AudioFrame(metaclass=_AudioFrameMeta): - pass - - monkeypatch.setattr(track_util.av, "AudioFrame", _AudioFrame, raising=True) - - -@pytest.mark.asyncio -async def test_audio_track_handler_initialization(_monkeypatch_audio_frame): - """Test that AudioTrackHandler can be initialized properly.""" - mock_track = MockAudioTrack() - callback = Mock() - - handler = AudioTrackHandler(mock_track, callback) - - assert handler.track == mock_track - assert handler._on_audio_frame == callback - assert handler._task is None - assert handler._stopped is False - - -@pytest.mark.asyncio -async def test_audio_track_handler_start_stop(_monkeypatch_audio_frame): - """Test that AudioTrackHandler can start and stop properly.""" - mock_track = MockAudioTrack() - callback = Mock() - - handler = AudioTrackHandler(mock_track, callback) - - # Start the handler - await handler.start() - assert handler._task is not None - - # Stop the handler - await handler.stop() - assert handler._stopped is True - assert handler._task is None - - -@pytest.mark.asyncio -async def test_run_track_mono_audio(_monkeypatch_audio_frame): - """Test _run_track with mono audio frames.""" - # Create mock frames with mono audio - frames = [ - MockAudioFrame( - sample_rate=48000, - samples=np.array([1, 2, 3, 4], dtype=np.int16), - channels=1, - ), - MockAudioFrame( - sample_rate=48000, - samples=np.array([5, 6, 7, 8], dtype=np.int16), - channels=1, - ), - ] - - mock_track = MockAudioTrack(frames=frames) - received_data = [] - - def callback(pcm_data: PcmData): - received_data.append(pcm_data) - - handler = AudioTrackHandler(mock_track, callback) - - # Start processing - await handler.start() - - # Wait a bit for processing - await asyncio.sleep(0.1) - - # Stop processing - await handler.stop() - - # Verify we received the expected data - assert len(received_data) == 2 - - # Check first frame - assert received_data[0].sample_rate == 48000 - assert received_data[0].format == "s16" - np.testing.assert_array_equal( - received_data[0].samples, np.array([1, 2, 3, 4], dtype=np.int16) - ) - - # Check second frame - assert received_data[1].sample_rate == 48000 - assert received_data[1].format == "s16" - np.testing.assert_array_equal( - received_data[1].samples, np.array([5, 6, 7, 8], dtype=np.int16) - ) - - -@pytest.mark.asyncio -async def test_run_track_stereo_audio(_monkeypatch_audio_frame): - """Test _run_track with stereo audio frames (should convert to mono).""" - # Create mock frames with stereo audio - stereo_samples = np.array( - [[1, 2], [3, 4], [5, 6], [7, 8]], dtype=np.int16 - ) # 4 samples, 2 channels - frames = [ - MockAudioFrame(sample_rate=48000, samples=stereo_samples, channels=2), - ] - - mock_track = MockAudioTrack(frames=frames) - received_data = [] - - def callback(pcm_data: PcmData): - received_data.append(pcm_data) - - handler = AudioTrackHandler(mock_track, callback) - - # Start processing - await handler.start() - - # Wait a bit for processing - await asyncio.sleep(0.1) - - # Stop processing - await handler.stop() - - # Verify we received the expected data - assert len(received_data) == 1 - - # Check that stereo was converted to mono (average of channels) - expected_mono = np.array( - [1.5, 3.5, 5.5, 7.5], dtype=np.int16 - ) # Average of each stereo pair - assert received_data[0].sample_rate == 48000 - assert received_data[0].format == "s16" - np.testing.assert_array_equal(received_data[0].samples, expected_mono) - - -@pytest.mark.asyncio -async def test_run_track_wrong_sample_rate(_monkeypatch_audio_frame): - """Test _run_track raises error for unsupported sample rate.""" - # Create mock frame with wrong sample rate - frames = [ - MockAudioFrame( - sample_rate=16000, - samples=np.array([1, 2, 3, 4], dtype=np.int16), - channels=1, - ), - ] - - mock_track = MockAudioTrack(frames=frames) - received_data = [] - - def callback(pcm_data: PcmData): - received_data.append(pcm_data) - - handler = AudioTrackHandler(mock_track, callback) - - # Start processing - await handler.start() - - # Wait a bit for processing - await asyncio.sleep(0.1) - - # Stop processing - this should raise TypeError due to wrong sample rate - with pytest.raises(TypeError, match="only 48000 sample rate supported"): - await handler.stop() - - -@pytest.mark.asyncio -async def test_run_track_cancelled_error(_monkeypatch_audio_frame): - """Test _run_track handles CancelledError gracefully.""" - mock_track = MockAudioTrack(should_raise="cancelled") - received_data = [] - - def callback(pcm_data: PcmData): - received_data.append(pcm_data) - - handler = AudioTrackHandler(mock_track, callback) - - # Start processing - await handler.start() - - # Wait a bit for processing - await asyncio.sleep(0.1) - - # Stop processing - await handler.stop() - - # Should not have received any data due to cancellation - assert len(received_data) == 0 - - -@pytest.mark.asyncio -async def test_run_track_general_exception(_monkeypatch_audio_frame): - """Test _run_track handles general exceptions gracefully.""" - mock_track = MockAudioTrack(should_raise=Exception("Test error")) - received_data = [] - - def callback(pcm_data: PcmData): - received_data.append(pcm_data) - - handler = AudioTrackHandler(mock_track, callback) - - # Start processing - await handler.start() - - # Wait a bit for processing - await asyncio.sleep(0.1) - - # Stop processing - await handler.stop() - - # Should not have received any data due to exception - assert len(received_data) == 0 - - -@pytest.mark.asyncio -async def test_run_track_to_ndarray_error(_monkeypatch_audio_frame): - """Test _run_track handles to_ndarray method errors (PyAV compatibility issue).""" - - # Create a mock frame that raises an error when to_ndarray is called - class BrokenAudioFrame: - def __init__(self): - self.sample_rate = 48000 - self.layout = Mock() - self.layout.channels = [Mock()] # Single channel - - def to_ndarray(self, format=AudioFormat.S16): - raise AttributeError("'AudioFrame' object has no attribute 'to_ndarray'") - - frames = [BrokenAudioFrame()] - mock_track = MockAudioTrack(frames=frames) - received_data = [] - - def callback(pcm_data: PcmData): - received_data.append(pcm_data) - - handler = AudioTrackHandler(mock_track, callback) - - # Start processing - await handler.start() - - # Wait a bit for processing - await asyncio.sleep(0.1) - - # Stop processing - await handler.stop() - - # Should not have received any data due to to_ndarray error - assert len(received_data) == 0 - - @pytest.mark.asyncio class TestPcmDataChunking: """Test PcmData chunking and sliding window methods.""" @@ -1008,9 +675,62 @@ def test_resample_preserves_tone_at_target_rate(self, resampler): freqs = np.fft.rfftfreq(len(signal), 1 / 48000) assert abs(freqs[np.argmax(spectrum)] - SINE_FREQ) < 20 + def test_upsampling_leaves_no_image_of_the_tone( + self, resampler: FrameResampler + ) -> None: + # From 16 kHz to 48 kHz, a 7 kHz tone has its image at 16 - 7 = 9 kHz. + out = _frames_samples( + [ + frame + for chunk in _sine_chunks(7000, 16000, total_ms=1000) + for frame in resampler.resample(chunk) + ] + ).astype(np.float64) + + middle = out[len(out) // 4 : len(out) // 4 + 24000] + spectrum = np.abs(np.fft.rfft(middle * np.blackman(len(middle)))) + freqs = np.fft.rfftfreq(len(middle), 1 / 48000) + image = spectrum[np.abs(freqs - 9000) < 50].max() + assert 20 * np.log10(image / spectrum.max()) < -80 + + def test_stereo_input_is_averaged_to_mono(self, resampler: FrameResampler) -> None: + pcm = PcmData( + samples=np.array([np.full(480, 100), np.full(480, 300)], dtype=np.int16), + sample_rate=48000, + format=AudioFormat.S16, + channels=2, + ) + + out = _frames_samples(resampler.resample(pcm)) + + assert out.size == 480 + assert np.all(out == 200) + + def test_mono_input_is_copied_to_each_stereo_channel(self) -> None: + r = FrameResampler(rate=48000, layout="stereo", format="s16", frame_size=0) + ramp = np.arange(480, dtype=np.int16) + + out = _frames_samples( + r.resample( + PcmData( + samples=ramp, + sample_rate=48000, + format=AudioFormat.S16, + channels=1, + ) + ) + ).reshape(-1, 2) + + assert np.array_equal(out[:, 0], ramp) + assert np.array_equal(out[:, 1], ramp) + + def test_rejects_an_output_format_other_than_s16(self) -> None: + with pytest.raises(ValueError): + FrameResampler(rate=48000, layout="mono", format="f32", frame_size=0) + def test_matched_rate_is_passed_through(self, resampler): # The resampler targets 48000 mono s16; a chunk already at that rate needs no - # resampling and comes back unchanged (same-rate swr is a lossless passthrough). + # resampling and comes back unchanged (same-rate input is copied). chunk = _sine_chunks(SINE_FREQ, 48000, total_ms=20)[0] out = _frames_samples(resampler.resample(chunk)) assert np.array_equal(out, chunk.samples.reshape(-1)) @@ -1028,7 +748,7 @@ def test_resample_is_usable_after_flush(self, resampler): resampler.resample(chunk) resampler.flush() - # swr is at EOF after a flush; the wrapper must rebuild, not raise EOFError. + # A flush must not end the resampler: later input is still resampled. produced = _frames_samples( [frame for chunk in chunks for frame in resampler.resample(chunk)] ) diff --git a/tests/rtc/test_video_forwarder.py b/tests/rtc/test_video_forwarder.py new file mode 100644 index 00000000..0cbbecc8 --- /dev/null +++ b/tests/rtc/test_video_forwarder.py @@ -0,0 +1,42 @@ +import asyncio + +import getstream_rtc +import pytest + +from getstream.video.rtc.video_forwarder import VideoForwarder +from tests.rtc.video_source import FrameSource + + +class TestVideoForwarder: + async def test_run_returns_when_the_source_ends(self): + source = FrameSource([(320, 240)]) + + await asyncio.wait_for( + VideoForwarder(source, getstream_rtc.LocalVideoTrack.vp9()).run(), timeout=5 + ) + + async def test_odd_and_changed_sizes_are_written(self): + # The SDK encoder rejects odd sizes; the forwarder scales them. + source = FrameSource([(321, 241), (640, 480), (320, 240)]) + + await asyncio.wait_for( + VideoForwarder(source, getstream_rtc.LocalVideoTrack.vp9()).run(), timeout=5 + ) + + +@pytest.mark.integration +class TestVideoForwarderOnACall: + async def test_run_returns_when_the_call_is_left( + self, joined_call: getstream_rtc.Call + ): + target = getstream_rtc.LocalVideoTrack.vp9() + await joined_call.publish_video(target) + # 10 s of video, so only the stopped track can end the run in time. + source = FrameSource([(320, 240)] * 150) + run = asyncio.create_task(VideoForwarder(source, target).run()) + await asyncio.sleep(0.5) + + # Leaving stops the published track. + await joined_call.leave() + + await asyncio.wait_for(run, timeout=5) diff --git a/tests/rtc/test_video_properties.py b/tests/rtc/test_video_properties.py deleted file mode 100644 index acdbcfdf..00000000 --- a/tests/rtc/test_video_properties.py +++ /dev/null @@ -1,216 +0,0 @@ -import pytest -import os -import asyncio -from aiortc.contrib.media import MediaPlayer -from getstream.stream import Stream -from getstream.video.rtc.connection_utils import prepare_video_track_info -from getstream.video.rtc.track_util import ( - detect_video_properties, - BufferedMediaTrack, -) -from getstream.video.rtc.pb.stream.video.sfu.models.models_pb2 import TRACK_TYPE_VIDEO - - -pytestmark = pytest.mark.integration - - -@pytest.mark.asyncio -async def test_detect_video_properties(): - """Test that video properties are correctly detected from a video file.""" - # Path to the test video file - file_path = "/Users/tommaso/src/data-samples/video/SampleVideo_1280x720_30mb.mp4" - - # Skip test if the file doesn't exist - if not os.path.exists(file_path): - pytest.skip(f"Test media file not found at {file_path}") - - buffered_track = None - - try: - # Create a media player from the file - player = MediaPlayer(file_path) - - # Ensure we have a video track - assert player.video is not None, "No video track found in test file" - - # Create a buffered track for testing - buffered_track = BufferedMediaTrack(player.video) - - # Test the property detection function - properties = await detect_video_properties(buffered_track) - - # Verify detected properties match expectations - assert "width" in properties, "Width not detected" - assert "height" in properties, "Height not detected" - assert "fps" in properties, "FPS not detected" - assert "bitrate" in properties, "Bitrate not estimated" - - # Since we know this specific file is 1280x720, verify those dimensions - assert properties["width"] == 1280, ( - f"Incorrect width detected: {properties['width']}" - ) - assert properties["height"] == 720, ( - f"Incorrect height detected: {properties['height']}" - ) - - # FPS should be a reasonable value (typically around 25-30 for this test video) - assert 20 <= properties["fps"] <= 60, f"Unexpected FPS: {properties['fps']}" - - # Calculate expected bitrate range - # For 1280x720 at 25fps with 0.08 bits per pixel: - # 1280 * 720 * 25 * 0.08 / 1000 ≈ 1843 kbps - # Allow some margin for different FPS values - fps = properties["fps"] - expected_bitrate = int(1280 * 720 * fps * 0.08 / 1000) - margin = 100 # Allow some variance - - # Verify bitrate is within expected range - assert ( - expected_bitrate - margin - <= properties["bitrate"] - <= expected_bitrate + margin - ), ( - f"Unexpected bitrate: {properties['bitrate']}, expected around {expected_bitrate}" - ) - - print(f"Detected video properties: {properties}") - print(f"Expected bitrate calculation: {expected_bitrate} kbps") - - finally: - # Ensure resources are cleaned up properly - if buffered_track: - buffered_track.stop() - # MediaPlayer has no public stop(); rely on GC and buffered_track.stop() - # Give some time for cleanup - await asyncio.sleep(0.1) - - -@pytest.mark.asyncio -async def test_buffered_media_track(): - """Test that BufferedMediaTrack correctly buffers frames.""" - # Path to the test video file - file_path = "/Users/tommaso/src/data-samples/video/SampleVideo_1280x720_30mb.mp4" - - # Skip test if the file doesn't exist - if not os.path.exists(file_path): - pytest.skip(f"Test media file not found at {file_path}") - - player = None - buffered_track = None - - try: - # Create a media player from the file - player = MediaPlayer(file_path) - - # Ensure we have a video track - assert player.video is not None, "No video track found in test file" - - # Create a buffered track - buffered_track = BufferedMediaTrack(player.video) - - # Peek at a frame - this should buffer the frame but not consume it - frame1 = await buffered_track.peek() - - # Peek again should return the same frame without consuming it - frame2 = await buffered_track.peek() - assert frame1 is frame2, "Peeking multiple times should return the same frame" - - # Now receive a frame, which should be the one we peeked - frame3 = await buffered_track.recv() - assert frame1 is frame3, "Receiving after peek should return the peeked frame" - - # Receiving again should get a new frame since buffer is now empty - frame4 = await buffered_track.recv() - assert frame3 is not frame4, ( - "Receiving after consuming the buffer should get a new frame" - ) - - # Now let's get multiple frames in sequence to see if PTS values work as expected - frame5 = await buffered_track.recv() - frame6 = await buffered_track.recv() - - # Check if the frames have presentation timestamps - assert hasattr(frame5, "pts"), "Frame should have a pts attribute" - assert hasattr(frame6, "pts"), "Frame should have a pts attribute" - assert hasattr(frame5, "time_base"), "Frame should have a time_base attribute" - - # If pts are properly set, the second frame's pts should be greater than the first - if frame5.pts is not None and frame6.pts is not None: - assert frame6.pts > frame5.pts, "Frame PTS should increase over time" - - # Calculate delta in seconds - if hasattr(frame5, "time_base") and frame5.time_base: - delta_ticks = frame6.pts - frame5.pts - delta_seconds = delta_ticks * frame5.time_base - estimated_fps = int(1 / delta_seconds) if delta_seconds > 0 else 0 - - # For a normal video, this should be a reasonable FPS value - assert 20 <= estimated_fps <= 60, ( - f"Calculated FPS ({estimated_fps}) outside expected range" - ) - print(f"Calculated FPS from frames: {estimated_fps}") - - finally: - # Ensure resources are cleaned up properly - if buffered_track: - buffered_track.stop() - # MediaPlayer has no public stop(); rely on GC and buffered_track.stop() - # Give some time for cleanup - await asyncio.sleep(0.1) - - -@pytest.mark.asyncio -async def test_prepare_video_track_info(client: Stream): - """Test that prepare_video_track_info correctly processes a video track.""" - # Path to the test video file - file_path = "/Users/tommaso/src/data-samples/video/SampleVideo_1280x720_30mb.mp4" - - # Skip test if the file doesn't exist - if not os.path.exists(file_path): - pytest.skip(f"Test media file not found at {file_path}") - - buffered_track = None - - try: - # Create a media player from the file - player = MediaPlayer(file_path) - - # Ensure we have a video track - assert player.video is not None, "No video track found in test file" - - # Prepare video track info directly using the utility function - track_info, buffered_track = await prepare_video_track_info(player.video) - - # Verify the returned values - assert track_info is not None, "Track info not returned" - assert buffered_track is not None, "Buffered track not returned" - - # Verify track_info fields - assert track_info.track_type == TRACK_TYPE_VIDEO, "Incorrect track type" - assert len(track_info.layers) == 1, "Expected one video layer" - - layer = track_info.layers[0] - assert layer.video_dimension.width == 1280, ( - f"Incorrect width: {layer.video_dimension.width}" - ) - assert layer.video_dimension.height == 720, ( - f"Incorrect height: {layer.video_dimension.height}" - ) - assert 20 <= layer.fps <= 60, f"Unexpected FPS: {layer.fps}" - - # Verify that the buffered track wraps the original track - assert buffered_track.id == player.video.id, "Track ID mismatch" - assert buffered_track.kind == "video", "Track kind mismatch" - - print(f"Track info: {track_info}") - print( - f"Video layer properties: width={layer.video_dimension.width}, height={layer.video_dimension.height}, fps={layer.fps}, bitrate={layer.bitrate}" - ) - - finally: - # Clean up resources - if buffered_track: - buffered_track.stop() - # No connection was created in this test - # Give some time for cleanup - await asyncio.sleep(0.1) diff --git a/tests/rtc/video_source.py b/tests/rtc/video_source.py new file mode 100644 index 00000000..faeb7ef8 --- /dev/null +++ b/tests/rtc/video_source.py @@ -0,0 +1,34 @@ +import asyncio +import fractions + +import aiortc +import av +import numpy as np + +VIDEO_CLOCK_RATE = 90000 + + +class FrameSource(aiortc.MediaStreamTrack): + """An aiortc video track that gives grey frames of the given sizes, one + per `1 / fps` seconds, and then ends.""" + + kind = "video" + + def __init__(self, sizes: list[tuple[int, int]], fps: int = 15) -> None: + super().__init__() + self._sizes = list(sizes) + self._fps = fps + self._pts = 0 + + async def recv(self) -> av.VideoFrame: + if self.readyState != "live" or not self._sizes: + raise aiortc.mediastreams.MediaStreamError + await asyncio.sleep(1 / self._fps) + width, height = self._sizes.pop(0) + frame = av.VideoFrame.from_ndarray( + np.full((height, width, 3), 128, dtype=np.uint8), format="rgb24" + ) + frame.pts = self._pts + frame.time_base = fractions.Fraction(1, VIDEO_CLOCK_RATE) + self._pts += VIDEO_CLOCK_RATE // self._fps + return frame diff --git a/tests/test_audio_stream_track.py b/tests/test_audio_stream_track.py index 134249c8..8556cfdc 100644 --- a/tests/test_audio_stream_track.py +++ b/tests/test_audio_stream_track.py @@ -64,7 +64,7 @@ async def test_initialization(self): @pytest.mark.asyncio async def test_write_and_recv_basic(self): """Test basic write and receive functionality.""" - track = AudioStreamTrack(sample_rate=48000, channels=1, format="s16") + track = AudioStreamTrack(sample_rate=48000, channels=1, format="s16", pace=True) # Create 40ms of audio data (should be enough for 2 frames) samples_40ms = int(0.04 * 48000) # 1920 samples @@ -99,7 +99,7 @@ def test_rejects_f32_output(self): @pytest.mark.asyncio async def test_format_conversion(self): """Test that write converts input to the track's s16 output format.""" - track = AudioStreamTrack(sample_rate=48000, channels=1, format="s16") + track = AudioStreamTrack(sample_rate=48000, channels=1, format="s16", pace=True) # Write a full 20ms of f32 input at half scale. pcm = PcmData( @@ -119,7 +119,7 @@ async def test_format_conversion(self): @pytest.mark.asyncio async def test_sample_rate_conversion(self): """Test that write resamples audio to the track's output rate.""" - track = AudioStreamTrack(sample_rate=48000, channels=1, format="s16") + track = AudioStreamTrack(sample_rate=48000, channels=1, format="s16", pace=True) # 20ms at 16kHz = 320 samples; upsampled to 48kHz that is one 960-sample frame. samples_16k = np.zeros(320, dtype=np.int16) @@ -140,7 +140,9 @@ async def test_sample_rate_conversion(self): async def test_channel_conversion(self): """Test mono to stereo and stereo to mono conversion.""" # Mono input to a stereo track -> stereo output frame. - track_stereo = AudioStreamTrack(sample_rate=48000, channels=2, format="s16") + track_stereo = AudioStreamTrack( + sample_rate=48000, channels=2, format="s16", pace=True + ) samples_mono = np.zeros(960, dtype=np.int16) # 20ms mono pcm_mono = PcmData( @@ -157,7 +159,9 @@ async def test_channel_conversion(self): assert frame.samples == 960 # Stereo input to a mono track -> mono output frame. - track_mono = AudioStreamTrack(sample_rate=48000, channels=1, format="s16") + track_mono = AudioStreamTrack( + sample_rate=48000, channels=1, format="s16", pace=True + ) samples_stereo = np.zeros((2, 960), dtype=np.int16) # 20ms stereo pcm_stereo = PcmData( @@ -178,7 +182,11 @@ async def test_buffer_overflow(self): """Test that the buffer drops old data when it exceeds max size.""" # 100ms cap holds at most five 20ms frames. track = AudioStreamTrack( - sample_rate=48000, channels=1, format="s16", audio_buffer_size_ms=100 + sample_rate=48000, + channels=1, + format="s16", + audio_buffer_size_ms=100, + pace=True, ) # Write 200ms (ten 20ms frames) of non-zero audio; the cap must drop the oldest. @@ -204,7 +212,7 @@ async def test_buffer_overflow(self): @pytest.mark.asyncio async def test_silence_emission(self): """Test that recv emits silence when buffer is empty.""" - track = AudioStreamTrack(sample_rate=48000, channels=1, format="s16") + track = AudioStreamTrack(sample_rate=48000, channels=1, format="s16", pace=True) # Receive frame without writing any data frame = await track.recv() @@ -218,7 +226,7 @@ async def test_silence_emission(self): async def test_partial_frame_buffered_until_flush(self): """Sub-frame writes are held; a final flush emits them, padded by recv to a full frame.""" # Without a final flush, a sub-frame write (10ms) emits nothing. - held = AudioStreamTrack(sample_rate=48000, channels=1, format="s16") + held = AudioStreamTrack(sample_rate=48000, channels=1, format="s16", pace=True) await held.write( PcmData( samples=np.full(480, 100, dtype=np.int16), @@ -234,7 +242,9 @@ async def test_partial_frame_buffered_until_flush(self): # With final=True the buffered partial is flushed; recv pads it to a full 20ms # frame: the first 10ms carries the data, the last 10ms is silence. - flushed = AudioStreamTrack(sample_rate=48000, channels=1, format="s16") + flushed = AudioStreamTrack( + sample_rate=48000, channels=1, format="s16", pace=True + ) await flushed.write( PcmData( samples=np.full(480, 100, dtype=np.int16), @@ -253,7 +263,7 @@ async def test_partial_frame_buffered_until_flush(self): @pytest.mark.asyncio async def test_flush(self): """Test that flush drops pending audio so recv falls back to silence.""" - track = AudioStreamTrack(sample_rate=48000, channels=1, format="s16") + track = AudioStreamTrack(sample_rate=48000, channels=1, format="s16", pace=True) # Buffer 40ms of non-zero audio (two frames). samples = np.full(1920, 100, dtype=np.int16) @@ -273,7 +283,7 @@ async def test_flush(self): @pytest.mark.asyncio async def test_frame_timing(self, monkeypatch): """Test that frames are emitted at 20ms intervals.""" - track = AudioStreamTrack(sample_rate=48000, channels=1, format="s16") + track = AudioStreamTrack(sample_rate=48000, channels=1, format="s16", pace=True) # Write enough data for multiple frames samples = np.zeros(4800, dtype=np.int16) # 100ms of audio @@ -302,7 +312,7 @@ async def test_frame_timing(self, monkeypatch): @pytest.mark.asyncio async def test_first_frame_is_immediate_and_pts_zero(self): """The first recv anchors the clock: it returns without waiting, pts=0.""" - track = AudioStreamTrack(sample_rate=48000, channels=1, format="s16") + track = AudioStreamTrack(sample_rate=48000, channels=1, format="s16", pace=True) start = time.time() frame = await track.recv() @@ -314,7 +324,7 @@ async def test_first_frame_is_immediate_and_pts_zero(self): @pytest.mark.asyncio async def test_pts_advances_by_one_frame_per_recv(self): """Successive frames carry pts stepping by samples_per_frame (960 @48kHz).""" - track = AudioStreamTrack(sample_rate=48000, channels=1, format="s16") + track = AudioStreamTrack(sample_rate=48000, channels=1, format="s16", pace=True) pts = [(await track.recv()).pts for _ in range(4)] @@ -327,7 +337,7 @@ async def test_slow_consumer_does_not_accumulate_drift(self): Pacing anchors each pts to the start time, not the previous frame, so a stalled consumer catches up rather than stretching the timeline. """ - track = AudioStreamTrack(sample_rate=48000, channels=1, format="s16") + track = AudioStreamTrack(sample_rate=48000, channels=1, format="s16", pace=True) await track.recv() # anchor the clock at pts=0 await asyncio.sleep(0.1) # fall ~5 frames behind @@ -344,7 +354,7 @@ async def test_slow_consumer_does_not_accumulate_drift(self): @pytest.mark.asyncio async def test_pacing_is_independent_of_buffer_content(self): """Pacing advances even while starved: silence frames still step pts by a frame.""" - track = AudioStreamTrack(sample_rate=48000, channels=1, format="s16") + track = AudioStreamTrack(sample_rate=48000, channels=1, format="s16", pace=True) # No write(): every recv starves to silence, but the clock keeps ticking. frames = [await track.recv() for _ in range(3)] @@ -360,7 +370,7 @@ async def test_flush_during_inflight_write_discards_pre_flush_audio(self): flush that lands mid-write can't be followed by frames the write computed before it. """ - track = AudioStreamTrack(sample_rate=48000, channels=1, format="s16") + track = AudioStreamTrack(sample_rate=48000, channels=1, format="s16", pace=True) # Prime the resampler with a sub-frame chunk of a distinctive "old" value: # held in the resampler, nothing enqueued yet. @@ -399,7 +409,7 @@ async def test_flush_during_inflight_write_discards_pre_flush_audio(self): @pytest.mark.asyncio async def test_continuous_streaming(self): """Test continuous audio streaming scenario.""" - track = AudioStreamTrack(sample_rate=48000, channels=1, format="s16") + track = AudioStreamTrack(sample_rate=48000, channels=1, format="s16", pace=True) # Simulate continuous writing and reading write_task = asyncio.create_task(self._continuous_writer(track)) @@ -443,7 +453,7 @@ async def _continuous_reader(self, track): @pytest.mark.asyncio async def test_recv_consumes_frames_in_order(self): """recv hands out buffered frames one at a time, in FIFO order, then starves.""" - track = AudioStreamTrack(sample_rate=48000, channels=1, format="s16") + track = AudioStreamTrack(sample_rate=48000, channels=1, format="s16", pace=True) # Two distinguishable 20ms frames: first all 100, then all 200. samples = np.concatenate( @@ -469,7 +479,11 @@ async def test_recv_consumes_frames_in_order(self): async def test_buffer_overflow_drops_oldest_across_writes(self): """Overflow across multiple writes drops the oldest frames, keeping the newest.""" track = AudioStreamTrack( - sample_rate=48000, channels=1, format="s16", audio_buffer_size_ms=100 + sample_rate=48000, + channels=1, + format="s16", + audio_buffer_size_ms=100, + pace=True, ) # First 40ms (value 100), then 200ms (value 200); total far exceeds the 100ms cap. @@ -501,7 +515,7 @@ async def test_buffer_overflow_drops_oldest_across_writes(self): @pytest.mark.asyncio async def test_media_stream_error(self): """Test that MediaStreamError is raised when track is not live.""" - track = AudioStreamTrack(sample_rate=48000, channels=1, format="s16") + track = AudioStreamTrack(sample_rate=48000, channels=1, format="s16", pace=True) # Stop the track track.stop() @@ -515,7 +529,7 @@ async def test_media_stream_error(self): async def test_resampling_is_high_quality(self, src_rate, dst_rate): # A clean tone resampled up or down must stay clean: the fundamental should # dominate, i.e. high SINAD. Linear interpolation lands around ~28 dB here. - track = AudioStreamTrack(sample_rate=dst_rate, channels=1) + track = AudioStreamTrack(sample_rate=dst_rate, channels=1, pace=True) for chunk in _sine_chunks(SINE_FREQ, src_rate, total_ms=500): await track.write(chunk) drained = np.concatenate( @@ -530,3 +544,43 @@ async def test_resampling_is_high_quality(self, src_rate, dst_rate): noise = spectrum.sum() - fundamental - spectrum[freqs < 30].sum() assert 10 * np.log10(fundamental / noise) > 60 + + @pytest.mark.asyncio + async def test_unpaced_recv_returns_queued_frames_at_once(self): + track = AudioStreamTrack() + await track.write( + PcmData( + samples=np.zeros(4800, dtype=np.int16), + sample_rate=48000, + format="s16", + channels=1, + ) + ) + + start = time.monotonic() + frames = [await track.recv() for _ in range(5)] + + assert time.monotonic() - start < 0.05 + assert [frame.pts for frame in frames] == [0, 960, 1920, 2880, 3840] + + @pytest.mark.asyncio + async def test_stop_ends_a_waiting_unpaced_recv(self): + track = AudioStreamTrack() + recv = asyncio.create_task(track.recv()) + await asyncio.sleep(0.01) + + track.stop() + + with pytest.raises(aiortc.mediastreams.MediaStreamError): + await asyncio.wait_for(recv, timeout=1) + + @pytest.mark.asyncio + async def test_wait_for_flush_returns_after_flush(self): + track = AudioStreamTrack() + waiter = asyncio.create_task(track.wait_for_flush()) + await asyncio.sleep(0.01) + assert not waiter.done() + + await track.flush() + + await asyncio.wait_for(waiter, timeout=1) diff --git a/tests/test_connection_utils.py b/tests/test_connection_utils.py deleted file mode 100644 index 6fa71814..00000000 --- a/tests/test_connection_utils.py +++ /dev/null @@ -1,129 +0,0 @@ -import pytest -from unittest.mock import AsyncMock, MagicMock, patch - -from getstream.video.rtc.connection_utils import ( - connect_websocket, - ConnectionOptions, - SfuConnectionError, - SfuJoinError, - join_call_coordinator_request, -) -from getstream.video.rtc.signaling import SignalingError -from getstream.video.rtc.pb.stream.video.sfu.models import models_pb2 - - -@pytest.fixture -def mock_ws_client(): - """Patch WebSocketClient and yield the mock instance.""" - with patch("getstream.video.rtc.connection_utils.WebSocketClient") as mock_ws_cls: - mock_ws = AsyncMock() - mock_ws_cls.return_value = mock_ws - yield mock_ws - - -@pytest.fixture -def coordinator_request(): - """Set up a mock coordinator client that captures the request body.""" - mock_call = AsyncMock() - mock_call.call_type = "default" - mock_call.id = "test_call" - mock_call.client.stream.api_key = "key" - mock_call.client.stream.api_secret = "secret" - mock_call.client.stream.base_url = "https://test.url" - - captured_body = {} - mock_client = AsyncMock() - - async def capture_post(*args, **kwargs): - captured_body.update(kwargs.get("json", {})) - return AsyncMock() - - mock_client.post = capture_post - mock_call.client.stream.clone_for_token = MagicMock(return_value=mock_client) - yield mock_call, captured_body - - -class TestConnectWebsocket: - @pytest.mark.asyncio - async def test_raises_sfu_join_error_on_sfu_full(self, mock_ws_client): - """connect_websocket should raise SfuJoinError when SFU is full.""" - sfu_error = models_pb2.Error( - code=models_pb2.ERROR_CODE_SFU_FULL, - message="server is full", - should_retry=True, - ) - mock_ws_client.connect = AsyncMock( - side_effect=SignalingError( - "Connection failed: server is full", error=sfu_error - ) - ) - - with pytest.raises(SfuJoinError) as exc_info: - await connect_websocket( - token="test_token", - ws_url="wss://test.url", - session_id="test_session", - options=ConnectionOptions(), - ) - - assert exc_info.value.error_code == models_pb2.ERROR_CODE_SFU_FULL - assert exc_info.value.should_retry is True - assert isinstance(exc_info.value, SfuConnectionError) - - @pytest.mark.asyncio - async def test_non_retryable_error_propagates_as_signaling_error( - self, mock_ws_client - ): - """Non-retryable SignalingError should not become SfuJoinError.""" - sfu_error = models_pb2.Error( - code=models_pb2.ERROR_CODE_PERMISSION_DENIED, - message="permission denied", - should_retry=False, - ) - mock_ws_client.connect = AsyncMock( - side_effect=SignalingError( - "Connection failed: permission denied", error=sfu_error - ) - ) - - with pytest.raises(SignalingError) as exc_info: - await connect_websocket( - token="test_token", - ws_url="wss://test.url", - session_id="test_session", - options=ConnectionOptions(), - ) - - assert not isinstance(exc_info.value, SfuJoinError) - - -class TestJoinCallCoordinatorRequest: - @pytest.mark.asyncio - async def test_includes_migrating_from_in_body(self, coordinator_request): - """migrating_from and migrating_from_list should be included in the request body.""" - mock_call, captured_body = coordinator_request - - await join_call_coordinator_request( - call=mock_call, - user_id="user1", - location="auto", - migrating_from="sfu-london-1", - migrating_from_list=["sfu-london-1", "sfu-paris-2"], - ) - - assert captured_body["migrating_from"] == "sfu-london-1" - assert captured_body["migrating_from_list"] == ["sfu-london-1", "sfu-paris-2"] - - @pytest.mark.asyncio - async def test_omits_migrating_from_when_not_provided(self, coordinator_request): - """migrating_from should not appear in body when not provided.""" - mock_call, captured_body = coordinator_request - - await join_call_coordinator_request( - call=mock_call, - user_id="user1", - location="auto", - ) - - assert "migrating_from" not in captured_body - assert "migrating_from_list" not in captured_body diff --git a/tests/test_signaling.py b/tests/test_signaling.py deleted file mode 100644 index 688d1bb0..00000000 --- a/tests/test_signaling.py +++ /dev/null @@ -1,519 +0,0 @@ -import asyncio -import pytest -import threading -from unittest.mock import patch - -import websocket - -from getstream.video.rtc.signaling import WebSocketClient, SignalingError -from getstream.video.rtc.pb.stream.video.sfu.event import events_pb2 -from getstream.video.rtc.pb.stream.video.sfu.models import models_pb2 - - -class TestWebSocketClient: - @pytest.fixture - def join_request(self): - """Create a sample JoinRequest for testing.""" - return events_pb2.JoinRequest(token="test_token", session_id="test_session_id") - - @pytest.fixture - def mock_websocket(self): - """Create a mock WebSocketApp.""" - with patch("getstream.video.rtc.signaling.websocket.WebSocketApp") as mock_ws: - # Configure the mock - instance = mock_ws.return_value - - # Make run_forever block until the WebSocket is closed - def run_forever_side_effect(): - # Keep running until the WebSocket is closed - while hasattr(instance, "_keep_running") and instance._keep_running: - import time - - time.sleep(0.01) - - instance.run_forever.side_effect = run_forever_side_effect - instance._keep_running = True - - # Mock close to stop the run_forever loop - def close_side_effect(): - instance._keep_running = False - - instance.close.side_effect = close_side_effect - yield mock_ws - - @pytest.mark.asyncio - async def test_connect_success(self, join_request, mock_websocket): - """Test successful connection flow.""" - # Create client - client = WebSocketClient( - "wss://test.url", join_request, asyncio.get_running_loop() - ) - - # Prepare a successful response - success_response = events_pb2.SfuEvent() - success_response.join_response.reconnected = False - success_response_bytes = success_response.SerializeToString() - - # Start connection - connect_task = asyncio.create_task(client.connect()) - - # Wait briefly for connection to start - await asyncio.sleep(0.1) - - # Verify WebSocketApp was created with correct params - mock_websocket.assert_called_once_with( - "wss://test.url", - on_open=client._on_open, - on_message=client._on_message, - on_error=client._on_error, - on_close=client._on_close, - ) - - # Simulate websocket open - on_open_callback = mock_websocket.call_args[1]["on_open"] - on_open_callback(mock_websocket.return_value) - - # Verify join request was sent - mock_websocket.return_value.send.assert_called_once() - sent_bytes = mock_websocket.return_value.send.call_args[0][0] - - # Parse the sent message to verify it's a JoinRequest - sent_message = events_pb2.SfuRequest() - sent_message.ParseFromString(sent_bytes) - assert sent_message.HasField("join_request") - assert sent_message.join_request.token == "test_token" - - # Simulate receiving join response - on_message_callback = mock_websocket.call_args[1]["on_message"] - on_message_callback(mock_websocket.return_value, success_response_bytes) - - # Wait for connect to complete - response = await connect_task - - # Verify response - assert response.HasField("join_response") - assert response.join_response.reconnected is False - - # Clean up - client.close() - - @pytest.mark.asyncio - async def test_connect_error(self, join_request, mock_websocket): - """Test connection with error response.""" - # Create client - client = WebSocketClient( - "wss://test.url", join_request, asyncio.get_running_loop() - ) - - # Prepare an error response - error_response = events_pb2.SfuEvent() - error_response.error.error.code = 403 - error_response.error.error.message = "Unauthorized" - error_response_bytes = error_response.SerializeToString() - - # Start connection - connect_task = asyncio.create_task(client.connect()) - - # Wait briefly for connection to start - await asyncio.sleep(0.1) - - # Simulate websocket open - on_open_callback = mock_websocket.call_args[1]["on_open"] - on_open_callback(mock_websocket.return_value) - - # Simulate receiving error - on_message_callback = mock_websocket.call_args[1]["on_message"] - on_message_callback(mock_websocket.return_value, error_response_bytes) - - # Verify connect throws an exception - with pytest.raises(SignalingError, match="Connection failed: Unauthorized"): - await connect_task - - # Clean up - client.close() - - @pytest.mark.asyncio - async def test_connect_error_preserves_error_code( - self, join_request, mock_websocket - ): - """Test that SignalingError preserves the SFU error code.""" - client = WebSocketClient( - "wss://test.url", join_request, asyncio.get_running_loop() - ) - - # Prepare an SFU FULL error response - error_response = events_pb2.SfuEvent() - error_response.error.error.code = models_pb2.ERROR_CODE_SFU_FULL - error_response.error.error.message = "server is full" - error_response_bytes = error_response.SerializeToString() - - connect_task = asyncio.create_task(client.connect()) - await asyncio.sleep(0.1) - - on_open_callback = mock_websocket.call_args[1]["on_open"] - on_open_callback(mock_websocket.return_value) - - on_message_callback = mock_websocket.call_args[1]["on_message"] - on_message_callback(mock_websocket.return_value, error_response_bytes) - - with pytest.raises(SignalingError) as exc_info: - await connect_task - - assert exc_info.value.error is not None - assert exc_info.value.error.code == models_pb2.ERROR_CODE_SFU_FULL - - client.close() - - @pytest.mark.asyncio - async def test_websocket_error_during_connect(self, join_request, mock_websocket): - """Test WebSocket error during connection.""" - # Create client - client = WebSocketClient( - "wss://test.url", join_request, asyncio.get_running_loop() - ) - - # Start connection - connect_task = asyncio.create_task(client.connect()) - - # Wait briefly for connection to start - await asyncio.sleep(0.1) - - # Simulate websocket error - on_error_callback = mock_websocket.call_args[1]["on_error"] - on_error_callback(mock_websocket.return_value, Exception("Connection failed")) - - # Verify connect throws an exception - with pytest.raises( - SignalingError, match="Connection failed: Connection failed" - ): - await connect_task - - # Clean up - client.close() - - @pytest.mark.asyncio - async def test_event_callbacks(self, join_request, mock_websocket): - """Test event callbacks are executed correctly.""" - # Create client - client = WebSocketClient( - "wss://test.url", join_request, asyncio.get_running_loop() - ) - - # Prepare a successful join response first - join_response = events_pb2.SfuEvent() - join_response.join_response.reconnected = False - join_response_bytes = join_response.SerializeToString() - - # Create a callback tracking variable - callback_results = {"participant_joined": 0, "wildcard": 0} - - # Register event handlers - async def on_participant_joined(event): - callback_results["participant_joined"] += 1 - assert event.participant.user_id == "test_user" - - async def on_any_event(event): - callback_results["wildcard"] += 1 - - client.on_event("participant_joined", on_participant_joined) - client.on_event("*", on_any_event) - - # Start connection - connect_task = asyncio.create_task(client.connect()) - - # Wait briefly for connection to start - await asyncio.sleep(0.1) - - # Simulate websocket open and join response - on_open_callback = mock_websocket.call_args[1]["on_open"] - on_open_callback(mock_websocket.return_value) - - on_message_callback = mock_websocket.call_args[1]["on_message"] - on_message_callback(mock_websocket.return_value, join_response_bytes) - - # Wait for connect to complete - await connect_task - - # Prepare a participant joined event - participant_event = events_pb2.SfuEvent() - participant_event.participant_joined.participant.user_id = "test_user" - participant_bytes = participant_event.SerializeToString() - - # Simulate receiving participant joined event - on_message_callback(mock_websocket.return_value, participant_bytes) - - # Wait for callbacks to be processed - await asyncio.sleep(0.1) - - # Verify callbacks were called - assert callback_results["participant_joined"] == 1 - - # Clean up - client.close() - - @pytest.mark.asyncio - async def test_thread_usage(self, join_request, mock_websocket): - """Test that thread is properly used and managed.""" - # Create client - client = WebSocketClient( - "wss://test.url", join_request, asyncio.get_running_loop() - ) - - # Mock threading.Thread to capture usage - with patch("threading.Thread", wraps=threading.Thread) as mock_thread: - # Start connection - connect_task = asyncio.create_task(client.connect()) - - # Wait briefly for thread creation - await asyncio.sleep(0.1) - - # Verify thread was created with correct target - # Check that at least one thread was created with the WebSocket target - websocket_thread_calls = [ - call - for call in mock_thread.call_args_list - if call[1].get("target") == client._run_websocket - ] - assert len(websocket_thread_calls) == 1 - - # Simulate join response to complete connection - join_response = events_pb2.SfuEvent() - join_response.join_response.reconnected = False - on_message_callback = mock_websocket.call_args[1]["on_message"] - on_message_callback( - mock_websocket.return_value, join_response.SerializeToString() - ) - - # Complete the connect - await connect_task - - # Verify thread is running - assert client.thread is not None - assert client.thread.is_alive() - - # Clean up - client.close() - - # Thread should be joined during close - assert not client.running - - @pytest.mark.asyncio - async def test_connection_lost_emitted_on_unexpected_close( - self, join_request, mock_websocket - ): - """An unexpected WS close after handshake emits a `connection_lost` event. - - The owner (ConnectionManager) relies on this signal to drive - reconnection — without it, a transient socket drop leaves the - session hanging until the frontend times out. - """ - client = WebSocketClient( - "wss://test.url", join_request, asyncio.get_running_loop() - ) - - received: list[str] = [] - - async def on_lost(reason): - received.append(reason) - - client.on_event("connection_lost", on_lost) - - # Complete handshake so we're past the initial connect phase. - join_response = events_pb2.SfuEvent() - join_response.join_response.reconnected = False - - connect_task = asyncio.create_task(client.connect()) - await asyncio.sleep(0.1) - - on_open_callback = mock_websocket.call_args[1]["on_open"] - on_open_callback(mock_websocket.return_value) - - on_message_callback = mock_websocket.call_args[1]["on_message"] - on_message_callback( - mock_websocket.return_value, join_response.SerializeToString() - ) - await connect_task - - # Simulate the remote dropping the connection (not user-initiated). - on_close_callback = mock_websocket.call_args[1]["on_close"] - on_close_callback(mock_websocket.return_value, 1006, "abnormal closure") - - # Allow the threadsafe-scheduled emit to run on the loop. - await asyncio.sleep(0.1) - - assert len(received) == 1, ( - f"expected exactly one connection_lost event, got {received}" - ) - assert "1006" in received[0], ( - f"reason should mention the close code, got {received[0]!r}" - ) - - client.close() - - @pytest.mark.asyncio - async def test_connection_lost_emitted_once_on_error_then_close( - self, join_request, mock_websocket - ): - """A chained ``_on_error`` → ``_on_close`` fires exactly one event. - - websocket-client typically delivers an error followed by a close - for the same drop. Consumers (including the SDK-public re-emit via - ``ConnectionManager`` wildcard) expect one notification per - disconnect, not one per callback. - """ - client = WebSocketClient( - "wss://test.url", join_request, asyncio.get_running_loop() - ) - - received: list[str] = [] - - async def on_lost(reason): - received.append(reason) - - client.on_event("connection_lost", on_lost) - - join_response = events_pb2.SfuEvent() - join_response.join_response.reconnected = False - - connect_task = asyncio.create_task(client.connect()) - await asyncio.sleep(0.1) - - on_open_callback = mock_websocket.call_args[1]["on_open"] - on_open_callback(mock_websocket.return_value) - - on_message_callback = mock_websocket.call_args[1]["on_message"] - on_message_callback( - mock_websocket.return_value, join_response.SerializeToString() - ) - await connect_task - - # Error first, then close — what websocket-client actually does on - # most transport-level failures. - on_error_callback = mock_websocket.call_args[1]["on_error"] - on_error_callback(mock_websocket.return_value, Exception("boom")) - - on_close_callback = mock_websocket.call_args[1]["on_close"] - on_close_callback(mock_websocket.return_value, 1006, "abnormal closure") - - await asyncio.sleep(0.1) - - assert len(received) == 1, ( - f"expected exactly one connection_lost event for the chain, got {received}" - ) - - client.close() - - @pytest.mark.asyncio - async def test_call_ended_suppresses_connection_lost(self, join_request): - """A `call_ended` event before close suppresses `connection_lost`. - - The SFU sends `call_ended`, then closes the WS with no distinguishing - status (close code and reason are both None). Without an application- - level intent signal this would be indistinguishable from a TCP drop - and trip a spurious reconnect. - """ - client = WebSocketClient( - "wss://test.url", join_request, asyncio.get_running_loop() - ) - received: list[str] = [] - - async def on_lost(reason): - received.append(reason) - - client.on_event("connection_lost", on_lost) - - call_ended_event = events_pb2.SfuEvent() - call_ended_event.call_ended.reason = models_pb2.CALL_ENDED_REASON_ENDED - - client._on_message(None, call_ended_event.SerializeToString()) - client._on_close(None, None, None) - - await asyncio.sleep(0.05) - - assert received == [] - - @pytest.mark.asyncio - async def test_close_frame_during_handshake_raises( - self, join_request, mock_websocket - ): - """An ABNF close frame mid-handshake unblocks `connect()` with an error. - - websocket-client surfaces a server-side close as both an error (an - `ABNF` close frame in `_on_error`) and an `_on_close` callback. If - this happens before the first SFU event, neither callback sets - `first_message_event` by default, so `connect()` would hang on the - executor wait. The synthetic error path in `_on_error` is what - unblocks it. - """ - client = WebSocketClient( - "wss://test.url", join_request, asyncio.get_running_loop() - ) - - connect_task = asyncio.create_task(client.connect()) - await asyncio.sleep(0.1) - - # Server closed the socket before sending any SFU event. - close_frame = websocket.ABNF() - close_frame.opcode = websocket.ABNF.OPCODE_CLOSE - - on_error_callback = mock_websocket.call_args[1]["on_error"] - on_error_callback(mock_websocket.return_value, close_frame) - - with pytest.raises(SignalingError, match="Connection failed"): - await connect_task - - client.close() - - @pytest.mark.asyncio - async def test_on_open_traces_ws_open_and_join_request( - self, join_request, mock_websocket - ): - """Test that _on_open traces signal.ws.open and joinRequest events.""" - from getstream.video.rtc.tracer import Tracer - - tracer = Tracer() - client = WebSocketClient( - "wss://test.url", - join_request, - asyncio.get_running_loop(), - tracer=tracer, - sfu_id_fn=lambda: "test-sfu-id", - ) - - # Prepare a successful response - success_response = events_pb2.SfuEvent() - success_response.join_response.reconnected = False - success_response_bytes = success_response.SerializeToString() - - # Start connection - connect_task = asyncio.create_task(client.connect()) - await asyncio.sleep(0.1) - - # Simulate websocket open (this triggers tracing) - on_open_callback = mock_websocket.call_args[1]["on_open"] - on_open_callback(mock_websocket.return_value) - - # Simulate receiving join response - on_message_callback = mock_websocket.call_args[1]["on_message"] - on_message_callback(mock_websocket.return_value, success_response_bytes) - - await connect_task - - # Check tracer recorded the events - snapshot = tracer.take() - tags = [record[0] for record in snapshot.snapshot] - - assert "signal.ws.open" in tags, f"Expected signal.ws.open in {tags}" - assert "joinRequest" in tags, f"Expected joinRequest in {tags}" - - # Verify signal.ws.open has correct structure - ws_open_record = next(r for r in snapshot.snapshot if r[0] == "signal.ws.open") - assert ws_open_record[1] == "test-sfu-id" - assert ws_open_record[2] == {"isTrusted": True} - - # Verify joinRequest has payload - join_record = next(r for r in snapshot.snapshot if r[0] == "joinRequest") - assert join_record[1] == "test-sfu-id" - assert "requestPayload" in join_record[2] - - client.close() diff --git a/uv.lock b/uv.lock index c981d044..7a26cce5 100644 --- a/uv.lock +++ b/uv.lock @@ -9,9 +9,11 @@ resolution-markers = [ "python_full_version < '3.11'", ] -[options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. -exclude-newer-span = "P7D" +[manifest] +members = [ + "getstream", + "getstream-rtc", +] [[package]] name = "aiohappyeyeballs" @@ -923,19 +925,12 @@ webrtc = [ { name = "aiohttp" }, { name = "aiortc" }, { name = "av" }, + { name = "getstream-rtc" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, - { name = "ping3" }, { name = "protobuf" }, { name = "python-dateutil" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "scipy", version = "1.16.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, - { name = "soundfile" }, - { name = "structlog" }, - { name = "tenacity" }, { name = "twirp" }, - { name = "websocket-client" }, - { name = "websockets" }, ] [package.dev-dependencies] @@ -966,6 +961,7 @@ requires-dist = [ { name = "aiortc", marker = "extra == 'webrtc'", specifier = ">=1.14.0,<1.15.0" }, { name = "av", marker = "extra == 'webrtc'", specifier = ">=14.2.0,<17" }, { name = "dataclasses-json", specifier = ">=0.6.0,<0.7" }, + { name = "getstream-rtc", marker = "extra == 'webrtc'", editable = "getstream-rtc" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "ijson", specifier = ">=3.4.0" }, { name = "marshmallow", specifier = ">=3.21.0,<4" }, @@ -975,7 +971,6 @@ requires-dist = [ { name = "openai", extras = ["realtime"], marker = "extra == 'openai-realtime'", specifier = ">=1.93.0" }, { name = "opentelemetry-api", marker = "extra == 'telemetry'", specifier = ">=1.26.0" }, { name = "opentelemetry-sdk", marker = "extra == 'telemetry'", specifier = ">=1.26.0" }, - { name = "ping3", marker = "extra == 'webrtc'", specifier = ">=4.0.8" }, { name = "protobuf", specifier = ">=6.31.1" }, { name = "protobuf", marker = "extra == 'webrtc'", specifier = ">=4.25.1" }, { name = "protobuf", marker = "extra == 'webrtc'", specifier = ">=6.31.1" }, @@ -985,17 +980,8 @@ requires-dist = [ { name = "pyjwt", specifier = ">=2.8.0,<3" }, { name = "python-dateutil", specifier = ">=2.8.2" }, { name = "python-dateutil", marker = "extra == 'webrtc'", specifier = ">=2.8.2" }, - { name = "scipy", marker = "python_full_version >= '3.14' and extra == 'webrtc'", specifier = ">=1.16.1,<1.17" }, - { name = "scipy", marker = "python_full_version < '3.14' and extra == 'webrtc'", specifier = ">=1.15.3,<1.16" }, - { name = "scipy", marker = "extra == 'webrtc'", specifier = ">=1.15.3" }, - { name = "soundfile", marker = "extra == 'webrtc'", specifier = ">=0.13.1" }, - { name = "structlog", marker = "extra == 'webrtc'", specifier = "<24" }, - { name = "tenacity", marker = "extra == 'webrtc'", specifier = ">=9.1.2" }, { name = "twirp" }, { name = "twirp", marker = "extra == 'webrtc'" }, - { name = "websocket-client", marker = "extra == 'webrtc'", specifier = ">=1.8.0" }, - { name = "websockets", marker = "extra == 'webrtc'", specifier = ">=15.0.1" }, - { name = "websockets", marker = "extra == 'webrtc'", specifier = ">=15.0.1,<16" }, ] provides-extras = ["openai-realtime", "webrtc", "telemetry"] @@ -1017,11 +1003,23 @@ dev = [ { name = "pytest-timeout", specifier = ">=2.4.0" }, { name = "python-dateutil", specifier = ">=2.8.2,<3" }, { name = "python-dotenv", specifier = ">=1.1.1,<2" }, - { name = "ruff", specifier = ">=0.12.1" }, + { name = "ruff", specifier = ">=0.12.1,<0.16" }, { name = "torch", specifier = ">=2.7.0" }, { name = "torchaudio", specifier = ">=2.7.0" }, ] +[[package]] +name = "getstream-rtc" +version = "0.1.0" +source = { editable = "getstream-rtc" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, +] + +[package.metadata] +requires-dist = [{ name = "numpy", specifier = ">=2.2.6" }] + [[package]] name = "google-crc32c" version = "1.8.0" @@ -2410,15 +2408,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, ] -[[package]] -name = "ping3" -version = "5.1.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0d/e5/702dfb79e74990585d502734065f8a1610d18473bbd4bd18e4058abe9dbc/ping3-5.1.5.tar.gz", hash = "sha256:6c99bc844e0b7dbc5c9765e8b530140daf1ccd2112c99db01ab79831bd8081cd", size = 19995, upload-time = "2025-08-10T16:07:30.582Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/34/03/2580e349f6cab60865456db4b8984a50c91c98e88fc5055a3cd7c30485a5/ping3-5.1.5-py3-none-any.whl", hash = "sha256:9503164d054b87b01a6bd0df83a49c29e7c8427eb06940f874ebd4ed2d8ba8f6", size = 15945, upload-time = "2025-08-10T16:07:28.777Z" }, -] - [[package]] name = "platformdirs" version = "4.9.4" @@ -3046,142 +3035,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" }, ] -[[package]] -name = "scipy" -version = "1.15.3" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.13.*'", - "python_full_version == '3.12.*'", - "python_full_version == '3.11.*'", - "python_full_version < '3.11'", -] -dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/2f/4966032c5f8cc7e6a60f1b2e0ad686293b9474b65246b0c642e3ef3badd0/scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c", size = 38702770, upload-time = "2025-05-08T16:04:20.849Z" }, - { url = "https://files.pythonhosted.org/packages/a0/6e/0c3bf90fae0e910c274db43304ebe25a6b391327f3f10b5dcc638c090795/scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253", size = 30094511, upload-time = "2025-05-08T16:04:27.103Z" }, - { url = "https://files.pythonhosted.org/packages/ea/b1/4deb37252311c1acff7f101f6453f0440794f51b6eacb1aad4459a134081/scipy-1.15.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:aef683a9ae6eb00728a542b796f52a5477b78252edede72b8327a886ab63293f", size = 22368151, upload-time = "2025-05-08T16:04:31.731Z" }, - { url = "https://files.pythonhosted.org/packages/38/7d/f457626e3cd3c29b3a49ca115a304cebb8cc6f31b04678f03b216899d3c6/scipy-1.15.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:1c832e1bd78dea67d5c16f786681b28dd695a8cb1fb90af2e27580d3d0967e92", size = 25121732, upload-time = "2025-05-08T16:04:36.596Z" }, - { url = "https://files.pythonhosted.org/packages/db/0a/92b1de4a7adc7a15dcf5bddc6e191f6f29ee663b30511ce20467ef9b82e4/scipy-1.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:263961f658ce2165bbd7b99fa5135195c3a12d9bef045345016b8b50c315cb82", size = 35547617, upload-time = "2025-05-08T16:04:43.546Z" }, - { url = "https://files.pythonhosted.org/packages/8e/6d/41991e503e51fc1134502694c5fa7a1671501a17ffa12716a4a9151af3df/scipy-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2abc762b0811e09a0d3258abee2d98e0c703eee49464ce0069590846f31d40", size = 37662964, upload-time = "2025-05-08T16:04:49.431Z" }, - { url = "https://files.pythonhosted.org/packages/25/e1/3df8f83cb15f3500478c889be8fb18700813b95e9e087328230b98d547ff/scipy-1.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed7284b21a7a0c8f1b6e5977ac05396c0d008b89e05498c8b7e8f4a1423bba0e", size = 37238749, upload-time = "2025-05-08T16:04:55.215Z" }, - { url = "https://files.pythonhosted.org/packages/93/3e/b3257cf446f2a3533ed7809757039016b74cd6f38271de91682aa844cfc5/scipy-1.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5380741e53df2c566f4d234b100a484b420af85deb39ea35a1cc1be84ff53a5c", size = 40022383, upload-time = "2025-05-08T16:05:01.914Z" }, - { url = "https://files.pythonhosted.org/packages/d1/84/55bc4881973d3f79b479a5a2e2df61c8c9a04fcb986a213ac9c02cfb659b/scipy-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:9d61e97b186a57350f6d6fd72640f9e99d5a4a2b8fbf4b9ee9a841eab327dc13", size = 41259201, upload-time = "2025-05-08T16:05:08.166Z" }, - { url = "https://files.pythonhosted.org/packages/96/ab/5cc9f80f28f6a7dff646c5756e559823614a42b1939d86dd0ed550470210/scipy-1.15.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:993439ce220d25e3696d1b23b233dd010169b62f6456488567e830654ee37a6b", size = 38714255, upload-time = "2025-05-08T16:05:14.596Z" }, - { url = "https://files.pythonhosted.org/packages/4a/4a/66ba30abe5ad1a3ad15bfb0b59d22174012e8056ff448cb1644deccbfed2/scipy-1.15.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:34716e281f181a02341ddeaad584205bd2fd3c242063bd3423d61ac259ca7eba", size = 30111035, upload-time = "2025-05-08T16:05:20.152Z" }, - { url = "https://files.pythonhosted.org/packages/4b/fa/a7e5b95afd80d24313307f03624acc65801846fa75599034f8ceb9e2cbf6/scipy-1.15.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3b0334816afb8b91dab859281b1b9786934392aa3d527cd847e41bb6f45bee65", size = 22384499, upload-time = "2025-05-08T16:05:24.494Z" }, - { url = "https://files.pythonhosted.org/packages/17/99/f3aaddccf3588bb4aea70ba35328c204cadd89517a1612ecfda5b2dd9d7a/scipy-1.15.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:6db907c7368e3092e24919b5e31c76998b0ce1684d51a90943cb0ed1b4ffd6c1", size = 25152602, upload-time = "2025-05-08T16:05:29.313Z" }, - { url = "https://files.pythonhosted.org/packages/56/c5/1032cdb565f146109212153339f9cb8b993701e9fe56b1c97699eee12586/scipy-1.15.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:721d6b4ef5dc82ca8968c25b111e307083d7ca9091bc38163fb89243e85e3889", size = 35503415, upload-time = "2025-05-08T16:05:34.699Z" }, - { url = "https://files.pythonhosted.org/packages/bd/37/89f19c8c05505d0601ed5650156e50eb881ae3918786c8fd7262b4ee66d3/scipy-1.15.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39cb9c62e471b1bb3750066ecc3a3f3052b37751c7c3dfd0fd7e48900ed52982", size = 37652622, upload-time = "2025-05-08T16:05:40.762Z" }, - { url = "https://files.pythonhosted.org/packages/7e/31/be59513aa9695519b18e1851bb9e487de66f2d31f835201f1b42f5d4d475/scipy-1.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:795c46999bae845966368a3c013e0e00947932d68e235702b5c3f6ea799aa8c9", size = 37244796, upload-time = "2025-05-08T16:05:48.119Z" }, - { url = "https://files.pythonhosted.org/packages/10/c0/4f5f3eeccc235632aab79b27a74a9130c6c35df358129f7ac8b29f562ac7/scipy-1.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18aaacb735ab38b38db42cb01f6b92a2d0d4b6aabefeb07f02849e47f8fb3594", size = 40047684, upload-time = "2025-05-08T16:05:54.22Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a7/0ddaf514ce8a8714f6ed243a2b391b41dbb65251affe21ee3077ec45ea9a/scipy-1.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:ae48a786a28412d744c62fd7816a4118ef97e5be0bee968ce8f0a2fba7acf3bb", size = 41246504, upload-time = "2025-05-08T16:06:00.437Z" }, - { url = "https://files.pythonhosted.org/packages/37/4b/683aa044c4162e10ed7a7ea30527f2cbd92e6999c10a8ed8edb253836e9c/scipy-1.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac6310fdbfb7aa6612408bd2f07295bcbd3fda00d2d702178434751fe48e019", size = 38766735, upload-time = "2025-05-08T16:06:06.471Z" }, - { url = "https://files.pythonhosted.org/packages/7b/7e/f30be3d03de07f25dc0ec926d1681fed5c732d759ac8f51079708c79e680/scipy-1.15.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:185cd3d6d05ca4b44a8f1595af87f9c372bb6acf9c808e99aa3e9aa03bd98cf6", size = 30173284, upload-time = "2025-05-08T16:06:11.686Z" }, - { url = "https://files.pythonhosted.org/packages/07/9c/0ddb0d0abdabe0d181c1793db51f02cd59e4901da6f9f7848e1f96759f0d/scipy-1.15.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:05dc6abcd105e1a29f95eada46d4a3f251743cfd7d3ae8ddb4088047f24ea477", size = 22446958, upload-time = "2025-05-08T16:06:15.97Z" }, - { url = "https://files.pythonhosted.org/packages/af/43/0bce905a965f36c58ff80d8bea33f1f9351b05fad4beaad4eae34699b7a1/scipy-1.15.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:06efcba926324df1696931a57a176c80848ccd67ce6ad020c810736bfd58eb1c", size = 25242454, upload-time = "2025-05-08T16:06:20.394Z" }, - { url = "https://files.pythonhosted.org/packages/56/30/a6f08f84ee5b7b28b4c597aca4cbe545535c39fe911845a96414700b64ba/scipy-1.15.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05045d8b9bfd807ee1b9f38761993297b10b245f012b11b13b91ba8945f7e45", size = 35210199, upload-time = "2025-05-08T16:06:26.159Z" }, - { url = "https://files.pythonhosted.org/packages/0b/1f/03f52c282437a168ee2c7c14a1a0d0781a9a4a8962d84ac05c06b4c5b555/scipy-1.15.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271e3713e645149ea5ea3e97b57fdab61ce61333f97cfae392c28ba786f9bb49", size = 37309455, upload-time = "2025-05-08T16:06:32.778Z" }, - { url = "https://files.pythonhosted.org/packages/89/b1/fbb53137f42c4bf630b1ffdfc2151a62d1d1b903b249f030d2b1c0280af8/scipy-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cfd56fc1a8e53f6e89ba3a7a7251f7396412d655bca2aa5611c8ec9a6784a1e", size = 36885140, upload-time = "2025-05-08T16:06:39.249Z" }, - { url = "https://files.pythonhosted.org/packages/2e/2e/025e39e339f5090df1ff266d021892694dbb7e63568edcfe43f892fa381d/scipy-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ff17c0bb1cb32952c09217d8d1eed9b53d1463e5f1dd6052c7857f83127d539", size = 39710549, upload-time = "2025-05-08T16:06:45.729Z" }, - { url = "https://files.pythonhosted.org/packages/e6/eb/3bf6ea8ab7f1503dca3a10df2e4b9c3f6b3316df07f6c0ded94b281c7101/scipy-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:52092bc0472cfd17df49ff17e70624345efece4e1a12b23783a1ac59a1b728ed", size = 40966184, upload-time = "2025-05-08T16:06:52.623Z" }, - { url = "https://files.pythonhosted.org/packages/73/18/ec27848c9baae6e0d6573eda6e01a602e5649ee72c27c3a8aad673ebecfd/scipy-1.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c620736bcc334782e24d173c0fdbb7590a0a436d2fdf39310a8902505008759", size = 38728256, upload-time = "2025-05-08T16:06:58.696Z" }, - { url = "https://files.pythonhosted.org/packages/74/cd/1aef2184948728b4b6e21267d53b3339762c285a46a274ebb7863c9e4742/scipy-1.15.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:7e11270a000969409d37ed399585ee530b9ef6aa99d50c019de4cb01e8e54e62", size = 30109540, upload-time = "2025-05-08T16:07:04.209Z" }, - { url = "https://files.pythonhosted.org/packages/5b/d8/59e452c0a255ec352bd0a833537a3bc1bfb679944c4938ab375b0a6b3a3e/scipy-1.15.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8c9ed3ba2c8a2ce098163a9bdb26f891746d02136995df25227a20e71c396ebb", size = 22383115, upload-time = "2025-05-08T16:07:08.998Z" }, - { url = "https://files.pythonhosted.org/packages/08/f5/456f56bbbfccf696263b47095291040655e3cbaf05d063bdc7c7517f32ac/scipy-1.15.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0bdd905264c0c9cfa74a4772cdb2070171790381a5c4d312c973382fc6eaf730", size = 25163884, upload-time = "2025-05-08T16:07:14.091Z" }, - { url = "https://files.pythonhosted.org/packages/a2/66/a9618b6a435a0f0c0b8a6d0a2efb32d4ec5a85f023c2b79d39512040355b/scipy-1.15.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79167bba085c31f38603e11a267d862957cbb3ce018d8b38f79ac043bc92d825", size = 35174018, upload-time = "2025-05-08T16:07:19.427Z" }, - { url = "https://files.pythonhosted.org/packages/b5/09/c5b6734a50ad4882432b6bb7c02baf757f5b2f256041da5df242e2d7e6b6/scipy-1.15.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9deabd6d547aee2c9a81dee6cc96c6d7e9a9b1953f74850c179f91fdc729cb7", size = 37269716, upload-time = "2025-05-08T16:07:25.712Z" }, - { url = "https://files.pythonhosted.org/packages/77/0a/eac00ff741f23bcabd352731ed9b8995a0a60ef57f5fd788d611d43d69a1/scipy-1.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dde4fc32993071ac0c7dd2d82569e544f0bdaff66269cb475e0f369adad13f11", size = 36872342, upload-time = "2025-05-08T16:07:31.468Z" }, - { url = "https://files.pythonhosted.org/packages/fe/54/4379be86dd74b6ad81551689107360d9a3e18f24d20767a2d5b9253a3f0a/scipy-1.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f77f853d584e72e874d87357ad70f44b437331507d1c311457bed8ed2b956126", size = 39670869, upload-time = "2025-05-08T16:07:38.002Z" }, - { url = "https://files.pythonhosted.org/packages/87/2e/892ad2862ba54f084ffe8cc4a22667eaf9c2bcec6d2bff1d15713c6c0703/scipy-1.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:b90ab29d0c37ec9bf55424c064312930ca5f4bde15ee8619ee44e69319aab163", size = 40988851, upload-time = "2025-05-08T16:08:33.671Z" }, - { url = "https://files.pythonhosted.org/packages/1b/e9/7a879c137f7e55b30d75d90ce3eb468197646bc7b443ac036ae3fe109055/scipy-1.15.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3ac07623267feb3ae308487c260ac684b32ea35fd81e12845039952f558047b8", size = 38863011, upload-time = "2025-05-08T16:07:44.039Z" }, - { url = "https://files.pythonhosted.org/packages/51/d1/226a806bbd69f62ce5ef5f3ffadc35286e9fbc802f606a07eb83bf2359de/scipy-1.15.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6487aa99c2a3d509a5227d9a5e889ff05830a06b2ce08ec30df6d79db5fcd5c5", size = 30266407, upload-time = "2025-05-08T16:07:49.891Z" }, - { url = "https://files.pythonhosted.org/packages/e5/9b/f32d1d6093ab9eeabbd839b0f7619c62e46cc4b7b6dbf05b6e615bbd4400/scipy-1.15.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:50f9e62461c95d933d5c5ef4a1f2ebf9a2b4e83b0db374cb3f1de104d935922e", size = 22540030, upload-time = "2025-05-08T16:07:54.121Z" }, - { url = "https://files.pythonhosted.org/packages/e7/29/c278f699b095c1a884f29fda126340fcc201461ee8bfea5c8bdb1c7c958b/scipy-1.15.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:14ed70039d182f411ffc74789a16df3835e05dc469b898233a245cdfd7f162cb", size = 25218709, upload-time = "2025-05-08T16:07:58.506Z" }, - { url = "https://files.pythonhosted.org/packages/24/18/9e5374b617aba742a990581373cd6b68a2945d65cc588482749ef2e64467/scipy-1.15.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a769105537aa07a69468a0eefcd121be52006db61cdd8cac8a0e68980bbb723", size = 34809045, upload-time = "2025-05-08T16:08:03.929Z" }, - { url = "https://files.pythonhosted.org/packages/e1/fe/9c4361e7ba2927074360856db6135ef4904d505e9b3afbbcb073c4008328/scipy-1.15.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db984639887e3dffb3928d118145ffe40eff2fa40cb241a306ec57c219ebbbb", size = 36703062, upload-time = "2025-05-08T16:08:09.558Z" }, - { url = "https://files.pythonhosted.org/packages/b7/8e/038ccfe29d272b30086b25a4960f757f97122cb2ec42e62b460d02fe98e9/scipy-1.15.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:40e54d5c7e7ebf1aa596c374c49fa3135f04648a0caabcb66c52884b943f02b4", size = 36393132, upload-time = "2025-05-08T16:08:15.34Z" }, - { url = "https://files.pythonhosted.org/packages/10/7e/5c12285452970be5bdbe8352c619250b97ebf7917d7a9a9e96b8a8140f17/scipy-1.15.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5e721fed53187e71d0ccf382b6bf977644c533e506c4d33c3fb24de89f5c3ed5", size = 38979503, upload-time = "2025-05-08T16:08:21.513Z" }, - { url = "https://files.pythonhosted.org/packages/81/06/0a5e5349474e1cbc5757975b21bd4fad0e72ebf138c5592f191646154e06/scipy-1.15.3-cp313-cp313t-win_amd64.whl", hash = "sha256:76ad1fb5f8752eabf0fa02e4cc0336b4e8f021e2d5f061ed37d6d264db35e3ca", size = 40308097, upload-time = "2025-05-08T16:08:27.627Z" }, -] - -[[package]] -name = "scipy" -version = "1.16.3" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14'", -] -dependencies = [ - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0a/ca/d8ace4f98322d01abcd52d381134344bf7b431eba7ed8b42bdea5a3c2ac9/scipy-1.16.3.tar.gz", hash = "sha256:01e87659402762f43bd2fee13370553a17ada367d42e7487800bf2916535aecb", size = 30597883, upload-time = "2025-10-28T17:38:54.068Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/5f/6f37d7439de1455ce9c5a556b8d1db0979f03a796c030bafdf08d35b7bf9/scipy-1.16.3-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:40be6cf99e68b6c4321e9f8782e7d5ff8265af28ef2cd56e9c9b2638fa08ad97", size = 36630881, upload-time = "2025-10-28T17:31:47.104Z" }, - { url = "https://files.pythonhosted.org/packages/7c/89/d70e9f628749b7e4db2aa4cd89735502ff3f08f7b9b27d2e799485987cd9/scipy-1.16.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:8be1ca9170fcb6223cc7c27f4305d680ded114a1567c0bd2bfcbf947d1b17511", size = 28941012, upload-time = "2025-10-28T17:31:53.411Z" }, - { url = "https://files.pythonhosted.org/packages/a8/a8/0e7a9a6872a923505dbdf6bb93451edcac120363131c19013044a1e7cb0c/scipy-1.16.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:bea0a62734d20d67608660f69dcda23e7f90fb4ca20974ab80b6ed40df87a005", size = 20931935, upload-time = "2025-10-28T17:31:57.361Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c7/020fb72bd79ad798e4dbe53938543ecb96b3a9ac3fe274b7189e23e27353/scipy-1.16.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:2a207a6ce9c24f1951241f4693ede2d393f59c07abc159b2cb2be980820e01fb", size = 23534466, upload-time = "2025-10-28T17:32:01.875Z" }, - { url = "https://files.pythonhosted.org/packages/be/a0/668c4609ce6dbf2f948e167836ccaf897f95fb63fa231c87da7558a374cd/scipy-1.16.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:532fb5ad6a87e9e9cd9c959b106b73145a03f04c7d57ea3e6f6bb60b86ab0876", size = 33593618, upload-time = "2025-10-28T17:32:06.902Z" }, - { url = "https://files.pythonhosted.org/packages/ca/6e/8942461cf2636cdae083e3eb72622a7fbbfa5cf559c7d13ab250a5dbdc01/scipy-1.16.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0151a0749efeaaab78711c78422d413c583b8cdd2011a3c1d6c794938ee9fdb2", size = 35899798, upload-time = "2025-10-28T17:32:12.665Z" }, - { url = "https://files.pythonhosted.org/packages/79/e8/d0f33590364cdbd67f28ce79368b373889faa4ee959588beddf6daef9abe/scipy-1.16.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7180967113560cca57418a7bc719e30366b47959dd845a93206fbed693c867e", size = 36226154, upload-time = "2025-10-28T17:32:17.961Z" }, - { url = "https://files.pythonhosted.org/packages/39/c1/1903de608c0c924a1749c590064e65810f8046e437aba6be365abc4f7557/scipy-1.16.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:deb3841c925eeddb6afc1e4e4a45e418d19ec7b87c5df177695224078e8ec733", size = 38878540, upload-time = "2025-10-28T17:32:23.907Z" }, - { url = "https://files.pythonhosted.org/packages/f1/d0/22ec7036ba0b0a35bccb7f25ab407382ed34af0b111475eb301c16f8a2e5/scipy-1.16.3-cp311-cp311-win_amd64.whl", hash = "sha256:53c3844d527213631e886621df5695d35e4f6a75f620dca412bcd292f6b87d78", size = 38722107, upload-time = "2025-10-28T17:32:29.921Z" }, - { url = "https://files.pythonhosted.org/packages/7b/60/8a00e5a524bb3bf8898db1650d350f50e6cffb9d7a491c561dc9826c7515/scipy-1.16.3-cp311-cp311-win_arm64.whl", hash = "sha256:9452781bd879b14b6f055b26643703551320aa8d79ae064a71df55c00286a184", size = 25506272, upload-time = "2025-10-28T17:32:34.577Z" }, - { url = "https://files.pythonhosted.org/packages/40/41/5bf55c3f386b1643812f3a5674edf74b26184378ef0f3e7c7a09a7e2ca7f/scipy-1.16.3-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:81fc5827606858cf71446a5e98715ba0e11f0dbc83d71c7409d05486592a45d6", size = 36659043, upload-time = "2025-10-28T17:32:40.285Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0f/65582071948cfc45d43e9870bf7ca5f0e0684e165d7c9ef4e50d783073eb/scipy-1.16.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:c97176013d404c7346bf57874eaac5187d969293bf40497140b0a2b2b7482e07", size = 28898986, upload-time = "2025-10-28T17:32:45.325Z" }, - { url = "https://files.pythonhosted.org/packages/96/5e/36bf3f0ac298187d1ceadde9051177d6a4fe4d507e8f59067dc9dd39e650/scipy-1.16.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:2b71d93c8a9936046866acebc915e2af2e292b883ed6e2cbe5c34beb094b82d9", size = 20889814, upload-time = "2025-10-28T17:32:49.277Z" }, - { url = "https://files.pythonhosted.org/packages/80/35/178d9d0c35394d5d5211bbff7ac4f2986c5488b59506fef9e1de13ea28d3/scipy-1.16.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3d4a07a8e785d80289dfe66b7c27d8634a773020742ec7187b85ccc4b0e7b686", size = 23565795, upload-time = "2025-10-28T17:32:53.337Z" }, - { url = "https://files.pythonhosted.org/packages/fa/46/d1146ff536d034d02f83c8afc3c4bab2eddb634624d6529a8512f3afc9da/scipy-1.16.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0553371015692a898e1aa858fed67a3576c34edefa6b7ebdb4e9dde49ce5c203", size = 33349476, upload-time = "2025-10-28T17:32:58.353Z" }, - { url = "https://files.pythonhosted.org/packages/79/2e/415119c9ab3e62249e18c2b082c07aff907a273741b3f8160414b0e9193c/scipy-1.16.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:72d1717fd3b5e6ec747327ce9bda32d5463f472c9dce9f54499e81fbd50245a1", size = 35676692, upload-time = "2025-10-28T17:33:03.88Z" }, - { url = "https://files.pythonhosted.org/packages/27/82/df26e44da78bf8d2aeaf7566082260cfa15955a5a6e96e6a29935b64132f/scipy-1.16.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1fb2472e72e24d1530debe6ae078db70fb1605350c88a3d14bc401d6306dbffe", size = 36019345, upload-time = "2025-10-28T17:33:09.773Z" }, - { url = "https://files.pythonhosted.org/packages/82/31/006cbb4b648ba379a95c87262c2855cd0d09453e500937f78b30f02fa1cd/scipy-1.16.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5192722cffe15f9329a3948c4b1db789fbb1f05c97899187dcf009b283aea70", size = 38678975, upload-time = "2025-10-28T17:33:15.809Z" }, - { url = "https://files.pythonhosted.org/packages/c2/7f/acbd28c97e990b421af7d6d6cd416358c9c293fc958b8529e0bd5d2a2a19/scipy-1.16.3-cp312-cp312-win_amd64.whl", hash = "sha256:56edc65510d1331dae01ef9b658d428e33ed48b4f77b1d51caf479a0253f96dc", size = 38555926, upload-time = "2025-10-28T17:33:21.388Z" }, - { url = "https://files.pythonhosted.org/packages/ce/69/c5c7807fd007dad4f48e0a5f2153038dc96e8725d3345b9ee31b2b7bed46/scipy-1.16.3-cp312-cp312-win_arm64.whl", hash = "sha256:a8a26c78ef223d3e30920ef759e25625a0ecdd0d60e5a8818b7513c3e5384cf2", size = 25463014, upload-time = "2025-10-28T17:33:25.975Z" }, - { url = "https://files.pythonhosted.org/packages/72/f1/57e8327ab1508272029e27eeef34f2302ffc156b69e7e233e906c2a5c379/scipy-1.16.3-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:d2ec56337675e61b312179a1ad124f5f570c00f920cc75e1000025451b88241c", size = 36617856, upload-time = "2025-10-28T17:33:31.375Z" }, - { url = "https://files.pythonhosted.org/packages/44/13/7e63cfba8a7452eb756306aa2fd9b37a29a323b672b964b4fdeded9a3f21/scipy-1.16.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:16b8bc35a4cc24db80a0ec836a9286d0e31b2503cb2fd7ff7fb0e0374a97081d", size = 28874306, upload-time = "2025-10-28T17:33:36.516Z" }, - { url = "https://files.pythonhosted.org/packages/15/65/3a9400efd0228a176e6ec3454b1fa998fbbb5a8defa1672c3f65706987db/scipy-1.16.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:5803c5fadd29de0cf27fa08ccbfe7a9e5d741bf63e4ab1085437266f12460ff9", size = 20865371, upload-time = "2025-10-28T17:33:42.094Z" }, - { url = "https://files.pythonhosted.org/packages/33/d7/eda09adf009a9fb81827194d4dd02d2e4bc752cef16737cc4ef065234031/scipy-1.16.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:b81c27fc41954319a943d43b20e07c40bdcd3ff7cf013f4fb86286faefe546c4", size = 23524877, upload-time = "2025-10-28T17:33:48.483Z" }, - { url = "https://files.pythonhosted.org/packages/7d/6b/3f911e1ebc364cb81320223a3422aab7d26c9c7973109a9cd0f27c64c6c0/scipy-1.16.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0c3b4dd3d9b08dbce0f3440032c52e9e2ab9f96ade2d3943313dfe51a7056959", size = 33342103, upload-time = "2025-10-28T17:33:56.495Z" }, - { url = "https://files.pythonhosted.org/packages/21/f6/4bfb5695d8941e5c570a04d9fcd0d36bce7511b7d78e6e75c8f9791f82d0/scipy-1.16.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7dc1360c06535ea6116a2220f760ae572db9f661aba2d88074fe30ec2aa1ff88", size = 35697297, upload-time = "2025-10-28T17:34:04.722Z" }, - { url = "https://files.pythonhosted.org/packages/04/e1/6496dadbc80d8d896ff72511ecfe2316b50313bfc3ebf07a3f580f08bd8c/scipy-1.16.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:663b8d66a8748051c3ee9c96465fb417509315b99c71550fda2591d7dd634234", size = 36021756, upload-time = "2025-10-28T17:34:13.482Z" }, - { url = "https://files.pythonhosted.org/packages/fe/bd/a8c7799e0136b987bda3e1b23d155bcb31aec68a4a472554df5f0937eef7/scipy-1.16.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eab43fae33a0c39006a88096cd7b4f4ef545ea0447d250d5ac18202d40b6611d", size = 38696566, upload-time = "2025-10-28T17:34:22.384Z" }, - { url = "https://files.pythonhosted.org/packages/cd/01/1204382461fcbfeb05b6161b594f4007e78b6eba9b375382f79153172b4d/scipy-1.16.3-cp313-cp313-win_amd64.whl", hash = "sha256:062246acacbe9f8210de8e751b16fc37458213f124bef161a5a02c7a39284304", size = 38529877, upload-time = "2025-10-28T17:35:51.076Z" }, - { url = "https://files.pythonhosted.org/packages/7f/14/9d9fbcaa1260a94f4bb5b64ba9213ceb5d03cd88841fe9fd1ffd47a45b73/scipy-1.16.3-cp313-cp313-win_arm64.whl", hash = "sha256:50a3dbf286dbc7d84f176f9a1574c705f277cb6565069f88f60db9eafdbe3ee2", size = 25455366, upload-time = "2025-10-28T17:35:59.014Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a3/9ec205bd49f42d45d77f1730dbad9ccf146244c1647605cf834b3a8c4f36/scipy-1.16.3-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:fb4b29f4cf8cc5a8d628bc8d8e26d12d7278cd1f219f22698a378c3d67db5e4b", size = 37027931, upload-time = "2025-10-28T17:34:31.451Z" }, - { url = "https://files.pythonhosted.org/packages/25/06/ca9fd1f3a4589cbd825b1447e5db3a8ebb969c1eaf22c8579bd286f51b6d/scipy-1.16.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:8d09d72dc92742988b0e7750bddb8060b0c7079606c0d24a8cc8e9c9c11f9079", size = 29400081, upload-time = "2025-10-28T17:34:39.087Z" }, - { url = "https://files.pythonhosted.org/packages/6a/56/933e68210d92657d93fb0e381683bc0e53a965048d7358ff5fbf9e6a1b17/scipy-1.16.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:03192a35e661470197556de24e7cb1330d84b35b94ead65c46ad6f16f6b28f2a", size = 21391244, upload-time = "2025-10-28T17:34:45.234Z" }, - { url = "https://files.pythonhosted.org/packages/a8/7e/779845db03dc1418e215726329674b40576879b91814568757ff0014ad65/scipy-1.16.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:57d01cb6f85e34f0946b33caa66e892aae072b64b034183f3d87c4025802a119", size = 23929753, upload-time = "2025-10-28T17:34:51.793Z" }, - { url = "https://files.pythonhosted.org/packages/4c/4b/f756cf8161d5365dcdef9e5f460ab226c068211030a175d2fc7f3f41ca64/scipy-1.16.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:96491a6a54e995f00a28a3c3badfff58fd093bf26cd5fb34a2188c8c756a3a2c", size = 33496912, upload-time = "2025-10-28T17:34:59.8Z" }, - { url = "https://files.pythonhosted.org/packages/09/b5/222b1e49a58668f23839ca1542a6322bb095ab8d6590d4f71723869a6c2c/scipy-1.16.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cd13e354df9938598af2be05822c323e97132d5e6306b83a3b4ee6724c6e522e", size = 35802371, upload-time = "2025-10-28T17:35:08.173Z" }, - { url = "https://files.pythonhosted.org/packages/c1/8d/5964ef68bb31829bde27611f8c9deeac13764589fe74a75390242b64ca44/scipy-1.16.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:63d3cdacb8a824a295191a723ee5e4ea7768ca5ca5f2838532d9f2e2b3ce2135", size = 36190477, upload-time = "2025-10-28T17:35:16.7Z" }, - { url = "https://files.pythonhosted.org/packages/ab/f2/b31d75cb9b5fa4dd39a0a931ee9b33e7f6f36f23be5ef560bf72e0f92f32/scipy-1.16.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e7efa2681ea410b10dde31a52b18b0154d66f2485328830e45fdf183af5aefc6", size = 38796678, upload-time = "2025-10-28T17:35:26.354Z" }, - { url = "https://files.pythonhosted.org/packages/b4/1e/b3723d8ff64ab548c38d87055483714fefe6ee20e0189b62352b5e015bb1/scipy-1.16.3-cp313-cp313t-win_amd64.whl", hash = "sha256:2d1ae2cf0c350e7705168ff2429962a89ad90c2d49d1dd300686d8b2a5af22fc", size = 38640178, upload-time = "2025-10-28T17:35:35.304Z" }, - { url = "https://files.pythonhosted.org/packages/8e/f3/d854ff38789aca9b0cc23008d607ced9de4f7ab14fa1ca4329f86b3758ca/scipy-1.16.3-cp313-cp313t-win_arm64.whl", hash = "sha256:0c623a54f7b79dd88ef56da19bc2873afec9673a48f3b85b18e4d402bdd29a5a", size = 25803246, upload-time = "2025-10-28T17:35:42.155Z" }, - { url = "https://files.pythonhosted.org/packages/99/f6/99b10fd70f2d864c1e29a28bbcaa0c6340f9d8518396542d9ea3b4aaae15/scipy-1.16.3-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:875555ce62743e1d54f06cdf22c1e0bc47b91130ac40fe5d783b6dfa114beeb6", size = 36606469, upload-time = "2025-10-28T17:36:08.741Z" }, - { url = "https://files.pythonhosted.org/packages/4d/74/043b54f2319f48ea940dd025779fa28ee360e6b95acb7cd188fad4391c6b/scipy-1.16.3-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:bb61878c18a470021fb515a843dc7a76961a8daceaaaa8bad1332f1bf4b54657", size = 28872043, upload-time = "2025-10-28T17:36:16.599Z" }, - { url = "https://files.pythonhosted.org/packages/4d/e1/24b7e50cc1c4ee6ffbcb1f27fe9f4c8b40e7911675f6d2d20955f41c6348/scipy-1.16.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2622206f5559784fa5c4b53a950c3c7c1cf3e84ca1b9c4b6c03f062f289ca26", size = 20862952, upload-time = "2025-10-28T17:36:22.966Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3a/3e8c01a4d742b730df368e063787c6808597ccb38636ed821d10b39ca51b/scipy-1.16.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7f68154688c515cdb541a31ef8eb66d8cd1050605be9dcd74199cbd22ac739bc", size = 23508512, upload-time = "2025-10-28T17:36:29.731Z" }, - { url = "https://files.pythonhosted.org/packages/1f/60/c45a12b98ad591536bfe5330cb3cfe1850d7570259303563b1721564d458/scipy-1.16.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3c820ddb80029fe9f43d61b81d8b488d3ef8ca010d15122b152db77dc94c22", size = 33413639, upload-time = "2025-10-28T17:36:37.982Z" }, - { url = "https://files.pythonhosted.org/packages/71/bc/35957d88645476307e4839712642896689df442f3e53b0fa016ecf8a3357/scipy-1.16.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d3837938ae715fc0fe3c39c0202de3a8853aff22ca66781ddc2ade7554b7e2cc", size = 35704729, upload-time = "2025-10-28T17:36:46.547Z" }, - { url = "https://files.pythonhosted.org/packages/3b/15/89105e659041b1ca11c386e9995aefacd513a78493656e57789f9d9eab61/scipy-1.16.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aadd23f98f9cb069b3bd64ddc900c4d277778242e961751f77a8cb5c4b946fb0", size = 36086251, upload-time = "2025-10-28T17:36:55.161Z" }, - { url = "https://files.pythonhosted.org/packages/1a/87/c0ea673ac9c6cc50b3da2196d860273bc7389aa69b64efa8493bdd25b093/scipy-1.16.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b7c5f1bda1354d6a19bc6af73a649f8285ca63ac6b52e64e658a5a11d4d69800", size = 38716681, upload-time = "2025-10-28T17:37:04.1Z" }, - { url = "https://files.pythonhosted.org/packages/91/06/837893227b043fb9b0d13e4bd7586982d8136cb249ffb3492930dab905b8/scipy-1.16.3-cp314-cp314-win_amd64.whl", hash = "sha256:e5d42a9472e7579e473879a1990327830493a7047506d58d73fc429b84c1d49d", size = 39358423, upload-time = "2025-10-28T17:38:20.005Z" }, - { url = "https://files.pythonhosted.org/packages/95/03/28bce0355e4d34a7c034727505a02d19548549e190bedd13a721e35380b7/scipy-1.16.3-cp314-cp314-win_arm64.whl", hash = "sha256:6020470b9d00245926f2d5bb93b119ca0340f0d564eb6fbaad843eaebf9d690f", size = 26135027, upload-time = "2025-10-28T17:38:24.966Z" }, - { url = "https://files.pythonhosted.org/packages/b2/6f/69f1e2b682efe9de8fe9f91040f0cd32f13cfccba690512ba4c582b0bc29/scipy-1.16.3-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:e1d27cbcb4602680a49d787d90664fa4974063ac9d4134813332a8c53dbe667c", size = 37028379, upload-time = "2025-10-28T17:37:14.061Z" }, - { url = "https://files.pythonhosted.org/packages/7c/2d/e826f31624a5ebbab1cd93d30fd74349914753076ed0593e1d56a98c4fb4/scipy-1.16.3-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:9b9c9c07b6d56a35777a1b4cc8966118fb16cfd8daf6743867d17d36cfad2d40", size = 29400052, upload-time = "2025-10-28T17:37:21.709Z" }, - { url = "https://files.pythonhosted.org/packages/69/27/d24feb80155f41fd1f156bf144e7e049b4e2b9dd06261a242905e3bc7a03/scipy-1.16.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:3a4c460301fb2cffb7f88528f30b3127742cff583603aa7dc964a52c463b385d", size = 21391183, upload-time = "2025-10-28T17:37:29.559Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d3/1b229e433074c5738a24277eca520a2319aac7465eea7310ea6ae0e98ae2/scipy-1.16.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f667a4542cc8917af1db06366d3f78a5c8e83badd56409f94d1eac8d8d9133fa", size = 23930174, upload-time = "2025-10-28T17:37:36.306Z" }, - { url = "https://files.pythonhosted.org/packages/16/9d/d9e148b0ec680c0f042581a2be79a28a7ab66c0c4946697f9e7553ead337/scipy-1.16.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f379b54b77a597aa7ee5e697df0d66903e41b9c85a6dd7946159e356319158e8", size = 33497852, upload-time = "2025-10-28T17:37:42.228Z" }, - { url = "https://files.pythonhosted.org/packages/2f/22/4e5f7561e4f98b7bea63cf3fd7934bff1e3182e9f1626b089a679914d5c8/scipy-1.16.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4aff59800a3b7f786b70bfd6ab551001cb553244988d7d6b8299cb1ea653b353", size = 35798595, upload-time = "2025-10-28T17:37:48.102Z" }, - { url = "https://files.pythonhosted.org/packages/83/42/6644d714c179429fc7196857866f219fef25238319b650bb32dde7bf7a48/scipy-1.16.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:da7763f55885045036fabcebd80144b757d3db06ab0861415d1c3b7c69042146", size = 36186269, upload-time = "2025-10-28T17:37:53.72Z" }, - { url = "https://files.pythonhosted.org/packages/ac/70/64b4d7ca92f9cf2e6fc6aaa2eecf80bb9b6b985043a9583f32f8177ea122/scipy-1.16.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ffa6eea95283b2b8079b821dc11f50a17d0571c92b43e2b5b12764dc5f9b285d", size = 38802779, upload-time = "2025-10-28T17:37:59.393Z" }, - { url = "https://files.pythonhosted.org/packages/61/82/8d0e39f62764cce5ffd5284131e109f07cf8955aef9ab8ed4e3aa5e30539/scipy-1.16.3-cp314-cp314t-win_amd64.whl", hash = "sha256:d9f48cafc7ce94cf9b15c6bffdc443a81a27bf7075cf2dcd5c8b40f85d10c4e7", size = 39471128, upload-time = "2025-10-28T17:38:05.259Z" }, - { url = "https://files.pythonhosted.org/packages/64/47/a494741db7280eae6dc033510c319e34d42dd41b7ac0c7ead39354d1a2b5/scipy-1.16.3-cp314-cp314t-win_arm64.whl", hash = "sha256:21d9d6b197227a12dcbf9633320a4e34c6b0e51c57268df255a0942983bac562", size = 26464127, upload-time = "2025-10-28T17:38:11.34Z" }, -] - [[package]] name = "secretstorage" version = "3.5.0" @@ -3245,26 +3098,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] -[[package]] -name = "soundfile" -version = "0.13.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e1/41/9b873a8c055582859b239be17902a85339bec6a30ad162f98c9b0288a2cc/soundfile-0.13.1.tar.gz", hash = "sha256:b2c68dab1e30297317080a5b43df57e302584c49e2942defdde0acccc53f0e5b", size = 46156, upload-time = "2025-01-25T09:17:04.831Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/28/e2a36573ccbcf3d57c00626a21fe51989380636e821b341d36ccca0c1c3a/soundfile-0.13.1-py2.py3-none-any.whl", hash = "sha256:a23c717560da2cf4c7b5ae1142514e0fd82d6bbd9dfc93a50423447142f2c445", size = 25751, upload-time = "2025-01-25T09:16:44.235Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ab/73e97a5b3cc46bba7ff8650a1504348fa1863a6f9d57d7001c6b67c5f20e/soundfile-0.13.1-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:82dc664d19831933fe59adad199bf3945ad06d84bc111a5b4c0d3089a5b9ec33", size = 1142250, upload-time = "2025-01-25T09:16:47.583Z" }, - { url = "https://files.pythonhosted.org/packages/a0/e5/58fd1a8d7b26fc113af244f966ee3aecf03cb9293cb935daaddc1e455e18/soundfile-0.13.1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:743f12c12c4054921e15736c6be09ac26b3b3d603aef6fd69f9dde68748f2593", size = 1101406, upload-time = "2025-01-25T09:16:49.662Z" }, - { url = "https://files.pythonhosted.org/packages/58/ae/c0e4a53d77cf6e9a04179535766b3321b0b9ced5f70522e4caf9329f0046/soundfile-0.13.1-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9c9e855f5a4d06ce4213f31918653ab7de0c5a8d8107cd2427e44b42df547deb", size = 1235729, upload-time = "2025-01-25T09:16:53.018Z" }, - { url = "https://files.pythonhosted.org/packages/57/5e/70bdd9579b35003a489fc850b5047beeda26328053ebadc1fb60f320f7db/soundfile-0.13.1-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:03267c4e493315294834a0870f31dbb3b28a95561b80b134f0bd3cf2d5f0e618", size = 1313646, upload-time = "2025-01-25T09:16:54.872Z" }, - { url = "https://files.pythonhosted.org/packages/fe/df/8c11dc4dfceda14e3003bb81a0d0edcaaf0796dd7b4f826ea3e532146bba/soundfile-0.13.1-py2.py3-none-win32.whl", hash = "sha256:c734564fab7c5ddf8e9be5bf70bab68042cd17e9c214c06e365e20d64f9a69d5", size = 899881, upload-time = "2025-01-25T09:16:56.663Z" }, - { url = "https://files.pythonhosted.org/packages/14/e9/6b761de83277f2f02ded7e7ea6f07828ec78e4b229b80e4ca55dd205b9dc/soundfile-0.13.1-py2.py3-none-win_amd64.whl", hash = "sha256:1e70a05a0626524a69e9f0f4dd2ec174b4e9567f4d8b6c11d38b5c289be36ee9", size = 1019162, upload-time = "2025-01-25T09:16:59.573Z" }, -] - [[package]] name = "structlog" version = "23.3.0" @@ -3286,15 +3119,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, ] -[[package]] -name = "tenacity" -version = "9.1.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, -] - [[package]] name = "tomli" version = "2.4.0" @@ -3632,15 +3456,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" }, ] -[[package]] -name = "websocket-client" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, -] - [[package]] name = "websockets" version = "15.0.1"