pax_global_header00006660000000000000000000000064141760456640014527gustar00rootroot0000000000000052 comment=406a8f220606b5129d80f101da44cf832546ed90 aiortc-1.3.0/000077500000000000000000000000001417604566400130115ustar00rootroot00000000000000aiortc-1.3.0/.gitattributes000066400000000000000000000000341417604566400157010ustar00rootroot00000000000000*.bin binary *.ulaw binary aiortc-1.3.0/.github/000077500000000000000000000000001417604566400143515ustar00rootroot00000000000000aiortc-1.3.0/.github/ISSUE_TEMPLATE.rst000066400000000000000000000011071417604566400172650ustar00rootroot00000000000000Before filing an issue please verify the following: * Check whether there is already an existing issue for the same topic. * Ensure you are actually reporting an issue related to ``aiortc``. The goal of the issue tracker is not to provide general guidance about WebRTC or free debugging of your code. * Clearly state whether the issue you are reporting can be reproduced with one of the examples provided with ``aiortc`` *without any changes*. * Be considerate to the maintainers. ``aiortc`` is provided on a best-effort, there is no guarantee your issue will be addressed. aiortc-1.3.0/.github/workflows/000077500000000000000000000000001417604566400164065ustar00rootroot00000000000000aiortc-1.3.0/.github/workflows/tests.yml000066400000000000000000000071261417604566400203010ustar00rootroot00000000000000name: tests on: [push, pull_request] jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v1 with: python-version: 3.7 - name: Install packages run: pip install black flake8 isort mypy - name: Run linters run: | flake8 examples src tests isort --check-only --diff examples src tests black --check --diff examples src tests test: runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-latest] python: - '3.10' - '3.9' - '3.8' - '3.7' steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v1 with: python-version: ${{ matrix.python }} - name: Install OS packages and disable firewall if: matrix.os == 'macos-latest' run: | brew update brew install opus libvpx sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate off - name: Install OS packages if: matrix.os == 'ubuntu-latest' run: | sudo apt-get update sudo apt-get install libopus-dev libvpx-dev - name: Run tests run: | python -m pip install -U pip setuptools wheel pip install .[dev] coverage run -m unittest discover -v coverage xml shell: bash - name: Upload coverage report uses: codecov/codecov-action@v1 package-source: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v1 with: python-version: 3.7 - name: Build source package run: python setup.py sdist - name: Upload source package uses: actions/upload-artifact@v1 with: name: dist path: dist/ package-wheel: runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: include: - os: macos-latest arch: arm64 - os: macos-latest arch: x86_64 - os: ubuntu-latest arch: i686 - os: ubuntu-latest arch: x86_64 - os: windows-latest arch: AMD64 - os: windows-latest arch: x86 steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v1 with: python-version: 3.7 - name: Build wheels env: CIBW_ARCHS: ${{ matrix.arch }} CIBW_BEFORE_BUILD: python scripts/fetch-vendor.py /tmp/vendor CIBW_BEFORE_BUILD_WINDOWS: python scripts\fetch-vendor.py C:\cibw\vendor CIBW_ENVIRONMENT: CFLAGS=-I/tmp/vendor/include LDFLAGS=-L/tmp/vendor/lib CIBW_ENVIRONMENT_WINDOWS: INCLUDE=C:\\cibw\\vendor\\include LIB=C:\\cibw\\vendor\\lib CIBW_SKIP: cp36-* pp36-* *-musllinux* run: | pip install cibuildwheel cibuildwheel --output-dir dist shell: bash - name: Upload wheels uses: actions/upload-artifact@v1 with: name: dist path: dist/ publish: runs-on: ubuntu-latest needs: [lint, test, package-source, package-wheel] steps: - uses: actions/checkout@v2 - uses: actions/download-artifact@v1 with: name: dist path: dist/ - name: Publish to PyPI if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags/') uses: pypa/gh-action-pypi-publish@master with: user: __token__ password: ${{ secrets.PYPI_TOKEN }} aiortc-1.3.0/.gitignore000066400000000000000000000002301417604566400147740ustar00rootroot00000000000000*.egg-info *.pyc *.so .coverage .eggs .idea .mypy_cache .vscode /build /dist /docs/_build # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ aiortc-1.3.0/.readthedocs.yml000066400000000000000000000002071417604566400160760ustar00rootroot00000000000000version: 2 formats: - pdf python: version: 3.7 install: - requirements: requirements/doc.txt - method: pip path: . aiortc-1.3.0/CODE_OF_CONDUCT.md000066400000000000000000000062211417604566400156110ustar00rootroot00000000000000# Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at jeremy.laine@m4x.org. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] [homepage]: http://contributor-covenant.org [version]: http://contributor-covenant.org/version/1/4/ aiortc-1.3.0/LICENSE000066400000000000000000000027501417604566400140220ustar00rootroot00000000000000Copyright (c) 2018-2019 Jeremy Lainé. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of aiortc nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. aiortc-1.3.0/MANIFEST.in000066400000000000000000000002661417604566400145530ustar00rootroot00000000000000include LICENSE recursive-include docs *.py *.rst Makefile recursive-include examples *.html *.py *.rst *.wav recursive-include src/_cffi_src *.py recursive-include tests *.bin *.py aiortc-1.3.0/README.rst000066400000000000000000000104341417604566400145020ustar00rootroot00000000000000aiortc ====== |rtd| |pypi-v| |pypi-pyversions| |pypi-l| |tests| |codecov| |gitter| .. |rtd| image:: https://readthedocs.org/projects/aiortc/badge/?version=latest :target: https://aiortc.readthedocs.io/ .. |pypi-v| image:: https://img.shields.io/pypi/v/aiortc.svg :target: https://pypi.python.org/pypi/aiortc .. |pypi-pyversions| image:: https://img.shields.io/pypi/pyversions/aiortc.svg :target: https://pypi.python.org/pypi/aiortc .. |pypi-l| image:: https://img.shields.io/pypi/l/aiortc.svg :target: https://pypi.python.org/pypi/aiortc .. |tests| image:: https://github.com/aiortc/aiortc/workflows/tests/badge.svg :target: https://github.com/aiortc/aiortc/actions .. |codecov| image:: https://img.shields.io/codecov/c/github/aiortc/aiortc.svg :target: https://codecov.io/gh/aiortc/aiortc .. |gitter| image:: https://img.shields.io/gitter/room/aiortc/Lobby.svg :target: https://gitter.im/aiortc/Lobby What is ``aiortc``? ------------------- ``aiortc`` is a library for `Web Real-Time Communication (WebRTC)`_ and `Object Real-Time Communication (ORTC)`_ in Python. It is built on top of ``asyncio``, Python's standard asynchronous I/O framework. The API closely follows its Javascript counterpart while using pythonic constructs: - promises are replaced by coroutines - events are emitted using ``pyee.EventEmitter`` To learn more about ``aiortc`` please `read the documentation`_. .. _Web Real-Time Communication (WebRTC): https://webrtc.org/ .. _Object Real-Time Communication (ORTC): https://ortc.org/ .. _read the documentation: https://aiortc.readthedocs.io/en/latest/ Why should I use ``aiortc``? ---------------------------- The main WebRTC and ORTC implementations are either built into web browsers, or come in the form of native code. While they are extensively battle tested, their internals are complex and they do not provide Python bindings. Furthermore they are tightly coupled to a media stack, making it hard to plug in audio or video processing algorithms. In contrast, the ``aiortc`` implementation is fairly simple and readable. As such it is a good starting point for programmers wishing to understand how WebRTC works or tinker with its internals. It is also easy to create innovative products by leveraging the extensive modules available in the Python ecosystem. For instance you can build a full server handling both signaling and data channels or apply computer vision algorithms to video frames using OpenCV. Furthermore, a lot of effort has gone into writing an extensive test suite for the ``aiortc`` code to ensure best-in-class code quality. Implementation status --------------------- ``aiortc`` allows you to exchange audio, video and data channels and interoperability is regularly tested against both Chrome and Firefox. Here are some of its features: - SDP generation / parsing - Interactive Connectivity Establishment, with half-trickle and mDNS support - DTLS key and certificate generation - DTLS handshake, encryption / decryption (for SCTP) - SRTP keying, encryption and decryption for RTP and RTCP - Pure Python SCTP implementation - Data Channels - Sending and receiving audio (Opus / PCMU / PCMA) - Sending and receiving video (VP8 / H.264) - Bundling audio / video / data channels - RTCP reports, including NACK / PLI to recover from packet loss Installing ---------- Since release 0.9.28 binary wheels are available on PyPI for Linux, Mac and Windows. The easiest way to install ``aiortc`` is to run: .. code:: bash pip install aiortc Building from source -------------------- If there are no wheels for your system or if you wish to build aiortc from source you will need a couple of libraries installed on your system: - OpenSSL 1.0.2 or greater - FFmpeg 4.0 or greater - LibVPX for video encoding / decoding - Opus for audio encoding / decoding Linux ..... On Debian/Ubuntu run: .. code:: bash apt install libavdevice-dev libavfilter-dev libopus-dev libvpx-dev pkg-config `pylibsrtp` comes with binary wheels for most platforms, but if it needs to be built from you will also need to run: .. code:: bash apt install libsrtp2-dev OS X .... On OS X run: .. code:: bash brew install ffmpeg opus libvpx pkg-config License ------- ``aiortc`` is released under the `BSD license`_. .. _BSD license: https://aiortc.readthedocs.io/en/latest/license.html aiortc-1.3.0/docs/000077500000000000000000000000001417604566400137415ustar00rootroot00000000000000aiortc-1.3.0/docs/Makefile000066400000000000000000000011341417604566400154000ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build SPHINXPROJ = aiortc SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) aiortc-1.3.0/docs/_static/000077500000000000000000000000001417604566400153675ustar00rootroot00000000000000aiortc-1.3.0/docs/_static/aiortc.svg000066400000000000000000000211151417604566400173710ustar00rootroot00000000000000 image/svg+xml aio aiortc-1.3.0/docs/api.rst000066400000000000000000000044061417604566400152500ustar00rootroot00000000000000API Reference ============= .. automodule:: aiortc WebRTC ------ .. autoclass:: RTCPeerConnection :members: .. autoclass:: RTCSessionDescription :members: .. autoclass:: RTCConfiguration :members: Interactive Connectivity Establishment (ICE) -------------------------------------------- .. autoclass:: RTCIceCandidate :members: .. autoclass:: RTCIceGatherer :members: .. autoclass:: RTCIceTransport :members: .. autoclass:: RTCIceParameters :members: .. autoclass:: RTCIceServer :members: Datagram Transport Layer Security (DTLS) ---------------------------------------- .. autoclass:: RTCCertificate() :members: .. autoclass:: RTCDtlsTransport :members: .. autoclass:: RTCDtlsParameters() :members: .. autoclass:: RTCDtlsFingerprint() :members: Real-time Transport Protocol (RTP) ---------------------------------- .. autoclass:: RTCRtpReceiver :members: .. autoclass:: RTCRtpSender :members: .. autoclass:: RTCRtpTransceiver :members: .. autoclass:: RTCRtpSynchronizationSource() :members: .. autoclass:: RTCRtpCapabilities() :members: .. autoclass:: RTCRtpCodecCapability() :members: .. autoclass:: RTCRtpHeaderExtensionCapability() :members: .. autoclass:: RTCRtpParameters() :members: .. autoclass:: RTCRtpCodecParameters() :members: .. autoclass:: RTCRtcpParameters() :members: Stream Control Transmission Protocol (SCTP) ------------------------------------------- .. autoclass:: RTCSctpTransport :members: .. autoclass:: RTCSctpCapabilities :members: Data channels ------------- .. autoclass:: RTCDataChannel(transport, parameters) :members: .. autoclass:: RTCDataChannelParameters() :members: Media ----- .. autoclass:: MediaStreamTrack :members: Statistics ---------- .. autoclass:: RTCStatsReport() .. autoclass:: RTCInboundRtpStreamStats() :members: .. autoclass:: RTCOutboundRtpStreamStats() :members: .. autoclass:: RTCRemoteInboundRtpStreamStats() :members: .. autoclass:: RTCRemoteOutboundRtpStreamStats() :members: .. autoclass:: RTCTransportStats() :members: aiortc-1.3.0/docs/changelog.rst000066400000000000000000000373351417604566400164350ustar00rootroot00000000000000Changelog ========= .. currentmodule:: aiortc 1.3.0 ----- * Build wheels for Python 3.10 and for arm64 on Mac. * Build wheels against `libvpx` 1.10. * Add support for looping in :class:`aiortc.contrib.media.MediaPlayer`. * Add unbuffered option to :class:`aiortc.contrib.media.MediaRelay`. * Calculate audio energy and send in RTP header extension. * Fix a race condition in RTP sender/receiver shutdown. * Improve performance of H.264 bitstream splitting code. * Update imports for `pyee` version 9.x. * Fully switch to `google-crc32c` instead of `crc32`. * Drop support for Python 3.6. * Remove `apprtc` code as the service is no longer publicly hosted. 1.2.1 ----- * Add a clear error message when no common codec is found. * Replace the `crc32` dependency with `google-crc32c` which offers a more liberal license. 1.2.0 ----- * Fix jitter buffer to avoid severe picture corruption under packet loss and send Picture Loss Indication (PLI) when needed. * Make H.264 encoder honour the bitrate from the bandwidth estimator. * Add support for hardware-accelerated H.264 encoding on Raspberry Pi 4 using the `h264_omx` codec. * Add :class:`aiortc.contrib.media.MediaRelay` class to allow sending media tracks to multiple consumers. 1.1.2 ----- * Add :attr:`RTCPeerConnection.connectionState` property. * Correctly detect RTCIceTransport `"failed"` state. * Correctly route RTP packets when there are multiple tracks of the same kind. * Use full module name to name loggers. 1.1.1 ----- * Defer adding remote candidates until after transport bundling to avoid unnecessary mDNS lookups. 1.1.0 ----- * Add support for resolving mDNS candidates. * Improve support for TURN, especially long-lived connections. 1.0.0 ----- Breaking ........ * Make :meth:`RTCPeerConnection.addIceCandidate` a coroutine. * Make :meth:`RTCIceTransport.addRemoteCandidate` a coroutine. Media ..... * Handle SSRC attributes in SDP containing a colon (#372). * Limit number of H.264 NALU per packet (#394, #426). Examples ........ * `server` make it possible to specify bind address (#347). 0.9.28 ------ Provide binary wheels for Linux, Mac and Windows on PyPI. 0.9.27 ------ Data channels ............. * Add :attr:`RTCSctpTransport.maxChannels` property. * Recycle stream IDs (#256). * Correctly close data channel when SCTP is not established (#300). Media ..... * Add add :attr:`RTCRtpReceiver.track` property (#298). * Fix a crash in `AimdRateControl` (#295). 0.9.26 ------ DTLS .... * Drop support for OpenSSL < 1.0.2. Examples ........ * `apprtc` fix handling of empty "candidate" message. Media ..... * Fix a MediaPlayer crash when stopping one track of a multi-track file (#237, #274). * Fix a MediaPlayer error when stopping a track while waiting for the next frame. * Make `RTCRtpSender` resilient to exceptions raised by media stream tracks (#283). 0.9.25 ------ Media ..... * Do not repeatedly send key frames after receiving a PLI. SDP ... * Do not try to determine track ID if there is no Msid. * Accept a star in rtcp-fb attributes. 0.9.24 ------ Peer connection ............... * Assign DTLS role based on the SDP negotiation, not the resolved ICE role. * When the peer is ICE lite, adopt the ICE controlling role, and do not use agressive nomination. * Do not close transport on `setRemoteDescription` if media and data are bundled. * Set RemoteStreamTrack.id based on the Msid. Media ..... * Support alsa hardware output in MediaRecorder. SDP ... * Add support for the `ice-lite` attribute. * Add support for receiving session-level `ice-ufrag`, `ice-pwd` and `setup` attributes. Miscellaneous ............. * Switch from `attrs` to standard Python `dataclasses`. * Use PEP-526 style variable annotations instead of comments. 0.9.23 ------ * Drop support for Python 3.5. * Drop dependency on PyOpenSSL. * Use PyAV >= 7.0.0. * Add partial type hints. 0.9.22 ------ DTLS .... * Display exception if data handler fails. Examples ........ * `server` and `webcam` : add playsinline attribute for iOS compatibility. * `webcam` : make it possible to play media from a file. Miscellaneous ............. * Use aioice >= 0.6.15 to not fail on mDNS candidates. * Use pyee version 6.x. 0.9.21 ------ DTLS .... * Call SSL_CTX_set_ecdh_auto for OpenSSL 1.0.2. Media ..... * Correctly route REMB packets to the :class:`aiortc.RTCRtpSender`. Examples ........ * :class:`aiortc.contrib.media.MediaPlayer` : release resources (e.g. webcam) when the player stops. * :class:`aiortc.contrib.signaling.ApprtcSignaling` : make AppRTC signaling available for more examples. * `datachannel-cli` : make uvloop optional. * `videostream-cli` : animate the flag with a wave effect. * `webcam` : explicitly set frame rate to 30 fps for webcams. 0.9.20 ------ Data channels ............. * Support out-of-band negotiation and custom channel id. Documentation ............. * Fix documentation build by installing `crc32c` instead of `crcmod`. Examples ........ * :class:`aiortc.contrib.media.MediaPlayer` : skip frames with no presentation timestamp (pts). 0.9.19 ------ Data channels ............. * Do not raise congestion window when it is not fully utilized. * Fix Highest TSN Newly Acknowledged logic for striking lost chunks. * Do not limit congestion window to 120kB, limit burst size instead. Media ..... * Skip RTX packets with an empty payload. Examples ........ * `apprtc` : make the initiator send messages using an HTTP POST instead of WebSocket. * `janus` : new example to connect to the Janus WebRTC server. * `server` : add cartoon effect to video transforms. 0.9.18 ------ DTLS .... * Do not use DTLSv1_get_timeout after DTLS handshake completes. Data channels ............. * Add setter for :attr:`RTCDataChannel.bufferedAmountLowThreshold`. * Use `crc32c` package instead of `crcmod`, it provides better performance. * Improve parsing and serialization code performance. * Disable logging code if it is not used to improve performance. 0.9.17 ------ DTLS .... * Do not bomb if SRTP is received before DTLS handshake completes. Data channels ............. * Implement unordered delivery, so that the `ordered` option is honoured. * Implement partial reliability, so that the `maxRetransmits` and `maxPacketLifeTime` options are honoured. Media ..... * Put all tracks in the same stream for now, fixes breakage introduced in 0.9.14. * Use case-insensitive comparison for codec names. * Use a=msid attribute in SDP instead of SSRC-level attributes. Examples ........ * `server` : make it possible to select unreliable mode for data channels. * `server` : print the round-trip time for data channel messages. 0.9.16 ------ DTLS .... * Log OpenSSL errors if the DTLS handshake fails. * Fix DTLS handshake in server mode with OpenSSL < 1.1.0. Media ..... * Add :meth:`RTCRtpReceiver.getCapabilities` and :meth:`RTCRtpSender.getCapabilities`. * Add :meth:`RTCRtpReceiver.getSynchronizationSources`. * Add :meth:`RTCRtpTransceiver.setCodecPreferences`. Examples ........ * `server` : make it possible to force audio codec. * `server` : shutdown cleanly on Chrome which lacks :meth:`RTCRtpTransceiver.stop`. 0.9.15 ------ Data channels ............. * Emit a warning if the crcmod C extension is not present. Media ..... * Support subsequent offer / answer exchanges. * Route RTCP parameters to RTP receiver and sender independently. * Fix a regression when the remote SSRC are not known. * Fix VP8 descriptor parsing errors detected by fuzzing. * Fix H264 descriptor parsing errors detected by fuzzing. 0.9.14 ------ Media ..... * Add support for RTX retransmission packets. * Fix RTP and RTCP parsing errors detected by fuzzing. * Use case-insensitive comparison for hash algorithm in SDP, fixes interoperability with Asterisk. * Offer NACK PLI and REMB feedback mechanisms for H.264. 0.9.13 ------ Data channels ............. * Raise an exception if :meth:`RTCDataChannel.send` is called when readyState is not `'open'`. * Do not use stream sequence number for unordered data channels. Media ..... * Set VP8 target bitrate according to Receiver Estimated Maximum Bandwidth. Examples ........ * Correctly handle encoding in copy-and-paste signaling. * `server` : add command line options to use HTTPS. * `webcam` : add command line options to use HTTPS. * `webcam` : add code to open webcam on OS X. 0.9.12 ------ * Rework code in order to facilitate garbage collection and avoid memory leaks. 0.9.11 ------ Media ..... * Make AudioStreamTrack and VideoStreamTrack produce empty frames more regularly. Examples ........ * Fix a regession in copy-and-paste signaling which blocked the event loop. 0.9.10 ------ Peer connection ............... * Send `raddr` and `rport` parameters for server reflexive and relayed candidates. This is required for Firefox to accept our STUN / TURN candidates. * Do not raise an exception if ICE or DTLS connection fails, just change state. Media ..... * Revert to using asyncio's `run_in_executor` to send data to the encoder, it greatly reduces the response time. * Adjust package requirements to accept PyAV < 7.0.0. Examples ........ * `webcam` : force Chrome to use "unified-plan" semantics to enabled `addTransceiver`. * :class:`aiortc.contrib.media.MediaPlayer` : don't sleep at all when playing from webcam. This eliminates the constant one-second lag in the `webcam` demo. 0.9.9 ----- .. warning:: `aiortc` now uses PyAV's :class:`~av.audio.frame.AudioFrame` and :class:`~av.video.frame.VideoFrame` classes instead of defining its own. Media ..... * Use a jitter buffer for incoming audio. * Add :meth:`RTCPeerConnection.addTransceiver` method. * Add :attr:`RTCRtpTransceiver.direction` to manage transceiver direction. Examples ........ * `apprtc` : demonstrate the use of :class:`aiortc.contrib.media.MediaPlayer` and :class:`aiortc.contrib.media.MediaRecorder`. * `webcam` : new examples illustrating sending video from a webcam to a browser. * :class:`aiortc.contrib.media.MediaPlayer` : don't sleep if a frame lacks timing information. * :class:`aiortc.contrib.media.MediaPlayer` : remove `start()` and `stop()` methods. * :class:`aiortc.contrib.media.MediaRecorder` : use `libx264` for encoding. * :class:`aiortc.contrib.media.MediaRecorder` : make `start()` and `stop()` coroutines. 0.9.8 ----- Media ..... * Add support for H.264 video, a big thank you to @dsvictor94! * Add support for sending Receiver Estimate Maximum Bitrate (REMB) feedback. * Add support for parsing / serializing more RTP header extensions. * Move each media encoder / decoder its one thread instead of using a thread pool. Statistics .......... * Add the :meth:`RTCPeerConnection.getStats()` coroutine to retrieve statistics. * Add initial :class:`RTCTransportStats` to report transport statistics. Examples ........ * Add new :class:`aiortc.contrib.media.MediaPlayer` class to read audio / video from a file. * Add new :class:`aiortc.contrib.media.MediaRecorder` class to write audio / video to a file. * Add new :class:`aiortc.contrib.media.MediaBlackhole` class to discard audio / video. 0.9.7 ----- Media ..... * Make RemoteStreamTrack emit an "ended" event, to simplify shutting down media consumers. * Add RemoteStreamTrack.readyState property. * Handle timestamp wraparound on sent RTP packets. Packaging ......... * Add a versioned dependency on cffi>=1.0.0 to fix Raspberry Pi builds. 0.9.6 ----- Data channels ............. * Optimize reception for improved latency and throughput. Media ..... * Add initial :meth:`RTCRtpReceiver.getStats()` and :meth:`RTCRtpReceiver.getStats()` coroutines. Examples ........ * `datachannel-cli`: display ping/pong roundtrip time. 0.9.5 ----- Media ..... * Make it possible to add multiple audio or video streams. * Implement basic RTP video packet loss detection / retransmission using RTCP NACK feedback. * Respond to Picture Loss Indications (PLI) by sending a keyframe. * Use shorter MID values to reduce RTP header extension overhead. * Correctly shutdown and discard unused transports when using BUNDLE. Examples ........ * `server` : make it possible to save received video to an AVI file. 0.9.4 ----- Peer connection ............... * Add support for TURN over TCP. Examples ........ * Add media and signaling helpers in `aiortc.contrib`. * Fix colorspace OpenCV colorspace conversions. * `apprtc` : send rotating image on video track. 0.9.3 ----- Media ..... * Set PictureID attribute on outgoing VP8 frames. * Negotiate and send SDES MID header extension for RTP packets. * Fix negative packets_lost encoding for RTCP reports. 0.9.2 ----- Data channels ............. * Numerous performance improvements in congestion control. Examples ........ * `datachannel-filexfer`: use uvloop instead of default asyncio loop. 0.9.1 ----- Data channels ............. * Revert making RTCDataChannel.send a coroutine. 0.9.0 ----- Media ..... * Enable post-processing in VP8 decoder to remove (macro) blocks. * Set target bitrate for VP8 encoder to 900kbps. * Re-create VP8 encoder if frame size changes. * Implement jitter estimation for RTCP reports. * Avoid overflowing the DLSR field for RTCP reports. * Raise video jitter buffer size. Data channels ............. * BREAKING CHANGE: make RTCDataChannel.send a coroutine. * Support spec-compliant SDP format for datachannels, as used in Firefox 63. * Never send a negative advertised_cwnd. Examples ........ * `datachannel-filexfer`: new example for file transfer over a data channel. * `datachannel-vpn`: new example for a VPN over a data channel. * `server`: make it possible to select video resolution. 0.8.0 ----- Media ..... * Align VP8 settings with those used by WebRTC project, which greatly improves video quality. * Send RTCP source description, sender report, receiver report and bye packets. Examples ........ * `server`: - make it possible to not transform video at all. - allow video display to be up to 1280px wide. * `videostream-cli`: - fix Python 3.5 compatibility Miscellaneous ............. * Delay logging string interpolation to reduce cost of packet logging in non-verbose mode. 0.7.0 ----- Peer connection ............... * Add :meth:`RTCPeerConnection.addIceCandidate()` method to handle trickled ICE candidates. Media ..... * Make stop() methods of :class:`aiortc.RTCRtpReceiver`, :class:`aiortc.RTCRtpSender` and :class:`RTCRtpTransceiver` coroutines to enable clean shutdown. Data channels ............. * Clean up :class:`aiortc.RTCDataChannel` shutdown sequence. * Support receiving an SCTP `RE-CONFIG` to raise number of inbound streams. Examples ........ * `server`: - perform some image processing using OpenCV. - make it possible to disable data channels. - make demo web interface more mobile-friendly. * `apprtc`: - automatically create a room if no room is specified on command line. - handle `bye` command. 0.6.0 ----- Peer connection ............... * Make it possible to specify one STUN server and / or one TURN server. * Add `BUNDLE` support to use a single ICE/DTLS transport for multiple media. * Move media encoding / decoding off the main thread. Data channels ............. * Use SCTP `ABORT` instead of `SHUTDOWN` when stopping :class:`aiortc.RTCSctpTransport`. * Advertise support for SCTP `RE-CONFIG` extension. * Make :class:`aiortc.RTCDataChannel` emit `open` and `close` events. Examples ........ * Add an example of how to connect to appr.tc. * Capture audio frames to a WAV file in server example. * Show datachannel open / close events in server example. aiortc-1.3.0/docs/conf.py000066400000000000000000000145611417604566400152470ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # # aiortc documentation build configuration file, created by # sphinx-quickstart on Thu Feb 8 17:22:14 2018. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import os import sys # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.insert(0, os.path.abspath('..')) # Mock out binding class MockLib: ssrc_undefined = 0 ssrc_specific = 1 ssrc_any_inbound = 2 ssrc_any_outbound = 3 def srtp_init(self): pass class MockBinding: ffi = None lib = MockLib() class MockAvLogging: restore_default_callback = lambda x: None class MockAv: logging = MockAvLogging() AudioFrame = None VideoFrame = None class MockAvFrame: Frame = None class MockH264: H264Decoder = None H264Encoder = None h264_depayload = None class MockOpus: OpusDecoder = None OpusEncoder = None class MockVpx: Vp8Decoder = None Vp8Encoder = None vp8_depayload = None sys.modules.update({'av': MockAv()}) sys.modules.update({'av.frame': MockAvFrame()}) sys.modules.update({'av.logging': MockAvLogging()}) sys.modules.update({'pylibsrtp._binding': MockBinding()}) sys.modules.update({'aiortc.codecs.h264': MockH264()}) sys.modules.update({'aiortc.codecs.opus': MockOpus()}) sys.modules.update({'aiortc.codecs.vpx': MockVpx()}) # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. # # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx_autodoc_typehints', 'sphinxcontrib.asyncio', ] autodoc_member_order = 'bysource' intersphinx_mapping = { 'av': ('http://docs.mikeboers.com/pyav/0.5.3', None) } # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] source_suffix = '.rst' # The master toctree document. master_doc = 'index' # General information about the project. project = 'aiortc' copyright = u'2018-2019, Jeremy Lainé' author = u'Jeremy Lainé' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = '' # The full version, including alpha/beta/rc tags. release = '' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. language = None # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This patterns also effect to html_static_path and html_extra_path exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = 'alabaster' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # html_theme_options = { 'description': 'A library for building WebRTC and ORTC applications in Python.', 'github_button': True, 'github_user': 'aiortc', 'github_repo': 'aiortc', 'logo': 'aiortc.svg', } # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # Custom sidebar templates, must be a dictionary that maps document names # to template names. # # This is required for the alabaster theme # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars html_sidebars = { '**': [ 'about.html', 'navigation.html', 'relations.html', 'searchbox.html', ] } # -- Options for HTMLHelp output ------------------------------------------ # Output file base name for HTML help builder. htmlhelp_basename = 'aiortcdoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # # 'preamble': '', # Latex figure (float) alignment # # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ (master_doc, 'aiortc.tex', 'aiortc Documentation', u'Jeremy Lainé', 'manual'), ] # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ (master_doc, 'aiortc', 'aiortc Documentation', [author], 1) ] # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ (master_doc, 'aiortc', 'aiortc Documentation', author, 'aiortc', 'One line description of project.', 'Miscellaneous'), ] aiortc-1.3.0/docs/contributing.rst000066400000000000000000000026061417604566400172060ustar00rootroot00000000000000Contributing ============ Thanks for taking the time to contribute to ``aiortc``! Code of Conduct --------------- This project and everyone participating in it is governed by the `Code of Conduct`_. By participating, you are expected to uphold this code. Please report inappropriate behavior to jeremy DOT laine AT m4x DOT org. .. _Code of Conduct: https://github.com/aiortc/aiortc/blob/main/CODE_OF_CONDUCT.md Contributions ------------- Bug reports, patches and suggestions are welcome! Please open an issue_ or send a `pull request`_. Feedback about the examples or documentation are especially valuable as they make ``aiortc`` accessible to a wider audience. Code contributions *must* come with full unit test coverage. WebRTC is a complex protocol stack and ensuring correct behaviour now and in the future requires a proper investment in automated testing. .. _issue: https://github.com/aiortc/aiortc/issues/new .. _pull request: https://github.com/aiortc/aiortc/compare/ Questions --------- GitHub issues aren't a good medium for handling questions. There are better places to ask questions, for example Stack Overflow. If you want to ask a question anyway, please make sure that: - it's a question about ``aiortc`` and not about :mod:`asyncio`; - it isn't answered by the documentation; - it wasn't asked already. A good question can be written as a suggestion to improve the documentation. aiortc-1.3.0/docs/examples.rst000066400000000000000000000003171417604566400163120ustar00rootroot00000000000000Examples ======== ``aiortc`` comes with a selection of examples, which are a great starting point for new users. The examples can be browsed on GitHub: https://github.com/aiortc/aiortc/tree/main/examples aiortc-1.3.0/docs/helpers.rst000066400000000000000000000010361417604566400161350ustar00rootroot00000000000000Helpers ============= .. automodule:: aiortc These classes are not part of the WebRTC or ORTC API, but provide higher-level helpers for tasks like manipulating media streams. Media sources ------------- .. autoclass:: aiortc.contrib.media.MediaPlayer :members: Media sinks ----------- .. autoclass:: aiortc.contrib.media.MediaRecorder :members: .. autoclass:: aiortc.contrib.media.MediaBlackhole :members: Media transforms ---------------- .. autoclass:: aiortc.contrib.media.MediaRelay :members: aiortc-1.3.0/docs/index.rst000066400000000000000000000045621417604566400156110ustar00rootroot00000000000000aiortc ========= |pypi-v| |pypi-pyversions| |pypi-l| |tests| |codecov| |gitter| .. |pypi-v| image:: https://img.shields.io/pypi/v/aiortc.svg :target: https://pypi.python.org/pypi/aiortc .. |pypi-pyversions| image:: https://img.shields.io/pypi/pyversions/aiortc.svg :target: https://pypi.python.org/pypi/aiortc .. |pypi-l| image:: https://img.shields.io/pypi/l/aiortc.svg :target: https://pypi.python.org/pypi/aiortc .. |tests| image:: https://github.com/aiortc/aiortc/workflows/tests/badge.svg :target: https://github.com/aiortc/aiortc/actions .. |codecov| image:: https://img.shields.io/codecov/c/github/aiortc/aiortc.svg :target: https://codecov.io/gh/aiortc/aiortc .. |gitter| image:: https://img.shields.io/gitter/room/aiortc/Lobby.svg :target: https://gitter.im/aiortc/Lobby ``aiortc`` is a library for `Web Real-Time Communication (WebRTC)`_ and `Object Real-Time Communication (ORTC)`_ in Python. It is built on top of ``asyncio``, Python's standard asynchronous I/O framework. The API closely follows its Javascript counterpart while using pythonic constructs: - promises are replaced by coroutines - events are emitted using ``pyee.EventEmitter`` .. _Web Real-Time Communication (WebRTC): https://webrtc.org/ .. _Object Real-Time Communication (ORTC): https://ortc.org/ Why should I use ``aiortc``? ---------------------------- The main WebRTC and ORTC implementations are either built into web browsers, or come in the form of native code. While they are extensively battle tested, their internals are complex and they do not provide Python bindings. Furthermore they are tightly coupled to a media stack, making it hard to plug in audio or video processing algorithms. In contrast, the ``aiortc`` implementation is fairly simple and readable. As such it is a good starting point for programmers wishing to understand how WebRTC works or tinker with its internals. It is also easy to create innovative products by leveraging the extensive modules available in the Python ecosystem. For instance you can build a full server handling both signaling and data channels or apply computer vision algorithms to video frames using OpenCV. Furthermore, a lot of effort has gone into writing an extensive test suite for the ``aiortc`` code to ensure best-in-class code quality. .. toctree:: :maxdepth: 2 examples api helpers contributing changelog license aiortc-1.3.0/docs/license.rst000066400000000000000000000000601417604566400161110ustar00rootroot00000000000000License ------- .. literalinclude:: ../LICENSE aiortc-1.3.0/examples/000077500000000000000000000000001417604566400146275ustar00rootroot00000000000000aiortc-1.3.0/examples/datachannel-cli/000077500000000000000000000000001417604566400176365ustar00rootroot00000000000000aiortc-1.3.0/examples/datachannel-cli/README.rst000066400000000000000000000012771417604566400213340ustar00rootroot00000000000000Data channel CLI ================ This example illustrates the establishment of a data channel using an RTCPeerConnection and a "copy and paste" signaling channel to exchange SDP. First install the required packages: .. code-block:: console $ pip install aiortc To run the example, you will need instances of the `cli` example: - The first takes on the role of the offerer. It generates an offer which you must copy-and-paste to the answerer. .. code-block:: console $ python cli.py offer - The second takes on the role of the answerer. When given an offer, it will generate an answer which you must copy-and-paste to the offerer. .. code-block:: console $ python cli.py answer aiortc-1.3.0/examples/datachannel-cli/cli.py000066400000000000000000000064341417604566400207660ustar00rootroot00000000000000import argparse import asyncio import logging import time from aiortc import RTCIceCandidate, RTCPeerConnection, RTCSessionDescription from aiortc.contrib.signaling import BYE, add_signaling_arguments, create_signaling def channel_log(channel, t, message): print("channel(%s) %s %s" % (channel.label, t, message)) def channel_send(channel, message): channel_log(channel, ">", message) channel.send(message) async def consume_signaling(pc, signaling): while True: obj = await signaling.receive() if isinstance(obj, RTCSessionDescription): await pc.setRemoteDescription(obj) if obj.type == "offer": # send answer await pc.setLocalDescription(await pc.createAnswer()) await signaling.send(pc.localDescription) elif isinstance(obj, RTCIceCandidate): await pc.addIceCandidate(obj) elif obj is BYE: print("Exiting") break time_start = None def current_stamp(): global time_start if time_start is None: time_start = time.time() return 0 else: return int((time.time() - time_start) * 1000000) async def run_answer(pc, signaling): await signaling.connect() @pc.on("datachannel") def on_datachannel(channel): channel_log(channel, "-", "created by remote party") @channel.on("message") def on_message(message): channel_log(channel, "<", message) if isinstance(message, str) and message.startswith("ping"): # reply channel_send(channel, "pong" + message[4:]) await consume_signaling(pc, signaling) async def run_offer(pc, signaling): await signaling.connect() channel = pc.createDataChannel("chat") channel_log(channel, "-", "created by local party") async def send_pings(): while True: channel_send(channel, "ping %d" % current_stamp()) await asyncio.sleep(1) @channel.on("open") def on_open(): asyncio.ensure_future(send_pings()) @channel.on("message") def on_message(message): channel_log(channel, "<", message) if isinstance(message, str) and message.startswith("pong"): elapsed_ms = (current_stamp() - int(message[5:])) / 1000 print(" RTT %.2f ms" % elapsed_ms) # send offer await pc.setLocalDescription(await pc.createOffer()) await signaling.send(pc.localDescription) await consume_signaling(pc, signaling) if __name__ == "__main__": parser = argparse.ArgumentParser(description="Data channels ping/pong") parser.add_argument("role", choices=["offer", "answer"]) parser.add_argument("--verbose", "-v", action="count") add_signaling_arguments(parser) args = parser.parse_args() if args.verbose: logging.basicConfig(level=logging.DEBUG) signaling = create_signaling(args) pc = RTCPeerConnection() if args.role == "offer": coro = run_offer(pc, signaling) else: coro = run_answer(pc, signaling) # run event loop loop = asyncio.get_event_loop() try: loop.run_until_complete(coro) except KeyboardInterrupt: pass finally: loop.run_until_complete(pc.close()) loop.run_until_complete(signaling.close()) aiortc-1.3.0/examples/datachannel-filexfer/000077500000000000000000000000001417604566400206735ustar00rootroot00000000000000aiortc-1.3.0/examples/datachannel-filexfer/README.rst000066400000000000000000000015711417604566400223660ustar00rootroot00000000000000Data channel file transfer ========================== This example illustrates sending a file over a data channel using an RTCPeerConnection and a "copy and paste" signaling channel to exchange SDP. First install the required packages: .. code-block:: console $ pip install aiortc On Linux and Mac OS X you can also install uvloop for better performance: .. code-block:: console $ pip install uvloop To run the example, you will need instances of the `filexfer` example: - The first takes on the role of the offerer. It generates an offer which you must copy-and-paste to the answerer. .. code-block:: console $ python filexfer.py send somefile.pdf - The second takes on the role of the answerer. When given an offer, it will generate an answer which you must copy-and-paste to the offerer. .. code-block:: console $ python filexfer.py receive received.pdf aiortc-1.3.0/examples/datachannel-filexfer/filexfer.py000066400000000000000000000064241417604566400230570ustar00rootroot00000000000000import argparse import asyncio import logging import time from aiortc import RTCIceCandidate, RTCPeerConnection, RTCSessionDescription from aiortc.contrib.signaling import BYE, add_signaling_arguments, create_signaling # optional, for better performance try: import uvloop except ImportError: uvloop = None async def consume_signaling(pc, signaling): while True: obj = await signaling.receive() if isinstance(obj, RTCSessionDescription): await pc.setRemoteDescription(obj) if obj.type == "offer": # send answer await pc.setLocalDescription(await pc.createAnswer()) await signaling.send(pc.localDescription) elif isinstance(obj, RTCIceCandidate): await pc.addIceCandidate(obj) elif obj is BYE: print("Exiting") break async def run_answer(pc, signaling, filename): await signaling.connect() @pc.on("datachannel") def on_datachannel(channel): start = time.time() octets = 0 @channel.on("message") async def on_message(message): nonlocal octets if message: octets += len(message) fp.write(message) else: elapsed = time.time() - start print( "received %d bytes in %.1f s (%.3f Mbps)" % (octets, elapsed, octets * 8 / elapsed / 1000000) ) # say goodbye await signaling.send(BYE) await consume_signaling(pc, signaling) async def run_offer(pc, signaling, fp): await signaling.connect() done_reading = False channel = pc.createDataChannel("filexfer") def send_data(): nonlocal done_reading while ( channel.bufferedAmount <= channel.bufferedAmountLowThreshold ) and not done_reading: data = fp.read(16384) channel.send(data) if not data: done_reading = True channel.on("bufferedamountlow", send_data) channel.on("open", send_data) # send offer await pc.setLocalDescription(await pc.createOffer()) await signaling.send(pc.localDescription) await consume_signaling(pc, signaling) if __name__ == "__main__": parser = argparse.ArgumentParser(description="Data channel file transfer") parser.add_argument("role", choices=["send", "receive"]) parser.add_argument("filename") parser.add_argument("--verbose", "-v", action="count") add_signaling_arguments(parser) args = parser.parse_args() if args.verbose: logging.basicConfig(level=logging.DEBUG) if uvloop is not None: asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) signaling = create_signaling(args) pc = RTCPeerConnection() if args.role == "send": fp = open(args.filename, "rb") coro = run_offer(pc, signaling, fp) else: fp = open(args.filename, "wb") coro = run_answer(pc, signaling, fp) # run event loop loop = asyncio.get_event_loop() try: loop.run_until_complete(coro) except KeyboardInterrupt: pass finally: fp.close() loop.run_until_complete(pc.close()) loop.run_until_complete(signaling.close()) aiortc-1.3.0/examples/datachannel-vpn/000077500000000000000000000000001417604566400176725ustar00rootroot00000000000000aiortc-1.3.0/examples/datachannel-vpn/README.rst000066400000000000000000000025211417604566400213610ustar00rootroot00000000000000Data channel VPN ================ This example illustrates a layer2 VPN running over a WebRTC data channel. First install the required packages: .. code-block:: console $ pip install aiortc Permissions ----------- This example requires the CAP_NET_ADMIN capability in order to create and configure network interfaces. There are two ways to achieve this: - running the script as the root user. The downside is that the script will be run with higher privileges than actually needed. - granting the CAP_NET_ADMIN capability to the Python interpreter. The downside is that *all* Python scripts will get this capability so you will almost certainly want to revert this change. .. code-block:: console $ sudo setcap CAP_NET_ADMIN=ep /path/to/python3 Running ------- On the first peer: .. code-block:: console $ python3 vpn.py offer On the second peer: .. code-block:: console $ python3 vpn.py answer Copy-and-paste the offer from the first peer to the second peer, then copy-and-paste the answer from the second peer to the first peer. A new network interface will be created on each peer. You can now setup these interfaces by using the system's network tools: .. code-block:: console $ ip address add 172.16.0.1/24 dev revpn-offer and: .. code-block:: console $ ip address add 172.16.0.2/24 dev revpn-answer aiortc-1.3.0/examples/datachannel-vpn/tuntap.py000066400000000000000000000047101417604566400215610ustar00rootroot00000000000000import fcntl import os import socket import struct TUNSETIFF = 0x400454CA TUNSETOWNER = TUNSETIFF + 2 IFF_TUN = 0x0001 IFF_TAP = 0x0002 IFF_NAPI = 0x0010 IFF_NAPI_FRAGS = 0x0020 IFF_NO_PI = 0x1000 IFF_PERSIST = 0x0800 IFF_NOFILTER = 0x1000 # net/if.h IFF_UP = 0x1 IFF_RUNNING = 0x40 IFNAMSIZ = 16 # From linux/sockios.h SIOCGIFCONF = 0x8912 SIOCGIFINDEX = 0x8933 SIOCGIFFLAGS = 0x8913 SIOCSIFFLAGS = 0x8914 SIOCGIFHWADDR = 0x8927 SIOCSIFHWADDR = 0x8924 SIOCGIFADDR = 0x8915 SIOCSIFADDR = 0x8916 SIOCGIFNETMASK = 0x891B SIOCSIFNETMASK = 0x891C SIOCETHTOOL = 0x8946 SIOCGIFMTU = 0x8921 # get MTU size SIOCSIFMTU = 0x8922 # set MTU size class Tun: mtu = 1500 def __init__(self, name, mode="tap", persist=True): self.name = name.encode() sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sockfd = sock @property def ifflags(self): # Get existing device flags ifreq = struct.pack("16sh", self.name, 0) flags = struct.unpack("16sh", fcntl.ioctl(self.sockfd, SIOCGIFFLAGS, ifreq))[1] return flags @ifflags.setter def ifflags(self, flags): ifreq = struct.pack("16sh", self.name, flags) fcntl.ioctl(self.sockfd, SIOCSIFFLAGS, ifreq) def get_mtu(self): ifreq = struct.pack("16sh", self.name, 0) self.mtu = struct.unpack("16sh", fcntl.ioctl(self.sockfd, SIOCGIFMTU, ifreq))[1] def up(self): """Bring up interface. Equivalent to ifconfig [iface] up.""" # Set new flags flags = self.ifflags | IFF_UP self.ifflags = flags self.get_mtu() def down(self): """Bring down interface. Equivalent to ifconfig [iface] down.""" # Set new flags flags = self.ifflags & ~IFF_UP self.ifflags = flags def is_up(self): """Return True if the interface is up, False otherwise.""" if self.ifflags & IFF_UP: return True else: return False def open(self): """Open file corresponding to the TUN device.""" self.fd = open("/dev/net/tun", "rb+", buffering=0) tun_flags = IFF_TAP | IFF_NO_PI | IFF_PERSIST ifr = struct.pack("16sH", self.name, tun_flags) fcntl.ioctl(self.fd, TUNSETIFF, ifr) fcntl.ioctl(self.fd, TUNSETOWNER, os.getuid()) self.ifflags = self.ifflags | IFF_RUNNING def close(self): if self.fd: self.ifflags = self.ifflags & ~IFF_RUNNING self.fd.close() aiortc-1.3.0/examples/datachannel-vpn/vpn.py000066400000000000000000000055001417604566400210470ustar00rootroot00000000000000import argparse import asyncio import logging import tuntap from aiortc import RTCIceCandidate, RTCPeerConnection, RTCSessionDescription from aiortc.contrib.signaling import BYE, add_signaling_arguments, create_signaling logger = logging.Logger("vpn") def channel_log(channel, t, message): logger.info("channel(%s) %s %s" % (channel.label, t, repr(message))) async def consume_signaling(pc, signaling): while True: obj = await signaling.receive() if isinstance(obj, RTCSessionDescription): await pc.setRemoteDescription(obj) if obj.type == "offer": # send answer await pc.setLocalDescription(await pc.createAnswer()) await signaling.send(pc.localDescription) elif isinstance(obj, RTCIceCandidate): await pc.addIceCandidate(obj) elif obj is BYE: print("Exiting") break def tun_start(tap, channel): tap.open() # relay channel -> tap channel.on("message")(tap.fd.write) # relay tap -> channel def tun_reader(): data = tap.fd.read(tap.mtu) if data: channel.send(data) loop = asyncio.get_event_loop() loop.add_reader(tap.fd, tun_reader) tap.up() async def run_answer(pc, signaling, tap): await signaling.connect() @pc.on("datachannel") def on_datachannel(channel): channel_log(channel, "-", "created by remote party") if channel.label == "vpntap": tun_start(tap, channel) await consume_signaling(pc, signaling) async def run_offer(pc, signaling, tap): await signaling.connect() channel = pc.createDataChannel("vpntap") channel_log(channel, "-", "created by local party") @channel.on("open") def on_open(): tun_start(tap, channel) # send offer await pc.setLocalDescription(await pc.createOffer()) await signaling.send(pc.localDescription) await consume_signaling(pc, signaling) if __name__ == "__main__": parser = argparse.ArgumentParser(description="VPN over data channel") parser.add_argument("role", choices=["offer", "answer"]) parser.add_argument("--verbose", "-v", action="count") add_signaling_arguments(parser) args = parser.parse_args() if args.verbose: logging.basicConfig(level=logging.DEBUG) tap = tuntap.Tun(name="revpn-%s" % args.role) signaling = create_signaling(args) pc = RTCPeerConnection() if args.role == "offer": coro = run_offer(pc, signaling, tap) else: coro = run_answer(pc, signaling, tap) # run event loop loop = asyncio.get_event_loop() try: loop.run_until_complete(coro) except KeyboardInterrupt: pass finally: loop.run_until_complete(pc.close()) loop.run_until_complete(signaling.close()) tap.close() aiortc-1.3.0/examples/janus/000077500000000000000000000000001417604566400157475ustar00rootroot00000000000000aiortc-1.3.0/examples/janus/README.rst000066400000000000000000000015051417604566400174370ustar00rootroot00000000000000Janus video room client ======================= This example illustrates how to connect to the Janus WebRTC server's video room. By default it simply sends green video frames, but you can instead specify a video file to stream to the room. First install the required packages: .. code-block:: console $ pip install aiohttp aiortc When you run the example, it will connect to Janus and join the '1234' room: .. code-block:: console $ python janus.py http://localhost:8088/janus Additional options ------------------ If you want to join a different room, run: .. code-block:: console $ python janus.py --room 5678 http://localhost:8088/janus If you want to play a media file instead of sending green video frames, run: .. code-block:: console $ python janus.py --play-from video.mp4 http://localhost:8088/janus aiortc-1.3.0/examples/janus/janus.py000066400000000000000000000170321417604566400174440ustar00rootroot00000000000000import argparse import asyncio import logging import random import string import time import aiohttp from aiortc import RTCPeerConnection, RTCSessionDescription, VideoStreamTrack from aiortc.contrib.media import MediaPlayer, MediaRecorder pcs = set() def transaction_id(): return "".join(random.choice(string.ascii_letters) for x in range(12)) class JanusPlugin: def __init__(self, session, url): self._queue = asyncio.Queue() self._session = session self._url = url async def send(self, payload): message = {"janus": "message", "transaction": transaction_id()} message.update(payload) async with self._session._http.post(self._url, json=message) as response: data = await response.json() assert data["janus"] == "ack" response = await self._queue.get() assert response["transaction"] == message["transaction"] return response class JanusSession: def __init__(self, url): self._http = None self._poll_task = None self._plugins = {} self._root_url = url self._session_url = None async def attach(self, plugin_name: str) -> JanusPlugin: message = { "janus": "attach", "plugin": plugin_name, "transaction": transaction_id(), } async with self._http.post(self._session_url, json=message) as response: data = await response.json() assert data["janus"] == "success" plugin_id = data["data"]["id"] plugin = JanusPlugin(self, self._session_url + "/" + str(plugin_id)) self._plugins[plugin_id] = plugin return plugin async def create(self): self._http = aiohttp.ClientSession() message = {"janus": "create", "transaction": transaction_id()} async with self._http.post(self._root_url, json=message) as response: data = await response.json() assert data["janus"] == "success" session_id = data["data"]["id"] self._session_url = self._root_url + "/" + str(session_id) self._poll_task = asyncio.ensure_future(self._poll()) async def destroy(self): if self._poll_task: self._poll_task.cancel() self._poll_task = None if self._session_url: message = {"janus": "destroy", "transaction": transaction_id()} async with self._http.post(self._session_url, json=message) as response: data = await response.json() assert data["janus"] == "success" self._session_url = None if self._http: await self._http.close() self._http = None async def _poll(self): while True: params = {"maxev": 1, "rid": int(time.time() * 1000)} async with self._http.get(self._session_url, params=params) as response: data = await response.json() if data["janus"] == "event": plugin = self._plugins.get(data["sender"], None) if plugin: await plugin._queue.put(data) else: print(data) async def publish(plugin, player): """ Send video to the room. """ pc = RTCPeerConnection() pcs.add(pc) # configure media media = {"audio": False, "video": True} if player and player.audio: pc.addTrack(player.audio) media["audio"] = True if player and player.video: pc.addTrack(player.video) else: pc.addTrack(VideoStreamTrack()) # send offer await pc.setLocalDescription(await pc.createOffer()) request = {"request": "configure"} request.update(media) response = await plugin.send( { "body": request, "jsep": { "sdp": pc.localDescription.sdp, "trickle": False, "type": pc.localDescription.type, }, } ) # apply answer await pc.setRemoteDescription( RTCSessionDescription( sdp=response["jsep"]["sdp"], type=response["jsep"]["type"] ) ) async def subscribe(session, room, feed, recorder): pc = RTCPeerConnection() pcs.add(pc) @pc.on("track") async def on_track(track): print("Track %s received" % track.kind) if track.kind == "video": recorder.addTrack(track) if track.kind == "audio": recorder.addTrack(track) # subscribe plugin = await session.attach("janus.plugin.videoroom") response = await plugin.send( {"body": {"request": "join", "ptype": "subscriber", "room": room, "feed": feed}} ) # apply offer await pc.setRemoteDescription( RTCSessionDescription( sdp=response["jsep"]["sdp"], type=response["jsep"]["type"] ) ) # send answer await pc.setLocalDescription(await pc.createAnswer()) response = await plugin.send( { "body": {"request": "start"}, "jsep": { "sdp": pc.localDescription.sdp, "trickle": False, "type": pc.localDescription.type, }, } ) await recorder.start() async def run(player, recorder, room, session): await session.create() # join video room plugin = await session.attach("janus.plugin.videoroom") response = await plugin.send( { "body": { "display": "aiortc", "ptype": "publisher", "request": "join", "room": room, } } ) publishers = response["plugindata"]["data"]["publishers"] for publisher in publishers: print("id: %(id)s, display: %(display)s" % publisher) # send video await publish(plugin=plugin, player=player) # receive video if recorder is not None and publishers: await subscribe( session=session, room=room, feed=publishers[0]["id"], recorder=recorder ) # exchange media for 10 minutes print("Exchanging media") await asyncio.sleep(600) if __name__ == "__main__": parser = argparse.ArgumentParser(description="Janus") parser.add_argument("url", help="Janus root URL, e.g. http://localhost:8088/janus") parser.add_argument( "--room", type=int, default=1234, help="The video room ID to join (default: 1234).", ), parser.add_argument("--play-from", help="Read the media from a file and sent it."), parser.add_argument("--record-to", help="Write received media to a file."), parser.add_argument("--verbose", "-v", action="count") args = parser.parse_args() if args.verbose: logging.basicConfig(level=logging.DEBUG) # create signaling and peer connection session = JanusSession(args.url) # create media source if args.play_from: player = MediaPlayer(args.play_from) else: player = None # create media sink if args.record_to: recorder = MediaRecorder(args.record_to) else: recorder = None loop = asyncio.get_event_loop() try: loop.run_until_complete( run(player=player, recorder=recorder, room=args.room, session=session) ) except KeyboardInterrupt: pass finally: if recorder is not None: loop.run_until_complete(recorder.stop()) loop.run_until_complete(session.destroy()) # close peer connections coros = [pc.close() for pc in pcs] loop.run_until_complete(asyncio.gather(*coros)) aiortc-1.3.0/examples/server/000077500000000000000000000000001417604566400161355ustar00rootroot00000000000000aiortc-1.3.0/examples/server/README.rst000066400000000000000000000024661417604566400176340ustar00rootroot00000000000000Audio, video and data channel server ==================================== This example illustrates establishing audio, video and a data channel with a browser. It also performs some image processing on the video frames using OpenCV. Running ------- First install the required packages: .. code-block:: console $ pip install aiohttp aiortc opencv-python When you start the example, it will create an HTTP server which you can connect to from your browser: .. code-block:: console $ python server.py You can then browse to the following page with your browser: http://127.0.0.1:8080 Once you click `Start` the browser will send the audio and video from its webcam to the server. The server will play a pre-recorded audio clip and send the received video back to the browser, optionally applying a transform to it. In parallel to media streams, the browser sends a 'ping' message over the data channel, and the server replies with 'pong'. Additional options ------------------ If you want to enable verbose logging, run: .. code-block:: console $ python server.py -v Credits ------- The audio file "demo-instruct.wav" was borrowed from the Asterisk project. It is licensed as Creative Commons Attribution-Share Alike 3.0: https://wiki.asterisk.org/wiki/display/AST/Voice+Prompts+and+Music+on+Hold+License aiortc-1.3.0/examples/server/client.js000066400000000000000000000203351417604566400177540ustar00rootroot00000000000000// get DOM elements var dataChannelLog = document.getElementById('data-channel'), iceConnectionLog = document.getElementById('ice-connection-state'), iceGatheringLog = document.getElementById('ice-gathering-state'), signalingLog = document.getElementById('signaling-state'); // peer connection var pc = null; // data channel var dc = null, dcInterval = null; function createPeerConnection() { var config = { sdpSemantics: 'unified-plan' }; if (document.getElementById('use-stun').checked) { config.iceServers = [{urls: ['stun:stun.l.google.com:19302']}]; } pc = new RTCPeerConnection(config); // register some listeners to help debugging pc.addEventListener('icegatheringstatechange', function() { iceGatheringLog.textContent += ' -> ' + pc.iceGatheringState; }, false); iceGatheringLog.textContent = pc.iceGatheringState; pc.addEventListener('iceconnectionstatechange', function() { iceConnectionLog.textContent += ' -> ' + pc.iceConnectionState; }, false); iceConnectionLog.textContent = pc.iceConnectionState; pc.addEventListener('signalingstatechange', function() { signalingLog.textContent += ' -> ' + pc.signalingState; }, false); signalingLog.textContent = pc.signalingState; // connect audio / video pc.addEventListener('track', function(evt) { if (evt.track.kind == 'video') document.getElementById('video').srcObject = evt.streams[0]; else document.getElementById('audio').srcObject = evt.streams[0]; }); return pc; } function negotiate() { return pc.createOffer().then(function(offer) { return pc.setLocalDescription(offer); }).then(function() { // wait for ICE gathering to complete return new Promise(function(resolve) { if (pc.iceGatheringState === 'complete') { resolve(); } else { function checkState() { if (pc.iceGatheringState === 'complete') { pc.removeEventListener('icegatheringstatechange', checkState); resolve(); } } pc.addEventListener('icegatheringstatechange', checkState); } }); }).then(function() { var offer = pc.localDescription; var codec; codec = document.getElementById('audio-codec').value; if (codec !== 'default') { offer.sdp = sdpFilterCodec('audio', codec, offer.sdp); } codec = document.getElementById('video-codec').value; if (codec !== 'default') { offer.sdp = sdpFilterCodec('video', codec, offer.sdp); } document.getElementById('offer-sdp').textContent = offer.sdp; return fetch('/offer', { body: JSON.stringify({ sdp: offer.sdp, type: offer.type, video_transform: document.getElementById('video-transform').value }), headers: { 'Content-Type': 'application/json' }, method: 'POST' }); }).then(function(response) { return response.json(); }).then(function(answer) { document.getElementById('answer-sdp').textContent = answer.sdp; return pc.setRemoteDescription(answer); }).catch(function(e) { alert(e); }); } function start() { document.getElementById('start').style.display = 'none'; pc = createPeerConnection(); var time_start = null; function current_stamp() { if (time_start === null) { time_start = new Date().getTime(); return 0; } else { return new Date().getTime() - time_start; } } if (document.getElementById('use-datachannel').checked) { var parameters = JSON.parse(document.getElementById('datachannel-parameters').value); dc = pc.createDataChannel('chat', parameters); dc.onclose = function() { clearInterval(dcInterval); dataChannelLog.textContent += '- close\n'; }; dc.onopen = function() { dataChannelLog.textContent += '- open\n'; dcInterval = setInterval(function() { var message = 'ping ' + current_stamp(); dataChannelLog.textContent += '> ' + message + '\n'; dc.send(message); }, 1000); }; dc.onmessage = function(evt) { dataChannelLog.textContent += '< ' + evt.data + '\n'; if (evt.data.substring(0, 4) === 'pong') { var elapsed_ms = current_stamp() - parseInt(evt.data.substring(5), 10); dataChannelLog.textContent += ' RTT ' + elapsed_ms + ' ms\n'; } }; } var constraints = { audio: document.getElementById('use-audio').checked, video: false }; if (document.getElementById('use-video').checked) { var resolution = document.getElementById('video-resolution').value; if (resolution) { resolution = resolution.split('x'); constraints.video = { width: parseInt(resolution[0], 0), height: parseInt(resolution[1], 0) }; } else { constraints.video = true; } } if (constraints.audio || constraints.video) { if (constraints.video) { document.getElementById('media').style.display = 'block'; } navigator.mediaDevices.getUserMedia(constraints).then(function(stream) { stream.getTracks().forEach(function(track) { pc.addTrack(track, stream); }); return negotiate(); }, function(err) { alert('Could not acquire media: ' + err); }); } else { negotiate(); } document.getElementById('stop').style.display = 'inline-block'; } function stop() { document.getElementById('stop').style.display = 'none'; // close data channel if (dc) { dc.close(); } // close transceivers if (pc.getTransceivers) { pc.getTransceivers().forEach(function(transceiver) { if (transceiver.stop) { transceiver.stop(); } }); } // close local audio / video pc.getSenders().forEach(function(sender) { sender.track.stop(); }); // close peer connection setTimeout(function() { pc.close(); }, 500); } function sdpFilterCodec(kind, codec, realSdp) { var allowed = [] var rtxRegex = new RegExp('a=fmtp:(\\d+) apt=(\\d+)\r$'); var codecRegex = new RegExp('a=rtpmap:([0-9]+) ' + escapeRegExp(codec)) var videoRegex = new RegExp('(m=' + kind + ' .*?)( ([0-9]+))*\\s*$') var lines = realSdp.split('\n'); var isKind = false; for (var i = 0; i < lines.length; i++) { if (lines[i].startsWith('m=' + kind + ' ')) { isKind = true; } else if (lines[i].startsWith('m=')) { isKind = false; } if (isKind) { var match = lines[i].match(codecRegex); if (match) { allowed.push(parseInt(match[1])); } match = lines[i].match(rtxRegex); if (match && allowed.includes(parseInt(match[2]))) { allowed.push(parseInt(match[1])); } } } var skipRegex = 'a=(fmtp|rtcp-fb|rtpmap):([0-9]+)'; var sdp = ''; isKind = false; for (var i = 0; i < lines.length; i++) { if (lines[i].startsWith('m=' + kind + ' ')) { isKind = true; } else if (lines[i].startsWith('m=')) { isKind = false; } if (isKind) { var skipMatch = lines[i].match(skipRegex); if (skipMatch && !allowed.includes(parseInt(skipMatch[2]))) { continue; } else if (lines[i].match(videoRegex)) { sdp += lines[i].replace(videoRegex, '$1 ' + allowed.join(' ')) + '\n'; } else { sdp += lines[i] + '\n'; } } else { sdp += lines[i] + '\n'; } } return sdp; } function escapeRegExp(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string } aiortc-1.3.0/examples/server/demo-instruct.wav000066400000000000000000043641701417604566400214700ustar00rootroot00000000000000RIFFpWAVEfmt @>dataL        "#)#     $(  '$N8a{R\$ *AQn<!!Rs08 !!O )A*`  ju_  #8"<@e&#E#K)Ϗ΁\v _S,>-V)]#pKqGl֐vΎiؒ!b396 ?6*.jtj؊=pO% )'x < F )Պٹo{ #:5v<`>7.!:W7 y"#%E"" qj ۀr (=8>}?.s zc̝G # "7#"ۚ֡ב!w( ?;]t$*AZcV? 01AP|_G 7a_p rMV@Q{y^w\ D5(dh#*Pe->D1iDS e}8[A,{+:JUd"Ob G1^`y39@*4_#cM|C4sE.+i0Sxu"%>l-O>gY0(1F6vjQaIHkV+%SRU(/Nu#/]D#qB[h%*z_"d0'W>I6CH7Il4$;}z 6{.b!7DW\#q>L;Ma;C&.z5hPp-]!xpREf=U2$Qc0U]4P e x1[m P_~FTU7 )oFQULpVhmFZNN+4}w! > Q U ( \+L;>Q UW >- M r kd/UJ8M,  YEk o U M5K$ U&XC 4 _!a(OW = ]WZ CZ>K_u "c"$:%(yZl"s|ZPOS!r"qm% RN]Ra63$ } Cu#R\,!<m Z<Z~eݙSt"),-w% ez!۳("4(+,B%mb@>ܐ#f!j'{)*# C,sW!&)r*!";x<޵ޓL"_&**-"6 :LX2ޭ9_"'*)P"W=%bA6<"&*S*",<\pk? M &)(%? ~D>? $5%J$7%Cl-htN C|6@0 1 1 f 3 x,tT{*#x+ n 5N{* 60w9nOjuC P;fQc> ArJF'NEK8=.94m[m-3PV-qg=8H_hx@H _ d]jq "bEqgmI4)UK-u/ ~%bdI8^* ;|W6 u\AV5* qtf  N{z.[~rc]oj#j)P(dGmy+ P NV|Y3tc 7-/Hi ?JPZ awf 62iI&OPh GYwt *\m3d#T"~ $s,ElS{ *0w@#Ukz ,o-$#*l^`;0>7 `  `EC]=%5B7FhD G lD.< 1B=Rcj#}+=Zqs$(4;*B0-; |8Tqu:q:C/>  pYZw 10C,;^u*3 Z=@ߩs,6VxUX H!:L= 36 /lQ  !9Lg~/2 r9;;q05y)-m Bw)DY+d}&3)7*$ g{,^Y, sL 7-(0%`" |d js`($G(% $U1u AU7O"[f=+=C#!$V))y>"j'K}&BTKR!~&*A# ZTr*$]yfL7/)_([ ktM(a +Q!k2JA)&&2Ue.-];lYfJ<  T"b" cdb i ^P&=`k7IVQ ILYo;f-$.:$m .( D !Wk]_7 Tb;|\ ]<-&T CJ=!A"Px&SOWD] %>- ,+U#cfWQ)3B+]\=U& AX)9nmyLRV8 6;KU|1kmt]$l7w1U~`W6ghs1PGXL$^;0eUTxRea>e Hl'TksNMb2^`t 4*Ddq6%VT:GTM1 :eU<#nZ<A b4gM q 12h_I4qY{p%9(sUhdu8u/UOV"m^ o-n|B<3 $)~*& &$_f.hl[ "b\zaG 5 l g8jliEh_ c 8g|Q  [p p5!EQCy3kbD  frzv.uk9B[,@ Kf%c)T$q "[ zlJ+B7uR}<{z#%).-T$&'Z8v\CzClpO| )+-,3)$|*9=3y^n %.<0.+(W]|J!7r9Bc ]! 2 4e1+!& NHDNd&}~DfN'956 1&$TxTn8+(> 8 >K', o05a0Y%C"6)cjg P'-!S @]1=@  r 6 t`4ma)0]-(Q&"G\ EK#} !n--+'(D&;#/ '2Z~ 4 zh4\O S)5-$,*(P$~C}c i+ax PYw k uap_.4 )+"+)&;"$Z"m2H COO8 (%*)F)'h$q\ 3d  !]qBF4$%&m%C%  OW35O8 $1> EAPk&s 9[.9K 5ie 4\r*q_0t@ kv}cUfMlytu d {fXQOaFXw> J| E s>#-v5 HcTraGI  S + hloAD;F.%_Q3T# > ~ B cLz|A@Yr7#$[? _j# :` 22jR77uU`U:!z{O= wRb & %9kGU~S,/}P'|sw[ )$+bM> s/` AKS:Ja|&"S !hV?6008cX|:xYh$C*)  P>IrQ.(>7*o^ "'(t AZ  AEn%4tMm#AV $S%m'K"U &9x 6V  _2V=m3 $o$%& 4'q/ 7 # B 5$k6/ (mZ[ X$J$2%"O, { z  Y Mh+r`aoz?+?7> L#!%#$K `c X h 73A;PBX#!%= g ` :'dE)<3u&!/$  ( #  =+ P:@Hg C4E" 3 >SF ;&5'].RF]w|WY!6 2 C | d~qztL)wBY*Nu > R    J ?$#[c28n%Kbyl ? G wHTn&QDG0AvF5KyS  m & e= ^ iDBB`Y[.m0s} N ]  (0}\rD9d'~@D`+ ~|H s [ W * %%^l/e7 r(jj h,%H < ] ,  ( p j_tlIqP<|  u U U - 'P a[`i !Tgyg3q  X  #1wP/Y! ; : *  K;{=hD `j!^g: Q,  C3 :Gߨu.\ #1  \  ' y)v*:qPݗߜ 6 W *}  *  ]/WYTB݅ܜ$   r  Z0NbCQ,%1I/\ ` - +=^^j G Kx p[7 *</ 4 ?*2WAiw /Z^]M I  DP]Q,;2;mk KH1By wltu$p+!c c~ST I<aBh=R5\Jr/Aoc+% @. K6* 1P*;ެ e ` TqY {jQaeKhސFC0m25 It.= }hN`6y&ަXVy% '/~T)I |pw"$  m:O ?^}{vH +eSr&1 ? Uh ja_Fi^MIk<]N rq[ ;ota!Un-'AlqY9hCMq) . * T n QH[K!\dEB?XMz^&350I [DM q JF ,Yh u`)j%$M25713! 0? @Cafrj4 29:^6%'|%F # & R _R,,zNndVAq] [J8@<( Xmp  Xm4I+ ~:)Rq MnuS\(< @5}X ,t8 uK*2^}h2hJUH! g0:5%$ N ' xzA(I_GcpL`) Dc43;3ܮ<V? 9+F)SX  pZ,v]exJ*;Z;Z) X! +#ftn_ XIlmeWBM(;=-JZqYTgU 8A+eN =N -; _'~+5s2$ Fj ]d+ h!e; D-bZt3I *-o T"*G+A%{U_)ox\ q + ~sji "dboI l$-," .b@/   TN ]JZB*[+4/J5_+qF]@(  4 =k} e vl/#owAo4E*dc*3n)A/J n UyGkD}G1~BNb\-5`(  8 r  )GcM*.b2 "^z /^qE @Y)6m=b~PZ9?,2%"ZWztnz <} i-H@?M=F z$0$*t{<^+ D dgqosic;DQh0+"|?%o"| /=T)` x T#mpuQqc-\h+n;'#Q& ,*ta8  MrVaIAgaX}Sq&.& PB'GED%mt 9 JjPjhFy ~)<   1,Z*`7H[  & ai$bNNWISb"C#T7 ymDG4o /' ]~p*{ ~ =S:< GB}PB\_vz~]F.OouO$[Vk&TZ7-ufi$vu^$zt J-bel Jb2t[20eNQy4H:),|7A|A<gT@,WZQw(O$3 w=6G(n`-d "1wT0|)$1B1_@NWE1h=~iaQLT[:@XUZ=YGYD\O_mH{ Mbl6^5eM?8;QevmgG1  2_)Z$)#mT) yR>')Dc"IfxdUB/&""$&% lbSIHPXe$2<FQ[bfkqnnole^UPE>71+'! ~{(8GP\dijifb[SKE=73/-(%!'5?JPWXWUOGDA>:40/*$%*044030,,))('*('.#  ,4,+-,  &&3/2 "!/'#  "    $ !y7 @ Ypw #1Xo:jY,DtN0?%Od1SL 6&v_A7I/F" s<=mj!T4v|h$Y/RKdrD? ="}QfktzA: lr-!go-fy8!9xG+0ej&[u Q]SM ApDPeAfSDt^!8J%3-0X9K&/ %-}\HK<J"gToh?-D/QYkf~{"M >9 <]{LWccq ;sVHZn|5gJ\F F`'?[BP>L 9VoY(`8 I Y 1 j)> { X_N[ eLb=+SoiLE z7"-Fyc= 6hilfPSLJ |UTI\d7dq1|wi]L@yE4:=JE5QzI",-*" #0(i[eL d7 4~V -iYLuTYRn}T;MbnxoP5 ":EVd\?',ELM) +-,"--*'$$ *%.#+++ $*(  &.7+(!-75)  )$AJcm{jEvxp$8DW`spq\G+ IMTn?(XM278 ~5Y(n)Ggy#GZbir T|n/9;?JVm|eL-|pe[Z`alsuq+f|@Y'*\DGu'pyJ$6u^GSn[.vg  " sqC[~'O|}U  @ (~JREl ?  i Orq3AdIt@!,i   Q  ~tCbYh5G~?H K 6 u w `5hnk [Uo  q d q 1iD9\~qL.     ] (XSj9'5`PjU   ] =>mu%cJ + K   =`O\MtA   .S7iI:wis4  $ yn|-Q|Je F  v,2$*` b'%/9n}C!#/ J 2~ &a\&ݷTs })aG(v L #}O~Jo ]ڬݝ@ -)!'&O `^*[8 3)" ۤ";, YR"1o')R X`pE#=( [@%-:# # x ?Rt a Q,AK%Y? r Zcu M2UjyypaLl)] L *  E B =}h7 4lK%?\<(=U*Ld4g l I"?[l'3HPW4 y H&sCX>jyY(Q,5!s+X:[QZW?cn _A"gg/kuq !?4&ikc<Bb!k+)L;s"ou@ @ofKifw3 "{c \*31^ jUeba4[=].)"+-^Q.}?3mk8X6 w2tOGx$O?~2}Q-[ :@ S M E#e>:?{%% KH .g'zO MyLV *?ZZZf7r>(^1V3,&"" "y޹t>eO Ps;.{*gaK<&e290'!8(kp=!>6y#k 6 I5($&O&88.% S~ӈVX;E :Y|T &q*d,IsU$6N80%9(݀Ԥ֯ܺ-<( HSo4>lZ#4|50&mbެ xfc'' Bz\[w#3[B8&mE$U4z3y0E&?T֋Dm bq Zwq P:W(?.31 *^!H2 53'5!blo;"12/d$2.:*AֽY 1X9.#xC!(3"2;.b  ; KI܏{d' {7\[R1E g-42+ !4j8֞-=7@ f y=j:$3V3/#jfN2ۦծ=g ~ !@z q%43p0$ !rtbپ3H (L  5ujyiw\ X*32f-!Yװ)_ \`Ls=h") ,p6W4- h TY*oSG -+O /~3BtMh k@19:5,o$ w_r NQx.c Y31w$6:1L(Vbx' [ +BXF0RN^$29 5-('J" fy{-9kVQJ|<0;g:1*g$<ڮV_ `# ?L- (6;8p30$` x f[-H |xvH< 5A@ޚ_ L&6?>5- ٦؜lvOFn6S!si& Z )\ߍc7"\"6g<>8w1%; ݐ.b 6\ Z B 2W[= '9&>Q e&Uh9He/~>U/s.3i.  N O U/ EGwIEuow,! ~ @\d[q3UgGPEwlMz H ~5(i-ZyyD _XUww` ^ U~ P ?8Ft L-Uq A'ZI#B^' 7 5 2  xdTr> Eli*LO6 $ h'7 ` i -hv H *{q76 t=rLjX.eE[)h$% uk|1H@f' I^5"_j4Ol?op {jfPkgfaT@;^zGe F/fw0=C. z8B )^+9()eA2mw9IgR *)8P2_kEp6-t'7Te 3qkU S&EbH ;^YRGck1,tRpc`*RDR.{iP_)Yd%Z!QA-|} hEeD~o Ubn%p `]`WiT] j}pD]yO^W&JRUn tb}Ct?Mr3#IL,1~9 x Z &Ie,/q=,"|.. M D  b`79E Ijq$l2P 0 -lxwH 5S(D*MO"2 XIAfx?HY sC{w fghF.\v enf.k|>WXH)e'!N` K=H,dx l3 $& Wi{ >7 8Ykj;W Vrm(I"]u1G& eal `  GM-\5+|rsU$!6. ~x&|8F-lm)v^'HYC & vj  b:Tk[]Pf N  j  ?kp+MZ1 .-0Wp @p b h 5OH~n'^ B6!; (M@,=en} & I`e/ F=kX  . O +}]'AN]IHEdB| | [ HQj5 *hP<_h.B4s f @s#0*wA/h&paf2d7x? ~NzT#d&x| W^J+._+ ~KW3ZE%+Y#GY j|`Nv,L\xE 4g o%|C*##% ybsG #2&gZ;Jz':!*ej /~D:w?//$#(' C OY'@OvyQvRHoT~? w(M!+i! vdaVh<-}7K &"3+3-0iD!P]j#Uxhm O,4#0M/Y7v<!tZ# 2"(* b +[2~Ld5lj+|[r`{ )3^6^A L? k-! vepX/#lV` XiE  CX&bz8 rrkhZ\/] ^K p wxX;)TQE+` _ v 3 e :It#;ASGV] z0Y q dxt  R NoFO% L6ibU K pE@ ~; ߐYS  fqvRI_?7g]ZB/ @gj*?*2u<_ M' VBy`.Cj$Zu < )9-LPxQ8M?eT 'j m  IzjH3_OKo7j_ |{ - J R{wv+3&| ;[  a 9 lxN]K*88qNbESxJ = r ] S zHSto6'Ge<FS;Y W hJx9IX''oh/~y`{R1,fS4lQ!nv/=.vaAw>LaB D^AW<~~)TH<h-8 "r%"yheaRt/ iY4 ,OL-Ta,=F6wSN]qK uz9Er  q} G C' `[wd 9#l  #,zP|WCuNy RN(R S.,d}l,W Ilm80[RXY8 sWqCcl|pu.+FhG$ mxNBX68=P Lle- B;yZ9g386 m0~4. xL Gd.D J`0 Wy7H f[GdE;2pFMf B=Ls[U y  d #4,/fkwIH - [h;. 8U03\EaA % Lv+B\  cf YqtYzVY&Ss0thE+  X !Aeo pw:RO3D9e |'wyOQ(O#XNk7VU,gmRf_~1w*N;T[7;L/R>_j+GIf$[uogs~g hSd%o~>  NFM]kG#SR."/k1+ yhS}<Y" G99<lv={9Pu$`i~[i;v;,tBb4Am>u^pmZ <*B! L L  J p;wgAexM4M bC * c0(P0jT=Q 2V8o- wy l~zr  L2 ""Rp RJ5XT p | hlF@wEf-yf"+2,V$V i4TPZ=pe3+:k2 7%(/L," a?1T$o?"3?v:W(3),B*2,1 kS*eY^rpz8)f7,N %!')'4VE!-o ov1 k7LM?nve!)"%$] 3=21[FI<<:z: >d [`u Wo:6K{h>,[/XS)fMzQ4n0N[$LS>f(Kf}Q[4, 6Cq)h S l  aMH'U. 3mL'H%IsK=$b,hxG~hAXiMBSW-`#PXI }*9Is8nWfu-DM rZW><'s<_>2I LtQbrJA/p:d?k,&v]6AY(% n\oZB\U VV`D;s7.|\3m&9XEyZ * *aiIwrG  > &lbE  WL`\` q U f~zPJ(nbM}3701#5 =ٟ]+uFF<  pf(|i/+5?oX1,(aP  t K X7[c$T   ; B"$^.8k24)] ގZ=DZ 9tEBJtx!!'>-5k'8|(c $U H(57H>8&>. $0- P Yw9+s, ->s1 TeyQA(jh7@~1dMm,.5\9E{ | _()@huJf 7<1;)#S >{>'; :[ ]X<>e .$>>3{)|b  XOp@ WH:  z L:NyI#W>8& ~NiF Wi-  eKn!<;'e^lk<) H\I$  ,Y+w0G@3 +m;D4c*8 |]k[ Ioc+s87%T;6n!6=\.>& 9e,+ ; \~tS2<52u`8$ 7~ jX) l; *P#E#? .;^5#?3?7 .o|5+ 5A o{.3 '{LTQ89,76YH +*D~#6 5Ri -N? X5<3 W7/4 h   tI,L$zb?5q8=0 #sYMZ O2 .aRx@ fJw=7"4d+a5JV3.M1W;Hi !QN b: * Hp622+>] K[V PcaX_\|CdN%;3 )' z, c_` XH#G* *4-&I(GlTeo,  )W.DJs|!St$yH;8K~,-l%?-b~G^141"dQyY/ al3j_r+d2m5F~'&`]#8ZA KWS@GukL ROzQ9rv"R z??3/>EvXX_&Xi|4oj' ] 1@'0JF|6VcW/W9O)(5<};0!=--  v k p >!!^()$m(("3eR; R6sOr/+**!(g$&wvI)5 {  R,.? #+!k-  sfCVs s5Q#c1OV3$N!WkrO P 2 R 'trIMHm<}?7"`C  U m47e+;%!R"q:O%&y:7$\2%) K %GLvF u>)vJ"]. [35. .,T El84xnLu h0f_-%i7\ k ;"quh]"m~ %Fw"8e wSX('8} K\|~EK_^]Wz2j9aaQ(.4a : "? LD$ao8 y/<c,[bykC 2*$g jV ~MPzfm1W{sV5&a2`g6BwMe_XJr>+~q:+-^ 5 ~ M )AtSxY[7?W[[gulu{'&  "a{N>;!Nx-<~5nt* L#x& 'C(}1_@9?[#l qS 1 !v1^IRt&50wP)l ]N!BTfxC ~|>@%Kd+6k0' z PrB=W %253iqjvUEShC A[F FlV 0O)=#0txl j9h!Q3 fr I 5 uC4E?~P Y1fDK @#z<,dpvE4F1]@I(X*{=?f=Zs  <N'5:LbP}wo= 6 kL@x2GrU gTW2h^ BbwB FA?< RfC Uq&VypCp?P Mx 0I]V.p V^K !W)i#)*L`zo YP.P: Qu ^Z :'W;[Gj[S'xy 6W:3` Y.WY4 *K"mod-b   #@ JFJ1Oe\ G58fB=D &' ~+aIw]#PLa 4 "O#S< ;RA]@( U|6""+B)^ :|vY1aF;#m}}\',1;= +"MOaf!4s i`j $b%Ym#~>}Ml 3\h\\/BA P V m45]<?h' p_bHZNj #(j MQ~}/W+tN M f X^lUpC@  ^ o 2Ma\  R6rKzFT7fX-`8"FB6k NM>tFc2rZ"#O|B4dp,J~e!< ,}SIQphOl8WWr,d Rl <>z6">fT3u,[&/n-72rTJUC[T%[z|W=2P,a&AdErcfa*S"$o0>w A T?eWMQim+/&;r}SD$>o"Np$(xw2pOo660BLwfZ'" /0 ivHi#j P:'A`P99ws| U5}lF6:*@-7%P. g% #" = .>=&F-=|X?_Of>`UJq\eQW|FkX$[Ag] BG: 2>;>*?CA%FUtN}ttT4_DFtO[_mA1PL%Vyn%Fi:0 U4p5:2/O>EOOfw y[Sc%z25\V?(I^fyCy<IBr1f q=-.EC7!n<DwzDRZb57lGgkf[A[-L*| RWrQ.D5}9BPr1X,9/G F_! (<3'"  Y!Y[Xe!Mtc  k7;g Q*c}V`@jZr))!h->d E S@ B$~bFx`2O)'! 2# S4 bF$'xl;-l a2#)h eG{xn7 RcU {g4=aq/hdZ[}'( S%<n Z * = !*u_&1_N !#"D~"a< }. zD Aa U, ) UJC!L5lB t n 3 ddl VPR3Yv0:r n n 1i"@2M;  'Be<_SOf^0} ZC? U638 K | G 9rB}/V'yY+ @p` H B TV+ph zf  0JkO%/ M } f9- d.H1 q6{,>S /AN A * J 0~ 8 }eQj,JHS Du2m =SnLmw % !2M]d1tZR wN|()w> P  A HT4Df1<DO.[e*<m#RPeO]*67s5_UI7hFL ?(3L1Ho4)0Mr{;1r#jjaN=975:ye1A,,Nbtv|&Reu2pTrt;;ra;tf{@lI^i[x>pyS=Y5 FHE#?W^[mNT8 |>n"efx)}4xf5gV{(fOogXN-XwR>$3*.2870(4)  *9+%0=1#"                           %*,+'  8\z(;>0T[JJH#ddY}I |/LebV0Kr|TnaVF,Nd4>xK@msX7z wnCm'RJkSU1f(J`P MKE0hAnk=?T=:yHa R G~' b5K'EAT\_+ " / s 5r(EU ('cTJ"i~W f U 7l0QAeb b g ~ 8 {Y>%2wGM"IhPBn }) p!+" B E W 9"Eje<vs:~M=X<=2RY}2=+6Dri<BM}ME_3D(;mi/C%ptc=J)Q1PckdT>% FRx n?"5Gs8iJEL_| .I[[PHC>>]/ c2 / f;^_eX?]2(`dd`0w8`HGG)Uyy7U;' #5K~C?3{1;)}O!c2 sS:+761,(37 1OHw[^E0:/<*  1=@6 jP@IAbE^ 'YI|d{aN};-x/<&aZ_dj?8=R<.U>xXhR :J4  :5hFq(3@Cd[K"! qpݢ,a|50 ܀ԸR86o;aJ+G;\]Ǡȅ~ڦ&g P$ [CFڥ584J8q/64F+!'X~ *Xp lݬ{{C:2%Sp pa  ^- ;R)b h'U6(&TIv5 1=aXZ 9 5O 7Q O(FOI(@q. | i!dm A o 3<1E, 0|!F7<0!:i<Z4yP:|N< $(߽׷7#~ DHH(/T- kW=o u ` u ^b^VB&mi%G+G>@ i =  L=d !@1 ?; S6 Or |tlzb(l;U:) a3 ^^ a& =  S*46?y2T:t"{Ay* Acn"55mg a3 E  Qs]Lu *;(   ijTOwJ2.r!70` {G "%;d K 4$VruDO 8B/  ufILScLl"41aA"S&*b7#+e%YQobbzQ*S s3807Pk :0z]3E-sX mo*3v/#5IgT;(0C v |^Y*% ( A mMZ/}ru ,+/ _ G ]Db60Ee) (R$3 O()P8q@vP @-P> eU<_2JFUv*$o\N6zqb_;*`aq~QY(!Lv('v5I@-~4jqH3Lz'\ j fHZ&ek97_,EmX9w6 pW+ n#w%u#;KS/^4\xP19'6So& u@ \;yIj"Ylo+C;l5z2;| h  p ;MqSP7Pw D-V74`03 Tܬ/P 7AFHrK)m,Vz\$~ O333j&Z$$& 0I^3oaeivLG--216,u6i#f$!7 6%!H %e+oFSkO$Nu( a  [fOq (%PSg!'~W*)fcTvoWEqa3H8_8. 4aY r1~+U}m`Q5Dw5qCS^ mW:sXYJX\8c*6E6PkVaD4pN<{M!oo@dxiZJIPk !(c!s=)Hl*Y MG~vDd@g87^"OrvHiF&8a+-'#B`yyeO9 %" $7I[kuyxrcP:$ ~{z~"?Ym|zm^OC8/,**-19AB?8+~rjfafjz,;GOPQOG>61-.29FSaltsj[K7! }{z    ;L_otqfXL8")5=;9%'(26+''(# '+ 5LQiB3-^)Q.xkK_x'X"F\XMYQ^2Y .:<\3iHQKvFFObk`p{ XX-FUsX#K7_<gqC=O <pYPj$ {$4pA-qj7<(jN nE3M1Q CA%-~izf4e<d!$0u*v)V!WVY"e8gwF'>}ah]<dZhu'LyN#yr'VR#om I5WzX)[8dZ\|%0O!ug+b5@RoG~$-#k,L/%A`3_z xDTB'5`)Cf{-Y\hRcf'H+Fn&|{dZ  b^uGu?yG|4  @ MY  q<${s k  P x$fM\ m JYVr(`1 |zp!,Y?!}\X 7Vd*  T2#C=KMGYT YcD *)s*BE{F~0"F["  U!P6 M@;'! >9  -UqYO*=#f`+ q! "3 rQTAY/S2gbv  !""! 4 (}|Tge J2jk "> %!#/!|  [ GkPY,/qW P|  $ ! A  )q0 >%% wA+S{!0"U}  4#&# GAQ(K_ߺLFE F u!U$/&"Ve3@hq, (BcpYg !"$%!}iP TLYs& x |[߉K$3"I#$2$r!~l( t 9\ t"y#v$#G!\mX U)OI28zfE\"$Z$"  G7YPSdHngV w@B BKb#$$!Q_6+:Ac/#37p U #J$P# 9Z? B7GB %1߾9!##w!N" RW 2Wj0 "#!V8 AN @2p+߆&5% # v,"# ;%h 5VYPhVN"B~'?߶BdmzH!\! "Ic!:JpN;r  V v %u \ xPo^<{Rީ߫Pf %9l. gB(&_H};oޔޣގ ZXjvGf QYhSV&R 7Ed@݃ biO: u 8 T YqtnL~## OS)B: y x s jfE} s߰hlJV.% ; qqYKV2S 74ZK8 C D (vDXI*# )'b ' q cL2lF No X , b "A`~VV3C @y n[ 6 TklgGXT,F!" &  Eh`8d-H< "Ld N , LEpY?>IO]  9 6;l < -&|o7f6XYR# 0/ ^ tO!KE10!| T 51&kw h PK ptrv`* ` WY^ @}%:Um8 D-KP ~W4? ,b:}'lXvP:  r> z Qy%Ilc:ykT 4}I k~9s>B0b=,  _ #n 7 %/+,G.& 0[Bw#  cl=arY x .Nl 47o:<M'9)I~| ,d J k MC5Ma9@ \}5 Mb4@hngNzqXb ]<"r  9}-KR@ZS y1M6  c g/F% n)U9h4 LI5?QI  M~o ZtظݻJݺg^< 4/"ZzSF>  Il@e ژ/߻< W'"Z( `&  n g  )MBuN$Jo,&, &;   Q  s_bX_sqڠڍ(G _3-.#)T#f] H]< ) ?|T[V8d?o*z]ߐ84(4=2& u JF" ; c(Njݶف# J'?T1p+|I f = ;1޺:-o O26!6<:,! c tej=b3eޱp(4$84N+o <~p=1 K f$eFsj 0U,\-8'S0%# Lltt&5%(3-6%eO *y2IJ]3).-5&Axr|  `Jfs88gFMl{)G1+0$yK 5 ,7%y[x_a'CohfTH9 71/.k+ C J e zUH&9BN>7d %1))!! KVEPaiOK }- oQ*.%% ?F +N4 T42[Y)6;?mPR'2,V%9! ;U yB7|q@:C5zO))"xw4:   m.& 6Pi_^'< i+ ^o |h0 20!)]YN}MI D-q$!0 9 OB`FE#  Q/$ *  bkBDH[SW0 B lPRN< ./=$z.~ " S7Tgi^*NP6Q ",*$jr L]LI_BudG ndsL*$R l k Mmb/9Ws!=$E;y )%f E[   l~0)OYmF2#saU[ ,+5 Y essfzH/=9 3wZELPO9zI+t~p <Tyued?P ([2YGd$ "& ==K+,v9_kUWe\>FETY] |gy(eb|fK$~cfF&O wW~g&wRb %%AC>~q( <s R 3KSZO 7[A7'Cj" H$yO" ECf=,yWV4E /N%3X//b'>:" @}LCnf]!.U(_1z]E7b?gEIk \] Z 1$^*g xHb85<&a%EsfbF]o e s+E"V UI!$GQ3#f x r?96 )V[eu37)0H5.xmXi E T {g :+BdE"L"`hYj2 ![G&fw8 B a|]. |S}_wc"9o$- T V  r^$^3~t^O?L: MFZ~@Ig0 [6(]s<>AYdi|l_^PK4Q~K;? B rlbZNP|1<["'xgx.@aWHBF(;,K3> 2 L;M!JpgI01'/1d(3bc  NT>fqOB6ey n+ bbn3bVy 7r%{XppB$J!,>dRo8 HK O=X( A7CO6xvR{x2h>O` Q >OLd"`Kx3C+Z;gk~r7hTs@N Gdxy+g]   !nyix dJI fc_n t4%a;Ok''>`U 9V84C8s%5 NdiPy~ygZm $\ zg6x ^^&=s BqbQ{w] :d\ ?g>^YNCLeONN7Sn)YnnGAGf.2!+\JD&u/V L\2zH?G46'_\PEYF^2+4L{.Lo7WaBIw.(1x4ZFTtGU()3f )h\4 ]v 9n/* 6I8 >!#`t.)&4T;7Of?\@z6>p|c^nm%SDtP VB~\ 7'kKacR&I;8r6>(Z_B7XoD .( !8DC(#bNG0.YAU>BY_1Du5:9|OSfIL/+) !d[=7[}tiO1!0.                      _xn1;/Y%,,$-3*." "!):*7   #PLc UC>ZT w#]qmA [ '1ER9>}pmG9N 42I~=wtpߦ޼0Ct 4-%0"OR2i,im`+u Y4* ېr؋  -%36&$Y^Mܔ41,ތF O OLZ_y%  $@ 7 $9Zq=B : i]%C L3#ot2C)qYt'($+,|FSI; ;sZYy*]<&&#.+ <w8tّt2I\ ^()&.25.#_AdH W3`jNZn5=%-%I.{-7 $ݹW}_9"S ߌwD '2-2~0%0 hd-E!ܻ{H4t/\xr 0"56.1/(= lT*%W^$Tm_"8Z3_10(Kk׿K) n\K @!8 :t21[+_PjcE" ~7 %"2<8/H,y% G .k) Wb ܌<-,G< ?3r*r#;g0;#.1u9 @:Q-"Mg O=*A{{!$n K46i;>86%)<pkxj19oQ F,6y6$:o7)N?*h\Wjt@Tq,312.m! \<#qZV =}6v Y%b$# p:2/Kr` sAR a <j's>V3],4_hcj4 _:ycKLSa?&Ff UyJC Beoom ,[Lv@*}=]~7HS!e8py 2.@ Lq[6N'Q4xShk{aK+?L]:-Ae >%)GglL2sK&uPUJFI+~ (]A BB!] t;?l.em~zO%E*Q*SE]PF Dt P8D 77`LqSzxdnMt'3 ' U   ' `K yn 1  [  K L^'qoz C J<dd TkR/ed#[Q&G h E$Uv &)mE `$ 7W! :ivx`G^J sO]& gP4(eI}5`rxS 5v X&gOyk#i@q h!suYy\N;4N@ _;|ZM&d$lYqN I]+6~d F]R {k$ Pw_"bE ] vߕ 3`G L RX$"N* v*\ &"B%')!+/k\xwk-'O"B' *8"' EcװڜmE L+$+*b"t)hԒ*פ4+x*5'.&ZG"jZdKk)=-Z)r*D(LZ݀"ٖ։&| > *,*'4$# ,JEt127 m-f(:-X+(}$F/`EVز-(w' .T./*V#Gd.eB۸׮'ی z"*`/22+"5KUږYMS+#)(/34-!Gj9%^IxнnY'166D5* $ڵw1˰{\(I'07=:90$ _B*8Ԓǎ;Ӝ.~&09N;=;+3&8UCۑ0%5xˮmQ(1G9K;9;2R%u5'w-\~rڊw rɗ" Q C.8w;696)5)*4Ll1űǪ *6S9c85@*jzSbϖ!ǹjk(B' 46]5<4(Z?T3KE&031[/:$SmGߌӲ͔Ϫۦb w2#--.,0*i/ID7! Tm l')&#W(w'M [I.=v Sr!FP 6 0 X4~A)C$ -i<- 5g Q/7pJ{^!xB 8iyYuOZRe7<M wuAK|6 :MS*~ @B7Wd)+| XgwuB>T EXaMM2iZuVj/*" )\5<'CmI o9z pzujL "W>gg# RMu*=%$#c"kx;ThK Cl OMv9 s rl9 >^ޭX\ Y?k?!Z EWlILxށxޛw"@,""#qh g LE -"$"S z 1LbF#+Q}&T| *#l_ 1:fP{hr:z F[>W_Dz-+1,%!9SC;c~n.r6.3"{ ).9H6AZM 0A;$<Tw ;8;#FZO#*=)>* |" & *Zie-?*:|!c~?69jtvKc24qA4w?.L8 N-8] c/= 4yC)  Z'X0*߿ $:6e k1=ug f-=-A j,a! gj,LJ?1Z;9%t):DXgZ'" /9';,Q96*NhgDP*8`- O1HI Wp/n5\#- o^grr'F3gF05}! Y6}P !))Y  e'qy gN;.j G*. Zfp} feIF#0U bw% 6 oa1DvP0QtS,1'z dY u%z &,)!uS^? h  Yne* +ha1QR)O$r# v - a K ,10&|$-.n|3aDnvJPomiv X(   ,[DlN] &5N_ B},adPWm,V^+q``b,yQPr8gqyeB[\l;{I+ ~pwf;t~u]uwJhiJwyrzs^qvQ|mmwx/=\mXdF`,9+& )-$  *313/% 49::<$ %>59:8   IWI+>+ C N>H%FsLc.u D(A J3K~>ELa5 U}.nR-,E 0 h/+.`rUI\uJ4q++y,JBB'Y U Q| >( NOIL5 EH3H lbB6 b^6~gV++/[4 : U  _H K.!j,CgZ|b [;4 8g)+Z&06^( ua z@ AN.=wK,dFF t> #!!#7[@!K: ;UN#:%#*(t++'D! x"|!dVvP PVMXW< !,//m.%(@#3W q bE?H 9K  C/4.30n( :VLߨ__RL%nBߚ T /6T43O+:!-8lnVBݜݡ. zNK޽ܰ=jHr$Y+M99/+!OClݼ%[mw  2oFߗ]1d!'5d4-(ti%3J+Xh KߩO2DO{6,|20(!A 8V\ 2;gW?N#+S>whn$0.4,' J6{I]"T@ P 9<<ݪ=5Gu * /B-($}{(Y  !@,n<1% h/fD ~ JJn[%  7]sA(N6#xu*"OcKSX+SUspDWCP"8sbcJpn7P+  b-I{lZDl YS_pj+.QlD<7[Y9==(D .&p )r0Gv:Fxx!EImL3Ao> ?Fy&q dk?z-PnWS "(QwwNAtF'P/^F5R_$!3MP1Lk|1N,xUphJDjOaXt_J`B8edZ~~ib'\H}4fAR)  ,}cjReq`Bi78zkU} ?nE>sZpTm  `E   x ' Qy= )s 3  Ivn4dN]O#,8H`, 8 Th2YV }zU86GlvlP]B JI $S  Sa(z (>[cL=+}^ n Q7 yYrs> Up[rB#3z1>u  gLA 1G~Y d1 2 XC Bp lTI#".23{n'Mxi" "x *tg ' C('02+Si%]$$# 8{~ 1 G 2$-5 " sZ#2*#.TgC vPp:QRY t! ]~O b~f$DiF/$U,0, / sCVjeK_+t#8 qI tb BS`|q Hl#/~"?'"B ? euA^@9 .a)5: {g5D*~A'9 .f(3:`u %X  _Bj<hk( &'E ed kVA 3a0+<A4[~_d)  "(Wu } 5 T+@41)7{!Y$&1 + =w  ')R7I@kw(:|} #!Z vs8M -p $(s"\, h2 7 S)a7"0@r^ #bf (% W@[;~b:43uR ;16 Sb[E8\;GC b 52B3`C,%z{l|q1@ZFwnPu4N^I(h ~])=~4Z@pu ThAa -^gGR% 2ACR{ uaD,Mr[ MeDXhz!g(!<\icgc &x'"Q);6j5  [ay/otrW\Hw7 ^e  Cf"u ixg^M3@QT)KF#6D >=mmR0gAQP f47 hnM(C.>;bCdv='2) 5Yn.SM+@} n*ZI_7  k]S -H|w<("$zxGDGdxT#zb2<jV) HG7):lO SBB4,A`  WG98OT n2&R4@_w<R r$z "S[N-wO`R4Q^7./0)&~-]FT$AK2{$3N[$ulA5YRDB[A5 3>Oqp01Ync!7+t`=;,!,XM%avj/"%BW )ML Ozj#(!/766LJs}N? !'hze.zR#bKd<j4, e7CM<@o:d%(wYf #tKI6%6&]Y[7v'T & $!S=P` )Q+7Ay1"-T/X!49B0 ! ,& (<-% &  A;`:) &)/$38A=@/  5!72/$    (!.&     #))   c51^'< gr WXaIX5* Rp^zI`D JlQ6j4(vG<sV7M:$`|B]LdzaC8K L `| ls#/xNAz#' #!>0V+02< Wo)@.b+Q&#e}Z 73L~  g8'S,68.r" /fޏ۾e tQ cyix+!'8<7j"4d   j ')33#0bx  &wRk(1&%*)5%>wfl` psYtH'$*+$"\k-|4i48BgU +,w/+ ! w+ B z8Ma_ $o$11-4, {W- 4>I? (,0I*!<V j dao(LZ &82,)*$+.q] p ( Fce 0gxJ/)++#g, > (.@,iYD@n(x(a"E Tyga B 5*Uq &G|Nww&( vtz & T~0V,g&n(+#'3  @A D Zoy?z5>&  0 ZKm\2*R_%$ |R l G-0}cgZ{Y I"t& $#E %kk%t[pg+[fki%!( 82 V {<.c@cE9& F##+} t?^  6"jM@S5Riiv &a " /jDl ]|HgtO;"& zl Mm:C#ag8 ^ZjE##Ige ` \\ \%^52%%hm&SC5"}der=P:V|\;$)bC2k>}%:|)", jjPAAS3D__+P;v$(],iX-qY/-pmr '&kT5xGR*(l}= &6(%*oqzR;[KA`ou -f (P&y x>Z~;E!{gjln/+ ?(( (V",6 ]Gzni Il#/=%"($<Y ^z"ZQ&:'f$7'#P~ ]]OP/>Vs =sV8J*]6C("%# Q$ I{MaPz$V`L7 7"#!^.xMc,-Sh@B{u c x=! T /@Zp] Y{ +s3:AlP? 8,KPQj f 2 `${4:/lHU MPUCG sU7N"Vz3v^P 36B{<O;mKdH=Dc<;rkaXLK4bM]l[QVPF6b :G'J-b99*2c8X4no:+n,>8R!U'JR@ 3D[8;~%@ !'-&#zpEni4;$lz\*4Qz/pj[bDQS>!CY^N'9n ] h6V0$<]^HF[s%lS1k[1!5t[RVcs#Ox )ZDXVw.Rc~qje\J6,lh!x E{nI'xT-u\K8&zlr j=uuX:%"1F^ovq`@xss&Fb}|m]G2%$=[ww`L%qA.9M[pAI x/KvR8;40D<5A?<81nYyV$r3zcC1=TdOlI>,O>[bHnQ0!h71jtsf>"p%E]:VT" >s|5k51(A   PL5 ~z P>>pdCl$.3p.'&"l ݭ=2h h{ T| dz% o -.65-(j.ܟטG)tY&9&n`huA& P"A/65.6&1 /|ڟ.i/>tX\msz ;M-g878i.r%= o;EW{oXyF!7M#-40O) : P ّS l_pw -yI7VhZq]",0e+$R|I܆F= < : /Cc.%g,*%% e߀No qW&E Ps9-q]d"'&"| } u t>7.*< dMOJ =u E Bu"d"7 "'Dt|3 MiC[|`_z ":1 P Tv` @@ u:}C.& J  ' EwR_+M{ -  :N(,| @V37 Y = ~Y@T0 ^ 8-9ZcA;`j  k r =BziFv{jDZ|d_3O[{vyJJ : M  Hx emuexAT8wT - a / 8@ =Tj>$,E`g&y]Z>{$ h 6 h)n 2Rpl%?Trau' :E[e v Hm+t L\r)JjCT' wSr~$X'qI> SmJHJcYO-H~+c;tx,URC,~di0&7O)#;_j3,+{G\pKLR9i3.*s5{7n3/+x9 @ }oJC 1@;-{i7R --- Kv ]i|0ty2F hrf;UGEHiC8c'Y{2X. # 44Nbnq9pw7[ ( i [ s2 b~?aL w*[26ANz-GF51 ! A r=@#.|& QL|',U/((V ?Ac%{ g pFQc#Okp/9l80JC 2hCPx* ^ ^6 Q( ~,2><5! GYcV\L \ A&J~tr ?1BA>3M,{z U3!&%F>B<(X s  Z O{M#qCDc޸!([1C<#3iJG@ U9;Ws<oI*w-9@9 18 cu aga> t 0>5*~! [ >XKYbuW&98,/\NG[go7 (>P*A9$78-ypP5~6$ e  " ELWoU-<3&66,o/^  (  dY4i )l870)UE1$f  e }  ,;0C;  '#61(osLc t w*;F )71&8wvFul C A&s ^M 09.y2G5tt 7 -KST&<*8y1" wK A   `Dqoy,s;! )/(*"D|e!NT]q u 5&4h$+:*!GP/2"&i; x& %>"6; #^&&$] 7~@>}V. 6C 4 {"u]U4vx"&'$M*WGn8 r > k VshO]U)')^#@ : -'t  \W\w_&P(N#?!Kc4ZZ  Hr I"C%Y!$ AO@a]vE v P 4  Ro#N%P, Q#ath !7qp"I". :R UdY3N9(+MiO9 O?{3PzIeOeO(VV n w)?${ % VHGaP.g|cYdQq 2P `C& 1%VT#8+h7 +Jv6h]nEF1iU\ m N Omn9Wk-zZi~X $ . C c (|YT#:3FR;~{<_+T98WkQ}bT ]R{xn<]cG&$N( d7>`=DIv7/Z 7t.8,_dj+(ZE~5noH #SUUm"vvZ5] {Y"an$wJE[@_F4W6Mg0VZOC DpF'`GM6E*(gR"eGqPYR].2/jqeQKE"J"^&]mk1B2bujl{=:VD$SuX5~0Ky7brS!6c-%UC0{9_] 8 chxQ }6 KQ;c+Z&UwiX.={b<Z-1ei="x'}0k2x8[N<F{1f]%ZZ:FR0!!dwj;!u_X%(xb>r [{,b'"}Iw8KF0[`|fTCw8uW4!~ %AU uI1 [F~g-.sfXOX>HEV]% HcQ2F4`yNm #!7_LG FTlWuQJ"!c\E;gkI t! 6 TtMsnYwk] k3g$MZLF\}O [# "v ' NB}F]+Q"i'*~)UP~m?sF}!=G#"?)#*:,$C 2#JFGIAF # $1*k*+&Q,x,C ~;#+) lM 1#%* (B*k(zG/S  %Q~jP#(-)**)7_:HdO  uD!3`'-D&n-2(*&O;{a1>P=wc6~!N*5w/R(f' u{ s S.Z-c",P:`/)$T T}?b w9  h A@c#1*84c(q$ 2>` M A , xN.of %2>Z/&a S݂/!= G[HFq4pdc%U %x5N?.:$To٥D mO RKs7`#Rr 7*d<5-( FjلXfnQ({w6v?.7Z-#mdg`  W#6e+2'ENީSn @ TJL01) *#Q>B^t KrR?WE\Sx{ b9Ni=0B0ga;t>2<#U+ >5 &Olre|kdn%@ i=l4!C o&dFcW o+i Xd2j{qr :#Q17;R8Q fi1S())g!7}6k2G+kD 7nVyK Hq,CaW}@!5L%fP 2x9@6vyR7es[8P,L[,r@4`l{v^k&1]#(P[?|wx E\Xp(Ow Vg+< w "0k6,^/1sr+*u3 PP$ .zvJH[#u[|_:D/==g]6@ %Z (O5@*cz8^.f k DRb~3By:c*X5l"%!p/6j Ey'wXa 0 [C~MWWV#)%  llu4Vu > /dd-M5K (*$C $+Dw\y E kI# AW)m,%jB ?W{T#ZR Gn  PQ^}= zhXDo !<,'\(( ^`fi>Q, bv2pu`E@#a*#!q `H&n# ];Dq9C&%MidP$&TJ 8[ \s1+Jw&5$5ly Un tO iK]4 F#gW%$Cd8+j {nIkXWH "#f# 'NkW a rg4Us"!Hf'N!  T$}Kjwk(gJ" 0o FsZrT] # A=*P;4"3]O~t3hL2Ah JciyUlY o #D ZakES>_p~H,S }%W6oX[^^sDp] Q!'H%J"p(zt)BOC+-4 Goi\Er'x[ |?@v|%231$R?B "jC_&U~ / 1Kyl<9xG^,-gi&% 0(lgVQZr3oNo>hg2_wew" MOF5 %(t1 G-_mjfk  ie>,"gM-U rR.; L$ .`m<A9Y:0IYv&~ `YqR  5*+B"7$=j_7ceIGBGmCQa2"H p+J(UR3nc%{[-P#j= {* as2i#"OA~^%r8L2t & \U}gAWN;= Mz#bJ_!'*0~\Ht ;8m%t}h0tkmg)5"QJ?Eg ]b^@P emcc8X+T#O&b' 6by Mg0G5* ?P#<-KNKL oP(f)vR9iHFSrZX.!K Vy_NI>j Pe4bjsI-t*Ku r?Y=]F-F,x$~F D QJKj Lz\KH%? x,4 NCb s}[1AH9F B+%(FjB~{:#4gs~ m3" +t[H*vm'w*?zLqY o?$n&380IT2 7t>/c>!>42 &jq t)?L r4 CKC|bUv$s 3e!!l\Rriq %","("$; 7._2a| =<  )d%47,O {xr[` z  ):uTUJP" bZp n ' y7o ! X lL *gFb<"xj7r#hvOy%bu!el9'1n<"o= 7WHi1L) Iwa2 }D 7 ^ddS K|TxL]Hs?`cBL :lSO!B+gc=kXuR (/\o=zh];[~`$zCE?+JKSm|iMS6*+5T[{}qS nh1</.%ofB>F`1[wU:"+7DebdiS3~[2.Y!9Um^ZL6%)BgwTF+.1;d ,CG:,>V~S&uohy/Hhrr^8# 6DCYV8 }}fiw(g}lY'"$/"wtxne|6abyyQS G8^NnXX?*4\bK   rRkcF-0#b +^%TicxH,6dn0Hbdyx-D(NVgbnuhK1[x(.)db~d5Lu,Yl+$RQ(Iw^`1ptO"YMsBONFGyTD8N8<3e18'`p0L:,*ERn tXsWabV.Ta"L3Z[zU)4gqq.y}1Z%MRE{~P:5TM4  _;/Y5!&>XW%Ruv,m`* S%.@<^E?:L  i P V 6WC?G,~4xoMqj, YA$ j)T @?I | m #_  T'N>yJY,#G c0!k   < { z!bsj b by7A b<<J#9|H6, U `!} )Xg5\#SUSR` Mtv3 # P!y45zhZl9I @IDUT /koCe[n\rmtYv/A Pr Q "L`7K Yc -q._0`_yW4 ;z<} im0*T: iu ?U dgm:8+6E, Zz^! 5d3)!;Ya*e  F n=^ &[gq*IY7 k[hH ddP<i2DRO~C]Tp k  @|Y59SC*7}-]va_ # r:%.0 ] "8JF -`<EQ'>) };+Tt"  gdgFPgw-v ) .t !GQ{f(:28A*3Zc ir $$RZ99l,wj{7SO j h v374Xw+&VdpQlfSE g 3(  a1VDf~},aXL# T A [Vk[=]|J;iT}54w8E_S)Rv2W\{~>LQkM_ zD=T7s*Xi <%a[Ldzz . !e+"6-Fx!aVF isjm ^2rX2y  nnN)$||H6'aw BX! U|t)c-a2BSL8wl:wO2  zyk{} 3/<B23!)+-26@JOYWHcURI.+ACD\`j\XTZ7" 1<;Sq_Xg/oO>LX|) X4{{j=!0n7m[ARr*7lc-g#5/YInExa>b_ ql)%9O%}<^Lnb z#D+ wF'~P3iTuP!g74'S~^ M> di4BF CcStQGkeiWORG)xLnIoG:|CU8W^1~kZapoJ~kil9,PZ& mM: G -M7-S ZX-V)arf{]`8. hs ^-1P)JYeSz! Sr D=oB+|hKHYi -Fog] mL  ##%v+~VKDEL={V*\(! 6b|9pa-N  '.(# jl 8[qY F*  # -,.(,%\8@M[LK5 * 8xY"1*D*1%t 1auHvVBn{>{Hd&(3)+o'd'U6JN9JPo =  zzp,/e+-' [dt&!4 P ) :e5 z0;+9-d1%:M &'1ޗ1jG /&h )2[w߰,'1.-%M a  UCWY&!&p1.m%9AۃޟCXbU %ZJ+߁({/)\&~H@$S b{] )#2&#iQ=zo =+ 5&f 3!3 t@-4  s X zfau ' >ruN_ m%|>x6*#   = !8wE WK ( ?Bp39 a wC |v ^ RO?!&Wt {,UNTM $ &HW^MqS /P/< w Fj5^zw|xp q8] . (,V%T?  v pg  cFyrz| =Es  4%V+yoS/ Z7 :N)@uv@|9B>2!Z 5aUcw3>{ft4V0X"h3uz14%Q;"C$`!(h$G4aV/nx&6tvO" Jw_/m3 3Og !1Eenwf^<,>F6(9S^[Q>jC;Kr*p  P'~ {VKZwF{\-K+;cO2tpeg>qq_D%~jX28{csHw?R<.d>WK 6H f=y M." 2A41*NVTs,koKd$!&Q8'*t~=awKIJ4 #>j[b+n$gKYN;VL rq B p*36&}u%A=%ct+.u .  <. K{g5vJ +t $n}G!pIHx66 sq . =i c:*-*1|z6-Gal !p$' J]lK;{>l '%pc9sMVX5>".0slC> ]HX$ $V *j_`}DoMi4s!fb;g3 2c; (1Nl66gy3&1'n}aF8;k] tQ  _r24Lp{sqj-b7Wzo {fM(9cWg{#Db ?`|_b>!fSkDc"^b?V W6*,*gWOZqcy< U*J'$fDr: wefu L9%rSTp-U 3^@#k;0K>X1lMX#ys$t,J&'k9e'~aK@:*";^K|GZ 9TtC{Ad]^$y{){pbKB6 O}zE3JLNB(U)w*}w54Z9Rb~6d!?f A_eAw]Rc)8e;z7o#Um\9 wC4 ]G/-K6{zwvQEp[omjRV .`{-F 2 ^ o{ \jZo vi< # rd__ Q09b`] G)I?ܤ!r*.Z;=>;]0f.,fњJu5%P).-+ \$߭A̭ ѵ6<#j?4?hG>/ ;&ֿؓψ,S4T ,!3K"T x>܁l,>.4-6P=7.h݈֑S<U Lj9f,= )0,960 w'٭v%m m!])HBޭV>%[7,+.h63h)P BzޏNfa&i A 9] xRl+q#<`$ !q#C*('-+|p +v'*wq{%d("&C)3%;t8xl>35| #&)'}'o$S.vwDp;e o('~&z$33/'7lo&64!*'% n!V^{{~K"y4VVP+C&(' ~m&;B0P@M ;#H&&!<|8 ^P[jmP%_er= %'c& 9X 3sX+p% t# /1+2#8~i 5( u z %LiKR$Q)/s/4("_Gq'b lX%R:T-B%=,-1,h'"=IU*x#rLXvN9,.,($r 4` 8nz BDiPg X+*(,r*#\ tXw aG/G`f(K)2Z0&OP\ZAzGGG N y=,e=Ds*) { R[! ^PA'tnqKYZX;Y@rl'- *blzk,GMSj)H89UJ 9]e5cY<DA x}5E,|8L$<J O?UVKV[G.h^ e IY n?"*LZ*qz~[! ??-.^blCL6/v3 A.x]=y<_TKRK]->J?3l.K8GW M& 8cwwZW{C. ml  AcMFZisRo(Ds eT ,(V+|~AD[TJEbG /"24 / .+* K c F c$q\@qw {FO^^ V ` G(i w&nG [`[IO,LUOg b P. n*G"-W*n4o:X [&\=W;A $ ; p6-"R;t!2+l2 ):YoM ]R 9  rBsa:iIy~ jF_l 645"> s  5 ) F)*EA`!8ALvr %/`7+P~ 1 F 0A[M \S  Tb(8\7}  ~ R 9=#7@X}#: ~ m-ah : f HL^O0M / ; Pc;r1j[@ c v + OI=j }B/(8_zC?|  qe#E !x T ^a:_DGh~h CT5c bN t v/bZ8 ~70R ; %~f2r C^ ?mV { U$Xl6"VdZ7%J C}SJ2N< * o0`3yy "F3V(E A ~CCj  cF E1? NF<([M}`:J2Zh8D^)/$  p { kGq^9b>% 9K\z@D@k,J|K {CC( Z 3  N c)jNK'wNX\L_L$x1AiXGrNL`yd PrKP"* lH"J4rTxiu;Y m? 9zjd)Vq =J `6"Nk|D@)W |JLqu\+o5;wRy,Q~?R^E(,9>lON<d|Y1Bx\%V.l]6g9HTIsh}B5\{!%V >zl^ED; S O  { Z.u~7Yp]-=s]kpz& e Ig {  z$4i% R zhZ><1) (V  c+Pr}l  /K*Y'M^P'i JwPHl cAuni_9Rl# !-N7!w1t 1JP=#^. D ^| cZ1HAXC` >^pDB2Td= kJDJ!Xp,; "   veVV" o c G#%j7/V<hv0+nG!T7NL/ZrR~At^R_VbZ+8a(" iA3ep  B Z  K!Pxp{Z4XB \&"n[5g q E MiyVa^b0[&I 6e8I2rn T W ) u$]g}\8x!~J[ iM C 1 d_X%, $=VES|Sfad\ pt/    p %c{YnM. ~>*l6-\ = FSfD'OwAj D TU+Ya6w[V2 y'0?)%\ A >p7? A @ \H AsVminxthC$=k $4m)!' f Yl! X7PEjUJ5xm-&7+3" A "ޥ1/  eq `m3=)4f;t0B:8(  ? *L Ctityft$1 k+ @^-W  WC<{MZi {zI)>m5 ]$X`;;2$ Mm C ggD (p_hwA` B/1 0 O|j87#B<;" D)x|o4J*Lot"N::e<%`#AHo[R  oIn0r}7tR&4 =n ]i SVO2-o(/#&bmcqT $ t7]Q cs.yqKg 2v+UFiv6 n$2 i n ::3LK?z k8"=dL)' S&A/EiR0NCyv"H&9a HwPs2a<M:'X, SF \ y /8[;mPj,$[!&Y ?o #g"o)3 17"kB\$'Hp#%]#%^"} 93Bvk V%BRB5Y ">&Z$ +K fXKZ[%[NV! ." u ,YkH Mx<u ~ \ N ] A{htU B:)l<\*s  4e>zcfmf H\`o72*% R1,mc2s# lz- *dTIfa-3aL}2OrFk/+ZGNVm[i:T@4|P's_Wu>V|1%Fp f4#"@F a8TYk9X=@V?fnGAOGKIrI2kUq=fiK;o{V_jQq]T3z' %'6mL:\YU{o d4E M3IraZxV@mA_FRh/l)g_gV=lI|FY0w?/B4v@O? 9%!9R0 =M4I9W`Oeuzl{F?Kbd 7e\K#q79W2GwI@FW"?[CA|}EQj+R$#_34A!V E`\NK j E 00/=X`xG-I,=4gY1+6b *WecZm*&!(;6" / SL:" FkE+&4_R(05Ryg:'_25&5tJwvGA^?})rm;}8CRX:U9h@{[~o> #*^+;kr70RYy7\"|M!p5` E' kX w?DbblRs+~ s3:A6f=|t!jS?  R-,BoH W@]< +KPOk$! (Pdd[G\% Y=qK| ~%p)S qoAEH *D] ""(-$  '2*v F<LFwd{%f!X$**)  "{KP3 %6  o::.z|#t (| K-G+.L %` }`8)]U6 x!#{ ;l^F"" pA mz, 89$IE6b}u=PbJBC 5 31#{Sw 5&"[ Q(.mW  "JXo5..jB>@ z^>ig~;02n) 0 Vx3-*=}cR{-S dp2!D"+z_E&L 1|q !"DYMC&W7"|Tx"!c['&D] L]E5 PO6YC%L'2 !W; S'f-l&4jaP%{7rsog j0k/ U$S'$]]?XD=Y^DDK_\063!`T,**tIz<^Z 6u]^$Jy X>aw8FwhX dNb2DZd =0(mcw1@/jU` # ZE"l^35Xx`N~m)1(FZ:!j s#%S o -jY(w\rz:b.Bt9 G tD e6rvKG ZjJ6G1P` r;6LG6p ?@/lJr/'#g{1L4X3{ K(#=n@A)[1&;u@AGTS,nQC XC910MQfTT~,%<ZJyd4V~nf5Dv iDd elnD> ,K)d!Xgu)YIbIXa\{ G&* 8+[Xvd~M 8%ypr-& I~: S  ([Ufp:mPXEgLB#6(o E  F< +} 1x'$llecSEBwV>Bt6B@0 p!J5 b@jk]zb >7]|GsHO._}4'P%UVNu'/)/+"|!LTGH*}5d4 Mb3-)h%JWwF\b]\.8C< n!N>WZk/Ma(BOR0{wLNTjqW]<V0qkARC {g1H                   &' 5&/5J0-.z9{@NJ[D cq +yvpcrT,` gQ=1o8#LvD|Gcs@y 7D;3)2$h;{1(pH3dRTReZ@EOL@r\%R;6*JIXQ72pUo<^WWVL%%cC)#".+8S,D47H!6  1.&>H,).!0((+%:6 F//:"E N$/1)6 - , "   0 '     &8BM[WQUM;1+ #02?@:?;9-($   !,=;@G;90  !ERrquL@uN# net2*{M",IQN/5y"z?M? G[Bm4:XUsa([+V@| E jEa6djnXyq&x3sx & x F ,pv O?y e  a  2S71Jqf>D?B - YL 1q>@!yO  $_H d53k T<'%u< ~ b7  (E"6- Xe.# - { p$>]u9x7;JiN[~I# n CAS.nwmpmY.{ w`/@ t  <$O9{Oe+49$5 { P $ o /EQ+CDPJ|Xuv4  h; H\a", 7O9P' r 8L_@x4 z {kns3PQ9pt  TH >@P$ ^S"hJbi ,[ >X]SPVN5V MG EV"2 Ayf#~>cb*+\& d &b"  a567XaK"R; -&~&i Q G 22Cc;}%no8v Gga)&%$Vi Y [Q bNRE( %| $m."X&& % zi[6CN<Z"#.$p#$ xlpټgRiaG ? %-&X%v#QN*+ݍpidg"Eps;U"M .+l$#jbߔޚuـ 8A; " r(&a" CW23J" ~` s#"F % dy-qHqJ<`$&W#R.9C6t #59H/a I --NT ^4U\9m, V9Mh %STn2T"[8_DCGVgcNzH p0JE`YnGE& }:f7iF{=yp)Q[N  V=^"D 5?NM:T ' ,U^I  H7vZi@S t g O ; KEp\ 1}B@[(xSbx[-%]3J'qgS7+C7hLag]~ s l`A~'a|t_@?CrVGWY r5F0?c1OB/g48v(kX~Q,6` BWYc\bmaYftK67(/iW^M{}sU)sjipzv/273KThiPc}n_V?pkK=`w %2R^`n~oJ6L<{V^SZVKKD+7GJBJSM6zY=@LS1/NYnpqtsOG30H? ^bf r6GSz_]qk.hWY^ u5ShW<rB`H<w^ WAq0^'&LBj|D"0*)rA, H_I_)%I%ajv]-tRU[rjC= .Q&d/s*]AP*Y8DyUq B u2C oa[  P1$%7"yrLq+,XN# HY $#>< TrRZ!I?]{8<m 8|de,/cxP|YA -gj7O(K @ 40!MWezk9.GJ{C.v  l7n &ք*BdDnN.P x $ y %s#1-z k? SK7` 9y >`P `py Z_c  A k! EnL7"BTg'}   rB  u+Q"Apc 3OU  l (S E@V  j:ZRPWR Dr  ;g] s@qigw I.JM )6 * ' 9,tH0 jt @ w <658C:duAQ&PN %33 Tk Cd(cWgA- (` Y sf3aS7D?Ojb?`S ,.G%5pzQ>3k (= 41'&&FdB|  n2> ]T Q^G/!O<3~Pz[ !lh'$ s,r@A{ql<CRB") =' Mdv=?PNyt8+~ UIR FbPO=zARcRrG)ti  C}~/)U VQDw:rr"Caa!*I$ ` D } p e@?Vn+`&y:%^F@ng }l\^?FG @O! rd S\ [d>>/ qRdN>L* 3 R[ {s*WA#$4{L ed2JL 0+pod-{l;o o|-XL P( I kwb4C\9Vwrrq g |7P`+ H4Y,s]^p:n.H . ' ;[wy$ p&G Z3,_& % N  ff:ZHn(K),$H q=Q3'QWW?3%nFQ:/4yCzLzPOQ0ak >HRUg7.J10RQ<.$6.Q A5 E8=G L: 4oYP-})ou 5z^ {Sh{#@_~.T5nVDFW,[L{MjLf< 1 Ph/'-9s,/xڐP 8  1  ] ~C!aCCX<0@50$?? FmT2" Kk^+V#BKv|pD;U4*"c&`ceSt$ (%,1]^n  `c'30#8?g^(Y62T+2#VtA % j0+ O`"fBMD 2$5o)h-D 7 6 6o<_]8yyz{o7)?f}"B: $0"sbcX0 L`  ;wI,[6>q:nC+k,?qI} BSm]B]QZ =LH)_7q~j.H-^ bW5,?Nv^WN)G%-hb<; '- R  9w Sa8&8CFSV( 6  *.[4 C Im e3$BA5~P W) qkfn?-/S%)6zi r ,6WlE TFBhYQH^hR@o"W8 ,f,S =^F{ eC:VrHYmw# u"Vp }(Z.R!hq;7 Fq|5ai p*trBHvp-_!/'h_F- _''w3W]xkD* !3[W*+ A+{ T;lxO 3+.x6X<`4 @/,*w <8fy g)_c@@2u"wazGP +*9(1  T }, z>s.>WDT3U%.)!@Q M ~+Y= eBs!@2 &0=Y%E l'b.$ B9ZY  ~ @ U(a*f_4Vb{$l.& uD7WL>_V>ku|wfx10WU*%[ :6OG'F2/iJ6MKhmm/Q#N)a*2b'U jS*A!9]cE|6 ~Q_q #"^jm} mY|?L"u67N6Db B # riN)]P8q UOF(5&[ z1GvD M !!v xu,rLh5$q9)u!GW fklEa-fOR +^*e\j Has UM"6&CY`0z (5g=/mwn]2 }~3Bc4Opr53mC_dIx/C pt$WB.0[ f [Y&#cQQ @/}Y{}y1V ] ` r&}{hBOER_M"M3`5?ODRBo)3 5Mi7Vv5m!DmX!CX 4fKKWZ4Ba{f5vy02/A/;ZO~zp[}53LEQP,\6V7:* IH1 #gnj7 g?UF+TvX'%BA` ,g|{pxJ|fG{}w &y6A) \92gq/&yfqg-}h{jgt`ugk&XOzu0 * &)?{ZD(k[qESZ]7[WK<>)zIC -+8VlF5!7N[9D Cz_[%|&` #GPt0MzS0211gvF2VB/*^Gj8d$g+q!)@DB^-;/ ct& ,da@?RR|))TtdYh}q RAQ` wt^>qT<wwcE=\aHux L3cdl+,Bw> t_/#Ic(8jx_idezZ9gs y!\YH7 rz0ty_bizo$1%GWQ}im?4&c"~s7xce1&x;+=Epy1pN7zWv jN6)Q(t=I;1*P.x` L!e6DU!LP96R^C#u-o3a>/`?O*zxw8)>SGHX_Ea x O > d]eOcj)*4A[8$^Y zq!i%ܡ;X%B)SC%\ ՛z?3qY L\8[ M R zaޟ s+IU=fJ0x2I4$ aT7- NhjeN!lB&iCO 3k׼ŮUd# q6;sc&:I(|1KKAwL^< P" AII w :D @Hl541 l"h4CL 3Y+E%Y@P{:> Z@L?J%< ! 9D\Tn}o%0 +CP25b/SbYr%:Ke%c@o5 -hPjSQ/%(I%V \7$/39;-o)<]W#:nX%I,{Jti &:,moBJ 8EEkn7 ,sP * *~8d#|_} T w~8_wnyPIo+4ny Nb|d+W wH_rs1.)Uo )ql{#W)bs,,%|3"3iPw>FPrq]0o.(^ >$m /~i-~ld L(o27  &trAO.sj67+} $-(= ,5,X &/f 4,W.  ; T,y&qjYO(TWNy-@ qxzD ehz/_z#V5:8fu~q*&%{ '9 - O8w2"c(?DlQ@(% XY ;W.36B2:oY !p*ekVh-aMgQF0M\g)> $w Qhn+k?)GpumF;mp(u6 < f 'VqP!I.XN, %bi,$ T [ 8 f<,qf MJbdk-_+#"` |s[3 M&Y#G;$Q""L8d4LAl0yuv)3#J"zM ZG BW8[0^ "%e W z55E/V {!%r7 F[ )%BJ%g %! h{  -~Vnyf}N *!/#k 6t7_c ho"+g \N3 f>imbCUn\YP 7W:1  ZL 3\{#p w F M6cp1Q^,%Y)jT=$g ! 0#un#bSN)w 6D#Kw=[4i cQDzdh+L*V[7WA%l fU'gZZ)7D)*6lR Lx*QJ q?!{DmM [BOP!(D+nah _ ]R5{l`u[ 3b RTrfH:0 @ |yqG? _Tgki8 6O Tpr_K+a^7R'w<qa&`$}Dli A 3-a=|h6E22kJ8 yZkM.;_bF6&>Qj+>Iu1M9Bezy3U zy p?%t]5Oy9w97/^3Ohmc5 zx,zQ" fO*n q1H?P[*C<EN;@645D2UhKI)2(]0|$fP2D*`InhJ `'<T,(yUL.]weyP=A^BFi6uw>'nk1$u OTn*4ng*N#Eu LO?nAt600V*U(>98- 8 J +v)vVUh1-Igh$ :  CTsLLbyR8 ; k7~Ja*F< m  =2 \yg) ] ] ,T<)$D.{oph1O>[2*//RJLE1cR~ }8 1=e-,OtFzLWa Ve{r8MYXJ<&?d]$r(5oR! 1[]FA 6LUc"hUomi'!&4(V;y_b<8Ay[#o,cfSEF\J P"~A.TSa>0KD1;26jHV:_<3=OX= 4LSXiV$ QW& <6]i97I`V%)@|?o^}TIUp_QH VXu}0F5F/-8g;0Kf'^Fo3P4lnE``AnQ G@ 78Je9"U[/$H/&T%ewILi$CvZ?'Mfsq>\2p":"s-ckPB/^y#XrDu;07/wm(*rKK*Hg'A:O_,cF\$@I/ 0=C Xo%Q&1]<` >cz|X!FDHCxq ,7N U=Ra+2DHw+Y n88 VI%4AciS]Ht57 'HBB<lWC/l&H2.GSM9'YgJa. fSgZv= ?qt=&y!A>k  L ~$ S}#JCC07P Vlyn ^9o(I8E E*'T=ffvP ?pr@aQ$h  [X3+5]g s#_#" g߉y9S/ V* '6%&~'&o$N68pJR kQ7& & 8@x yv*'))^$: {<1%"'_^ 8 ޾8Gc Q.m)+H.j%Qrղۘݢh E .r&@{-C,O*H/#'q)٠'ۑ /v [l< W+*4-'5,'JYyԤ! (S4vK*^~;g 3$-J&&4'@ XMc1Na n5|__qT3Jk+'!# I>=j&C; .V .n8iM$&' o oU޶t3 ? . wX\#!;0w_}T^ s v3Cxg  F;l` K Y9$ q ; c/E^w zM. |EbzSn9 | GjH&5t 7|}J :kkz6<oI  #0gL {^f lK`{&o FmC4g t a}.Up$DYkM)z + 27)33+HV*<QW ""'y2 [22b+<6X'pt$ {vH,}JEOW4{bLf5ic D iGJ^%1WUR_J :cvIU5F4"I+ 6ftRhg}>dL$ &$ HKY>BF~03ZEpH >#>RNWM WCP)}\cp`*GK Q["hQ .= hn(p~v#|@Pe5U>!re_g #-#dm I-\P}8)J-M#eae ;*uIMV.n)<zoeU'7,, J!pxML-[},,#n3ntj LReAU_,R`P>).c&M+HgZ^z )-R&I1(6q40=wkg }1+,& y+OZ<{W N?,*% aCFE["?rP]$g+{(d$u>;$i,j'?@+E( *'!h0  DNm_NWMjb9D* ''& UW2%l*5'at"&g'\# "xFPJu"o&&N "J~!3 DN(&;e"%%p7v"|q(Y #Y#nAdqz^ssWmrFNb""bk E&@ zfhN!2 d-$%/^ s| | !P4siVH0Y>-~j  |Sc85>}JRH+ 2J1vA v Bow =nA5x +f dfR5iv_S>h9H#R0 c KAeh (/v1g/  U VdO ZcHVAr5 / lHCgUwwro;wME :CRP 6YMAe39K;R J g@ ! "uZL #WIT,E/$j g ~b$}7 CA56Z:b-]r9s>NG)Q>$ z GiTE>?)WR}R3gi9PO"RM*Jl1ABt\cA+ Ip0 /{f7 vNaU[ >) D7[hhp=- <WxF}?8]q#WLzgxt:)}SR8&l&a iykoy]gS[zM`VzT ,E(UZYp'GZug:7*W5wQct]{Ry[Iz +QwhG1ZhHh_3Kw n}   , Wz \bXT9h)_Ph' Z 8[ dW)a: [NTVNt t%;* (EUr  %lR> 2`=yBpUUo EDe $0Mv'W9] 6   ~[ $ $5 #^ ^/_1#= % !jO.%( q y? BVg5/ \+!<_2}Qdu4 M#, ;q@pU :#!G='\qt&z! #PC`^Je L&y'R(ow~8_/-'k#0N=P=] +Vup-W#'Kf!Cf&%t~8 W:hM3*Y'O[Am4O]^e8ki@ $+P>  XT@Zfa,!)-0_e*O>V\ToY e .(+ CeJc,27!Y+(?Le4(E+[i8X"/!%y*g;&_u>V d1--=-  YNMmq.llO&R2p& W+> 49B,nr. :C4u., /n! 6O7_=\[(&0# d}=1 QyX5 Rr'O- ,^$ 7x7;%@wW&+c+Q*w!,Ij+ RZ~We.Y@s ";( Q ^V &LbIEza)))'sO >v~ Gd - RfJ(y}S()"2 T Kv-|=|ij:T9$)$/ E/Rx" v EJ*3T 3p (J;Y&X$zYR7v{3U/04{S>H#$a1v.Shy Pt1=rXpR=V<# iMj}Edu:S1+apR)"V\hs?8 ,b,lt0chwn$p|P$! XOh(/#Z+I~vJuMqkAf+R !^}\RWW`NIl$AB "" d8\a$Z?v]2[j @ p/   "5 MXu@q miDH vlIA3Wnokkc#u6XkPA+je:c^=A> J/l*<|0Lqi?6 x$?0j@:'u8^go>]h Y%0"N'ElI/[ 4)mVHbQgvI%'PX.}{kf}= gVD&NRSO^<HmX# AI7FvaG~&=b .4N$E2T2, ]MF is4]W Y,6SmA?WE,i4rPo 5G| {_0Wz-v9uGn#F#W:Evbj W|c/wlnTEeWj0NzZH$2M;oTw| fG _'lPf bPe[2P@q~LZ%F _pBxdRll.vFeD Aws:#9'=dn ' cl%et#}_h[>C_|))Ot\#F$t ce E 4+~Ca?L;>f7Q=e$e.HU  8kZT vRyYoa,ybPN{"/Cix  ? M E=G.Au.6,=Sj4P~X8.$ 2 u3&> ?L5 4^$HF7y%9t^?Q? ^ DsF,.@3\#%TwS 2@- X!lt 9c$On]9 ])f$w~ fXf="H82wb+^1c} <75P B ?X]l"r?w;En3s{.ADG:@8sjD)-7/e% i@ |q/-#f4:eA!F7$wr0d?"fepkARh-pSV-d<$S}^r60p(oQxe;T;>@U^iaE|-+NzdZF`R&9+\"k0V6Hw\NIXs0E;YkFz1E458]>VLi w#d@>n~\*8[ Y8 ,Z)FA' 1X-jiYUP<,FQb.LAcbX`,l`bN 4a$m2&IaY9(X0w'K6@@GcyL!\9u>W5&"&*1=K[kF,a%E]x|sbJ'^+lACi%Mz&),08BJMJ=( ybE&wh_XPE4")3=BHMU`t!6KaurbN;+ #)/49?EJNPPMHDA??>:6/)                           '! ' /,%cM|NUtr.DO"8t gFD CQ'Yu%$=?hQhz9jSJqP4/!PIV}Vj#|k[lGV,Ze@B*yZw*Fz?M1K(m2`l$ B0 \4-: 0iWb(d vO5wjS2M (>K[U ZzR<\tz  s ` 5 J   %p} ?U\fnw " F  ] k }k~Cz {CB?h  y G S o O + 5zT|T 0 O 3t XP:6sgR Z 6@  V7xaXWKT0K Le /kAqBl*< 0uOtx' +KGs  h--g\"Js(  ]0"j)^7m #!"j,d n,_,hR-|<f""%" /S#I";Mr43`v !&A%"m^fQ&r5aLnN ` D q()y&d NT n@eޘ@O?  _  ~5@y l%++4(= $/A Ci 4-Jkn |t(D-,.( \R@Ne![ v{a nD'}LSk*:0/*DO #־c^BU>;+Jf)  )4j62 ( i\؀_҆ܿzK f 'Pص׶n (/K;>7<+S{8ɚլ^<"-'&]&ۣ!J -E4:DB5#{ avwYbC",,&R% %Mۻբӕuڟd &2J;@FG5 3%z{˧ٝ'2(1( BwӜ N0h!]0E<EGqF>>c' C;шfҋ}":/40[&4 ,YVc:LϤ>#4@xGFFA:%MuaL}RmeriI#.}50&Tm DϪ~Y6x3>FG@v;f+Z>Ҙß@ԸP + 112+>~F ?\Seψؚ߿':o?D}C;4q [ƛ2DݓCN%a*N++X&h RWSѦ?qgd58V=?7?/0!] \EV$N$a&$ 5 @sLO %I3&4=86-'<P.(m ߨ ^!L"\ff`eE54?1*n MԨ}l>@ ,(*j-/' ~/q{%WHBG&2!1?>600"y`5)k.x\vU5" %j<" Xۼ ($ ~ o _p.hE[u`^l1nq  s  %ibZ~S fvgvlWJ -@I) 5 GJ.+0wGQMs5yR|#K:"z^ph_CZAf) q}agq L{4kJ!rnx75"OfLQ%w&mGD._"z,0l gj fDNJ"v4   FgT78;Hdg /W|ng[K4{toe^je]XVVOSd{ 0Rt{mk_SWVR^[\]K>+ k;C#Lgpj`L+,T~f2n58^&IbhbR9|*^j<_NHISo'PYarl`XP0#!Ba~mnW5 Y]:$5Xjv >dkuhC@4Wt^UE)K&Wm&)h&Xgg,/fQ}6u$/>CoM'u}>e"deRdE n y]iZNw5HgC Tv D-A/a!QYU?l6,*+S1_uy,.{JA&_D~&bg@qYHtdb9IL!('?0b}V Ttagi> .J8qqt[so!CedMSR2_oBK%|y{?v?, RNR`DWxT9.se3c@~?\6S\'}vpw6DlE6XOAdnZ{5| ;  '  [axXagYcgwuZ'N6:-];p9!V h  z#>L = G5""%#%" "B s:RC,& v > !,$g%j)$Al5n J UKx:(erWBm&%x-w)k!%&; |weD[I$w&>$*+!@ T 8$gyc]&'#)e(RP|PE mC>>& 6#޼m~f3F&I &7%8 kN ;i@-Gl0s&g$vfi ha dVn7!g} ~   I  >4 tߤj<aW f8e x YUEd_܄-Z 17AC1f qf`x[ YUlI4-M/ GT%G2(f\Nݣݻ0xB i$+ "-p**s~ZxJ:0L9}4  [/RgS9tF P6:w2/0%q%?]v8/z Y8,9C 8,z*)vxD^ x'#o  @-":;r ގ݃xeO k 0f o12J~nW-`~ pVfs;VU | ;Lw YU?3vw|ܒj@@w ] K wUxV >Ns-)?/D y + / XY}xk<^` Z+&* E ! G c b hXKaM)wg]\M^jc7 y  :'d0T"M~SE . c g ]  }R<>u@HRS{Z `"fne:Nc-!|&ybh  (  %(6v'1%q/M~T;-*BV R`/HUbJW$lm9q> ,sg{FYr- ,.UN j P'WSvDh| Br* B &T(J^F!l>Y$[?Mg  # og\G> #u_V f2 Mt5 DZA 0(k{BN}z) 5UK= w+0)IUL7{M5}sk1Vr{gJBA& S;eRBN}](0p,<) K8nn$n\t)iqi<Hqr:]iwTA:_'UkJR#  xT?BABqExvY@$0<JSH(upy>vuexrB !Xq$uwvkDR?@qg+ lL  S=B x9Ozo~A e*LS NR N`Cf z#%W[t9s "$$ !"J :v;2bZ]gl]z VE&*i-M)$b" '#GT dxxBwzc r)02-(##s_kH5N*< =Go(56?/'#u{ ! !F u5c|%055L0[&!T[ I3k*))F z b*cgkj! /61p(/ ?DeY +qpV  "}#441-TH2E *5@PoA| &*[5K0% )bJ@nS aYc4Z]}Yk 1).'o_t~ I x7p"At4rV!U,1+!(Tx- \u Y x,<^pw|C,3CSn~B#" .D/)  7 . Pf(Ew= s1r.u+gl4!-,&- 8.6_L|HI"{"!*1):#G V1dw2E- 8)-VV>:E(/.3##N!nhrI_p ef46:J NT:M a:KqCS~aqlmcg/, 8 O 7  ) *+f_]zi}Pm]?c/RJ & T: h <k<I@oh9DG(;O$$HVi[1=t$ m:_  Vb,Y,Fd L@S S@n 9 NvUc*?Y~Ko&"OGG+3\  r_* -/xy9:^EE9>K!2 ]  UhZ !{&$!wklE 4m<E& C i0 ^  Cpu#C"hV}s$s9xZAs +)9 E  '2X6= t[sDm5(zJ , r , CPn;GIR1Lv],9~oy k  A'^' !D;@< w8419< 8C 0Py|)_cUvu+45zzKW$k{\?i5i,_I&7 :.#+  " k%(f}c b-75IHole?"\Fa[q'E<URi~r  {^CE+(Wl 4KVFC@  %72.'&!# #'0*++ #$%:67=66,'" " .02=948*&-5@@=>5+#  ,(-+##      1<GZXXZD2! F*Yo>UO%X=c|("$X8bss LCx\Ft*'YIKD9V"/2) ) &+'>2A4"' '8C10( 87A:17,  !+:<USSR5 %(5$%%%u^hkk3RL&vA } '7(*2>XL6a?R"X7n #!cv"H7 yHh^aIg4 7iiqgze[;>~;3P g!&  VPJwi $O7 F/:% bW  Q  DM1  w x d K k}NRL R` |  wV >  ,IU(} \ ,CP  wo!)oh RSb j;  8[, q0xWD{ Es t h{-h??D8.\\.FE g .'l `x ,# e 9 Ge d W 7.u7`  ? S    [ Uy$a % J Z= B=& D A Eyfv79xG(y /  O D e  Y @ *@vdo V  $I 7 1`M%K uP2  f+#D.< 7SNz  r<hx!]sD(r   2<h1 >{1kyy O P l u/!p[I!3S 1 s6GKn_]"~M0 3 leq;-/Q7`>w+S g $KY.3D[bA { B 'lcK^/An}~  X  whx[<@T8!@#z R & z]bkuBy s W :  h 9e1xS\=&FG 3 " XBP#}i"& 1 `4q@U% ^ ?}y/UvAkJ  3 , 7g C, j k3a\r p1p : S N h0F.9|` @  * hXA~<8a> 4 !  z JRDO$A$ N+9_I(t% wf#xt4IWLq3|iiz"+%!u`TYNw`.]=#''=t(awC r_MWSfK}Ctht0Ldvx~~eL4=[kprfPXA$yx$=:BGO<;*3 43;F&B("za2vo 84T)|_G@MO\ *bS ;}CbtESBJG! NYn 9l,TpOA" [MXA rq p_Ecw75C'.l2B 0I!,fhhR*pr#|Q|`'9fPQm9\DR=+A DrZ^O=2v yugbL  =\BE;!_?:2x> D su;>vUh{   j > :  G1RneDP  GH T+o@+C* { { ' R0M!dg /a'u ql9')l.m ? F76 < '2eFlW( 2Fb: f}xNz QV  ~oCKv=v vE}L o W% . NS2L9  | +('H5+RI_5>%9)-,2)cePs!(8C:+1HFg+12v"-!"**74=jY).a(]]Lth"Y,'+5g0C܉K|hv*%,f3,5q|Tq 7B4O5E',R2/5 `N,/*<~{ e))/s22JjVi7W4 9*o' 15l1T.;F@%e_l,]*i$s+ 77/- Z`l>M~KF}+ $$9)a^c[p#HhB+%&;'wM(w(;z#S d]_1 Ma\otm " %/:(`^ h޴$lk[&Wl$13U @yPto$R'q': .1/Y 3݈i SF#% :#8g+ c6# X6< /:!ڎ$u7G7)Wм \e8 ;&)?483 qϥjoV#yC3%%vG+ >EI{X Qf"oF fD2P$ [k 0wp^q -}p #Az u&%N*GU4S {CXg5@  r* W H.Tml  ;Z7 s%#6R\R .S)Y@!} ^ JC]A#1 99rma;%|XO3TVz|U<&4(Hg6K*p*?JThQF+-[S=5*H a '$&2Td,7}jOE|h xwy`t|@pN?Gm+, X?][Q$Lqe> %yq.yfp?klQ=- *9Rm_*w.OVG.   ()1BKK8BYPA4$"!(   $ '&      &$ /3'&53! "//)1,"#   !(% !& "$'-(           )   #&!     (5-7) D {M+*HNl#r gUd Sn H< *#h}Dk1(R1]`u \F N |@1: C6U8LF}$3!&s Cx\g4S{K t+0 ;k"?&Tu ;G`R)Iy: k#( - nve*.2cf@|K GN+O , #  2ERuc3542% sH;ZGzM]Bgn<~ M8 6DoIZH |?x_aVD3oZ4D 5M 1[SO: >!Hdfr^T4  $ / Q 5M } Gc j B Z"~~k PIzt:y0 <c ) Jn][h,+Z I   ,8pT-}B%3  qi5Lz+P# G@  v"23< l$ %i *:. r~8` 3 _g3iKS f)0  OV-0x2[b %0 98 4?a.TR Oz B xab X[/a^a7 /@}Tz<g#GG 5"&d$H?  5RJGK @Sw |ns&~<%q9H / d"2etd38Gw I ; &QUI\nyjz e 8;PHm-,1 4 $BSD1q#  )g 62<}(cZ> )D Z4b & 6'Uucs,(f*O3Y  {k12?TfkhXb V l&G5Y4*O{9=D AX'lqoT0-/s~pfN48uJc*"U6IgN08B~-k~\8Xi1 Av)?F~UK2YsA!b$Aq+oi7 XJ6]Ky0OiO:' v}d@ivy*V$Xf- *} [)#%Wf[t('>?7ty|!!@67J(F@CO5IQ3 C! +".-9% 5'! " % " )%      (  1 0$ #F21) 6 .)# !Q[B56 &2*i 5Qw5 ZS{#_u/MxAw4>WSK ffCS='-o8Xj_Yy^{ :'{`]n/ `0HKFU.WE(TKz ?@?*>&%(TRf~<5I)DU :h{D~0NrD^ K*qd&FV0:z;k?63C^#b Q\3ue7U^e Em|k90  ~/n` Ug4.     .=t?bU:261A,  G  V%#_D k wWV "Ls{^! 8Kn+I6] N? Az+9"@-]U k_cU ;>YB@<; qEZ^ZG %dzv y J:V8Eow MM% K T](E[ b $ U<\S  E (Y^Vo O B 0Q ^dQz% &#Rjo a C BZ@EOCZlX s]8H (#G4 #L 9 !'3I0iR[6 "%i.Fg<&F {M!1Y 1^8O> n |L `ulTc$u2x` Noq sb n1Z|}vpH k'; !IPbT] V6 BucvB o' (e6K j-b3Sn %UEj/"*<% ` 2%\1,}p  ".7qJb t'E5 I' [0Mym !R: Q&n  X **'~Kn 4p _!O. -s_ KK! I m>INd)"=iT %Y~]q a (Ip07TsT # 4(B :T/ vZXxWMa`,-$$ y _;D^4oP1 k#RF-( !&'#T+h78otCF /#VZ }; `!B#('% -HM; bj6DA ]4aPg15C<F$_4o V3XN#\\#FJeE& 8' "3F)eCv0$1j835n2 ky d&z%,"#b!~ |۾gde 9J ݜ& ;'4+(%T C9ٳU !$C4pܵ%ՁT '-')3Y2X8]&{ C80ڹEνҕ5  ))M%9"]) OdسlEsb 70's1A( t_ץVPq U!b"+ K W n'JCx۫ .RD0&M$0I"ݖc"wT|{ݲy .()#),)IY)XNb 0SWt 6y Yߏ&M5($)!B !K6|)~ovf skQSR_  1RPX 9k/` L `J/q=<.c em 1 }{ &  uI,1a/Vr.BybGEmGbV_)ieB "Y\J5]*(a|Ldto^?ldke%1Ck. dwj{I[yOL'!g;/Jupp_oOI2#<-D/_f[wl'28O^Rl/5W`53>\jv0MJ.KFyO OiwqO0s_ep-/#"&<WmrrmY7" 42'/ *GGDM:#TedE#|w~1Pl{xoH& /<;=-& '2%4-3[WeZU.4;{uJ[|-v.qSSP LItq!0/aJdRC]5 '` UL-D!Nuy,_sR: 0,pL( x>RC+qYOeWD`L.m>LG6de] -RbJ'\H81yINp5>>=MbWZB~g+=T$Dey^MD(8Oc;;zA h*g U<rSmM"iMm8-glHM^trer6C#~D3 \36$-s`RyF'~qH&K.x:_ {Rpx\BOC]rILZk~[4S&d`UZw6R] ww;f8 ",*+72JTZ4xs|px?aL~ D/i20 /?) #@=J22#:/5$.$    !4   !7%:0/51BLTN |xq',      -*     ' 7 ;  -%3 !+-"H (LP eN4?8fpB{"n`3,a*% c(Ce1gaS :LV+*BH}Yl.%rsTQL.#U^ NU<gl9sO_BHlj$5zqNdZr*0yCdeK _, [P oByl9  |p@0CQhtX )gZE)vKue}8;  {N2qO K  k [ % T [ xn3R -];6nsW|y "@$tm68N:?>m i ?'Hf Gka+VUrPk\ 7 k%%>} 4,isH !*!Oj"L'dDBd \j$P%+ij%{  {&"* }+x& Ip}AST}&cVdI*!%p,#vcZE}l}n; ,&"c,!  s|mvum4e2jZ '-6#&% )`]mu/xD2<d 3f"c %PF#$ozGM-_:_z-%+!W-ZzvsEq?*]#.)F tFhG cz}&` W))u!bG{} \,] #8 Q\v#Cem&I~ ,{0\MIT<j f F oWo UG   ^{bR~>k F$_x 2y iZN/ Q8  &4 0|-$c| %]>7NV 4B )G  xurqyH  U  o ^ y[!A CLnoW d ~/ .` n [ W s3OsZ  L Y !ckkAn5]p t* *  $ my }  eW'rGP y \  U 0 b5k f jR L ?b " 5P9H.7f ;}?  Ao - n@/2k[CqhO"  : S" q T8U0gG # h } 9E TN%#p=6I; Z 6 l d !R~|QY6W 8 ~ s I|fXGOgq y )W   ?E2];fQePq#$9_h   7\  ;tl/|zuGb'M\td W PzVcT5m+,St\J  + % e=*s6`ME *L8?!5`g  FKXs%t?rx` NG y  01hZx|eqM*H|nX ) C``  R H$&yW^&m~iZ ^ [ M x W FtPGhyY as Y 8 B d sOU+!Qp;X$ UMu63X Y + g x :<T-@!~+LRYv\uP "   # [0C# RBB` XP r *& <G L M~oL$M-mV.p]H   l ?w " }9#iZD#n;-Tf M "\!}vHXHmh(K=@FG K gd8)k3=2 +v iHR7 m b $3'^v4YS@<\Mo5"?8w9Z  h&9+4#M&xYu]lL `rZ G%k/*|wr>ar/+~ O ,.-?DrfYO8jtKN25zNQ5jb9i[7 [*OEj r`|G".20M{EBN<PIHh ET0/ FI7[X8TK^g7Uha?g$1 a4.]#U?f+[PmhpW61)l,CWW g_2.RQ5A4;a8(#kd)9adPE2]QgcFC:097<Rlp>.d>Jr) O&v3yO04]]r#2%S(x1LqS @!?&Kk<:XO>Ak]b|0^ITILf}hOn~N_B2 !a4sWUo?G84))_v8IpuCv8DfX&>@n !D^3KZ]-A c9^Jf 4rLvrpX,tdA| _ gwpRO1#w*S)q/_gcccH7 c5 x X,YbVL%JfmKRbjsqT:!o1eJE@a/xiC*aPDXxSO Z &9PIG]iC^ c^5y'PRidw aubdr@^ <k_Zg*eg~)k""n[JIt}pm7 b26)+B>w|b1-& /0 m 7INo~?Zyvbp@XrH*k{+C(KG{YqM e4;/q# 9[kLDxT&(8%: VlT+>  9zxm;fRU{ uN L v|CNYXuZ qo0?R*EvW_j?jki p!X C#-Q] r]^PG(_&l/vR<gx}k *PLrV3SViq&eb@)=tW*M%vxj)*T$D8X9dCmB] { % 9E #@@?4+, && !    )'           $"$ ()'                {zAa OAgK+q-_A/(.C / +:J8/5   #-2#  ,+'Fp{D:J17F` }M[>uFtJ7As2\^Z fg zoBpI <'C  cP=a~%,(!d_d_}^<? n(1e xܲI]z ,8;}4!mTs׫ [| 9B`КWdT7FKjA- Fkԍ V ,{oٶ,T*#5>7. `IY&th6\b\r (,%]EyOJ)H3,m~*/5 4DJM@+ T`fu1-BI>-6^/IS~%DQ7t2BIAB.2eEw^{NQ*"=|@9)$s^W![x 4?3<[1z=$DPBp#)`wg)H:G>6([gDPcI}`@,/<<1!mC2n|W!A7]f#4i=8P*5YV34]Y6)9t>5$| g@z+|:=3 ,Chh0+e3nd,<:=/o y0D+;=|/N(>+FT(:>0L6{{_; >w5 %:c@3d Nr:Y4Ef_6@6X 8m?DtsQ91>9%G 1(c*Ga)a. 5& 8:/]d aNGTOmF (8,65*e(2pJg|$1q74!& !7|3C!I371!vpUN zd= %[5e6.' '78g $zQ s&525+ 8o=iw.f9(52'V  _cm9l%) [&~42&InQmU13 N%41@&"/-I6n=! 41P%-5N[mYiw/3( S>  G*:_,31)s ~7n_gX%T (5+^1sL/),!5. 0/B ut?J$544 "( L+ J<$|#jc .18&uUNc V 3:kA[#29?." .<} 2ILob}1^0213-7CpWdQs2)<Z CHrO <)Xzm ,5#IO:& Gd!F@Cn.0#D bxH<).  ]T + J^,$Yk 5wn== Y +^  \m_T w AHI%rQnr&=K! 8 B%>U+T-573lnZ r0 + 51uP1;W7!] t F NH@ 8!H ?cQQ5  P}<|  6NH|U)} mO)+}  _ `Cpk wg1u z Z=4s,& 3j6mcc88( NsUy^@2 (}p;^1vqqCNi.>p{]|*yeSx%NLN y) PCbA.\"*fPzh8<D NnY'q3vE!vkig4c)aooU .|P(t|MlT)3@47NEQT.$Yf<%60'54 (AD$)GS= -F4 !165 +(0:1$,& *67&/CB+ 7D7)1,  #&45*#' ' !/+            *07C9  & (!(%  %'PO:-$ . )8.*W,&=Rv<tKrQ(tB3'Z|K ?"FwJuȰ\ݻP]"zkgmO7LR@22 - eZ R ~*JB)ܒXw+((\1!3 )zTGJ1 :M#0ihܣned* c' b:I9mJCDKdN}63-6n CtF0tu0M=C`H$#  7r RFx*= "osq(NEn_+=R 69 ]:(JaI>N"M_2c30UJ FTC_W/PFZFH";o LFzl UdD  '<&Xt-pR)H@o17;*Y /)n6dkQH HjDb'\e2AL1!w i ?U Js3ZC2 '3"IFM,t y&r{6| RF(-2? /WICP0xr .$g^tZqxlJu7SXIV(9o -u(N6`4[ACV> N  jdni/q(Hl*J| c:a!e)H/Y!3=E"Q)T 7L=9*? 9<+ۛ 50TlX7'f5Up{L6^-iF#1+>4 $ )a` KMC9w] (| 0&lzhr3<7$H2v yְܺ# Y W O1.R!wK D A /A-8Rl.q&RX2(Ֆ{C a*=G ONoxk6%=Vp7.0%F Ȓ"J77!j$5rIb3* S_ݾGRE7>2AM-'>J#'P!E# LxY$jOD2M#/(8c)/Q7? Mm#<nMF"Kv Y +(42[$\ jןLd+ *O _&R@(. >''<0 ;@ P֋]gvP @XVxBfn#P 8#o+,'4,Qs't 9IX$j1'A"K}Eݰ1? pKXyuX bd"Lf++O!5[||!q  > C#cKh9 >f*#T"O9VLn d<  q #sfa1hY 2af id!iIWao>) Y(+2S : sS P%;P C ] /$z'x9 ex~HaIi% ߑlT"h6W&b %3TD޾|Ԗ B I!myse~=|l\ {& rVh-?V N la$hQOA YR'n]> z|t,U; > E 7\1^,H3\>"}F>5sI A[Rw>="pA4 } OC[ zN 4~  !>f: >j!Z3b[+!VN'$]J^tB`?$?npwh `]aC   P$\x{G}8giPI: ;\]=^d&G47MI;|3)l=6b;:;8fC.}_ - Xp _ ;$_OF +]pd&!!47 m zhE' T>`6x ##NH(r9 8QVu" B m $8s>OS&J*x<8Rq* u:} 3qBqH!/@/7&* Q B'yh UR}y I;c0,0: /%Pޠؚx Wb G .C8f}E.j,8)7/f'M,nޑ ifR zO u:w1 95+0&,,}2OM݊ # ^  4Pu}FC5l(0!&A_'S pbQ$  zNXaW5%k.$6{^N 8 ~X )#""%!-W+srukZ)|ux u% 8sCYF7!-0&)f5t n #V2$#y%}  N .3Ff_s0I#u# SߊtL& WdJ *> W44"H%g+EP1G$7jx{1e~.'yO(@$ , 4(, k@j rH iiU@Zp!0 !) ~R5 zB d!+ 1}uG H~ x,%&++U=.:/$V,T3d #K @-&q'){bdVCbX @f7\r EwR4q x -%,݆% Y,:R} \3#W (#5.f# }.?/)xp hR**PB bVL@RjL wO*8"h-/&":L FggYq N- xIR5W(#0 $ "n4{+l8& C4;gc`v )$72%_k$ ~oicAYR mL;mS *& 4;''!F!F`vdso w]ߴߢDw)- &+a ]3,\?"(5 հ6Sf!f~4 j\۾$ݵr &w/e"513%\v\hطq nh!$!wzݸlOݏbP + .%~:1!3)b}e0ܰޭ $#%B""Bn Y;Z׺,݇f.+g)=.$]+FFs#p 2 #$"!j! 9pקthܺ!T-)1*.=.%,G\.0pظ9_ #+##"`#7 w#A܃h6.$'0e=*D+,& RN,4)޲m z6k$=#"e#, 7 Z׈GM\o,#0y;,*+8+  Ra"BԳ~׷@ i#?"c"  p;CۓYZAX*k -9U'k**{ EcԊ$\<"1 iPKTA)09o,":&o)~:,nx7++Q[kw^B/ 1FGKT$ 0\[qf;0!?LgrkV0('NVYlkmV;,-4FKMAF4.)irFcq]5/FN\^L4 @VyxV3(BFUfxOGH,yG04/|> PYx{& f +Q?n  :WQ1 juyn N } t w`6{ -q j` ))x` {Xw@Wk(%B3 b/cV*Oy d&^C3'&( E  lzX U2sulIX+4|#|- K d R_iI0UOlM=tvc^&C2!Z,"M$E[9 v 6.^JOm2&E%)O&Gz`s'oE xR !,d#2!("" T2 BCTv?< _H^ ^!v8`='4*!(;=+ap0,U5%l:4ZU]_W$4,"O#] s(M@lBOM ;4/ B<|'&5'$'!k jawk:}rJ VB5<'.f:|%+  -v. /"Ol%L _Ut<2 #9`/*>)(KM{M "X \4 q +2y"O (;( (&.I} O;_rH|<  N2a%@o.'0Iadu4(  =r=d.Mc 9W+4<fV 2 cBO+ &b`/ wzhq k>XZy<# "h#rb 59oj@ )B ':)#b{ \ZX &7!U D i cta + 4$huz!n0bV[rv.R ~h iG ?W D78S9<!Q D2[@wP#Ka)lq:Nwo 233soe0zl$Wsrhk`- ]x.,SP~JW{}+|g2W*(7}c[7utUba3JT_\ID .+SXo4"D8Y(0\?YX[s3f}fOy|W <X mYa64E*L6 *Mvq)b! (i%203.{_c`ql'=|_fnS4&1*)G,@$'A Tcg8PlE-km~zMB'Jl@Wg3~F[,1E4^[ $B"9A }0IjAXL9|hM9<;veN?Xv\U*QQ~]%{<)&_:}Z\KKPO;N|.[ E A]r_vw9i\B-8.L ghMv\>U4Zf BMh@0?p?)#gzH!o1V<= HK7WuP;qOI]H aR=5"F|2!'9@(Rl!H-$     )B?O 9$. $               # +  #,  8<BfP.b;}!ij[0w)Ql|q|"m !R!z[QEp> ;;X>an&tJhkb;c &voF q;y1YO!;U@*fteka\]l@p$1A/d.(D7UcQ? & \qbMnEH[s AA P!$mm 9~T: b C B!"*'. 8u@0 RtZS?')!C ^.Oxg%l} *# 3+0 !IL//)f,k, j+6($#sqerTA^  zYohZ} %U6n.^'*RTBSq*\ K c w11D+.'LF"VWn|Q=B+@K `T/(^/ypa+ ( yG j5OEz3:FhRLL7mgF"; M = f 5%R;+0u=Xl4T/ ~E$ '  :F g !EfF!}?{6/K J nt ] 3u{R?I-:?0S(]t`5% 1 S#yeS b  87wMe5F o6`U%! r ~+ w'ZK le0Xg/ ^!&+-$pc E 3Fqi] /74"eXxwF1v$3/*#-3 Rf>%{~Tpk,W%0l$  lPN|Kp;SFvnv97CLs%~&y0$  S 5zA@!Q @"(C*#j  & ?uxK]x<,5ae$" , "'0   zS77F" TL Y3!& (Y  Iq9R ;zZfT/,,$ q   $Dzx a4t~QY!,'9>^RZ Wk-g-H_q"*)y&! BEMvfspPAK(*  1F W]DHg.[U%#0R g0q /!hQTL ")P 5p aE^ qzX} iNem!/Ie 'l}P Qu j%&in.YEbQ>{&P  B}A^dNU_llY8*>% L+c$ 'yE )JAGS  hW#f^$%P 1D_ T 1^:+ -O{ c;(8S8'W KF# %;+WOv* bjoMp}8B }<Am]f4!`A@i 7RNQ0j:J;+C%qwa8Khy f q> g$(()YG#F{2H wg `Uhf&b ;' < "}y">Dh  < & ^ ED4/q` H79r$)"axgO%;[.Ut=gcWZBdQ{qF;jP;f2 #rSG%W)q,jiINqu@!"cLL+ {24NsN$O#Yy%! l#-?'Na:RhZ9P=5Pt )3+45wZ B.[$!Ek|wM~|z!SP@ Ct |.=$:w=7kY|R.#"Hb^aN %wy.\30AA`]:Ar{ VQ(kFjk=cd[$ cWz"!;8VJ;U? n|^lS}?p%Scj/d_.<ND~P Ifu:p%&C^]JqHvVdO[txPf#IX a%^\8co;i</j?]J5v68P.3~7vp` WwfDC9U'")'/9',f U \ JB}Jq,*v9y3Y%w;  3Zc_vv[P+5U%(~0r h 1'\jg +X7f-=9GC D 5<@7-P ;e(Irt^e> % Ed:aY-cmL=5mmoo L>0 ;   .jkD{lNFK<H|o7U~fk   l  p_s,T\f1o~M4/?Z I u t : uo ?qi}IFrq OD[xJ#MB  '-EJ,MtRY8>q^!U.|]cb$CgeT1ixsD_y!!97'*MdC;2k g|4/vn5[yoQ'7 GMgQ~$%ap4|sm2PhdK2n!^6"?([M0+ Nl_Sz%B0(|.HLZO/q:>3C2{H^ ~G"$lRaAN =p zn&nCsHEeXx4"_O1GTT> n20E{H~X$ 6~94oS9Tj(CP,<[ePnZYX?xgo&F0i'Z:9i<mjJ/E&b,"iYaDBXf% UK>?$e OMeVP6/*%^ IB S,X) HV>C@_!3w~c&QV&$]s LNM|~>9yEwv:| B !J~3[V?Q9f8}Me@( &Tl>UGw050]qg,y!cr=2XA;0Mbvrb^bp@]wgL%Ve(_7 5f[AFtjBSNzaRRU]i~ OAC9-f$ӹ3jGY U(203-Y!!ۮҌʱɸ$Ն4'w;cETGCzC6'[P°0CڲID"1I99Q1 : hqaדpǷeˁ1@#A8H$MG1@<.,]f\Ôa~ '6=r;>/Vɓnьk$H6BID=M6/meuǒ0r "8+9652. aέ< 5/8>;J6T+$(" @WfL%*(h!dPzc8فK hP)13/*$! -ܧE߬:!"@ܙٞUJ'&,'/d1/$)^#]HYs߆ k:X[ % !w mف|݂(W'#. 110G*#_4߮TGٷG 5y(d(iMB*p*^*'-A2,& kw;&`o?CZEF$(,11)"wP}ܝيJ;HacOFv'| llFanL\@D(J&,R4-$1 ی4O r ^JM  PlT *]"u'z0&3 hܨ_-xg80 Up%" z" T"/**(!!Wي w 8 \ y^ݹl#20P'{)Akbק0ڇq Gb)!e >d۝Y ?,3(.*O(x{Vډbً ?q "# o6$JoG5!fQZyE-G/hT= {Hqa`qRbX$Fo=by~pQ B<YZ4h(d"88%E\>[(g8 }}[p6.is]I5 f< n7{rt 6a  &,6ARe} xKwjcahp}:Tjo^K;,  )3?HIG?-~hUMHLUd{ ()$ "6Us\5|u~/QvdD5 *'-9:CJQBDD5?JA3$ 8>9BCDCAGM@7DG:<16/!  #,+@8P22+;,&H8?NI=GD8<P_VBnPgZa s5Et q*:A B^Ak*Q0~C'L M    NA5tU~`~V + c%Bp&cVxej6:>B^:ud XF{TOHQ$)g'!TVg;ޅۚ 6 r'iN()-1,!\ \vLܔ7قSXJ 36 B0966R1 $}ImױaW J )+ܔ ,DA4+;x[ \V3OT(We&'-9!2"S '"{Azjo +$u'84)i;o2hzk+d{o>; sE)9!2*44"#3jMy<_y<Wq/t',3$ ~ THjPS,TV^|0,I1-zazF`2CV;qng,2,1w#, $<" F& Y.f,e6~,.L#a>e B -_;Z%r>- eAr64'.t+w9}Ka`u6U$ 91r51,CcX- < f|\?x/ !n0/2.' aW %t5#o-,.0+l Q9^' C+}.(QMuYT-.V**k"Q~C Z bQND3p4"[& -g+' -)b{ 9L 1~%Q 6$-(-"! 1d,It_t)*"2`=Q1 m)9O3 7%D*%^ " ! 8T) '&C d#(GKVN} #$ G&/?KkZ]+]  q7Egf(>??]xX;mI3 t/"B m,k4AsK-Uc,;]u pe[B^'&sB8[68V|C}Gv=xk  , rF3=oB/U<y-#A+b/r-|%19 XR2BErv-ig- t Rl2gcgq- {(UDMtOFW G~#7tl~6*G`CT^A NR#VbQ]X(2v:RfDEJ"V_d-5__ (x0sE2?ww(L%'mN|_3?DNK6-nlvlrRA &uBAh;xy@_ +V)22zSvufe,4  eRau5CkRM%z'.vz._q? }l1ski@f}eNyv"+Q-_NeU)j9aZj3R>Z]-oUWl..NtiH5;`,kPxkRGSS `#xeq0/P,Y:Tq!mhe65}v~MF? 1Uq|gRs2(=rCb0 eOnF56n T1ZxI2;6 TWAQh#(x{@CWsoHyWZX(I  !V7s5e414Um 1$3$@YG HK{jb}ju*dZow$.l >z z5c3@~o w&%G! %\~Sg;jI W eRPqM|XMd  oez4N^@|2!?W0, T >| ^@X8  Uj J1=pTBGZ2'~~ E2m3 s$ {.+=1DfQH1O2 ".\/9@ ]=Y= oz"Bfnp D9<0zCSF5"@5&eniajbmyqC::HA`6*~gMb,4.)| [yds(=mt=jl2yeCs#4w}-ef$5#qwm'#P+r@V~@i*0_)]TMM%=joh3k~a=t{cBl c\@f]6^A6_=b$N+c%"2RrXy2*b-,b \|7Wm 1=L3v#=|I[B(f ~S-&<lt_Ydq|y&=WCNi(6NmuJg~} 3` ?@+sVP=t=^M?0"X+<84>@q-ts6n&Zew <y9ys-kG;An^cPi1Cr %M:3xn7C)##b(|PTKHGY  $X '3k.0*.f[g,@>  GPN=^C5/<&$LaA!,-;& * #.C:0;5=?!^b&K~t89|,6J\z`o6CPwQAL .`tny>:bju 7:9!g^ZX#p5@u[ M,2`Zg?vOSGI}J'u{MMM0W*S`u _&|xr<{I,Vv:J~;8vdq'O^Oj?IP<7o?[B-Ng=+f-n>UlrR 99=NP}.X|v^U9Md'{j'1p7AKwF&SbYH bi; [t  T H?1L }^C P /<E$|+O<&bsR ' ^  T Q 2}|Tx ~7 z5S /+RPEAC QKcT *g9/K _IqWYe[ A 5/ZP)/s V54L-Q#[c_L7%={O@%$: W0~`:j&PFLm;[&  %UhZkJS]YUD' 1 {t.Xmsm# C6 :,|'nZ[,*)"T $M  xg{*6[M*, ,%bw7 sb]jo+et Be('Fgi :#X1DKN2pNCz "*%0 D}C%v6hN  H Zj7Yxz%+=dSB^j> a n 1)sAs'*X(fX"4a A l |>p# . #-XABX R?7]k 4 z1#+r`zN)%7@ qQ! 1 %CPXvV& "!*HlZ _ K y ( M|:c !y0)t#[8]if4Y+ :  U)O;M`&$ iQ g :2v3!i FK% W#4 FROy)N w mJO k#?"Q. vw 2 ! M0.MHi)#.<"wRr 6$&yj"7 iy2mWm$l ~JC[ZyG0 z.hU=B[f=bV1  mk66 cxD! ZjQ;M v4ev"]tO\#F2 <m HHpdhs!Grv,HX|8knmmll1d^~7^ E8 o f;pV t~g XX$xp/wah8H0rEDA&$Qqr9yw o4S \".bDAL8sx { ?%=w  pBP n!uKZ|bWrt~7 dXSw^ O e_&QQ8{ NN 0Nj>2/uD L`W,fJ@ Bfh W'{DK)q!r%!=P JZ =be;!2C#5Mn-si,D*_1->$ twFprA]c\Rj7EI1]']pYh_=U3$v9>kS~y!l`*R c5v]ptTlC#RPn]r]cTX r~Ps9Ff>ObJ06l3dV !_e !l] L_.2J8/% "ML#p> *,P[3a ] V  W)uz4j % i + 6  ^g5CDc% *{  *  pNBaA C:X q :` 8X]Uwvw Sej w  3 P,ib,WgY CX^X 6 7 u y'_$l!^vym o At >yz~mo_ EIK ; {   yYIT$W9qG b ?L:  P7`"1M{!z un .( ?XG  X+o4%kAiF`$f G ;c y ]*} V,Q n<G_s [ a` <Y e}OpR;rp t ; n V $aq6 rjnfuu  KJ( { &0Ckz M:*Y J&IY j pKwB?=C"eH Cm;l B8t7r) >sb2 Q  D2]s0pn^$x  <"% } sThc~9QM: F- 7 )iqzL_%p9OhvX )3 < qt=~yc{ @M "H + n$HrVq6+C2 s L|r   ?cXnnEc= K Ke R  Hi2s-Ce?}UIlX@  < ? )?~!J S 7 K  H?]+L>Vb)<=<6L$/{SRqq*t_H|;@+G#H-Y:M<$rS0Ut)K8/J"9KC]z#$7! S$[udezDXWl/frfrB;1y9xQ{9!wTo$lJWT)$M!C*?1V03u40zt]] f: 8hVi#@0ni N=X.(aU,Z5i(tF$kDAYv^_LYPZz`:\N8(.kb&s!"(GI&[K;S8YO<Z}-t.pe8K'5l`%?m*9\PG |p"O|D#%-obD>vNVy >NaQ~G6!;g8V_JR/Wl@S"M)GcYqd1 `>bH3$vq9~q c~E_[]""'EA VK1e*O}XWWP15jJ/YEV>D=i>hRy_v}zX>LU{,e(;^V{edeJGM'${}SkYS>9S(C UI"LU!W  wXD6 aU"S n"" g% (srEhfHK *V%O/UieMl#&*)# Nx\J,7 g^ &0A4 DK6I Mu&)+U+$@ GiL\ #fb .?H,3/U %),%' ##+ F wgG#p8L -?*||)}qB$0%A"Wu'.;D X Gf;%b48$u$#~HgT4XH c]AM0 !fRB^$O!f#J@{;H/ 0  L]H# T@H $I Q%iwj%z.A#  szgdP#%@$# nr;- 9 7 /p%.\0{p)z$J#>:$G i)N[V5 N A D#i{U 1h$#C/"  5[?@Mt} ? >< Lf$ U!\ + k}%TAtK}w"t!`)8VPzUW!Z =Chl4d5`_!to%LF"s &6V1Y`#WUe3(E0|:VvxP3>C}@3P|  } B$7Q;Jb1]%*(w9M@^+mQ]k|,<`O4 j)mX$h,UTafX!wc*G0v%$c,'?R1l]B ~)`6jRHlYH]-e9X#YGc-%N^A*^!)I^`YvSPU1`0m>^EX:ii3687aKvHU adpviPnH"yvQHQ2 :{A[_cU`H4N0M4wR5XXdPG8-zjD%*OvC `I = `SK-/Znsp]L57 C8iN^O  P%'AHm#eO} a '|<|9*-x#%#s`SBs K q[TsRttF*m1H( &/p `{٨-{" eDZ]D$2t.D"%"| ) m%Q/V 6m=F!-4)GO"'}t7da'a l 7iUk'1M "w41$#$$!q؉{)` aaK\+p6@, 'z.]c{މ|_, , j3gyO]XX/T5(#''bCڷhC ~dr w5c?-O4("~'cxQ^ AY *"xr#CM: (u3f, G! ')pq-ݵ0F E}1b~lcc G/u3X&#~$cH nP1O+k [a-L"B E 20 ~%%r^T&H*3i / UcJ3A@ f D$M6o-9(62<)WO  e 8} xT $54-!$)Dp2Y^4  kB.J%\sxn#6~.n#,nv9Z_'B*] l=  C L$N?&B5}1 e#-!o_$ۊ߼FEt! -m D6"uGi\4k4N!e"1&mW .,: 6u7| !(51{!'3!^=I7^6x4Q[H86":fRb5E )16)(#q1.fr-a,5TE9 !   F0kP)/2(Y)1" :[ 9U0$~Z:K45?a$sE>M a*L3)'`27(~U@:pxjrr b ]Co ^skblj-:je"4*%i1-eT3ށoW Z{+ za{4)<$2K(([4, =\K^5  H E :F'jx'>1%,C7[)p~ 0d޹ߗ@1s 9c56  ? 7ZCc; +/8&q1U7$l axrN}wmF I }  01y4.*)s74(! W~<o l` KucDZ-(2i:C/t H >4ڬ/Y9m !J mapPq|B )y'L,;8+?! 9 v(2 "%"[dz[Sܷ<{'#K1*<6c-3$q #8{/ڏm`  P&b%R vٞ"ۂ/ +9 80)y"N#y=O~׉ٚB% ! $#LOru۞N}pK|T-p0l-*D%g,4kV}l _0 fOS~ r Majf58RK1W=et/T\>' lmZU(U0Ki@Eh^l1%tgpo _ R N.RE_,PR3S/sQ\ z]oY /K kk`?7M^DV1?,u 8[tqs7^Atz$2R]o2{B!Ds]6r-arb;Y@& ,Aez@oL DAwXy9;1^|pETsX?b1~DwLIi &b}4WjY4l4A`zyi2jU&AF'* ]X4"Y[ kb mF_3uIkB!)b r? 0v uegz $1,M5"mu> @mh4T/&Cl 7]tzqZ8|qt5fZ)qmw!B[ij]F%zno| 5ZxoP.";OZ[Q?$5RfsvqdO5!5HTUPB,8ITXUH9( #9JX`_WI3 (02-$ $;N\ac`TC,%'"(<HOVRJ=/ $-5/' *A@BKF3#")& !(34,6*$&%*7% 5>Zivr[M1>SGF1& PtscYX5,-<&?nfwOlQoIEH_*v>n4an$EDx^U )VR"US:iqKI61"01,1Y/) U- _$jB wf޳>֬(v$(7>953,! ;rcu %),o(V(Іʑ[@!89BG@5$AC@tŧӳDy._2c2E7b*h 8&o9ǼHe6J{FE?E!:&$jdauRǼ{o #-9=.a1$ G`9Еw ɨ٧.#+EL<83dR ֐cU N")`42c"~fׇ@ҎҩsSPo.&9BU1*2( S}lN=.آnXQ< G:8 '&<%JODֽؕV ~$t"&@:3?)'BdI{޷< x~$1 qؓ۱e0V~"i%81)( <߲ A5Zm! q "J u&w87چۈB$/7+q(a%n 4#B@ qdAx۳c*pyax#34"+T)# ݶ|S؇=q ov @@܌?e,"e"-z3O,^),%I7tEC[ v#;I`iEݜ*%40c-L,$j Mqs)߯d/} F!!4u+} |RG&"2511,}# fՍ߸@]J Ab>ۉ}@Li d%,M+o*(#Vz /mK{ߴv T =GDv (Qp =A rTpZ(ofmq r Q  ;u&=w4<hp5P{vK*4[&| NnCyIm4z_&JD 846rqR4_Qs48k~&RA;rYK%hey6Kts"NFTh>s+M9a[]y?+(QC^(jn1~k Yj"3=KwpG:SNMR_PkQz 0!v2 VhJxVGX7!HRs+ 6iF4lKGAs,9~TN<@}BOb%842tBuF~k.B/X??B\^[P-%1?Fr@\v].I? 38*!3UgTpb06[,awY,JykZ= VK+02?]gW`'d%@!T{``4. ^M}yqZjM/\eU{~ Ao~vE\G&n=I  Cu:#*x|vJetvOE*NO(xS} Xhs.jIc_ y7>4Ftkfy']"hD<5)}uzC@#{rxWUGV]4 QtIJ)!zzwDfWD C 7~*^g(+|I0&\&P!D j g H w ZD jG2/HXVZ/  ! |JR c%H*.j,p&]l,b  XK } 11vH%S4$ 5/s,o]Rz o :) [_)l T~yq`E,"v Ah 6!L'Bh+qSJ $Nw/mI[1o~z/c0Azq=\x XX" =Iv#gR_Nof3#3 fB1pJ?tjJ&{T8t|uJ Vq TI*W?d&w6P wUosI::  nPe]stAbAVKaX #rB #L.| 6 d rE)P/1q{-7) ~YV:GZ:Q, ?a^|7  kT!lJ2,3&LPAG03+QFEA f  n J#Z`'k8@ VtQ_Yqv$]uV6m(, 8 X k 7&E5yXT-pB-d)a-SlZy" c ,%UE pLf<|+\LV] ?Q9w* _ G ?^2=G^?PkJD=`kU+:nq 9 XK FI X+6+l9"X9 Aj\[4dNYv0GD.]Gj2+ U`E/K)@eUr9bI&QC1]|Bp}'bq1 IlNUS&$/ ?ecF NK*:T P6YYrXrww}( YZBoWa2D*>h0])l.84SeW/&f@7J\dE%.HK\g. }=%l t3}@_ iDub $3g3  ;_S>~5u-7GB7BYgkX@+ +YM=Zf}.\,w-oL* VB9&LK3#.5x^dJc? 31iE$_`i&;)(E/7*m"_+>rX04UdJh$$= r## w-l)T(rR@o IYF$aT*po }^hyH>KJ89kDt_+|5z=]7wBjQ Vg+U" %"*lM/] $"H{x[>2ON #" $xD/2TzJoz{HZm6{\1A 7D Ix:.@_-BC@Ni|w^=d ` Jj;1" 8ZR2'Aa`KI{*t&W{:S]m h,Z v7sdUOUejgaelledlsvz8nM "8hba)V5Lq7Xl  5frbWp[?3L- SC4` H 7IKWxvm_:i&]3  =K`:m$27G`sulipq^C-"jDqN8(Iq'V~.F]z`B+cBnK/ 2F[w<`%3=FMME7'iG#zf[SG>::@BDFNY`eip{ 0DUeu{jU@- !(+06;?BCDEEDA=;73,&                  #  !(+C7)0:=ZieJV Tmr(#aT]n1 l DrDW;iN.Zq CI = tP.Y a$ /P  U c$5a(# i/,_ Y7hcmۏ?(M*5! We q TC N xgDٱg$)s7 Sf / k t VZ~0Kw"Z^".=#!R$ Q [U  vC_ښ/;v"!#43%$% Q z2  5uws\ZGx}ڇ{  K3Z*6!j$#cs V  U<fٗ ہej6$.7&#(#@  Km .*ܡ'ڄ-BH1R'<+4y-/8*y"g$)gp R &,~ޖxؽHWw 4a4S0+:%/*$%7< @w$tyޖ:|.۷)o6<166^*X%1! lUM1Kks߂@Z۞zL$51m5\8G.&"p iߘHڢ"6-:3}7*&!c@  Ua2;ۚl 61c5,6h2%n"~i<D u, /A/2<{;ތF5x0+q6-# h[_ _\EN5Oj'v۳Jژݤ$c[3.)4-$f!  , ^ 6 u [ٗ=YJ 0*l*0b)$B_  >])2 A&M{ I $%E&'$"7x xN@B~^ S\Z {1Q X5_bd jC{.4!Cbs@3(|_X O GB@loS bB>\ ^e"` Y A$ 6*^Ijx;:bmL&7 og !|\S?"`!4 0>=O /rs @'0/S&M<   = GYO6j=p k E1gV "%Yd5  F6"yjK !hS$j[; ,@#7uMIx7z=IgM.  M]c#WsH3|$8-gr&{[zn,N) !%St(Njc"6Z 84 7:F|,3.O < * x\(+TDl%SK|5y aI4V>ZrW8Ftm'q/\C+Z3h6G@?Y5cr\T6- |twR 6G,epL*>`\bcdyL:M(*#Q (`kuS~0I,Tqvpc7ujezP}Y8woiw&7>2( {n~#i&}/x`j $"(/><BMYed^ZOC<&z|7RXU?((<LUSH8+#GailgcYNH=7+$ 3KVPC- %#(  2BE; .6@',   1=GD) 2&G;MIY\fd:  ad'IM}Mhb %)((&*8jf(#P 3 j_+;Y/:7/6+Ai1oNUX*Y%{>jh"a, 66E0 RCPK J)ڜC*+*0$`198/ zdߥP|" d d2  ߌzգ@YD'Nm ,A:^?9*D=5`LSz ufޭlv,(=D'?-fFfPK u  N'KJML1AG@,Z3ijWc$e) E'>&q $O:ZF1Fd9# :c~) PpHAmJ23BF+> -5K2?d  bAfv,;@;,c~w QuyE\2BW.r:=5'm ;e_nL> bc u 16LAA2 ! ^hyH? o yR Rt/BG<'Au*h[A t8tC"wQ\@} :=IsG5h[5_h#oNܑ'AKD- 'd zw$mE8 (fCOG. D' mߓ9S8$TD.VRc:P-XH =@iN~ 0L@XL": n+(WJ)VV:jJGJ9M#3i  {U2?=-2 Y))Jhޔwi4*22'MI ޭۗBBX /.:2:, S4iBY{r py:(l-'*.zlwKKR%#'%M)"V_ q3*?oLhZ$N "4 s/6g"!G%'& 4#U.$Z)z%\icGr%&<"i= "?70nm?m W(&*. &@x /:W%)M  [FfwO+$q*" >Lm#8 '0"1! x'CzUMC  f&y@N128lQ1fZ6"LCw;aUmuxo*0ek$}[b+KIl<u^8): >qTi?D!O\~s-X%mLg9MY#Df@4#EI lwH; CQ׃Z (/:+,'mca)с=tN c-43Y3$/$'8@RE%H̋uLs$X35^5D5c,Bl3(ڠbi_b>0F&45/5$4*}-%BT)ly>N)( 4]213f1&(-[ˬX. a+]3g01,!W* FOoҩ͕x /o2E/.I(D^)ءL3_(20.+ ND;k1 s(01-6,&T (kQkl%9 X+=3.+_( vw Y(4V00+) +Q'3gߒgdu?%3=2*( EYR|Ԅ #K3 4I+&} c)'6іw /0"n25,%I *MPҥV/ VK!y16-$ES4ߦמѼ ?!~0e7/!$c\h~Kֲx ".4 0$j{9k֫p/U#,J40# jvg !ܤ+ %+j30#7vن׫ r|5!&)10S%\W gߌ\"&(10"0%` K((N3$&t&/{2$22BKEqG#G&&023# 4܇ۂ`  #).&b,G߼2//$K( 3zA*-sn5L!h"Ap  FS7x| $gV<4dv;W `6 ~DES |QiKo*h  L 7#w7WX,  :KBhQV MtZ\d%tIw-QP;[ASdaQO>Nvy\/  jiCp-qVL1{a"`bVKrhN&CHIrM3s@Ye\<6eBc}ei%?2cA&$^  &kMJ381}\e$jtAmf.1Fv[G=.0ZB IUn7lcws:xgSN.q;IS6 3Wxru@jAxF:bg9 (!n& ry P4(oXEdqn+{ 2dr|* A\kAx&@,?Ojv7)[  .Mhp~7ajbm)Z\kFQ /)55Ux46PKQ#BZ!Xdy#?/@7M^@?gke6@Op:d TE_ g9 ~!_%0 +XMZ\v oG )kSw=oK7E(+)S!Uq <&sGsH.902 -'lcL3>a / &0 1.0'B)F K %t387-n" *t>VB]EKXO)376@.$]89!pO/4[,17r7!,y#^#J[H%M )-67/Q+qlRSZQErN6L>ol',252*dtK$\^s-B@yL/7;6r) 152!? -&'_e?*WG1Sq ?u"),/2*(KeFra;) s=Wg&)458%(stuEq'i] s 7 =imߚ1J%06-"@{a$N O h2U !T,-J($ | c,  !Pa-L  J B   #IRP7c/{i5_') < YD; tkBE kls o#(~7H~='D+=t}K&k-L`3ikj04.qp;^& 8t"U)fBAZu I`O8|)6!&IXIL82M7IU t>`ZTD[Y`:Rf#Q5^j^/NC8486C`vaZ'orgd(NWCO ]35Qkb\Y2 |cn 9P8ho3\~ RQ^%Bo;?V*FO2"tkoC.& |kwf;An9t ~ߒ$T :+26i/~+Q$($֌p ] u F[5c2 C+25N1)S'|w*NG=Bx$qf^%L}i=a6^ *2.,&% l`0 ~gaW _6pJSkP6z&*7.'.''; S"tڜ|+J 7 M97 2u%w+-i-'$V0 "aܾۚx/BI?sg [+4eTcJL1o )p,.7)%t N/٭ܶ tK)B H(Z=P3}]LYdH='r0Z<$BJZ(.53Bzi(96?_|g)jPFzD@iri&Oi=" F'gR$V%P{Bw=&?S{yf(4E}/nC)BZ*{C!B2*jB d+[ +xn;t#56?,!= 9X5?)I:+ #  '   & %#, H#J) $N&!( %m|TAbb>>?X%  "  &2+/BB . &( yB.*6&/M)NIY<7;O/?,q):$562GoJWp6fa&iwfHkQtz? zPN:d<nzqA*T4 {Iz \[JyCtvSszlAZ[T+tHi.JV@O'P #T_c".,ax`gfu>|/0svNp ({@4i/Pc7@KC9/, o=i+v7yw}+oLbIh2fx&zg-8%4 Gl[i 1oFW(,#]b,<e3 tG*`(9> I#b@)*|5*OAo  9  #:? MqL1MP$ +oP z>k[Zd)M81!L^7QM `Q C:Q  zdxyC;H5u.,mF  o)1 46>)"(p\F!Dt`. Pq2Yp 7xX?eIS!1:V:=A ad G4I3z_a]i*,]#" nt/Z=D`m:aZ*% {$'7" *MQR)cMtt^ hvzbT#&! cAJ*4q6 p *i=cT4KP%%"g" oQ>r<.( 5p~o!w'.'%S$lthwHX2  5bzXoc".&n&&*$`:31y], Mx1M pA1D!%~'5'TW |Z(^J[i'CcWT>t!v$\&" lE07yUzgE%LmzLw%/RWSq!^Q O*\RX+,#5|u KAlN = T Q)OC=yBMY0_q!Mb{ NOss Yw r7\N+5 6 S T^B=.qS'Zi#US:O> {.Bum_a"N) 9w 8Q@ik<K69c)sf9?Y u\9& lqF# 3fOA=(nLPm(!4e2K~=Tcu<5CWF~Yvk+RA!6gTD4W&^ D tnS>T>#XK@|2hd"r@ y HcFGvzfE.j/>B^l 6p 0Nqeq(>\3qI~|[ke(P(qY$%GB;9w9 k ehf`[ O#<q~vIc1QsOiy:`rv+%MI3h "Ze:mHnx3x"g0WH//Q{3J&7%JF~5T'@VCU&OAB&K2  $!>&5=>9$8!#'$/+11,-#" #),-+.$ &+451$$   )   1,-"( %  &,$%+"$^b!rzOQ Fp'rRF~P,{pdCIdGJ}GI*T(HG KL&X,,NBk @beyF'c&6/~G9[X. <A,Pym& f U Ug )a2QiF~@QB!X^* YB{Ez+=n~9=U !(n'$-Zr#" $k%;_~A K$.N1@._&1 &t*F b$ujA <$-4+4)/!$aZ݊ A_b H8 %1SI0, (a.4w8g6'O ~D,կ9߀s ANT( H TY޳At,#8@:8N74j&Y* f^U() # m@LjקִYڻߨ)5 ?@;>5G,O ?!cҗg? &#)&)j#Hڲ9?Oٖޤh#M+6<9%2-)7܎֖FM  8""T tgJܸ Vw 3{"f+11-&\5"ۀ۳_Co7#%#n 8:<ܫۂ2޵Bh a%/562*y'Zݑ?P _$%("Ug6p ږ@ #-2/3/t'U gm '% o|bM# tE +je $+r.?,%[I ex#0 V <*c[K  Y!'Y)y%k `%O y ^!gQvgY+5dE SBgCQkS< ( |u=_*t[g 1 <(|C >&u C ; 0 UT :ke 5~hBW8f9HKlx iG  ^ e V2n &U1Iyw  +a8 Qw_c vY w ? Sav0082{oUj/<]j09vYB o M c =7e%L! mx>LOCMgLa  p O{Pt9[z*/T`@=r:zb 9 X sU^ 8 4 *qQr&[ui  t= yiI/); i7 5 Cd ATW !$q$: _avF5-JppN :+LuT<%+.+$X PKA\Y31ADRߗߖ[q2Q$187/"4"eC$15 ls(|߉-5h#(s *593) )p%T y* CA 'Tf n|%.32+# DC6VXQ1z   ! H$ )q Hm&D161n'<XR߉Bx2 Aup 1@t f.CK%050$ a>T! e iH?[ V/74s(s(D%ݕt y  A@vF$U37f1#DS v}~cO y#i" 6 03n/&?z#[HQ|  ]@d.vj+/`.)d!l3+fz)E [ r a # f{atOz .0K-&} x\4Ix i X 4 Jv^dh;iDp4*1-/a*#(iY}mG?f  O 21$N:$ v%3s1+&n P XHmo w / 0 CyH'G"U3 3+&!!AUL  = 6SY77.hv $43 1)% "6 '7.Qc0 ,'d|c-X*5/'$&"t@y+YRDm xU"l-R084+%F&"#hT1{;j `y s?.`R 21'$& {"fU1s -oO C"U1d-$%)" "nDr#PY Y v+X,|^h),&&$w)-'W A(,<* rp=H X ]-+%}$##O | I *1M-4 )+ &##yH[* 0( E~',L$!!l.MWhQpu>  -Iv X#9&~)p* !^jE%Hz  tG7IDW9U&%"7 _7M6} !$ZIR]P1 '#4 xD2gSc}EF&j#[mId&)"u EM:r?{"|/]0:5U4b')!)%QPYQ&TSeT" w{% o%d'# LJmp/,-0!du1g`/H>&D# 7>Ac@&C9-Gc^ B#o"Ue ; =ie^b\)O,0p(,jSYZ~#5 7w+P _ n" 3]EADB{,zt) 1#}K?s9UBT`NM,q`&-a2)Wp.lA>eu G~7'' PUhG ^ $XRs5_)lwT.RBY7{P=l!%x~QiQS- MGeVmgyj%pD :5~Sf/hGN$ lZ4*eb|uE`HnNr ?.ao^ h}M=Qw(o*//&v?w$`yr Odxr D=98Y)j^7T@]i{ K ~3zmA1~BLT5Ag9ePibVEq>O\[ G/ : 1~|`@5" -t 7YY2;V*cOF { vG\nmD{V 8qst5UyIj%l4X$]N?Rr45r1 _j~^DrS(=sNDn`E~{jP:1+0Gq2z#o-~~ 6MY\S6 <z/1!y5iF817TLtxbEykhu#2J_xzeD "/489- ;S`q~zp]B,-AINC8+# .:LT_`J9'#  3\ekla]:-$!6ERVYMC* '3&)0=<JGFRBB<!$  "$T D9/ [}MPN;SbEn3&2s"~GFuOl ^Wsq!WE X;+2dzY@ @{ l3${Q\D*qiOtZ] 0GLKF<ZMy)n5^rfK4(%} Qel>p>PaP<I]F^Uwv"O PtdPjF%&ekJQ :+HFL)U&O As4X*bf5HnEVg u'NE o 8Hc%~GWr aPt#R`A U7xz+5.4 $;&'=+%(-$                      ,*:;@;?54)+ &"'01B@E0&j)fM m%{I uj&eCZ|vbu!9f{uLOvsPcYzcIT&n  o cFg7~#y7m[}s]2 G % 4 95&U /-.AA  ` 8 ` ) u?xRr3l*^?9_6S:ie  U2 * a c6g<"\ub): Rf *PZ"[wi}t\ iD$r j# WmD5!(2ZW[@a\  9yov  % >sTXeE s#} }lF s,wVe;q|zK Yt:$. - M p1x!qy7 5=!x '#!q0 F7 (GLzO*"c#L(+,r! "m  TPG/]BcI.*;,Y,!#10 "2" Be$XMLXhT\ V(* 4s.,+e6/ "FwJW$MNH7=A,04.P22 &C @wP IHyKI n7>"#0654*/':!OUes? %4cj( {E"M)%2=56{81g%6'Z\Se>QhTm#+56f6H83'E3[  ۆ !0)b\dS[7 (5;996-!r=4shyR_  a8NtJFL$cݹF<=Z!/ <>&<47&/&#? RV$6L[w %. Jw@>( #.W9>>75+J>C`kJ XX tVH׏_ݕch#i,4(8c8H5z,!Yp218rGD|@HgOP Xp$N+"0m11-% swZu|FR _y~&3 y %())'"_4B|#=^?m3C 5@,qKb w&!6$# Gi Ts 9G)^5 gXa  3;F  "H$)AQ -Dg+1J wf{ )PC+U9 ~- kX,f`5CGe m^ mFNb (-.MK {)9<,i, ."S%"/Ka f yf*2 c-ruy=x-k%)B&"U [k`a;3 m 8>sa=3 #)-k)." "fj6"8 . C`fv\  +/U0(S & BazX r / *Qw6[:joX (+345/[#_L J3C  ZwpE,#v3O66)iP tmI0(N[ߩ. ]/{79m1-! B&rr@Pv,2mZVxߖ e3b'8:7$l^ uTk N xL\,6uSS> e5d;9*Tx-, ]fhߜ*`Y+=:7%1g~ o9- s hcP|ީ>( r2N91(I ) Y?g  wB=#ps'h .V225/%X ei>  y jzM. .4.'s cPu8 F?j:u#3[/b, tLer8|]T5SY.$cFk=Ad&/, ( d d-9NRCEKiy<F3S ('G#$ C;Qg" 1[uQ+e0 ~ + O;aZ#s( fN8&ql|A`& 6 w 1;qU6TNBjO{1 g.YHC SN\DLa67 PS |O G3hF8OPA}PTrL , Q:X 2  t* 9n P(p$ pQ=)wN2H/vpH-| 0! m h ef6-y@-W<$oO0 eS r*5eN=C2%/ f B}^ k0>vrW ZMHY zu *a>wz:>q7LyrnLEf :w X@P CYi^z(ep<`  !+!5SB Ud{\' 'I.l(%Knl9 =\qn f& d.,z'"Y( c1geyF:4PlzߦK*X !)*.E&$  c L/+ @y}EXa0w'(/$$!M5eIkjo!2ތ'%u",,#2(!8bn'S [_ i`~<\E350G%X&)We]UM =  {z ކ5~Y S)V)#|+#^\ 5x \ {+11~!*";*k)K> O-7_ Qn9 *"(k- i '%<-G~:0QH EYY& )-!b$0L, )\TgJOU9 h!y+ (g"&& Jn=4n,Q": { R#c|(#(<'"\U*f $5cV @;+)(` S&?&N#l 5aB@}Zl~/ ,%. zJ_$EnOeg h !6  EbW_=rahoMJD { 26 6 0>6 ?i%GFJ  ks 2 YAZ 5h1Q6^["tI &Jjo;dzqFe.;.R'.FU6.- n,NS`r!, Y)&iy+6 PUa] xBS|KX2e)0aalgq|h2 qyr'&fE:$$; x50ts>Gtx2!-oo[ jwnsS:sC>einJk c  $_.,jlOPUp% w j m S *h1 8'T"-J I/\Dj sL -OJ{'/R 2j6qE= 9]("!WEYGk|hx8N {$. b#M]=s )LeTjVS ,FzC+X |,7QX%BAMu>rUcsedGbs-Hn[o[7p0 K  v  %v? 6Ke@7J_)_ h9  y' vk-B0P^ >u4n\>uu1<+Ao5uN=vlat'3Rrrnkh\d qV IP_/TcnR;sorkdgghdmwnx%X`Wo20CL |`pj7 3o!.rR?:-ob!1i7X1Rw  (B^E@boE>6?Ul,Cz~E\!iG.0.^^mUry5ndoa1GhopaA  f 6v6~l;_7Kz P +/^ N0d{-j g vg 0 -cw(aRlZ"')%< \ (_(۞ߛ7i NUV!Z0b&)+&!] yLvM td4  tO O zHmZPJZ%3),$#  ^*%ݰى i 0V 60$ dP#2*-5)"w% #L5tܩیWpN + x pY61Ur9"(,.$%. Qۮc݅'7 2 KLX6 q_!'*K1.$@'q וm;+k: srF_YrY HF>j").1/%1' 4@[D:q;L zs_K4߆(Wu  "*/y12'& a"]O^ԅ=  9 Q= Md& ,00u(V% ``؏ד_6_hX =G. 7=ae ,H #d++'v" b,&. mHH 71Q }k*t54T <#Ijg $86Bp PC ] .&12 r   !@v}fe4"3S`anF2 b:<(fZee % Yy(o3m:UeXR .;ru$e2YXN9_w R^OR3yuYt%{;gnG3I D4']6Vs7T.GrcC SlRKQh19i]M) ]3}a8a/yygtF EV 3I}RYgc@%cH?'!Fs2FtMO-Mh>4;`^9QHw{Tf[7 QapZ9{9/{an5)CMV[d:c  nQd^bFy#s7_E`_hZ.;IZ|7}5qM=f &Y5 N='CN`6HS =(E\c#fHQ6ims?>X:gcX,N^fl![Jq.lY LMA~ G  E SeR7kFbP[3N<U "n7zUbW4 h M#)  cN  +r?Yp_@QM z  cP"d#!!"K jg WSi tObTC"@tEo D##!!ltul u3 Y2 6%3r7X> ' xhh 3/&k&]gMm0XL Y ;ONM h `jBh C,gW]U=mG vvFi z?|- ({8n? @z D[V6  "{ %(,:- ]oT nX *&6- sGm*BKi,CE >g] =F0V`F87.LJyR b{T 6,]S~#TY1qigts$a Zp ;u4.scclA+AO_V $t<l+ b$b 8-dgc)u (Sc- U'Ud .OWO^ I MNCmP(G7kg@ (!A{j e }* ^8&y7]Z G;.Nvu 4 f+T$J3[$|Kt +h& h ~_#/f. {\FFIgA^ |5\JkBsݗB%#$4#n}/IF bMB<ݶہ  ? p ,=,%/J l 8 !C!.[( -.(H \9t) ht n.?,c֊5&/- 3+ 4 'mTL|~,&Q)pJ״*,()4o 4hgZ`L9mW[/OuҼա.5#A/"imYLo mOu,^J " # v)!~[D  =| a/Nm!!#"' (p( {! Y eہ|%!7~ 3(,!%p]x #6)E$?."G5/ 90?qG# |I^ `r (.)3n@ !># O=7B4 ".m`?"3 8q * dvPp % ^ ';R5D&0N c/ 4 b= KGQ+ "es$8(qH!v 66e>G/Ki:BU k =o Ny"oT :G  UP >`NH; ZD 4'b2^*z6GERbm @2~hAJL<"jF`Y cB_3v9oie`{/EyPX'lUn9p u/.:_aj6%z/WBL09 g N70]C] z v O -G~vfDl&  3 ("B]k2py i   &_ x0Q5Jn'a#b$v @ae] [uDetSuq*xX:bMt]0~d }y"q2R\1|"Uw#Ul;LtZ]h8AipL'NB:. Ae%E>xyqI1Abg\K0#A`){;C=oc4JV>D7<[ryyYiY\k #?PlQ$rj1w,KL|;8De7=Gh')^vkP+A71*V\^hp ?s $." D/0C^S+!3U| R>i`R@ n3z{^v`I[ktitrgi!Hx_.>1H2-p9T~Ymq_*Vg<$S"t#ngI&0+?YyG< D lDaMqw9f0NB/JwuS~{"DVE0mcM[W e  +  W//X{zFN1 #3sRRT z i#)-y8 S (kFw zjQasp2F = !?"\  ^6TJW'tK  #%T'/QMK3, >] 9_MzF#'(&ay8Mp6MjI+R SkYmc9b*Vn+{%P)t(h&"! H!i8kvc&s*r &d7 t4 %f,%1c.9("38%+(߯bM q :jV? F&23/h+$h~XP3޳ڴؿR9b :63*S. %]0w1.`*#t^)?B9N NCAalN"Q#*E(%"&L Q-47SA h (m {~K8    >e!Yqbf8eef#n[u;=*h$w hR"b D,1-C(<5# prfpnr5$E(U^:dxl8?Y4h;Bul:*{cM*gj7WUz~O$"*vL6JgsU;` g:}=&ceV|HZsVTa1Td]Du3XKbSsU6m7n{G/2I/W9ffD)xU}%,W`=J!+3 Ox^[UPIp=a}*\9@|2xe`y4+l9= < [ l aNc =P&1-3rt!S=  Y ?*RQ -=b?xbp3x ` !J%(U$Y!^ *zL[Lkkw l QGS4.08yZ"%_#"Y6U{|-h N VW7@R5D#. + IdcZ%e>OA4i,?_q z=T#[ c 6 d]Sf:^079 Oov%; C] +r>Q S2 'Kd "3N48MmnFc ~[4~q=DHhArw`:7RpKVd%^5#NT +|3!U)]'1`c ?Ryp~u Lmk*+-tjx#mk}G5tm XIU'|lga~|4 kD&%%Hs<Werv_]E(" #300/!,E@OPKOG<:##*%0$C[nQ:  *5?TNSK=>$.>/-}u_z/RY~wp[=4  $5TKA 7  #U0Fwdi,%`Z!Z= 3Z; 9 ]B$^.r   TB'|%xd9{B!(X n=^ 6 ] 1 w1=>K"!N$zDn5] *N 4 Z MG~%oޢcޒ4%x'f( `PQf )  4d`'*S$!%||&bM=-HP: 't _odD  r?>d۫  &$d#_!;~KղL? (!M /7 okrW@'҈A]HNQ% 7Z H ;l` ѷѡ؁D !"R S6A,W' "s h)y)Ԕϝ\ԋ #$ \ CJ !x >BrY:ӡt%xTk?$!<e 2toY6עּ!!LHyY  3_gIӤΨM(N!9;|e!J n*ւ~ό"8>  a.6p% Au I7  QX`J&|' @Zv+K [ ^BlcdupA VGpL b o0[ %$   / ?-mD bd/ : sTp50 p{GC/kMJ0 _ y>W Z,l[I 0 _ M+%jp_hu" 8 * @ Hhy<0hDm"%\  ^W*$hxCo* w ) 2 ? !gR?9lc [  B { j6{q}  K60jl~!cIa; ( qy"eK  C - y Fmhd"F { n   z  wB]>K.3  ?  5Z1mx i k o  mEM n  IS(y*o;fmd!= 5fV+ca&#fBs` o , 7 a,#,b: {D[   O_24}iu 23.  6WFYF4ADQn7/:imxq9Q1^+f7~*DL0Z)d6^|e({y&khEP`R=dDlZoMf%G?D\Z5:deqRgth  *R6z@|) h>7 scl 2Pe_:K^d?CB|{"6M-PEo%;tXC):S _J A?'~f_m7k Llq>2 1[Pv A$al%3)6L e6}\N *BRLTIFJa !n<mmv E~oCR{JHo 6P \vL*AkZ"OwlS)VA  s  1 J "FKGqvRhh>)}  : eJq+L\1\\}GK & J  + PtuF'BFxUtY7wJ  zJ P a NOE}zvc[s7  rw M/$ I&(m2P K  2qlGV/m"/o 2W B AE;'$ Tz ~  hKh/3<12z~- S ` 9W:He6-1q6P}% RvZ&s!YW k <! R*_=eu%|E.?z $a#VcjRzEA,`0jh;'q!x vdv^deZ.=qFvv l A"k) $T@*gSnJܖߤ !])*KW@&A[R+O4Si5#ܛܸM/,c'Z "Y/%AX@  "[:d:h KnZ% })%K' 5 B `)n}@-,zeL! @u  +&/rRcR|\3J ,.mv(< * <({[GFH[bMr  _!.4j x]]uQvi? WD  6 +Cti>grC,%P q4 fhl-]=4Zx:rGM\sCvq e~RXY qhDAsy=#^9FC '7=Am}h [ 5: '8( 7^K{R8> ^k#   K  @O ELfE"WC;` Wu9e _[7!12;L1Ol#V /s!3D ;e3O }s=V< a^m+*/ p C(T ' l9L%He1oV  4yf E#tAzOjawIIb&'kgg7  rUXIX:;muE>Py3 mz:**+%]%-q  sn}9z6-U!/({#e i1>fRZ<8O/ 1DM;+$.i!0 fAl8jTp5 }-gLx;5k=*D/#"5o_COZo)AS_V iQ\& Q'o-h!? S8.BgLqt|3R-m "!)BA ,s0*P%q)FnO%6j+ |t,f]# .:/OZd\`N^1g~F 'z| ZX & vA6|5^yTG!~.& N2z*CreX-{DctU(Rh2PW~s2e mu< :6 Y -qa;:O:- '>X8 +#` U  8  _MfzJ"7S8 )/>j < Gk4b}Hf2ig/A`e -[ p `E3Q"8%4AqF0|O ") RF"/hw;" pU\|9vgi i B /%G2_~_VbkZ)(pAv3mb bK \Q}Nz{FZvEI=IpEH9G`=ndnu !|-6ctusFbcJz"a=9l3$ x O#a3Gg<\]Y{Wlx(1==(<b,-t }8%,_wr^  2`j t8 tI_1EVfsT6~j]RVc;Yr{wgj>GAOFYblpghR=umjk]`jf}$E[gYI9)3R[`ehWKP\gyaG2 v`KFJSj}9E0>\S -056RRXwv_KD=-T 8NTp_*('5PFd_T- ]R46R  #MgX^\ ]g(IPI 9,e`? Hsaef(=`xT*~d*yAC+Q:T~>rW &Ymfya+:.}<`7DfqxKDB7Kss)$R #X weucUuN`W;YM WPyVrO>[z Nu}aX =,t NgWn1A~S}S0f#H%Lycl/&N0),$!\|1nlb wLhqH; 2 uB Chm[aQWZ[,G/l1wX*nRMs^fV6QgL?`MW0B#`sf|ttuwPVS  4+ JKGEO^9EdPR`o^?Q>  + *1$$30$(2%$$                    $         %(#)  #%- A>QO\DP>G4>IF`hceR3D|N`iTR 4/ e & C s  vil|ߚd<5K!'CNMFj$K @_S8 a B   5DiEI4(?.' ;$  Y Lw(%ތc| L:871%D5wts#4=P ?K9} H;-*94u 99 3 r U#,mz3ޅZF9}/5rZ3pv Z K|HI6L=.i;;Df.e#Um` oV T'S-&CK*FZW -rm%rM  SBFD3K?#k!  _*QkBlaM4Jv UV;6;&$ L[O x I/1O.AB,>P:id+ M)SID(C0! xn+n-2,`*u`!:>w^+B('72-$mpvl1 Zk&qkDndHh1@~$[0V2g3#x =5 )<hdC]<%'/?uPEAv| yv$tL+8']."oL gCi  %Mu   U `\TcSa8sEjf6Z[u`tE aX  (`S*"25S"28U<6 2y N*z$ th+. KVk`k: tH7m$0 v Xr%uxwDWXu-*h~ -|v H ML2[LRV8Aw7p ~  y !xtbn{aE $w' l 2 lc^a [[G _Ol(C}aWs< V I 7  :  W xL.?)]K^g~ NA| JvsBgtb@I Ye!"%,( + n8DH vqmzU,-vk wST$.[ v]a>*F@|j W#Q/?>fA e/ IEJEhA}g bU@(;_ 9%CfXu U-XL Z: N # j6wu`LL_R\4\ a4tV<  1J'09rYewU O-"!#)2f@[jRbwoOd&& 9,K8 EsYR._ U b &""Xl 5B X YG \HGTJl C+.+s&q i?  1.p b^Hu{rR*0~) *>cXfCK[p=b F @#4g2" <]2P0accYa*}a p 8^J2/"gXz<m]Q1)L&d5LF K 0 rxU`+f0#tTI' <1 IpRa_z*MO*IzCvk B o@p& 2cE B1W0eR#] uN F8qEzQVSw$ 6Tw ^ ]y 75wc< O Z( x:B#`.VF,xr{0 lPa i-hk nTU'߭$hޜ2*Y $C `~Q' PhS-a?gWi&9.l#h Uxf3 g y=9&^xB"ItU'w k#F 6}1 S [%!t2ad0]YQ;!7F| ^, {Wl -))X)B8|M6e{)@G$:y 8|Tc \ P0E>+YL]*8j^ I$(! e  O S29@p3#{! ^>EA)S;0ZZK*^&[E*Hq!(+7|_-A;O^d625}U0|Iw\) y ,AcR3`d8}ZB* 4hyJ52+"WNLI);28Q)U<lD4EK\LQL"xOO+,\_x4FWl&^jk W`uUP;x@: r  _ \2Ki{^ 0uo!p}Ad/5|T ' %tl W_:@9! >o c4H&>"bD+Q:m.A' S҆[j  kVܧ @HjJ?.*'LD}<؜ԩJE j/FBzR (UHܣ6P 3>KIFc2-1a SCٯ][(K8s  Y Pٻ 6MIG>5l3q!m Eڿa;Z rWY Tu mPDE JHL?n8}iq\ ɨ*ٞnI! + pVU %^xG (/2v6s0E<`/, ҩ=Kb M (Dj , Kt=tis  %+)&!q 9{ WJ]"Y   0 AbF6.{O[scQ pw{D  R 'tX)JKQ{`7VPQ 6qAABt36>J0g8c]kRC%T(1=^!B: CV'E! Wp Q+;_(88u;otA*Eb%qA24xc"NS$q(qq re<XOs;2V"v'phSm.w'"QV2iJddMZuygpg=iF.QM'p:L4k0gDJp~_<7b0$9'1&04?A5IDoUa%   $,]Bow|Lo[EqPZq2U{a7fhl1HiEqV]rx&^&l%Ac* Q^f{a?VDR` 91h;vyyv-?<p,4%>{o}"k'u|'  ua $#g.p"$"  i|G(+WDC] 3~dܻܲz$;B #)-9,X&yO 4}w݊ݿ+>1! T>QJ 9ًؖߑh)$(i,/90+j"g )ExK׀&oO 2"!!=_4F s6&+265u.w$ *#fum܄zz"#E$"k" ۳@d7 #(-0V853))3,F'>ճ8U j _#!fSpx7 1խرcf.**-456.%HB sXX5֍H[@pT{ :3OߜA._uR%*t.30/J#Lz ? xDSׅ׮"5*'$)޷܅ ",g,q3w12'Si/3ԃ\ܮk E+zߋގc߽9Fd 1)c/.b4!0. "NEV ԲkH%KBj@$q0X,#+l1/4/J- m`Pq yxkbY6y X>*۹zHTX}$+2/5/-Q!]It/n*adqwO |{n܂{x];>$|-1$1e50- N"-.؟DӛؙC?)c e E݌ܕބߣ !|$61/3<20(LO{؂՚ڪ1 _+y1ad> eS["--00T0+  R_ۨ5O r{v ]cd ?<9*W ",*0n-.&ٮ >O P Rt  '(-++L$}7W HݍM\MW 0 2 i'X#e H#&\+*X)"ICVwKlkIbL  4Qwnufy!"(*#+w'!' 4ݛݳ?u `D  Or.>"fY >Q ')i,)#4?{ލ\) v9 d  ar&?(+&}! pt޹-r mX_ 0R!# )%"v } o ^ U .PpOn}J ?}!h&#  e&9  {2,v2>#1#!0 Xfi}k(gI{NbBNq V"!4!/ Iw6a9uRhZ2 u 8F^ :1oNPWe:NY^uY T_2]ZF Rhx 8&|Gcn -@&@mAc_=Vp T(_` e27B3/k 1 %n E.K+c S, yR %,<z M : QbDD/.Qv  sQ!&$$JiRv z TogE:m;Gv`.0-3q,l\ܕ܈#`>J {P'r {'(6Z f> ]L Jn-nQ b!(!#h ]}57f`("# ba@UJmx9z !*#! ^ P|[r+qoa u-!>!6 E P)fD sB ;f$$U<6lt x<}  $+*J =29{dJY* %&"&  4cg4M|[Z j PJ))# ' jGI md *yg<v  &"%"%&|s0EWv !!:&"P s]iY%{dkd&V#O{Y\H >' U3 !#dx zc>Xs40PgXt" TcV[g! 1yjT&t OiM5:V{@  L<  |!Cv+{  i  ze z"_]g M] Z ! e HPh7 T 3]j< BL<{gD HHmD  ma  YnIB5O  )5OI w$0re V{d.[Z5 ~@~  ~ ?0 )Shsy=z fD=h HsDh{^@sTI G G ~x 9j/!8   0 Rg VwqohZu3I *k k 8x0Ps@LTNI  &..sv=zlj& ~ ic =\ 1. zk$1, <1ip9 X u;KP^(FG8l{ hD9 dx jckqi.JRu jut2b8 9 u s t}<eqpR*}uK -0~ %zd AgKJ R }7)f8 h(.9  u#5}G3TVCzAJ .4%-61 t3' 6U `}f6#-"meLBWp{" jI }    tk>8Hj!?SQ3gtw~zpUO 1j J x 2 -}9NkQ~tC~&7;y l 8 ?QS^"Z s}D*KSBezs  3 d  p C1C_gM,R^,gV 9NH&-H{J  K F d qiPgeX@;w&xa ldkYYKp? w 8 +GTZTr4H r?nw $Jp7u#O/V T dz*=vT3P%2/C \w?jQ[Gc~A[}5,FB9s%/EI.G|O" ]C~ ^z(w-eg!]}=jftnN UMr ^1+(wO gOzGo< %xf5y~  X=<zW>&:QWmmV3tJe]TNi/z7YP{AFdW2SS@&4(RkH(7tiDxY37CQ{MCMbP]4PcAMtt/vO-? 3HZ0:(]KCOL,ns/oBA[J6S 7XKT+5hY[Dp6@f`O#;0/"ci qG8e}7+v^pZ6^+x1Evp"`X_j }+G?Tm>|W1IV'1F Gl 5 Q`N@wj,$ K=-vYiL u(>/G!4} Z_w 3zIN[L,@&Rl;$>r8#$H>J 6UCLS'* , NW%sQO&W430F'                 $   'EZ -MW/6UKN+Ci-#atNmC:.S/L_T t|\|1"qDSks*?(GSceq| ?se b@oj$@$+lW$izP>-O1o2*~j +.#Il$Sd]N9/9O )cJNJ"t[8) >];_Fmq5 D4nUXG"',$7*>r{ #hzp{\ZV7%# "  !  #&&%#"                  )(   ''#,0,*%-Fl~E1)[J88cgI+96Y$ "R `<?/Et<,iJUlVA̾@՝'5?Ju,-+nJ U5ކ32VhG*s$#;K ...#!|PޫMg S42߳qmPg@&ì_C f%!=߭'j"@},e1 @PlQBO97= H w+b$)M`UL&krJU:ICbu' IU~QL4bE1&R4e L8y@ G! MZ 3 @.41Ae;{jMH?[Tko~VKBXP67xR&dfat1)0J[a]_Y+&ki>}VHc3Io|xP./LbnKSV<%zCqJm6!Fd!KOn Y +N` Ki'wH?n/C3  ig>1 rj_QiT-E93fpJVsUqV6-kHrsJ78 *gIY 2!LKRR`  mDD(!|J n$x>PRR%$M>y%(L+hF{3Aa(Pn-pO_ 7 KI6-AUm$] )'t8/a]I/pzlo>q37J;-AZ.OuzW39 Z{\ !j?$. T"CP (? U QAtF=G:i &#DW +; F %V`-)Lg!I;mJt i G $H?( j%%J%Uq w H  jeؔR*['!Si Q!)8 SMEAo6iы  ;# [d W  m~RݩP B!m"bd g!#wDnW "I3"B?[dM x r*Gmt2mY4#YqP6Iup*8_'xeߕR "  [,w>Y$ 3, :nd13o 5J\Ew {f \ 3`es ` "p o 2Z l,; c } & r1 8 +LN-3gD4 ]Y  =,i kuT{ }[mw& `~#l C {yUE m^"h1uaPY D Xgz ;r-E]`8P8  Mm  0<" Bv ;xf &z ;-: iCvBFe6 ? b 8? :ziJNwISj EiO;d a r Y E o:s6)33iY SC 4HR?" + n }  #b%N q ^ %  z X=tp !!5Z/ %Yb27(Jl#_+ R tFnRdk 4 =ATN|AA`j` 0c-h19,ln. j*g6$t<a866L<u[ 3)aeK4]p6t\ czRXwGkd vT fqLe {;ۋފ.#i))5-W(! VcxΏ )u.14-)L  %{׺ӭͯߣ4| +g122R3+U"  } ۋ{B͔-  /0463?/B$^ dUؕϘhVQLi9 K#24N9;64& <_TɄ[ޚ#p$4X59;=`65t&` 23$0|˥t( )!'3Y38e18 -#m!)j"Zl -o[{+ :  "&t}U,K uPFxD88"hY!Vg "RU55 >aIEJLmA  }WNY > hEyz-4+:1&j-Ozo4\l`&) %9U  VZ"X\t`B#80d0B+ D`{bGY6XfJ{MW4j$7.0,*]5!w* YoK % DR3HqAx)-$2. 4`*k Ut2h`k_ e ; 0d?r-#S/ 3_.I54r'L Z{S ݱ ^z " #q. a>IQ/-u68 -l$7C _?-M ^aM5FڈIn*+6Y;1s*\<ޑRڹo9mBt) '5܅L+[`$D*M:93A.Y"qھP{vRpw$܋֞^ D 3743+6(7IG%c-( #p 37,/0849-!#, > עL ~,0__dUi"L&,,-)pn^ܻ|meS ^ l:e!~#&!4. rE ? !gA BV_3ZL8"XIG+C\B @< ,#T F9 v4Y1Bn5c=dKf/EL)DZ.Z&}hS0"[" &U=G`]T:  S2k.O@ -bV&TkJ) H07G?l10HNG2 ]E8BZ/`h? qltKuvR( 2Tn{zgFq@+V6cu]H7-+4DVh{xa;{R3$"3Mq1<@5" ;ig,~R<-/AUz  'AVra:u][i},HRWN63Rn~~mW<;HKG-2IidCso}6\twV4/VUPWE' qupg_Dk~jB#2V[aUd+sl02Ht8Vyn^' H `SM, pN$^+LAjdUNBo d? q9<AW%=2*$_Frb\ 2S 6untW3,tzHrg+og'P9MX_/DTWr1symX^AqhJ_L. * 3 d3,)nBWQKNOY` 9 Kp\PDqPL\RW%G$ 8IDtO''<?-w>dH&8?:n[>SkX; EUJIiZ5oW/" zg{hiHHHQLC.A+k>fb>b?$$}1]jzw/_d0'n  q6!2Ciy+- fr  Ui #XT!'9^4yG!# As s #=DZ_a D @( +p%V M;j޿ܚL@ *21&s/o5 @qVX;zUM,f:~)53*! q q-,` hY4gݴ߫,o J",!/*#A4 i&h G[L [|jZ Vh`c.530$JO < r /`}\=*Ax{|/".0(>  2ge?F(dhOEKl_M Wns!5,S.%]Z~1&r7Y)U9j#j z' .C'<M.U , ^ggY0&+R` $+A)  JqlDpC L=%ibfEE$s&f%-!0  ! N #D Id eu6LTb ~m""f$ # u ! %)D;Z&nN=D : n!#} lHT 1eOo}=YPm;h6Pah&2 3l""x/  A<[u[y7"`{W^YM z #lu  PzX5%;i0[!X1Yu4  f!*#+5g Cky k}:i6a }S#Md=O-M"yX]m   =!I E ! 'Y0qVHflUrT>*n ) VD y  "WN6tFg_UTg*3r c","e{ I+IL0D|  ![ $:SF Z VgHNtQ,L@S8 ]("#=%m ,ORj.\}V>cIJPL  :x9yB  i"M=D&d#$LD~ -=3H 2tJ"l#hGgG 'bWjq boK o]gvcgVGJFtgIke6O8kI~hq*b[mel= s4-o%y + {WZ L5?p H<s+G~:Z]1L:x1N"8&l + |_ wVMyJv{'I  0AC`!Wra?ne]I#3G^y  f8 l(WB@M =7 kfsGr_{""'PEZm |^OS}C=1[[/#mCd_m*8\/FY 9CTH(=:fkN{IZnH.z~A f};? &PvX(")Nc+<lvs\`<o^;pL>sNH{)q U}nrSKh?-')D)s::dmFlo[6 ^Qn>+?^ZJ7M<C:+ 2:$<$6$??KI ":"* %% /   ',&%"" ',599&/   , # #)$   -0(<,      #=S(+' ( Ip`Jdc0{Kq=N{ ;(V P/ ~3 RV(A( tjL!z_m  .&&RP) VMS.A} o l zgSت %O B.*Q0*4)}O׸Yo9)DM-%sGP<52Hq#x٣RBfO (V4w+hG-&~7G%>u0 <p}ݷW4!y PaqYA* LG%qRsLl_p & e2ރڸqUf\(V)?ABL ^׭ڸXQEutw:݆ܪ$UNL4rOo85H"Ki#ހ֖ t{R[ׄ>Cu!Rz3?G@" QԦ B$z[ݍWٛ[hK E_:5Qu1Y(;"$n{Ufm>dC e;Dyڈ6?a,0:zO3?3*+ڿTҮ CTAj/;_7(ܛ45^#( +EE5U5E5_J#2ܽ#ֹ֘ڏ;< [ A% t*J4&,.I3Z#f9Fڹ]M~ [fy9p%`( ';SJ_7Kj"}Jg"U(m [@.9e_jRI Y p#$ | ]kOzOxo Nh! bN( pSdb  a"N3  G= 'EGq<)$FFS A 6 c}[7J',GkHUd m6p WZi VW%rN6 (hW[ J3Po)4]5 ^< I1G 5Z}r0 $f< u   A|' !#[(mK 7  i k -.@9]awjo`?   #N!/6 `F6%V#0? p   yW%v.3 t<ePS  yj\%N!H-% 'ByTXiyq"  *F  M{ e^V>zJYN J lJ L +y}f-Wa4Q Vo3  >_]}dg<[ ;n    \ZyxpT1 5 .  c8=H;rv9rv g j  enD rhYTnkMk~NRNiyONmhZ<!?Z-UaV>-5CuxuQVcj2^Tn4`4OR~Z7 8-B:.:od{Fsjw?B,-GSD=Y-80 '&J-T:/=  '9?WY]lPX@*(#"058=?5>A4+)   #+%  '    M~Er-          !Z>6' !  ' 1. 4   85F(LB@' *Of(9{P 4`WQ-iQ.Z |o/d=*a)r6Vroop I~cS*:) 1 /""=O"AaiOHsataXX_hM52S&s._wmz<$XB8a,f=Aa~oW1 5|8O6Lr5gq!\p f \_;' `{V blI(Ps\5xH  ^ 4Ajgo/*/S %J ND :C:EO +y (*2;*3y_&BaSA?(1]=M_7dn|0S]i>5D mB@WO6,s5[ yRfr3tLSVhd4_Y{k6He:#zgKO(. xZ na,9@+4$L z;o:k"\>:";=_ \ 7d ^OuzPEK?> ~  ,` PhIrU<zs$>5Ie VJj'Z( *#&a!CK!|"~VuS" ./n)!!ut b  q034o* ka1)LFh/8:*0Bz$`Xie)2 G{i2#1\:@0-i^(- 8 q4;e؅?@[f2߃ދ%z44]6G._)M{ݰr $ Z))"2B31)g'\Sd{2iv 3"/5-&2 ",G,O #Y)3'i&s}Ey9YG9M#E).+!!`-A4D|A8Y ($!=L9F\VFELY8= (% !>rL1_1qj0,E'I !'hoN!)&%  f&! ob8jF 2XP0$$ Y\3pf96\zc Q* <&!!: gC /d@C'y#%! [+a<wmoa^! (%!"eB;~`*qu\*;!&w+@&G#;VQ]BV{-7E{(-H.&#=@%q`o/`3)-44,&LADN>Q_D/[Cs)W!W.2g3)?(d RBLwQ5Imz#-/6?-)#g0S$k9^?%o$./075r*[, ! _W.q,d/(0^4T+h/!)m)W] EAy,-R,-4 .0x'gKUfvU_x. ')+13!0//}T$@!8 *+7203&5eJ ' r+o.822%5J$|3޺f*!-k83B4*w#bO7hWs6| z%+4 6!21J = xt'b3iC=l(+(500* 95BRSa #$ '+&'p"[BO910 i*=SR\,: 9wj7:"8HWLFUB+u _L>W[6#N>9g:`N5515j^(8`u3S>I3D6Kd;JQ\<7 y ` KjOm 0nBe.X0~7g4$#{2dgXh R(~!eKYNp'L62n#*y:f D  w*gaS[T}jQ[Fs5^&Y<E1b| 7zN=80)tjkUbqBI#j(L\@/?9TMl;f)lQ-/MJrwBSR(1S\VnZN~@.`SJ8S9)`;P.o"k+&58RxjK# x ef \y<{c] 0m' d' LJcw1:  M=Fpk[_( 9B" -*f TcPPeNl6 D$*[4]1%F!k^9OvoH%yrS  ,+5GFS;(+)[!ߺbB v|q |38AVH5@*-nCm #$F _MDo 6"w< 0L'q@@3K8)F$~nѮ#R  HT_&=^3+%|AA#K7(#BEZ_)*  7aؽ%}BNBF6s#Zx` } pMkۘݵbD"$?=-Y; Ha8J@;&CG"S)<<8$ niUMv36 S jޓ@8!"3s<:,;C6 , ]:iT!,s8:3 $rqMt/VQ d{c>\3%596T%P|g1Bi ]&<u[089-L"Hf_^rM -^ tvhS? d +x6:3"q1DX l 5 : .ZEޝp~H! 0s74*$R%  z Mr9C!,,;((t!#Sh_ݜJ4F o *14]15x gwA1objA*~K VC[m+%1W>Az:^M0t!kT6s9-Mus_L@(v Q.#Yp{L*qE? _XXM>8)f 7]SV{e>33mJ_3~)ty1z[xb U~(L;;V%kY;F@m3|I[qjk'L_NQfLoR8MX}2l*W haaJN+  6wVtT3cR|iD :1hvrzm  xCVeI m&9B*PtOk_1ln[;p r?N2&H:qUS *0CwR$K*_fPP [XyM =A ~ K??#,USG@5k 3 EI\m-  Q4Id!;QWv/? hb42 \xm?Wm3MN *M7^ 7 vD! DL: Tu (P5 Z""! {y\rena%?=?yH!G$%$"1 \x7*idn N$>6 u y$K''1&" dIJhߥڋ Wy #1'))'#$R -E3o=a1Jrބ۸| ]"%c:^B$(*+(]$l]d ] Ta,2,Hs.[ޭ2^y#'*L+/*-& {, +W\Sd1T|QLx ~"&)**/(/#)< eBy],ܺsڣD}g $'(|)(A%DY #T3 ߄-܁H)x < V%}' ('&>#6 \SjWk/0E(߫H  u $$&K&%?$ &s 'OgXy t޽^ua ( !#6$##b!/  a6C2Xjxsqd/_hr!!G!E L 1 Fx_u g.  cz" q|Ct {F8 fH0/x4Kb $tLHG'hziD8 }nX VB(.0QZnP  *p&%3#G 6 y{t #TkyZ#[ .==)!.<} {Y ~~S(1y`B=]i9JFBDN/X ^: Mkl Q2/H 69n΁ԽW 9F!AX*\\  !q0X^j$m:2,5R33A I.??835A %[d_?kq?znXDBc4h<;)U 78;},dD}ֺ}169:%dq8,@,gE~؍z,6%;)2 Y ~Z^Jf{1dݱ>!.468|'W    P'H 6N"x3[ .87$FBP Ot {~WTzc Q3ix+w5\8=&_1 swE"9:pu\\tYY&0 :-d% Z Oy 0F_IQJ*% i:,-62+65wZ/{Z66mKs 1)04." XZ=( 37q~4-F8z'U0p8 *#l.6M)z $Be ?h["7O^!W.s8._l a `WI;= Cv)*{7_.9"2  } ~u 1/O}0T /s=t5# `l}  'd2796ލxl4A5.&<4 Fq^L 3 d;*s=MWpx{!$ !7@P6*r ;;V* Ye^_IAKLG#7=5]+u rJd(XgGX}F6/0#(+:94* q'%;aqj z.:8&3(  9R>|\t$  ?un "5;8b1&1 t=B\- A ._Bd]k'C9=80&'T \. U }c_)B:< 8/$h /'q&Y}[Bmx@X?mw!(8;7.# {GU\Mh_ D#D4h96-/$89 w%/nAEznDPK'`)-.54^/%-`?I\=8zgk`"TC:(,+l'| {XwDqqZ >'U=z X#)~(&!>~9pW~&L&1sxC9lgs |  5[)wS;7#-Q8 Gj r  rM|Us/o{`pD; : v   1giuF i)ZcWm Qm[SUUgA3w u,z:tfxxS`}'9zc>#obiR$+.x>VQy"Ug3I<\`>z+ TwSc` Y |a7'*6lD.eW8S"'$ gy GjsNZIMxSA"^.+2i.' "g[u*6MjE(pwoP#0-14Y/" `f@ 2jt#+uN"%3/31,;|?}hy8VQ=-AkI-#$+j521,!MU,7  lC0/="$i*H21h, Wl>43-[P#${,1-&n`bJ$x>:rI~! <$*/." $magSp=4>D[ws%+,_(X# q+jYBq-.hfRfOh$)^)9)< "g8TUl:$2c(e&dcG"%#n!R vZEN_3[syB[ kQJ! ] +* Hy:S8zZThd( $ :L\-0:ipA#~Uw[ZC/N CxCP p+<H'XY1bE-x,z:S8Zp!^ .SRRv+4X& wywO8RO# tT5D  `8Opqdxf=ZQy K%it8a_ MSM<x 8Ag)GS' gYfB Z1&65`~S`Dw($v ! pB 99/(9+Ne iS zaq0? 7  f 8J%!>>kwT. *c5M*mBD  2 TFev07f!L*eod Mm\/CCQ li1 R4kPN@^>FzG6>a6"P]"   X7]'7ncx>ea^hImh2AvH0>;;`6yHmE_p TGtSzA]ej1]7w s: N(O)p>]Naf eU`yMnvlg~m[YfmsXZ oc1^`* $WO!MfZ-]" [**)mi%GGy@P ) DKipv;->>Wma}7:xo[~4$.>[H8/~NGNPk+ ca'Hl8U-"Q=ZznFv-E~ox8Vb ulIq%m0>?5P86Jx&G:aJy/=V|RH? "[M!f|E$YKzB21V/5d:e ovJ$J}W$X}lXRI'Oo<v y(SF0z*{%T;A;599!"= /aO)BCxmhrA0+8sEUa!dA|)BPhraI78cw@#0G>-B|N|x N:K.^3! 3y{yo!f)K>-?UX@/.LlO%H:ZuEp&|6GX$E;Nt<*$i+ *,(;t0d&+sF1aYvT+G?(_l\(8kpoB]o%y42\Whtl5pM6${pt"|@3V?aot4"# ']s BJ B bw s gW *U6D\  f $ l?k$g `cީܽ\v# * .   M}KJ =@BEվs.G0 vQ d _zA5Kߙ{י\2A  !A!o1h ;3}ak 1HK) `5h!h# "3"D }0_3C[D7H4\ BL N!<#"> ? y+  &޹d{'(:0s{YV GM 6 ~!  .F!& abvwj (U1!&!n!^ I(\x(m F"~ 2!,"p!z!- `Fow8>=Nw]" xJ "c""6M (QYhhOYjC!f&&* ' ۚ֞x (iB{ 5 ",014C8X.# Q"'U1w 'wvf$%  :\k͸9Jb <*!58?5>O0$RҐ7ǷtW !,.?0W(T_uľsP+:>KB G:@+?8QΫ_ĥ[6,6:O6#,2!W05MJG(1e+;KEGGD4 v pƿ //=AZ;/#؁5nLc23)&8GELIDC<,(}- :Ӂ|o (49I>@9W-^< !I5̖I-/R<)uS<;;Jb:!8Y 5DWk4P{@{vLut 01 "*=KNWC) 12*%}uq-Oh~gF?Y`kjWL>mae/IUhvqX3zz5KrqAN/@_)TnrWG~&X~hYH#,#TB >qM 26J)O 9q)i; u2=+)wVmD$TC A M 2   EnK(XZp4;\,I~B_*%q5 Y -}rm/23 *Ri = 6a  +!tW^ @ (6-royn  `Sp5> Ar@(5T,- /H{vy\s P pa {W;A >dX9%iҹ GT*79 k %#h,6[% #Nl8k:u 4wFP)$\J@ (<]~[4.B0&^p U!LB[2|v/>Aj <;9R.W$ Af8 9 C3"(TOU I*F:3ޕ]q#&D#YWB&XB(>r2" _ Q6o!$3:?H7|/l9"8J pSptݽBN@C7c) Ot;NY3?E=.Ag[/ Y;F $~ߤ )=:3.e!zI| ?If` ('<3." ^d D|x^v7]2B.&1F xZ3y۲VmE86+s!,- ? n% \_5p 2]5950)%; gM.-f?1g911\V  wuygvUC42O5.Ie hfT+Xܦ&202S8&E"s (VM:] V19+ 0g2{+b  66=t0ۤރ5(*2 +)L"n? (0P%yg DfHQ/++*#%# WpXA&>[: $*(P b XJ6&%3!O,-.tVGO;'@ qG l)4ma3NDY Av,sY u  TS.'3w l   ewvhq1p_T4Vq0>h46u@c*:Z} %LC=\eiuz9/Q+Z V-t - 1 e{mRp_RA!D7=_WHbr:vTeF{cO(R0{<^#5J}k; XZ2xJyHh\*vlS(tz`+.+c RL/!*C`GW WCNp~PGj }1Rvw`gJgod(Qo Q'6)8& '" *ozִ 8alO{dczqK$J=mRhM;NAARRn+u/(7k6Z}3}GoYov*}\b$fRIRZpCF"NelnkM.f/p`TJ=<@4'8?;Qr'Ms}]V83=`jnzzy{u_L6*!2?<GHJVMMF;' 0<BTDXXB4u}'iu7KlW ]Yf [*tbwCP<FCz  _]TNQJYF?U[Hq[=b=3!SB/:]4/${~,9igqs}x!YaJ`L7D] qmwwy>Mc>  $I O!%SEqa3yNf:?#,.^vO(VVkF *L%)%2'BU,xF2xr?j'^Ul g v gKJ*1  #p :~3K!' x @QK n1"lW| ;^ l1= / .N_c  sl$ l&PfVqy|Brmcj<Cx-:|V0JAicG%n=#^9: .Wx'3Sm 'qkjuu~}~  $ 2 t:cf0 `I~O#! 3xDomu4'Vu # !s gC+}bbr_1  9 %[(5  mCdxo+E1 l X'&j{l v{B4 r}n, =6 Cc#IZjITBl}. f, >U'D!fh  =F&=-u f:~Fj6F{(Y3 TI3ozEF y`P046_/#uP$`GSd0,~,Bw^#Gwd(#pp$h-Z< ri! ovp'\<Jy5{jJvwX UOmpv) <k*W7 FV;-kPsKl7w-KAG$x > \&H(tPS*vx1!4(CE =5k#; xs?Z6 ${(y;]=xc_)8HD %4Br#J\tcmf 28D^G4+-J2~v L}Et^ %~mh`Mh:-| G>Ix S gAU& T<6$M13M.  ~!9{f `+OK!C>r(vz~F)+5%^h`8Rg6(5LWM4NBYs9#q[0`7U.N# 1 |/Xe%":qia&vB:z& _ zC*'BDOyFM! f7 ![!A;,h &#z}h{  $*9(!*r2Z2Z3gSWߏmH d2.'  SqtJ -^RLkp ;6+5 +X$~ gw@!-cFwsQމݹLT|"'<2a, 8$ WbvG  <owS+!ڷݭݠ4)('?b4."n7"+"J ~;w%*s߬aJ&k9;0,d} 0ft1Z]sf) sdfCB`;,2o=g0t.$ T.| 8L%0;rj3 KitYj*"':c3+5*i*47X"o*eR_R+7}V 3p*!!bZeu+VE+nb/  F u x he o1G Y zl4?: uZbmr9{ w\nt:)3nu2uw F`` _,R hYw.E2mHpY-#0 Mv#W )S\8c` bAa8 y/(gnc:9 ' #:j(a~ ] S u}wpIIwkq)!\e@ *}GDwW-jiun.4wI\@ l)4:>^J]}iwoK@  TY 6oC mRU={8J M KPqm-.'`>vM |IY!3JPhBU#G]4DwA3i{(O,Q p #ZIG8k|#n 8niJSv!9MuV@\5`XC`h-YUkl6]O#GP>9k|/66 %A  +9 dzmjXS~@T|b?( Cf+D 8b"U}zb|sppEw}zb9D#?]^!)6;6Q=Ovu{[YOKCDE%whsz{@QIZOQNFWZQbTRS@C459,1*+ 9 +0H`Zgr\N8"   :KdpwoN9#&39DLE.#  #1 &%)),*3&  $1JF5  !  +,#,(   7?Sxe1 %+dVdcR-7NqHHoVk\k# b'\@$0 3D;M U FwXyzPVp$>1?[%+/Le !O U  u;VVI? G+?Ls٠& 6,1uWn  vQ=  k i|;1,8' 8fTs*N`Q HB + TSU0&)?=+I.' j 6 =nv g " 7]9Q(r+5,Q&*`?  >FW{( u ( 4!8$=cH ?! pF}1Qߢj 7%#k]="$h0BVt,ݶ^.8&{%u#' PB9v&.ޔi i7)'wSn &!g[\^+rZ @dpI1ݺP&8+%&b, %J ZZ(9P5k"F;5.*!, F2B@ {#D 7 @}5 j4S-J(# b;N !  A"Q{,l 3*u)8@nT Un$<&%*bܡ 78-)d' ="fo| uC{x(/ I1h0*/-)U@_wAh ~,../*1  ]_ )|p\vzZ=ߏ߃ 'f*w10F s>] :l#B< , #&212%]/W LIh XWb)[+&dC[FE AuDJVEmE w LOuP0QjA'nz]1\)] Zo*'gP!Y2XB*=K]z)x uB fQ Xw%Xi* {NZ_(w"j/1+NhI$^Heg7dzyOxk?&VQ>#a, u_VyQ?Qnu UB9 $LPy"Wj3tQ@Kkx^bikQikT3BGO|o/),'#+-0 ( .59"4@Hw~sb(t~R~u4+_XgdPl.B<F GcZ'E=Kv+seDmznqf|3iNO= J*+c^wi@O|k*Jx p  `HZ PnR t:O?cMcn]`>~-_Wy F@ 9e $*6n,\ABBLNRa,]hy<*'Vz~\~I+u_Nd|Hii}  N 1D "hU|Z(  /U$ @}KpIEeSN O"&d# W P:cS? b%]1lk $n(-*!uDYr=ljxg"z0nJ$%,s1l1(*  Td*? U!q*/K65n*w#Ya 5?&%PGO$#-/16 6z,i! Z1s`[lT.!"C/367`0$Hs޲ i gVBo7A)+3s58806- 1?Dݒi 8Aa )*-M+24z7u4+r@?() cG>G^,D$13F66V1|&j:%0ݫF`xR <@ފ& !Z147!9J4+ Rޟ4M- r B2R6/ۥbFD)3Q5O:Y<7,sn҈[f cA<Ywtؒ"ؓzڼO&159>>|7%+ZdUϝDx,,#Vp{PxPUt|.48+?Ar;U,J=WִiR%0$i KBP2ӵ?-6;DE<-~Twm_(^(%{  4)nYyr x 4;AFC7)u%ǭF #a ))(#. QZWX_(٨).*9#9@TC b Lu _[ۖ{ $D" %R+95)^('bN:6L *?vk i_']H\H To{?(j.Jif2  UZrJHvOyq  X q d iW xlKz^6j{ I V 0  2 LxRK#=E> , G;B  } { $''=PzOZkdP2S%9-m)!@:v8!P l<2Go:}cv?5>|K(~I?? 2>`S<gm2#=~(~&hvn?+Zp!DT=IMM dF+ThNk5n ~$"o~HUIDX9:g',!cMtKn&Gam#!v GjT,tZ[u U\&+'K1E6/2Ob{2K n8O!Tb7tV C* IQS>ZYm`8z;#R:u QwyH75Q5Dnu=*$E9tvtT7?2W.lK"tVF0ZvM80Ey7{(B-2@ip]5TF({{ykC9P4( dLL9_?"7u{JDyY0]!!4a\V1024E<TT!K:vTDq<I^BF 4 v > g o    -&iFVaF:!Ksjb V f $  * /  p te0C$z6b!op T ^cy ,x ;   8rZ1grq-vTM0fFaiY O 3i- <mrR/Iwdni{]*# % %  ].8pT!u<O(    + _  zJ [s!IDZ] $  L9\ k@^UIP}Zl! q h.?D` o7!&!n6446l ah\X'}D[x,/ uNPlfcyg{=U698V}!X{h>i!&%w_ xTM7C7=B^S ayOf 2f!'(P' :*F< ,z2,P%@\F%;).)z$" `C"gz"  izUG%8@YP&*' B|^X"0b4#XDuYT [%2)['r u)X'VVI a k=45 pBP|$$c!X#7,WZrc>4?: {.jyTQ:~"!Z:  ?po& ,fXo[c^"aKeN<+  Far57SNp!kL~|,dMe3!p]fo _~5<OaXB. DZ/|zD :;N-;g,-c7j~n\-4b~T7g#EG6L0BDJdS ?5>dwtBTNGk? 9t}n#xV p^_&@WrF%_?{ NeD R`Rg}7rP-8)TR:o"VB^5z(l W Xy]3E4pH({6fLYIof6\/N5 n'd (6|36N73}ET  96:!  V "dw?F Q>J"4]Hbd@^4{& {^ 7` a-=fq+vW21)a``!i>!H5v I`c +WzL(K&cg;0rx @uprwN `"x Oea kQh''o2} dH )k{gkYr =FdpXJ:&*Ce@i  R'GqH-9ZYI|=:9oJP8 ^hSx}fcEG'+-Aaq~tuT/?4#{?6Iqt$~8Kue?_ MZ1y==_jGZXBu{s0XTG$z0ozK9CG$)s@mqChe,p64Tu\CB'b (|QSv3Nna$F=w)z59LJmmxhHR%QKM,gj,Jb#"O@'N 'SB7vV/R-BM^; Q]QKt*)_x 8Ur0/\'OICDV#<Gh + ) (25 o's0.*W9_" ,{f'3)SR.A-Y&? ~f4%Xts)?p9goWv}%%KZUTs]/Q0A=C]z+ ~U"=[S36TJ|!MnRF/@$s>-cN@C%,*zM'Tvts7 HpffuG7<&4Nq ,uf]x8^iJg*\iX :#LNh5@Ee@0Aw=b2.2BF 6^Tm +MkU  )           /  8=7&0"($c_Jr*:LgpBC"v.<W{J|XM*m+V3_}UagV V)[ I[>BTHBmh]q9;Qx-a7S2Si'efNd)2!]g\ IL 3n5ZI9\E\p22Bds#cM{s-w=7;Ih&(& %& ; 1#%" ) #  !                 !$ 7 )  1 =  0#) & 1#",4,"' %( U3 (= #  %   "         +7?DDA<7436@Obw{T"Nq4YvlpNO P%r6~]oVc&]?BHa"Uw6 y]I @,{b{hZu$2 :iE%~KSh6c? q BirR)\)GY\> l &) g9K# u {  k tF3.*:r! t' Q,eO~|E*7  E ) c l|Q+u(+ /}m 9'2  N: { B QL; jKH }-hkO * A - Z|t23EKPpKfp:_\YRdOLUz-8uWa .1gz6oW.  lp<ape}<6MJLqmj - \{^Z  0 @i ^ TtT9/\m3[)>\*-+ <@ ,]d9dD1C0NHQdH 7]44D&Ak!&. dxFr} p jF B|R!2$TN,rSb"934,Sn1 2aG+)e DgG0{1s9%=X x  Bd{i!e $ i~6Tq"<82r$  ?PhHb:QS6-{o 2u;t$1tAZ q% oQ,HT*E 6=P;5P;>! , ?YR+ '"@ l Q3:+$,:_g}g+(R e21 6|;a16a?W407(`" |M ^t~j   8Dn 1M2)$ thu+ _? Z 0 vdCZ:h)6b+(] 5 Rd z =  QI?%wLxoG8/*$Q J3EpM L^  +ESwN#}="63e, tVRk|G k zU&Mr ,.8>3\ C-Ir  3e&R6_b"55*o*M(<az6 j 0 m<4#$2c_)*55# # w4#w\nAxc  DG&nFv5*l504DpG$ Y<+ : u  H } k^61m +63:*,z Ed4 (F X  f =e+Hv4.)=2!+CM&>Vn;RUn_sG  @`"z"? Pn) +/$ D ifc3^XU&]*+TG(4#p 3i\TI 3!%)y#IZ 9{|+*diHV h_#7i #E % yok~59vb(mpt$ 3B@= wl!8m2R xyj= mb$p#wCc|f@t_ / >N\e+^ HsW4p (Vj]Tq9(py"%}HZ+gH4Y . {J\Ry_"%]p~BQ;  PDGT[hto`%[*($Q2qXfN } pM3 !d.U =ws,y> J%[q b 8 uI<(x|rPSm?Z)/_&KS(vbpVMQYISxqgA&wg4t`*NJ_Tr d9PIo\#u@I_krP8^/0L{V)233G}ZadbOq)_kPD>RU}1SuhE\ewxWUr$D"`0K?kvs3ax Kla>0zJPpy2]tOyOXmLBb^8M *`DJv['Y SB am,PxQ}X$6]}'mtxD'\1Q{u(<1L[j>bweO2rFc.{*_[?)YpEwI[_9/(b'n'|f ^HW=o^0RF4Udr$'F[9#$e #!t1# mbPOuic\JPDK>btVIB%d} *g90CF+za~l,_<[ @ %e6ykm ,1G&5J A1dtp?v9X%9<=(g2:"\gw;*]{_ }|6<'4OSMwyu"A 24 5P^|?5ZVJ/9Vgt:.k;+=C`RiO+]H73,Y3rz4s-X>jBs!IDVMj<~s?"JEBBqQ'm D6 FUpNl%$RB_[#IG`/ D$}`M[Wg[_NC2k3FI`66e L{u<BN/$X^DMG(g?;ysNzI/WDR9 lu'a}CxT vN e :5uuqwll uls]2YVE[S# &ZjdblSamSLq u%x{{ n*SH. FVW %xv   O2CyQ@Y}>t5w-<'.)'+%O2V=[^F 6z8Z "W+,v0^0 ^>y܋7.K r5p/~&!-V/b1-(A ^f;\LZw P0 ݤ2# ).1/z"-پݫc zx3er$Ze] O^#O(-g.'z=FHܾpq r 0tEAdW'G*&b_(#} "  Mu:]yh0> "% s)wQh{ dq}qz c^q`^x(X*)}G=*^ #p %tlLK~h:J ` - 0  Ce8lLJm  Y e2  tD~Pzu{e9 D J D M , ~02Da wY01 M p \V@%ZUL,Uvs}sH FPUOd>pjdWv];_<N?)ESFi\ zSX 5~v^c1!vT']w=$m;8}\u%l&mQ<A|Ud8nXz1 B{>|N9\f{K"g?'DlnTC"-.8(X9&%.BrFjqcUA::3+)dRUR^|7Up|zyb\H51)  ,?CPQIK;4.  #$)/,*," " !!,0*&"  458C8*#  )4A@90( %. #9?BEC;!!90, 3?22- /FP5N-G pgGJ(  `@B)6Vu [ IBzE0i} C-HRd7g%%0z\}r cuT&HM Q6c[Dj3Y<1[mf.]X~$L2 .o*j?(4_ M7J=3x-Z]#2r  yc\@P{M6zUD=,pY 3k!lL*xd v$t0b)hq{F-K2G,Fr2lJ: =t;_(dz9>0w* D:AeHy=U^"_Q#wA5 [_Iy=W0hL'~IL d&:f<@7gLUtLH Cl gOf Y."[gM#wA11 S(RYW,* 4q@-"cDS<1v\jK^BD  ]8[-(n77f "[}^ "oM \XEB6W8[v4;FwI`b4-T9U\-<(Bn1B$,kZ1i__vUh g<=>D^K#s ToBQ7T On:=gM >BIY\*'$M'h8 @% jU1$8L,%=YA;Mxs"T Va)iFQBA!7 X[e0E8GkqR"7@RCfqaA73r7d`ep\tFY I=C0'_ -1HtZ>0#l7 O%e(#  2  vބ& . d7 *@S4?l  *N.u,:)$'"ohV޶x4^40 h X.4> | u%1./-p'q8iL&P ^ߢfP*x!"1941E,#=ڹסfLv !! &D=ڃض0i0v05WE>5e."P.O/LR]9)"*")|/vj=n5 61;??=D?+1#9p pʠ "-qN)l**g)^ P'Ͼ҄  #$*DV@7;.$Bup܃VQeq$'&#N% L9* ڣ}Ֆ׈26 p(! 9=m,+-v+ u ߲j|9Q l vo =ql J<{"[%/17n'%& p7W*;9 { @d ;e7 T0od !"4.$!&u _aߢ#\ @ Nf>RiKKC^,31M!W !J?F ` I7N!fUW7 \)-L.-@cA kT1-G1 ; OOOtS @G]7 I.-%A S` I} #9; p@|O S g ).5ZB,Rqr 4R0`  d7 q xY!wg q#R")+L.S WkZX2} P R I u vrJ#!Aq~=|&"e$_-i(  kCd{ bL H ?  5WNB9Yf} ( &T',?5[ f (d[N%<*!!*H$K$cv 0J8  SvYS{GrHS.8@&1-#g( &(bm"3.##i$wLpAzY# pTU%==ZB-y2(#y$"CzK\]]&a M8bwiG!7I~] 8'V4q-I$}#!tZT> 3kN+A$_r!hQ: #4-1&"K  (Q[MnyC]jC/4_!31G(" 7s%0o2:"y6lLE*Ya1gVr!10)".T vAC*< ueHRi ^_ #{0/)V 2T|W;uRJG6nMD`+-z' .+%q.6] <^0/.Zn* IE(+s(" 3> (.XEGf`j,:P"'*D)!:B!D:> -#6@AF4KHZ (h(` TP  zl*f 4\r &7E;" !6Jtn&!&$Xq ]iN+V -xU&U=aK:8$$W in~]2  T: 'hz# #d D ~l~XXnf^ MQ^nOiK("2!@T c\@nT$ T /qY!P/j)R"#b!w#x FCE;~^Jl nsCc) * # ^nZ:W} g M \}T  /$l"" ]T)9bsod I  +:hO1!7 a#$!#" )b 0^M N >?sY4%$&%'"J*M d h DIQtVl{ L#~$(-+# =UAݨNT_ \> +,R r%$E,a2- %< a KA}Z   u"sV ]!n)2x0(#u3ylS 2 w Tt} z+2h.*&hCܚu?su]   0r7nVt$ 6"-*J*n+6% +o~`޶߻9&J 4@*kJ25$4$# &%5y D"9X[;^#iJ} <T 3  Q\8Xbp1iQ3T   |a e>oBVw>#i25f\u B+ * B>D\63?-hOS T gVi LL ^cf-Xg hOB NV ) `@Ye73J+$;Q *]`W #a~N4* aP{ n5NKoy5 $[sMzjGR4`#//9U- 19^"dh:' /qsQ>?;+hd!O+C 3+B pOJo9jLh_dt`u{%c5~&+;<pn Z-UO'j3' r*q -+< ~!=<_b|mjtVy  GnXokOPdFrs)t{F%   np9  _Yf?9<9l%clSqX G 'T@aW5P >"]N1 Mj |( n6ief $'%/Jt'pj9\#Q IJiF19 T!L%.// -'M  ܍ܟ?ܖ1- 7E^z: )+7T:5/$qCңёRo= c#m n/56B=59,0Y'jUѝ-iIw/{׉M!.&?>(;A@6'* RJa\BOm`. p"sNs\ۦ܍H\p!M#v";=15.y}ןӼ; E}6FIrks.Wfl!@ J3U0#=(;p-,M[ ?s c?lTi$/$"$ G5vPA  +zP.*U %7z "yE  y!qhPrDm 9 $+&Br aIWi I  S&K)E#M'M q!Bd 3@o3PtR7rqUk4W +%H#em %2nGN"d5;7**x0"\%!!c}P7Sku( !*j VYQHI Dx!\2<|1{Ou+[P!&QK0@[V%F!>p> xrwgwWyCLd>!#BXpoTLhonD}AliA}U HO. @*Z =7(KE)=7nQ 6{ l/Mz5 <\~^%T_d(dN2Av\3u<E  1Wsh{ol W BasmUx+A <2g3 ZY!%x POwLE 0O*u%7);e^kDmb\#;I &)j$T ^,&7ce_nI+qo0EO=}'~-7fI}VR 8axyNfw+-=:p 71 2UKGY5^`g=7s  ['V Rt4u2L#z 6 zV2w92|-b!&X "?c]acw"K. 1 f QmQF`9Pt[g >:q5 +6u Ly 0verK@;@= eUS d|EeQ-e*h  {ZE4%M5)mj|rQ2P ^e=|) 5 bi C } |/0$7bN>8sh! <itV??Nd!E9 \DZ T&w=_x&{}xq (0('h>Lw2 )KG N*g2Qxd}SW7Y2UJWR-1 Ww/  I TU*F)=n| qeQ1ozy/h  Rb]e5}"} eO7Qe%[$b|O?9~ x _;Tizq4L\%_qT.+ n K   {"Qw4y'IE:%+@JP6KZ ^ t 4  }NAoge>!0D_w#_M_3RC !@'NpUH3KA}5 ] #*It3dm;m{+=yO]9u|7[X$tU&HkuY { _"*kJj9"QCE =6O+ #2.L2?)x-upTK~5NOzLk# /yw ?.u9HE:'8XOh[G/*"]PA(u]Y$~}8ih#V`JDGF7 [FPphB#H7 &GpsW+l7\ Y-}}uW&(] I0W?xtBV'u= mO@=@CEM[q==;9731,&    #&()**((&%#             ((/+)(&$             (0:@CFHHC;1% ~vpor|L+ 2GF("O?|9 ,qQzZ7a:_W.AtAREPHs`3iGfw.<h|:wJow fHim\P_JZFeJ~0 3 = ! ;`P_9 { ET ) ' FnGLn'5BT/4z1t mu~W G|b: u%uA-tZ6 9X QZv6 E  V,EDW,M`R/ E 3 i'}`H?~Mn `o)@u "EeuiPbRWHHy\ \ 9 7sxEdm;gjHR7C k j  es| PFWK~2 | = >; z YQptOa}*= < P Y: E 3eQ'e? {e(,L2,O|w=Y/RS>{Hc1TlF5ons:jIugt*86#CAEm7Vt9o#h4Y yYGa!\xf`g,yp(m0(Sk|U9` a _"?ZN!|#;=BX{JzaRC=FZs'a~OF3^zo%h"cIgq- F6mlPNR V+rd^_%}u oVm|g\K8rp 1= taeb,g o = e#!#' z <Z97pZSJj 4 /"$\ "R  7Y0(C  % 2 DBjXY62Y] v -B#3'? edse`Ib<| < ;!b G 8M@g s.W Tl""5 y<:k:z Lg$#A!F*V|? 6H%i!a A z 8/jXninl_A#S$;R@z|`3 i!S#+lA&i]0.} EvHJ8"8E ~=(ax%20w K:P , wLl$YQ_^ Is!Q?|*b3/Z q"b fey~<[, 8"a di@zGV p#A$9 pV1Ov=!$!1JBv;*85 d"P#|> ]0OS~1cW!!! d 3.e![<D} @j&?P#yw ub@XXHjp dtza%_c#c[ u; r d  f' OB h   WS`pCI_1S 1\PM?X~#{=_;NkI\`&rBH'1 fh*'@ ({ 3 SQ3#9b`=T[k5\}1Gte?@xj@8EQ Uh:R|*>6)9Qr gI)7u^Y Ny$C:ek8vcq@*n)Ku2d}Wvvnlu=3vhe+H'@p~Tl\x#agFS@z'cze# U(V lG.4(!'|`/\Cr~jG~A/}-K@`ljfH$PyG3ucO+ng[]N~59xU4KJ3!3i`Fm-YrQjBp^c@>YvE2g7$}q';BDBlhq;;"U_ $H'OW_b-, k*CxTtA;D'EB0>B|t=3%>G)@PR.&uPX37f  '29&-(,%2  %   #    $ /                        &     558 \;0JsH].o^_g2= Pk|!{R_3;/^PG}j4ED+=#g$X18 s%aLr=n>zA|SF#>on4LY"^\l-x'8gBl,d SC Z q[* W"#=:d8s{*.'f9n'c7>$DWs0).ZJUm-$6Pv,#{&)\@z 5T# |exd HT'$NRcN36;DW3& V:{u  J{'}#g[3N] R  q q JL[abt>+O- .1 F 3 vZ  P &4yc"\ln M & >FhR<| dN3a|E6 K, ftPK [ 3j * ]G%.<N NC7WZeR jtG  kfI>5XV H @P &tP'b yHt t~ a3A!]6)nt>jX ~x!pGY xzxU0B1`'$&!l vpN 8"-YAAFLvq%'?++X#xC$ M,-*mdU4U yQ$/+: n = J/fLh@; T$1n)?;uN$ ) Z]%_ZkW:B#2&)9-" >6NX;uO6nl*`&,](0S: ^cbd)J&,'('E r d f=HtIBA4f .( (|":c wMrfZ _.+H'=&% Z}-52,X/'}'"'@:dy$1,~&%>JN]Bz\m0U{t)##!`qXVA`}le*aN%"" TQt$ e\(eCfK 2 !A 2#2 p =ZyJ!;P&"Ep4$F |vaJ% "7< * lIRAbf({uzg9 * :(sPOY, !T7;mb2JQe2rN8YF]k(n$:!<?,  qp 1>vzu+yK ) Q<~)'O3 3gWb x?5g7fszCk1M|vR{~ K9 <SW2|)sVenPU~z }+LzVLAd@&?1<] 6#][ aK& ' L2V_#L<y'(>my]~[O G5x G9rW,( @dS ] xG z/]\Gborrbg" ?k{Sf@ kXjm S_ LLF]ul3$9 8 M NGN! iI4# <  UfOLco vDs FO M   uXH? ] @%E{[ Ei eA5!b'dLwAXG0EutJ"|AoeVOKk[04[Y;8.rR#:( 5WQY F$jcVU.S+RfOSV_O<Yu[4{*Na3Ir?) &eVh:G|[i!=nu}Ml3J"Q2* dCO;TzY?<b ]^j4nX&uG)s5>AAns)EQGdeElS?Keh/Qj 3gj-IcD^W_U)m?8}a776va =X|21OJ#=#?rfF"*1fXgi+9JSK )"   "F/R'R !$              !6 ,{K<ilF|c!FH2#~ K-BVu_^]+pplx><pm64>ap]{a.Ko Mf1C- uzSb?;FBU?3gV> w4F~7a rpbKDRS7[6BS C[l}N>o$9 AAg'Ge" `:?S OxsEB   m'D89ou]pc2_ [Mz)Q(U..=Q   X <T{,dIa+fXy P:c8}8`S< )P89?s"YfOQMl7]2?4NzGb$H%! +Cc@xn6S) &P+YZ<)BUOaYJG*"7O[gb\U8# ';NQXSC7 &;OY]^VG3 #:MY^`XI9 6FUZZUH:# ).551.(         ,4386&'        '  !3ELlDHk.H [\ 6/& !g`B# Me0d[sFOVSN~)rHQ> \?e0>Vr-e IiyV57] 8;   } e2bEs} H d ;  Y83P ' :Z=Z^ s J26 RxRI.F `kn -?GY ,$!o()% HGg <u!Dr 4dxj T-"b0_:-F+B$c .әizݠ )0V)&  ݀Ґ^ˆMt6/;-2JJ;<;4.!@Q'  ;.'N)o2)1#|Z̍Ԙt)(5C;$<0$& ϱ]ڝaM'+'"p (Bb-,8B=6,8":UHϪ͊-ݚ, "1'~/V%^}J^ ͜~Ѻ.11(?D>8T)f 28Vʟ?c 6 ('/'#|/̈́ɡҝ$Pf17;DA:-GҟKxɫ.٪+_#E(,'#&R@.@h)n V57;@< 4'$ 'W" fn#b%t(9! L-h6 377;7 0v!c/ xFZ5L4 d $$ 5fR>؄\T)645l4Y/%L  ZԐؖ3+ ` 's)z2S~d#%%//.+Z)F"W ,w}nݳ^)=7  1DZcߟbR F#!s&'=$!W!Q 5ld85t dw $ #9ZUoc 1i` r"ic a  5 v NAAN q2 o &lqi~3 {Gc<  C kiWwtwi"ZX a^; V =%I6lOYmC |NxjgZ( xRiVyb[.A#-A@! #J.XaZ#Y ;|LP0@ 3 H";^UKV OSeZsXX $=?5 ORIj hD2 qa{6$L'Xi>`c"L Gt"7KXM-fe>A:$0Hjlg7; ^ey,;cnzwXh4\8vj*+A"8ur"$k\JUL-3(gb<8y=s1L 1G]" rB/1KOK") w|[&zAuWD&>%7"u 5 e8"$cC~W%2nVrOS- f \  XjqDWnbR~ <WH~TM U K &}J5[F t)I# X l% z i$*L^c5'@dG)? l 1}m|M3 / dw4m,wN5op 9, q( H[DO#3DedgL -6 lE n#|X/F qlK0 26uq9YP9 FRq~ \{-{ 4|0 ?,>>A-Tq&gw :~4 =!@j:!! S3'J 3rR&Q2`HkH z 0Y%+) . Ot;u; 3L@ jfTed3@*ySj2 *X Gop7\rl`nv64?7o,gJ !_ *?6hS!SGi Y=]A 6 X:=H:E2+\fI|l_;#ML y ;%QaCdB [CIf1n1R\1l[y' {f p+WTFs KNK_Y4Z@{ )$i l %3?3i>&qfu;U,  jZ^fyD:pLeG\q2E\s v 58)= (Y"P-'hU~`j>- ( )- XlF5dc2{A_h20E4*j 'c  8U {{~[( O%U9-{u$7>@$h_ +D.U  @" U7}-V:y@&1?1j?5  L`mT W2ng%(b+3<+# wh;bX3 "x&o6&) p~vp[-YS E O_s 9' *2$b'e l FPc=LD[8;o1#(Q-#Z! B ;y+noj4 Z 97 #&X 6Eg-A~237%z8I wUC ;#B# i7A (I(}xP7 }2;z-E"A$iTJ !Vi0]>F V& @ *8`82ScL+x$G w5 i= n2 nxzJ GTb (5&v(W Nk~lB /gEP )4s ?l-  {7EQRoo=]fZ [] GMUlzs8*}/3x q=H PAm+T(;Jr[| E$ ]QrV<#6*,9 ~ Y 6 E 4DA8g~g}l/b4&kn}lsjwV[@' ]^^_{}h@vcmYTQPA;.W7W\`W\aZEB:&  F>6C@5E=76H!15)!|7%O4XTZW7G<87(|>}MStfK%~'i)UukvWE!z(wNrb!2{{7?/S03~.H#b,x'E=q^{-;|#U\pdl 1h|4 Pd^Uc:0FTJ8dX`V^,k=EhR[9P=|[4+m&?sBw4Sa0Rt9`G'km:v#!D0#E0g&U @ <p3jcG$rKK/ *2R#+\KY # t O$vj\*^L+$bc D F  &jh)lR.YU   jk < G=KPJs ^@j_g|pV w K MAdpg^HqA2tht{wq L _/Y rp;wu9KXXjE_@n 0 |2  fwF s<-F* ' 6 ;)b- H6&U8T!8{y#lm%\\j R"{ay/tBJ~h%1 tHX8f$\zV0u$J -V~fT02  F U FH %\ R":Z)CNnh(k*%9HA7  4 B:  +u'G`BGs=`+j ?O1 7b=q7f$<LTL K3 CF|{EDyx(x~-L` n Q_=UZv 7 [> 3),TbzX`qc kx pUynn  5^W ]*HGdu mZ0 m m nJEp#Y,K Q=q _wc;1u;' sD 12 UbxE.U%R b Jc3vZl`;b Q7K q37M1WNI<*SK ^z ( I7!rf!%t D 9] YYT /pG~x|@G m   81O<%d q y];X - {**g|;iI  ! 8  bG#'b D  1 * =`KrAs m7K)#AjIMe%7Nc>3boo Rykk:S:@4}9*^HgG<I_qHdw q15ok%K7Nf{ v]{{yC<]G7.* n'sAZeIZ1wa|G.1<>!N@ 3LQR$qC8vJI~|DW !Df8\ G6\B)ZY^#ApHT;d}mp,avc5zGx6:}`dq]NeM5 8H$|B\ Aik +SvA@0SX&w_`YfMNf:=@AN[G1@P[Wja]#?:4 n|xB&(5Q*,9&o_ 5YO`P6 7,IP}Hdf#"~M d57'F/#Bw 8;XG{;qDE8#!%H{J S 4 r2? W)3) h\.Q )( .G2?">7  J ' 5+;H 8%7(2$ +, /0? )()+K!7!7E<;D ,%G%>9>++8>DRW(7 ,>4%/&3 9%N*Y~ QC4N=!7->_H'5 2++N=#EJ!- 1 ;T* *-05.@.aL14 UP9FJ  V6=(Z%2n- )bAI.sr+i&! nHO8a!_Tf6*!&BKeA #h1hA#@ 81t=H;,g#+2wP7h :CV]. 2>;+c1D QJU A;pjquJ5A;>' #gG W =" 3{k/>]|7B=(7!U P  d&# @b';;?:~#FeI ht( $e#+76C$B-i`x nNeqK,2Q@E`6JD;/Gq R#5&\DoN1u,=>D 9 'KE:-&V3 Q,Pb pJK(+>hE9~^GLy 3 78 ~qb2k]?6|(x U  ]<TBre A_o1;E7$9oq5g?  EKw* z 51RRmY 5hjl!D26g2( x%Cp%]Ia`| S /jO}V, :@+32M$ *z "  `nH>DK cW_)r/y, 1V1-vp4m^hXws nr <b&%^H(,8 e#@|r"4ni  P =R6BMXr^H|Lb#[  w TF*gIaD=' $0$K C   8)QCC0sypV$ngR x=7>#4+2s[!nzo%|#kh 9M O{2oft) )TGA528' *)k-U1~FPQ+];RD!svb7pcH q(Lov}?8#Lht [p]oL*I 4#h7f)bh9*\XB v%H nEPqs7u3pi;Fb|^X5R y(  UB{tM#I \ pbY"V-8GZke2wFFo 9X Bs3&'e8}newh'}&`7'U^%(QM `^ f M6`.4vRv^GmbS +cik|dlDc6nQ(%Xt$I)Kf-t2a/,GK  t ,  Kny8R V~)*37%;J }A -m.WqU1S! o #nWK7@AAA?;70+&"    "(,/1221/,)$                  !-+)   '=FGHDAADHORTNNC::7v^MJII, I3htNH\g~a+U"_u!8= cFu4D_j0IfK hk+;Hz1A1k\T 7  RM27n<h|x|Q |o?  Q F  ft|A'd :0ePHf  i -2FYB6 HEoN}eqc$%"CT/* Z( E\|ڼܡboh1'6=:+0!v^+FA?j] lHބJ/;xW3?Bs4u/*AHG'R7qIWւ /+7A=[84M/ M?x \X*p,QrҬ+սݤz&73=J<96+3h o(`e*88jC אҕѺԦeX E((2.<A7&/zKmXEI  Nܧք ޣ P!JP,<>15+z!q 0%dUד{ґӷ/ܫ~=8"5-95M# KP G > Dߕ(׵ڹvW SM+!+0)4O7')#(}u-?Vg2L i4d:CܕZH4) |#h,V* %D%@ h+$dH \ Qo$W{A p(bX  1Id:]w#FN| OQ A#bgyhi\]FH*d\r 9 fb(!hr;gnV- 85 ![ 7S {#qSU?KQZv?X [!g!{g  S {1%"O*6g K ?} j>R]Me[vX#Q X|\ rg Usu,f F|#PcD  FO N=|mC~T_2F$"9 tXsp@ Lxmn 6f-Xg! \Bsb`<  d+d]Ee^  & oR ho4G^|rrD.KO~} 8IY XSp7 ^ABA $F@L7 1Jr! I{:kg{&}!*gKays }$N5 nH! } $L *qZ$j=]DbhQrTSK "t "  Kz:EJV3]!;# !#!;OpP p~Su4_ 0$r!$c!!~~ &rZH^^!u B865n J jr%#=$%9! 5mXW;]J~RC' *O&F%&b!jJ7=1ZfMr_2g"MX`r ()r$'$OJoYDXTIxw+)>8, mfcta}"1()%'$ mH !ZgL I ',// %'m4N!4b < S  )0.31!<u)ҩ̼~@~9a 41>~s rT#Z0:531%KAm~ ? iL Ps@} Z;'^79;4/& JW.Q2% Qm| ک{g*%+29]9@4T' b9=eO.G L4[S TghuzQ*,#/49^=90a exi~9(;QC > r>m x(6:Q<8;4)+49v#ȈִN OJڃSX*7'?P?^;2 ((05B-I Lro &u i%tT *4;=7&-"~ gօ9l. J Xo>=?N] e$'+*--&uw B~W]?;\2~@ Y"#(  ^t| [r)ll`cd?T/' T 2k 2Z.N0uXn;2SqfY.'5  ?  Y cpx=M)k^uYjueh7 pPEuKk:weBeJ3I_@N&q.i/*AHg~%sX&IlP   t  G4V\Y8ff, ;{ o \U X c3 }VL HM_wdy Qz   'm.%  +d@4S?&AM+mg o KT  D D y)=>OP{w:6!g m   Qa*&?3, x3XF =;Zc E #tU u " ( C&HTbndtO  ,W `5 ;[23aO1OH:< C?6 ~(E?h^}$0:qo/i(O +>T>8nK{,(5 Ej_&_ q ~J1ZJ}>g0dQ[O z!Z 2[,y=AuU,5- !)' PL KzE8zs)$!#F_vn "%-+&/MW [ JA i~?6y066y /I'2 $Q H}DS= Nv= J;0@/78& p}X M"n-Q | pd'q!x* Eb`8;H(~lhYB j h=/ Vj8f1 o3n|4`?B(} @x IaOn&A hGo`5B$6F; ]`?1Jq, J6j\w53}wW6"F=" e  3X  Qc YDB)8 ( ]3 f  F~_C 6D4  j* i v YZ  68Tl//;<,6s &{= K0K t[~je/H0q({g?#tB8*0"i sx:KY6Wnf>S+yg6O|^ Zf4#KO_ruDs3P;I7 F`vXI#Ae,Tc| TNH?<#l o;d' WOcJ C g]) L'e_~ve^?p#CHs`=PicA+ |nO$-C?W%z01w?f|5| wl)]^DrTlTEFE}dYbE"3t|VLTv ?A=^`>"1[Np_J(<ZC zqz(k_9 %BI@-& 3KG,(8C8+  D[Z?8 ## /Ujtq^F+09E9! "9<4.$0DQJ9/"%%1=;+ $*/+-<BGABA3 )8BJ+0;SPJ<:+$689  .},t0]r#k rHG|-)a ndE  WC^/C !~% ta*_]HohTXvRwCo"; l}  bh|j}ytW+K iMj$ Ft~AD)A R\I24>$HkhdY78g_K k\(6^(v. /3~gH4pD)T8%;gg^6Zh $-N j2+)i`(YQghRdGppAYy.}dsQ|C!;VT&taFn #vm5$I 4Iw !t Kl1 r"% $= Lq bG0Q2h>l!N]X p @&X$r7iYGkxC&,u m"''8" #f| GL *S)#:!9R77PDVR%1 tKnqk_M!, +i(+&- m5c]=;/ }o('''"]*ZRd5^K a[  " "!d` N Yq~ +?OTi (>D :4O W * 1;GH>P?dRp~0%,\xFt bU O r^'6V0^d-TAsHMU;-iE N ~4`wb5n^s+M( I K\B  sN=~28En;=n>JG f0S . '2Do] mMTF5b[ =7 %hw hi m{E  Z,X0R g;r%7e9]NC,Bi9 0 ] " :3 x RapW+f;O9k .(^W$  MC}L[ HPNW%9I42n \ >]Z&POJx.u;]aT_& 6HY%D 7 Cn=S<)YRJ' `L Xx V6SxSSp| z VsyB 2t%[!m|q& C F?. yRhxZA.DW -',} {J,?s-?&(p ?3,/LF %ex*Dt~ & nBTg+} SA{041? V  ~P| T $Wycqc*P"wE<op2<H pyS %FYtf,CoJrB }+% t2`" tvs2H!n=e  D lE( xt m]CHCX|i5A#K  e4 z[ |B 8r\a֖QW; 5Hg%Y"h H>Bd Y[qPؑFՖ'' 3 $8*(#  |jf O5t Hذؕ`Bi $-',Z8K0p Wn8mrn Wֺ֖I ++"!%  SCvCt8b.ORX'R*$3|  4vU0jksQa5&&}-5 r+<4.u־لv&{ V) KV- hkQX6qSKM[!~;CS8?o%x?`1iB]sgZa4ljB$3qsz2Tbs_32MZqW'Uh-Z# W4F$pGJ(10@I}1=L94WE1eP 'VCi wILDH]mm~}Gl(^Y+6q{plP% .:;/PGL+&b_Xqwe#=a5 @BR'w}%ZjK)Nk" w`GMBcU#Q_OTzWURB[@E:X&Z6e9'qf7{]^~C0)S+8dUwP gJS^!p_W@PT7\ ]$Y2RI0!*p6Wgx2/utCU21I6?SPT1 } 1ySl $,`dhBM@'CUES*KM40 ,SQw|flJ']17S>r%< yimEY\9[X$jkL/l^XWw 4lr 6 - { t K aOdZO Kr Rs:zBBIy1(A | ` l -M"^8 'm` ({ P.OkE& ^ >5@bcRZ-"XXx~ *[ A %&#[i[ 4+SB0ZPJ7N H *&,.c'!;-7gn5>>!s,LA  +,.%/)` wgO9gN}{\X(y441/'s LId߲4ft : kSh6 MUg&S6n875U+}x[aSۄ` QC>mܰN"'8=@845-A[ZWRٱS` y O'?ޡܱ4vv ))5S@921/*>8cF4u8<]Zk cC :ޔX0H&D#/$s.h7/3L/<(}^P0۲(ވPeY oL []xbD$1 .?,+"CO ;_ [ 1 A],ATB7U!.2Z-+&VQth[ޥP} 8Zn Mid8YnSE*/c+l*&  $[ H]0 K i  $Ful5  ()A&$"=! 9y&C0 , M@/{#e   ? \=h["b8p)Siw }:Ise U0mD|Qh7RJ9HL-9Hz* o\z[vpjqx;   z $wcuM47axs)&f2}XU661-R^<3Yp$X;53$r>,a%QC4 |h^r1[-t9&( SFCa~7+ 0-Ay4(bvEcx>pa`i2}Q' ;8`ZawH_ |+Db c ]GiushO34NfzrQ)CDGIWmogaAGH#[A\zjd^tvi{|xr`P?#|qlQQgw'Lx}kWYT*F@IA&.$pzOZ%Y][ A@uIn!Q92cY-]/,R;1)[.X]G\,;iFfhXfz0THB YbPpX0YlIK9]JL'[^)#P B E Y^'j8ql=@2 % Z  ]w9pPl'P(-D6/pOIAwW x< LAF t{JYd2QfR$6",,)j%!'b"6 =9 g f!2!f,&3.'!1%N:UdG# MNyM&r$28/c({!+ |*޽ـܐޟhB,G dOgU*'54<2)!RK V x FGUْV;n)-087[,D# /@BٵۀߑU K& >,[%B8s "$_',+)"MF jyDQ| T(d ;ks%~-r(8y4x-Q ([[_l,q-iDAv" 9\f=n&_4=5 ?wW=DY>aD%UU&WI>S9p.2u2K!UVxHc | d'yB] >%' filF9E_OLr05j dz@oH^H`huW5>o" Mkwo,AlPE; m-e8 #3h#]*19KDC>LoTt7n ]8:Xg<t}L]yI=?iyr>>AE7U=jh#x^4^p/VdhD5dV l?e~F8A>V@ )q gVWk ?* L jY eI94m%X#k  mp:+E.D x! %QrD?iRE }@0GYvbC$12',-5'! O15F?%$! B*iOܴ܅h2xB/j !(4,=)t(UQ1 )GQ!%2LW \XVP=9!.,1l'($$;"eZa^V%}#E ELa'DB"m4.&1)d>79Վ(V!&q &J`k=:5v~W r>13'(! n /6)GB J d% :; ZMxލfBXr!7,154(&  d5Ձڮז~z| !#ULK^{ b! k 32j)%0}V!,!  HNZߍj [ #|% 31 +#=`֋@ܕ/ J!I"?z Fj(m޿_Y&3$q.2{1+6}iF7!o7c"!x x qJras(K3 e6$%~+3#3K.{${w3ԓlS { K""{ h[s }r [";')3:50p'VAװSڏqb I1!1Fݭmy^(?)%1`5F1)8Ibb4ڔ b0 W. ^ Mݮs@6.'$N*0/4)a %'4E@g8 fk uP64RsU2 #"S&'& _ M{ = : T| tp"c&v ze6[Mo[M AJSP&0 s }W^OJ9 R k 9h V#"BQhe3_b'dL2 8 yTy3=wLj  EE9,^X:L=JE1M)zDb=q[GT\'b)x1gfO.Y:tbMK;`C+0@Kd5NKSo8hL.+`x/ Yl'Jpp0_Np VLC%> yALD`LL%tvk=OYGfJ8dqPlCvQ11'OG ]9PtZ K G(h 35_<lc*F,V#Jz0/s qg9qyCjx>.@ f_8Em)v$Cj'Tl6C/8VYa~n~[D'n14gyO<P4|y *jO-})P/cb29aCJA_KnAb^46~!7['@"U`/5E )M(&OGotBRr z!P -xV~`o J, 9 "LC!Q;rG&|9 _O'H<  [u dEy-^m t5 D?&k H4 w ,X/54} , { =],5me A v<_[ }t -fxrI,-}rPezq-[1<YZO,tk!D3>b}Uo,hH]2= :3Iz~zlF{*]&G 58Z?;R w96C.Ec(,2?4nn73s../8MlTL z lq[e!"(1Euo'4j|@ %&&/GCJMF0x{ 7ZowoK) (8CE1%A]ecN0 +AQVI' 3LTRF. /@GD9% "    $**%   "%%  "*-) *  $+3)  3>971   #   *&'.:4$ .9A1(4:  (20<ZbvPE CDq;lV6yi{x1')gb, uaV  S@ U 1VIM)FJX$/JEzzF#c rL] t xVEBeC \=\,R, c % > cF+R1 G8k " X$:3AW% }Jx/ D?>((Y+ {}#8  jdeX:t2C8v-6^J 3a{b*  +1C7[L_T= Ab K ~iJDex'w;\ >pAWgB YM 44'zN1  dOU$A^$C*  .VyhFP 5yg::hHn9p3P?@!2&K,!/=$#c ?.m2>.}|AE3uY k zt3z'` pGSq "# v0,5j '.g7_z!PyLt )" fc ~xWe Y'V&r V}; p Bsu2PNL2H ] \|%GMxAz !i XIY` ^4V2sGrA  ! z9EKyS?by w6t?9hTzb*~|VegzVQ.d% 4c-F:1E^^  )? T\m_HT,K:uRf74C ^F]ne }8h>L  Uf O" Qim $.- #w& {Ov !CUTc@X!fF #$ +Q /dnh5 f3ba  . juB$P UjNtJ`HXTG!s# f7;S Le'Q9pgFm&htb-q"b7. xfTQPhqpc=$FBx[\`+Q@_^[6w8L5dQ/nn\N<Ep}:vA3L#fcwc^k3AI\81y-Gue)1,YnQ0D g(N}M0&$a((cm/) 2?4%25.VP9g#G4 !>EU*E)   !"/*51/  !%-I =1 % ;#9$'%!,0+7(, +#*+'#* "#+-?*9( '6@< +/1 ,)//! . =5%_Y.*&.F]IG&?t>aR> P|nxY K$U=ej5eiplODo8z:N*cE(|SE6l|kA@(rdyy'N5u<<p`Q+R&4DH`x_4)/'I*ZX2\a6x`fbB<! ; N 7 Y 7T;NzXlF,r  6X+iu"*/jAhxFn #4 RR(: ~T }(!.H#! EM-dJ> @ *2+ 2]tHX'$L"'} ^Dy=3 r' MTaߣGB$9,@&!Y&# ;ly ,d `߅ݦPD!! hn$/B-$`%$٩MyQfr"<[z[i"03*$ !m% +ZߪwP9R U( @0gfA_ݭ5; l!.50%/ %۟گݧ9 " M  $>SZ n #Y-<3-! ܠ:h5 I ~ kYD@%HmoF"K+-$[ $PnGp3 wnM gWyJ9k4n(,& 1 ,wy67g c7=ez)]߹*ph#T)$-,  =`';9V ~rjl :%ITܐcf_99+%0'K*_fA4 VDD JGߕ^~q] *@1(Je]ddkrbr V}6r "s.1G&~q?e߮6j !Zi{hB !(3R,U,Zeb+jC'6)AN$s.02,k$JwY M @n :a#,]/+#BEEvom3(f   RK\[ g Rc%,+c&L \2d9Ou  GXWQ$%'&[!x `U^mYO`<X*&p 0"!c!I6bi/y^!e<+eQ0\>g~I {9P wqx.U\,y]c=ye) -7/n R fpM}4rs61Rb k!q."uQ{vg _Yg?eBp2Da( 'si%(o/ A+^3H-heV3#vO Q{/M4syNCKcqq]k9sQ78%c8dd/,*l&fa.yT%l>7pq !sT,,aKWJ+Vf]xkQwNpRaP%Y i&lE~K7hfK5P-= 4'C84VvjH   !%.CKWSGC&26UXZRE>! $$ %?BKIZE=)!')!!75S)F(! #+,$*#%         !   $    3# + 08'$0) 4EEY[p4.:ErvQqzGq*y*Kp ,%1Z:7O@$jkBnGtJTg}l8@Hs{f;i|,0c'PUcjfB?#7S|sUCl,h>fAZC y@/ 5!F7k]k{~ h+l 0M%%5!<W#X"`T&ac^C69W#N,6 k"2^erRRhoeYV>s6,alo 5|"&:X8~Wf8I;x/k^tp "~0~ rnVeFy(y<@EI]EB/U]DjU/P }w'5/r E^f'dzkG`7na^@UCyT?"4 Rnj4.l'k& ArIXIN gO g}(&w[w7Wd.Htx1f"Vy auY#qU|LjC Ti, 4o8T){.jz_# niM T Lbu (mybAsz^eC\^pR\w~BoLbstK?Kc)";0+T;?i.v&> jJP9JK"-jO Fm"T|>3OIb6 +:}>(6nb| t j  LM*z#e ^6z^ t e pJ ) YnN >&>'"="@3ra~*M <&$J,%;""1#0~*h# %B مlo@ uw?8 |,h?\1>f5*-R u]ik "X s`y_کJ/$#>.?;*6 L+aL" FS߬۔M%)p'40@52 iނԫN: ^H h$6ߓI=5-'&68!m,NB~Fk1^w shl,;*-&%7.lR^* D rn k K) A..! '4']p$_7 0AVb=M 0)c,11` 6Hu8  & >.\+/!$0*"&46__X R ;KߖO5 &.##/,qH <  ;jd &{* $1;,+6vXC9|)Y6cv)+W;p^ N:,9D/iO# o!Y( #5X9[r64o4M-))!w Cl@H   H  b!"`8 ? G b"YNN'[ 1  nb 6 8f! V6Gb */= s I:o7_{SjSW[`iY!H1 /xUZu @;= + GIz>Gp,0Yny} qQL ]n "`]Z m6n O ]M+$@ sK =f sop < Y~(lhOb/O |C  w _)UT^$z 9 HQQ()~Rb!* i + w@2R1A #9 gw,Q}"@!~", W y ;d #%Z uBn ud$  p9AZ&J)cMF# !S,O{5!!3 m0`Zy}W [ 4@ KiY[)jk$^ ]' TR Ada2)uJW0iN?W S"T*Tnz v n -|cC"j35K|a;lV D s +Z81r*dAZ&AELvP^, QlJXP TJkI.4C<`pLM# e5Ui q_Vhc =rB\Dn )`dto&3Mk"UEO^dQN+O%cF(~Ja* b,[a~0?o`/}AtW  ?FW:D'2V!t*[iF=G;,Q@C"oNjWVvzCp9JecC9?@i>f;yh^`H8S0P:&[  G+czahqIYJK 'Av E <=IpoTbt]{ et$2!\&; s CJ  ? x/QZRa0,J-:$&{1`,4vR%/* I 7t , s< d K%3/(1-1_".RQ ` Z{l_< !x53'&X,:!#w݀$q b? ?k!D~yzJ-n0&N#(F#@2ybtJ Q Ufd$*q*' !2$EKBnuyvS=%8Di&&B $;Crm:Wb06]% rix1I&("!Q$ nYyEZSbD]5A#B+Db /!'% G}h;3y?0~8Sc)ys{?\7y9 b'I" Gz,a +f>dk+]tMU ( { " 64F-#_x>5@RvI  z%)+g#, 4ONhC g:c 1Wvq^UI!  ,o&orak'^ r7+:2 k&/P#JoOx./# UHO :[ + >z=# #I C*; V_j+,QFM> %2Fc9_\ (s uK1p ],6{V4Pnr\}uBU;h,Gc  BUz`;R?V4"\o>m1'6SW   'ph@@s2kAv4f-Li)J|# >5Xp@ed(^GG&{Y.n [ ~=K S.yYl j28 !JYJa2v/i7jSI.jm  'Y sU#}0HfA^BDs3vm{J!Y|r$q*O,,-)aZ@1r'Q:A- 1X FBjM 3Q?;\86?S PJ#BDwtr<4%hL@p&<* [D8L<wRuJOZ3#LT&)Y]@Iq549WG29>4*%,4*(0(4+0CG?:71$ )1-.:?4/42'"   #/768?A9564)"  !&*.025420/*$  !""#%&&'&$"                    &#2 ! CN7$&4!7;K 2D %0=+'+^>;14$T' ~ Q {Z_\\9WH>Y}!R#>O8L4Z3P riqr{  e6BQ$sv)oQj43_ \2*TCv[$2.-"la<ZV;JU >3JQ@b&z$)߮^$_tny=rfH5O3YG ,~XoqT| }s4Ha:S drzYNibE'6΅׃  *T`kwݮgݛ49/LjK^A-+C Jyw[ h/UJwPFPSبݚw:)h^gN38ú4_d N1yC Fنեiޞ cT,kaB((z`7t 3F O(ri YM([j[;g!d.݆[4:$ 0b ^l3 5}ۓ֐ծxQ(;Zk4]?'U+u | m;kQv9=WO;\qcP; !uSwˍuלuO|G, ;ep܁gj/x b&NEmT$N>g+by q = ) Q^{C e8{ R.a4c1,@mwot߅VT8 i< {^SlVzV'K LI QB)Q4Ps&{lJ] K}w?eS-J$  ; o D @7dgO.WCQo.[6\0D A:^E :~!v 2;~=C{i-3nl#tfj\ ^\wjUI t~b8`]y|_ sW O[M  .;3{pmGGSzZ^~%#f `5oc,  ( Za#FLW^3ly} GS |b4, z{8% 9q[qyh2dZ  w\a 4[MFK)V>U PNfs B{yGA{Bl76(i* vanaQ b3*"Zmx!g=VpLZ)u17 @ 1ޙ]WB$ 2(,2O2)1 ; J9>E  EJ'Wo0hO ER`@23x%_x"qA-_Ks[JW9 TB@)xMw !7@7g)pkH vSnLL+723a-\!A 0R?R=/P * Ru( 2GoaJHnrei e2>8+r/Zm$#@Y~G4tVIH1H:5*m*A;?oO7z- JnmNzQVs L)43]+f")l8Td$?XCVIbxkaZ % 2H&;/."( /e QTAR. 696c~%(<%m  \ V5.BofLt$rr~6?){ ",U![ ) kOP[=+HmLE5V5;!y g!Dy`:\k'0R<$@J1 A7m Y? Rd!R5'he;n6qYumJ/R_X|F[ qq1  GuyXc` `'m90  ` q P .<)pym   :!@#   Z,fV<`g#{ &'10(G (*"(P EP37Nw 2j%m 1 <6~*}"L\ݾ?F ;+"g MTptDX$.;bDx;,4!^Z= AtR PG 'Y>_8Gt w-F0K9H& ޻cS=^  S^A I fV@ 5*?wE!4d?rߋj `5 n | *|r - s V L@a/&yI %@;?0[ uPٮ=* \  'P](43  MHe:}n+ b7z=p30"YC'] [% n 3= [ S&rGDg8 '9<aa Um K`m`8`a 9w6CII8 *33Z*z)3@ A'2 [G&6b O_ &(%-y `(pB^} lCevdv^DOf ny Y UMS:*d>2.Ga1N)  P 5(%,8Rm}C,ud{iRiF+#p x U_ ] n mkea^N/sl !60`B_ VV  6 ,<P r  8{4&)iQ]*  R/| _  fOj`^C9nd(>zDYl'?SO . v hl 2 uu;1X j_ingLXK }:_ ; W^ #a< O;bQo| y=&_: [&R-kSI0a;b$ jP qh Wr#|  I~Z"{EnpO| $#E+ (|T=v#=m ZvqY 98  ^&d e/0(DOT/YBT! JI5"`6& L .pi.B6hw Ti ^8sV# $ ln *bNwy2LXm%pT=m; H "gbH k}W$o( *OA%2O_x,b% `lq ,!)/G}0Zq7]B&s h +4KvM' GU-SE@|FL(8+)jls ]q| n nthTR~A?N(>:+ ;mbX Z +> C *T|G7w'B^+hPk63 A @$$df 7X^, @@#Wx+{us}iBb]k"l p pSLZ ;v @Nvk3E"W{w.hMz U `i[eZ OSJhE%\x:!I ?&<% } jpEyH D|P  " ذܽ\u )7CPK;$I M$ ީVT xX#ӕU=Bb,A=L?%'(wX-72# d "D ; ߈$vdU6 t R5GWH/2qJm#cugl#"(sy|16!K 3 1C4G3?l?U.^S{f}QD%q)j1 +0AG5 n  2dWBR5 ]h(sݤ٘`WJ 0>D2,z;7N( N H= \!^ Ro6lcp #/L>@F/[ "=^ '5 qgD(2 Ԍ׋޷(4N'  ".53@=5m(ZkX\ #q C FpԵӝ ; 2&:@D:+yXE&\ $q9m ٿڤuAu/:HRD3$ $+<)n =u)('H} !݋5+l< 6HDDN?4|O X-lUl 0(. 3oE1p03.HLC6" Ub2Ը:h v  I . +?SMw$>( A{IF<*(iχJl   o݊a$+CHI=w+ Ehͯx:fE ` Z$\'#Pr ި3׿j ݒ #2CaDE8) 3"@  -!"%<*D'yܻ(?a : '%7DBcBK4}' {)sN!&,*#{ևhҐۇU5$*4@>>2'{hV`Z ն҉s n8#)*$^&.R ApyZ?'247:1)C;ڔڛQz9.: >`" ) j{7w?_%)H-d)g$p 5 2_M7l ;9?]Cvy>H#!3GD K~rkqV + !yd}FEJ0 8U 3 M"u.lwJ4 < *H: WP'1iiX[J\ lG \OR >% ]OEg<&2> uis H{6$ o9.ga8] iSJ!  [,n?U@ _TMښJ2 J#%0()(]",C >%L X(C(^>ؑwfh 6 &m*m)_&hG,IO! A" / tO|#~U@r [eadBk 4wO <"NP/E 2Wzu|P>1_X `Rs&kv{{eB@;IU^%w4e#2N>aH3scH{1$csw'rzvk4Hp;*F|!Xw|mLy? -j}-jsL"dxklx &=L[e^I)i1/C8lvLl1yP%@e(^n_QIDE<- zQ3%8IhNQ#DemriZ4~#I,!8Z'.P[C]r\Eq|Qq{3]j25 < ".3O0i)P~ ]sbfHt1 H p&!0Y@1=1#nJ7 ޞ\#q !z |&6W_  .AF4!9 #ܒvx}vRL}`U#\#1AQ@-TS;Eݙn.7h tiKgk!>0@@0bLIea? 96:X WSS" TP).?D1XkjZJh5FTk"yjbDKt5#&6G?&o(0^8ivdFl *~\U /Ut".;I8#M }}(uee.y'I x@z O@+TrzXm0^;|_o .6D7"'UvKArN.  *n T9w.)2|>?(,v &5,1#=9el 9 G#|xFQlV. '/I5A6* ooޡ7 J`Cb. q}?B( %08A5 , bZDzoT $ Z|!\]8z89P;-.|8A6z.!XXyMf  t ,  pgGyM)$6K>4/$v ^מLU: :ik.Z~Umr;"0}8|2.l%m۫Dۙާb/D/ gDj+,rx'1.+h%ZX sB]z+cM:F)Vm[0&+'#V c "-]' Gg x3g[~r3TT{ fH.  ' 1!*h"O>br3 A M6F]XfC[`9Y1x2z|BC @E-P6d#,a&_**Aj.p.VM6O4zO3)Ch(tt81sEFb\;X~=+pq BZ`[F(qD-'=j\_.yY:3LH8v%Cf %^9P@)&Qdes[ zmggrn~h5MP@S0XJ~&K;-A6  ,2($"!"&2=GJD6! 1@IQ_ZN<$qcVgv<_qyfX<"$5FFT``XV]]bXE:#{XB=53?QgAV[R?,(JXfuoTL=0}kQLWXRc{ C@IMN`|pxOR8 ,TW3g@y#, @&| \u d ]hJO{, / m`0Zsr4R)4AX?L"f;XxLk(Dk!G[[cp;* ~kOz^a `fz +a?v"{+ D 9; 2m~Rcw@lo0 r k??]8nVdR `OG %  | l Fv@1/onP{]{ F8Zk j = \N ]rKrMC5;-m  Oz pi8MGeG |I  :@ ; 0x4f~>C0b#  [YLR9%3r{@^P?E( qAOn.dyOP\&.1KIC@t^D}-<F> 1B b^ng@]Y4yW < #c m5Bt B# ( p3PU_]!7#-DG {t L 3@b''h",,D }3).!#XcZ(# 'uPb[YYS$K3$D'^%-ޝ"P5 99{>t,6'_RA)D.M"w)U#m2" AH  V*a+N P!+Z%W#8q%lot%X2 s"/_/[&,^wg:Qj -%!t z{/^IK4 s=ME >sBLPA-3| QH FP"Y P.yb< "2Z%Y):[^h` j q + i&zx rq)yh\1X>9 :)t XP@;Vb;r`  :eXuM:Q* c}n { M6 a / EBT (SJ + | = d=jF ceJXG]x9 ?gRW? j] 6jMXd  J M(TM m"$_<Jz 6& P Jv(pJ2m~ "f Wr|( A%_Id@jK_Xi_ qXD-O  F  8qkvEz8;Ne " \UFgkYG K C r 6 UibqgYV{s;k ;o j Sg:4% G 2 [~O/5,"Nw2YW_j b9U|i|RA M5 > ${* DpuBQOOl;]|6 4KkJx7B?g V P R +*g_PD"R8-:6 gT%! J rO !UJ(f|*8t (Y P %\2 N$q '  H VkM+%%  Cl!4 X hUZ,A<@^ L Y~<]F=fh .\+ e2cIKWt.Z 6ai=e%[f  _0t EETVZ$74*? * b 3r, )|Knf@ "E_ r`~cWC_$ d   2 ^ o[G 3Z,yYuZ8<S  ( 3  &CuFu]bj^R_q |A {-\}C@9A"RyxpK}]M%=} n(/!zadZTn>o08dTRn b#=H4`{VW)nn)#~&LpM`|Sb;#W;Caiw`!wdKm\Z$%H~Cr+vj&4> d;X gwFU`ar`+mYY) ?}F829~@6W/U#&C>7~suT; "p}~7{lBFd0DBn1j  ?+36g :y2chY_8S_+),? |%gy h>H;hj4,-6GQF]y?^Y1q;Lj2NT.:^AY>/+lzW.mrb1Tst3e]6WRK_[ 4$s49Shv/XQpF/igzD;.=SQv-?&k4l ) k,J<BBC->Rm o a"$H r~1 ;|l m&u{6. zi=qFZNv<*{oJZ3+R@eK$|# 8%<+0 !  * ,S"B![, HWb3Le2Am:qxIo+ e( Q[HGN'# %'2]&T`DjRDWZ\wXZ m; 7@CJ 8m)"\{f= MgQ% #ZQB{we_V%Pp  &*cZDLDzL.  VG *$?|KCbXpVj.~3Q U * X tKN"1Pn/=t6&ct['lx -%2b9l(L~d ~=-B [j=[vo+ l.%_uxXb- nrHC <+KT 2Np %h7|@:nk ))o1 `SD6wU a- ^ r2lQvxR)f 5L5J G&+PqL0Px_5h/"vF P eq 67:BwE_jm?Z01(WS  Q>xs_H >z"E0CU_ oa6 #= $VDoRS_6N~[dvuuHRW!#eT"78E/0!Aw/2'V>.HmmwZY$aB6PWJI`K|>^!t1/+U{$ )iJ9hy NmZ6#+UvlSp+~GRrb_I68zc.)9Ox4$2VgJ xbne9VE{-7#Kcr;?~ 3C+m G%_Y`;./Ip1:i.gzCD(CB' LI{j jdZW9>M||GYp:_Wmm 0xkclkmD9h?y{ !&xVLO.&fxv=E8U@ASFaJT4Y<O7SIWgY1`zx!lJx@1>=yU**)|mm%pL1KS]kRgv3~En ar=Q T-[QP S vHs<5gp kY9`w3;D4L9m, Lhk&iNoZ6'!?_c1y4XWCzz 0S(rG )HdC #*t 2r "%y!^m XJse8*4V|`$(Ik@;1wV1eu[d~x4}}tOBc ,xC+# Mq+m)ywD#9[ =j7@?3~U-FY/n`agq".=RgEr"XzjU:zX5 i3(@OVYWRMP_v"<Z|$LxoN3!v8x[; ,:GRW\co +Ki"A\nvsiWD/]9Y7BgCv/?KSXYVN<& jP2qbULB8-$  !/BVs!Fj%.3/( jN3}tnjhggjmsx}$5EUfuxiVD3!  #$%&(())(&$!            *. #  $*M+sm]EWy]{raX'0Z}4%[I)S5Inh3+yv],q}2FW6o=t:=J zxq*g MTE]|;PW?i[tk61P4 : (i( \6z +oVg<:z-ikc2\z1G,"B( --!6*9kQA!y >{=ֶZ %&Y%/:;I9n(ح֞/ 4Z"<+A)L"UnQ2.v;CDy@;$ATL?ӹ)GA" )i)$3C f!ʹͳz})-19@=0lezkW"  H"4` .P#  dDmrJ *n&`j"e] g"& vGf<0Jbj_A J 7?B1b!zCi4=o/#XhMk6)IaxciRr}Kg4^o8G-;X}[ Nl_ |$b=G IL<){4:]U)ey))U;9sy%j Eka|D0[%J XY{|^t:0&V(nV/e 4CIA , =>OWE!jOc'Q?KGV;qJj[cd dtQReF9Pjf )K/>Rmp7c u"1'4|,S`xokYU_r(P?4w&Xt2 g~AvK9=iX5)V5[q6.bgnZp>HZ>40Vt]ZlH\w+peYCr?zl>d=O!Vr#xi0c^L5d9Qaw<N%Xt}i*z dLn{dO&*N'T bGCs(ny 7 '^,%@fxMV5Woo;Ss67A ieg`wj3 pe&"p<\Z[|g*d.XF(F>Z4z4z~("`{(J/4 ?7GD'!Kn'\AL*Y3!Y&3} W?(Z3=,%(rq H 1 o^B#;ly%~uk-)h>,  5^7 A5MJI]|ojK!f&#3 [tx9nF ! $P"aj#u( >y38B p \y;Jt-A}."^@ * _"1?GTM7{Tޖ^۵%"q y&S2 }84 ;)? iyު^\ۮe oi!M$ ] N9xPBufb zC ~Ӄq- & !Mq 'O! &)^ 6rTgHz(S T;!-! \xCp!ޚ.{e$2X[ {s rsoGxz(Z X$GODf/bTg}r ~pvoopYT& %{4 [ sEWZ'f),xܤV#  F 0 WLZo&5R+W b */q # D-  +u9kM< V & ^ GX= )Rx5z߰Twa H i! diu$K= hw J3|A ?5wc8 b'8 -'DS5Q }NX!|q za}N1jr(N) N]Ho  K{WZH0 qX $  Zi;oz &Rkl -  [ZnC} y+ncA  4_b1 C T{|g3_5L!S ~ W<ufbt~ }Q_+ M\J`N KLMy m{wfT5Z G* C0} .N | 1 q>^C xw6VvDet!W^V&_ 9nL. $ c)"U +l S 1|d: :s / M ~F4[1[ |5e$,j? \AO/b7 0 y&.<m`'T Xt\yn%@eW(")7|l+rh*~_ tYFj'Tkn  N7 ]qrd#' ,r0l+ P;g#T  Vqe#BD'?dC:u`eu;kC# BFXbBN!~<-,RdO7SOR]:r3whJ"hL4 5 $ =T=Kra`s.j ]-/z 6HMSatsa; rV8eB hsB%'>|)fFqJ #`Zj.|b\dJol[^EC)oTo*+&@ZyWtv,'D_oY?+  ) "%!LSr<A G=cW<:![Y&c5v?%# K0}i'XL$p(I 0i( eNsqek1L9G7J5 .tZFi:CxGYq^Q; >M Er Y  g@>DX):>|12 omck gk7~ Z`A~!!F}~8aC659o#2'%&*((D9o"()$7> DY';3O'%A,-P' |lxU݆ =")+F'X>K02p* 3%+,d&" F{ VzIY3 %8-3-N%6$8_KVJ?/h]z  ')%`grFyg#x !K&%pdJ?26x[ +0v+V,`ul'1NV{%c'#;.;;02 % %,*t"6 pL`'R%'#@$KZ=^#m[xzZ+T$?%"!\9 yWWjp|"DMdD$7Yy%E! ]D ~3# .RBKz9ClEG}{. r# (0K~J4 bRl0 U   rOl\ %=m?>of%x!3# ](P 6%1GVD:P a`#VJvv9)dtPw: nHnysdj|{Z,7+w=& /Rpa(D\7]"lcUq GgP4 f"h>Tw^+&/-0+-5Jh<|eQMh=a7!DmpB!uCk}oU( gHK8Xy+IckU;17I^wz}{|j?gJA.[p%lw Gm=6R0w'|,j_u{aF H*gZkiz\Imi; .# g 2]MhrR,r*4 >~vrv0  )wNU^-N(zY !N2t)~7{x57=8 \ $!"#%,p'N#!Px L 4NO>~:{L+H{M)4MH]X\f9 > < \ E .:'-U356DUA#=j3999D m W !M4oVulD*.Kh3_`u#)qrgA5pk~R.GrJb}9H7Bh\ yKs`-dP-f~/"7.3 9'TS@tq~ii,1R`sDy-?V ;Q%lb6:  *N{;eYne1dl/z$su]ww-^n:} F)"  dJq([\r)H a _2vH\EGWEm B^~$:_ ~jc=j 7Zlm|j M&d Nh 7h;ܝb  )aj= $"{"u# 91Hw%F D P~Si !!7 GlEY 9"1d ( ` =$j z۾ex9 7 tm/2k^CT !Op qT_ h|[v) \c&;^;Dv #?+ k 3ߡ&h|{xU V:o6y A Tv=D?.64I e@L iKC-/tbi.%  #%awiM9aj0|Cs %*sLvKq }#  +|Z9Sc]/Od`4H2?)81 a\ Y`$ a Lw5"5.3A"z|A w.'s 7\Z +.p x $ icGp-/YqsD?I#U ~ l e! a.MVj?Uy{WGa < + )9hp '*<@2b-&hq3]qv]NS{xas_XPrC +CZS)s%Tj.L b"zYqEJ$+\Z_-d"Y8=UG]0BoGf(LM6Y&@[{z|#v06zmnD*& ]bDv%I m@8f82^}eMa.?WaE/W){ _-|h~.(cl ?j.X~R?(^xoDq5S  $'o}fM&}#G~3 W]m qFR" N1^k(Kl6 -G3,<G |wL7gh[!5 $ `v<67v 3$& R  uEZUf &| Bd$  {L; Fޟ'mr  ,?" 2+[!($H ~ 'sֆف:(3 7f`b 6$$G 5J<)!J'r@Ԛ8% 5zxg7ۡ^853-< @ *gLzۻLʾZn"$]%?7Dgש_!rADt46>*v N"˯%h׮`p,-"n litޘ,np%3%u:M>Ag/.h*_ӑ'˦(?. 5&&.(zvیsD҂Ka*h,2D*D&3%j !Iݾ В>ո <o$_&!(&Awf.էRڔ, #K.,(3>8;*#Z/T%ݨ!Q$o%/ ]sB. R 1!,g+-r43( eIօwkLR yNl?ߍ>F'&X*&<(,*B!M2Kݧ`O% Y{$$% %"Q6Ex\mN?g :[<@lA!!1!ieqiB .J V g&J(np-L|3B0 & X X%&bb  " Y!I0i!I! bh@wx< J sqRY8#AQY#"! B!4 + OX ;l YF eo S % $# $!m$ n@um>Nfb'% 8+^4%c8 QgVg$oAQy E e e- &k X3(!#h422; N 'O % rNz$[YY1.gh()+'&<2  X3|o [ SqY0T0IGHL H)V#Kc +/+`%<6>v"  _ 7* B{ED;f%6".+"?t:wL?ޗ/ V !5 c T R Vl9|ګj8:6(A:: * ܝIt  H@XlMBJKn )=.(Z1>R$7E[p# )W T  !ޟA&08i-X(@55݋ݣ}2 9mp XwU0` v? >]b߅ vi+1Z#> BW !wgrm6I 5_ u \ 2>SLߐoݡ'' 'fH<"! !*lEzt!A<Zm w?Dq{ ILy Z,o?+(-IN՛H cW;~( i(0(#"+[ ^1Y)62Jg+bw4+>|J *r,&%U%o"4_ W 8n\'~b #L.D%H#,E BR'?i O ;dw6 :))V#-( 7,! bT3ߝ?;z +$+t.d,7(R>ruۿې]+$-/4#DFC1- }$r,,W iAEږ7?'>&^2C4'& >1b:s2! IDlܹ׶ؗn!%&4 1H)f* P!ܓZiK /n: AH_^3s"_0m++-DWgܼeY4 K^5C]D $#{+C(afsCb1X  d@U0iI".BbQ'"`S1#rV  C! !\TI9U L !R 4 FS!,, sD&FCp  S Z^Q y[: i$  U9  &5;*{T;P WKTe|+@91 I&|-5M-Z we\Pa tN3.+eYKhS"7XubTSVhJGAKj[rb ZN%H w # b] r( A\=U8UH zX P5^5#? lJ$ d 'cA* P*1WZL<& p  ' B.Z y%aL3E[T jdlfH/ $@7;o>: <% {- + U /u4^Qm[ 3 y `$h D8 b z 9OoNkp /*='3 EuM 1no ~ceV0S w"WRiuzPm D4K #K  sKatqYW k8b aI&Y0 XFNM u1?. %: 1aY !fQ (  7}f`2{u[h`t(l) f D0%%-? d KrLlB%:; 0" -b (?r za{g  MW #@ < 0 ~x.C=7 x ` 18<^@n _ \1lv  c  e8 >;j =`a w]s2 #2i uwHFhW wY Jk ' kN9L@ 8o}=HN+5e I F # g,{yV5<) [L=?K0j!=D `e8 yO1 #ZqVE;u  >AA62 4H HD8!|,A!%]T/Lia? q ^"mpFy<_r'cE8; ; k<K [Lv M8::#;o4b e {)O 08 ATFf' ~Eby 8pqkQ&B-' x"jn Le)[f|;iu?= O  UC}`O[++s8 {wk# Xlzth-T r@4 % V~[ CmOus,+fcE$ !a'-z  >#4 n>^b#atG#(o%r 1   > ?#v@41-"('.,!] nSi,F,N=U$W f&)*)%> ? #JH*k[&.S4Njr<($%xSg.VkKF*zL j77I^Q"! x1?)Vlx)~dU z?z  'KzM3 t#5`i{KxH u@B  j b  4|A*ZelcA~"8\ZYZz . t nUGZ/d5'?Sc(h v] 1 . PZMZYm.-V ds+3  > _  9v`A?r@ec-80;  - p @@' em$@EC m , s,$BS(%Za%oO/Q_Y| ` 5 >#?ii|L&-nnkP8or14    \krd*cW|F>q@|lu { T y r > JdxFv7Mp y7 H=V+( 5  ' )Sc.436.n"W Y Z@~ _ H z Tj] [7`O FKhW d'V 2 7 s / k8I*NCt2_(7:*L5BB'\0^c I  L \Ry \t|2~R (n\?+6\"U): ` d  W``>t@cbB!dC T ${]a O{-Lb{jgj,]S{Gs/PNa.?x]!p#l/%jamjSySV-kIb@j&ZeCV i8g$@)<lp9oS  D"  }E$ ] < 60(CAuT<m;*{z2|r[939! sg_bQ TL@!Sd cil  @ FnR3V6QW6#? d.>VVSEuFt;0K\rfCyB65ZNO/EU^j 2 ceB?f C"Qg8z4b_eo>'[sI4"S|/?mfD(=!qZPI@I~e*Amh3' ua;kI$RfB ($UfO*  9?>RS/+("(1) /;1'694"('              ")""3-+7105-!," )25' $!    !*6*0781"LRqY; 9upjticbM |*xK*V #8nfB o *m Eo$u_"2h-~ ϴD k(2M% Ia &ڳ߳u ѣX8?*4+zN+." - T XW ! ߦ׽޻(-ݍG&:.7qBIڟbH-3& 2r (" E( ko[pܳ{*6d*.gJSC,/TDs@> V"[[lP`OT%:,b01'=GhH[/F/<"4g4eymo]^v ,'VR6#-,&sLnt- 0bߋݸmm + .x`'j,-l :=E$mw{q""")" 9V#*#7x*l$jߞCޝUp["',"&~S+]M,A dOJt l# sFl v! :|MU ]46(0,/!k"S"l H / RS@m o! L( Z x# v$zGu1w"V+V*I Lf|?(  y  R/ ?|ktD< LS RP&6 S\ *\%|0cj&g+XV1  3y7]*BO V3 6 hl7I AI 8 C "uSnx1J!@0~6 p F}N/KNh}6cA#`z>fP B7%$$N h=T=f h\tg1 hs&XrnMdv-yL, M4 +fr;,%wzO JN Uh2DPH&3:z.w G A$ MFtZ0%  m C f"1acZ8\=  eOcp6:V=-jiv q d yjeMckXL#MOI W E -1{'T/[T{;.0`1 |L"49-]A6W{n@GE8{F$~ Hg4 <822O8nQY6$< XKmx\swV2a3d5w=G7't AO2iS] GLKu4|\$ >XnRW[ ~ Z52`S`GN5k*W W,^*tAcG]QM6*vm[lS '- `#m/YP@Fk :D{Z{BM)?,`ROHAK"cX+HqG~bPx;2 "Jt8E:T+Tm( bk([+HG|\:oX)D;;->nOAY/y#O{F7, 5FA sz "PX(tyEWDQu|,^E 7n $v^HM t/ {4i P"*a3 l#!]WALvW JW!$&i$+!O J {MG07rK=())G#= CK ?bi@#(-4*%9 X=^=]EA\!/B/)!& $J5dU ,,,& v+*AA~ ' !*'#0 )6[$USK0DP (#(8 k%[>0dB'3ESxW U"%![oW5q Z n?J"s`9 %2#76     & x53ig7&#J! P / b> vlzIMj z% 4 X`G9H;b;u@$g$q; I eM i$6vbmGbP6[N Hd P i]y>| : 4j ( #B 1(@`MJ1Vk =^ BBw \ \ s td-lW (O b  X C Q:N/Az (^y Fd$ <B ] W~}z!@Cy3\M F ]c e~xW+"0P I v>m z-})S[)qpIjn  E |y.7$EWJ+ %p1  d r]IhIai< h`e  |. %dE}GS(=` + 'Kj?!e0)Xg ZJd eb] ~ A\ 1jx >Y* 8{H ; lKu\e p ~Us l!/  cpp=[6$0 % e > \k+p$c+erC0I JB l )g# $x on{ D4z X kL, #s  s5E.jh9I[!ZC Io 1%W%}gCJ  <&1lpdi{0 'F% nOE 5 (Z=]T!QRFY$Ij&$gbE % fv qY(BY v5 %##ZH5 ; U n A5sR,N#:&L%#  )5  &F @',:q=`@{3azPO:.Y|h'%_8ApVQt^J< y%e=>HA>TNMNGNFkJ#'$s!(:dZ 3 -Xmn7EL  #&$: C ?B   ON9Z/& Ur "#('"stktTvR+Go GYjbO]2fA!!"$x  Aj(R?R) > &g{(-FYk\ H  _;5p2d qi V (~p`p7 9>C p s~ 1  )w\n1 \ ::c>W ? bz  @}hL}n 3 C 6 q & V m.Y% l +>;_/(l,ED}Gk  6 k0( 3nq<0BvsBL"\I`:+s"FT_9^+-yn14/:+U10aX -X0A Yi8UnS?L  y Ca+ tRy&@r:--(#")esBNn_#^I{&,q: eg^@|c{u=M{]7r\ A;lIi/Oc[qFp-b56jd@%x\c1+OQ3EM;@9xE +(y(say%lK90P=ZWy6B,,YU/> ?=; =m-40BYFX+O5*,9.PLM" !~i<}X !m7NP =_GLUlv^Zb$%*7BC(:. MorneK6O$URb*k(% 6an[*+5!jX5!k#R7m'{HQJ #- [P _ |Q_+sF#/somj1W"z\2j`q(P?Z) y?{E?n\$P\9`uQH,1Qe\ h|r;bT ?=a:G#'RHjf YXpX?A:1@y!FH?n'fGAZ nhQ& 56'Iv*!'MnMECB):1`&(QRQ, + M  S~PYX%} J]GicIfaYg*!zqy^EDr>{RSJ'\}oxR?KL +2~qU  &MVa!5R  nb~cH l`4F 'G=?Bu illq " 5Ln k Oakt ^ 2 go V ~  A.;!~   U9w\J 2  8*#0? g@X O@g  qgDtZdFC&n0 }].X^5V8Ip` % /tA%[p1JYd  S'6dVL]}a[w'82.w\/s%|RWvH=p\BFg 3Z<;6atCYkCU'Js%Nw+ (;A{W6Zx $uU.Pc6ZIXtig4A<|enOs{~MsJ~Z+ eujja=@zn2^<JR>L_ !7RHfvF2SIZ7^y]QT t;93mST$K-5p.iU|<_#u!)J,j*_g*eXT@s8LE>YA#N;G?6vr _5N,#{kwd _$KMvJTZtb,-|K#o:<"r/SYbPg vdDn7{!w|bm {>DRRAj9611uj9;kheG#9ct^U,vk:\IZB)Ro[OaFt) PgkaG(? @_k\j0A*/5x^ 6'Z'1~l4KBDdK_J+S1C'3yS JQ|O@mLuq/#\|}A ]#*p "8J #Y*`nREo,)$`+2lDmMHX\Q,2w*c6tl1-:%rTcZk^K`cVK"&?!mmQ2 ::{Tcl)/}%[K2L+:&qCw&>2u6q{1y'~`k [w2<!9T\|azG7WzEPFvKy6Z2iR I\82?a4b5ECno X \ w A =2 dQ] d ](9l<+2ac R89k * 7Rl|;m o#/J M:z L-#wKS p x8y FWAvM_ %m$ [ Yh :5w.)GGC-"''#J ]>6h "|))%P M/,~^ ?`dG !m)+2'R  Rl&:E@h='+(#_;D;L# #+*l&1 `_hP12qDmJ&*(##~ZbLv5k6A  t)K*' R]9Z:Qe "),) &y# ?1p|UY " )r(%J7*3x .!'R'# N[|8a]l"&#pV o6si^ [[*%# % ( - p5' F _# &*6fBLhGXqP4+!M!x sg { QT <gIue*U(PW+ 5 cu VZ.a 5Sgxi<} KDb5+8 5iA< s:J{ "h I |~ &:Kw6n# @ C3 2u sX^%r-,rGLK  d \ n#Vt-$ u|b *1 15 :%+  D /FC! YB==M 5 x CJ2~NjxS`u&EqFor ASmVu,,49-6`&mo wpA2\  a1jd.s:BJ+Dwf*YN f=l*VWn AVVF4Z st g{kPj5_4) 5D4g;_{%_G0R5 Kd!. /; ;u=c@CHwmlnv # _ / _S68YI'01! T'eE9PiI+H\ia < b h*ixT 4| b0\ h i5~gWQHN| e n qqzB?5756p ~I y B(ga`X3(JPue&Z) (u | U`{; bRX< sL}OuI[!]DqT$J  =r H4%)/(xD_Sn* w 7 + : 5y%kjV($u" >|Gsp &// * 5B:VpQh:NeG7 tDF s$^btkx-;T9>4F Y VH ]3~ Wi(t]!g 4A ro-jda%Ij(w L ]D w8= gj]p' >0fS %3Z?/~*_}mT4}`"^g!;? iK:#nap6l`,p""j ` 0e5747u_Q 0UqA c*X!% <R;9pTt =#&qh2/fc*!  ""{" k(zW:peI2I^ S$L"#! "L+Bv\.[mx0k + bk$T$!<":YރyHa1$ 9s@!m!$~N5'ߕ Sl/Tu7y=M"#"pqjX/;!/srU{H  0rT, \| \=PN>s~wU`dbq  # / Yo mAGx~\s2+ PJc6 &3z\y;Y7EV / AsM yXw\AU y= e# L#IDPY^V0#]O nX- yK=>9k_ yD,9Na 3' /_~SBh`y# )KB.]Vxk<$  i ;[u$H&{1`;Wr   o_)e5 {ie9 +`1"C<R FGS x:TUm2 _]uL # X" |-WC;Vz &A HUvY6fz1 w ci#3Z5  XjOp4_AQY CRy  ^EYT8~,lNj; d (1A ~K9b OT :5MSdCaR{7 M ;oST' 6zUYx& FBlcY # e*u'Mwr z^ykd>q !ETqJ+i9${"E4(?G |h{> }\6-n ?  CV94 |I`VvpC1y Xg0^ W.qDH6:3)U89  -OJNb->E F~o1 [ RqlIH hsq#3nrw2p>B0 vIveD { z28%k?#oa^$ vH/;J X<,KEuIl8v:T :"s ML-T:UFx I-;" ~[~A=K y&\c ?D M7 ;6a'e#\N [X ;U& =\.{ ] n nrXTqVBrut WE:  R(1&=j5EA!  h yQ 8lETvP68B[   L ,S7&=c! V{s 2 i Bm SGd26i$ 7E  U8bVD& x> #J=bYn\tmdp!>nvQ<m\0*m5:Fy`lpgO'4*zpz["#r7:t W Ln> Y|OQ `CC Qlte%=KDe,sK,96Thc v e5"!mCGDy pIKzry)Q}K/& C^,?vfg5r?{#$>A9!`H#]J]Fb66'Zo524Q(6.Hw"$1q  oaJ_8Rysz<djiE(i~kTdeC//('Tl6 mjR0D SY&5jE 9[7Ct|'d#m,~x xCQ9=`Pl%w~G"-sr;j p bPK WajR3gS;i P i ? r:w!(Jl\9seA* m%$5**R!M:0  zDK+ oUM$? lGJ>~  D]YBA#9N@"GDxg7^$A[^Lb[j%5pE +%rcfJx\T\A:% p1/PPQX Y_\\XwxcH&|s85L3rr& !<tCS@ZS@ec457|DUrC l$  ?W,+tz\Gx)"Ic3U \kT`_Dx- sv(9d_lsV*oCcB3-]Sl^()18GB!du]z<kzA ^8p:r/_$lryVSqL#  LA}2 3G;%dI{ 6Vvg5Oo~K I*C%SxS|Lli ,hCwk {e{M  =N'hC `Q C@@e(1Q'\8K!h-FK`iV iD \/}p 8e1H\=aJb`DQIiUf:c@y _Hd   $3a=5,wz|xdSNV]RMcxok8O\{vQ!bRE""$%Bj !B^n|nY@+ #&),26788886530.,(!   #'()***(%$            %1:ENRY\ZVNC6&gSB7.,5Ic&Q}!.7;91#d5|$G H$q`DyV0Xkaw((l)eR{Y= -%Aj`jw.JSy@6bYG}]4 k Z & $ t _1jS6.t3M! ) uiPE\2@cnt~b@{f }U Z q [LbP"FSMk0B Y\ $ A >g;@&s%/kFGh^Z 0% p,%9p`BP!c}mZ snF6,3:B2M< zz"$=#v%x$txe , t?~rkeL_b !?*o.-7( ( eb~}Y#=1 _R}; B&0{660&3>j<,:ϴzz %q S# oQf*'3z52 /)#WMњ.؋ޤ}wn>4%(jX,<-Z4Dƣ7ڷx%t$6 Btۮ Ez^ D48C@HHVF;)hgӾ͎fO& (G!N]6oiи>;3&8~FII>FT8!o =ӓ8;{ s:+-)H}^h(̇h׀ %8GO]PK>*(t~>Ի۩:6)Y.7/)H ͈ ox,=G\NO`H7 , Kd#0b,0>2+tLͿɀVy )j:RD^M PJu<&'Սž '&353-T bldγ%E )4: CMDQ.IIJ9A2Q"`[c14¼N ["+-n3/I~uIQ(D.1;<4*~k0R5="37"!|M VR[#^V"8 [C8 ?r&7i`'6f]>%x!"*(wa ,KwViX.BNGbGVjz[r{oT''tbiGa|1R 'a(G})z\qCYwg]hX]RQQ}{ -Da 9o12X-ytZA>zx{YevjO> ;ha5W ]6;E.1x+Qޭ$!'7q80"Nl:˿Çb#{zp0HDEEY8X'48̏=O>%1h81.n#_p&ˣɔJ՗ގjjt/DT='::}.gS˯%+?F"C,/&%.! M"-YѳwR;^7-50)7| ] oC$Q)g"R$%J^p;Ғ] .7*3S; $S9׊ #%],+t3 *E[Cϊ/ !*9b03xD-2"?#Ee֔[0sH/"$ 2U3$y!nvk8pӔ_˰gm4/4"Fg9l)(-ExӰuϒUo "*"+*T CTg*ղ9n#&"5:S),V)9 >C)ױ^2k Xn] d$X#<5xk j2 ~Ma%6b/RCiC;X' t t>!td MNcPK  /1/dt0#x`Cs|Ygz.GLY0'=W 4}`(wKSi|m Vnem)cuK! qkTK<~#qoGJn*xBO{CcG Y$ Xq~KvDk7bsNk`dl0IP(}1oY< @Yd+ghn<')PCcWEB#)P h+l%eDnzvqZ5l8>zT 2=v-@ TaV0KVb\Ci=im1-nL [jzty*/t >Ff=@|9o2,VrC>y}$H/WR)!I D0:'^ q'N'xV4qEe%/6[moW e93<_Tz:W$*"4%!09GRQ!# ,  (.%$ #   "%5'% */!"3(*/,!    (/21'&4 ,Xr{zzcGL3',tJ-c0Ff03R#  cFE n1*c{:1 ;C~P} 17'Yi,%)XnLAa 9<3S M1? [{acrZ #_@5[ L S~X->1ptL - i!B#L('i f D? 5 0D(9۴E ,$()7"K$< Y  ݶӗT7?!08&$# u ]   Vgjh|k}ܭnc8 S ++)#_ +X 6Ui ft #ڿu'0C 3*#cv  0 PMm^$@8p4,#<LQ q  PwYnd<-u(!O4s/"P n K :AZ vqnY&Yk֫Bb *->.#> e J<Ga88R/يӺ N/U$+CC M * s}N-݀UJ܃I )U' zm- F E )I-|,[I ]i))"LA J  qf  I-T!wT#+ߖݖ߬DX8" !mR vV1 ? DtQ m q~)\;PޑCwj p), #q P)3ݔՖ+~Ko : Q <֋ڕ3 w]i 6RPg*?7"5? ( =BhP=C^W|Ev .PtK{XG$ d7 ڷ ع# , a 2J ? p3{%Q%C? ِ U *L   | v  lU6r#l\ڭ! $ E j9 @ z & Y  G 5d\i1y8.J - *   n|Mb9pvg8 . T]  DF:YJ"uXjT54E & c Lz CR.5"%~VF b p s H )~; =}w,3 + 0 ;$oa[.@IlW>MK % 5 9s8W*/O .k?N!&MI K /y(xM X7.g^K Yo  u ?Uil7Sz9  dX  tct~bEYuSF /T6^[kr %?~^dsSfzNOVL@Il *_$  E@9 Q @h<IT CY}~afZEJ) & S:U K-ntw } GA  'M K L | De^57.n9 TS+ c  J`*.o^ O 'M97 jx!_`;hx86 D!< ` BBs%q q"'!1"!a?|j!qh2#` @%)|#e$$)W{GqU=Asg ,sQ3%+* $ %% ng}6"{KEAs=߼/Cb"'; {$!C3S@&|.i ~A^ 3h^M  r v |]'c,2Im!a8C,_u{Z' U sZDk tHtYmqSr BTx*+z >C]P-f] D]{@"1+W 9= ~[N r{ @1uJ&HK[q +)w A67%gm w(B(js! L<p _#gGL{^15@?(Q qA @ q ?~3fIPNGAU:/ j s^Aj -Va0@y Uu  [2t<'UoyGH(N 5$  ]@ITCy]vZO_8wE@Z m2 bsBTWn@.N+D H%\T `$EtcA"OBvESR7 V\p^zqL2vA B Q M @sL(b,|O`,?#\]mZO;tlA X [Gh*  _wC04jC,*I 2 )|&g U}5aiWu;k_^F;o]XY( z 6Fcv 8]N35#_}m[hW ,c B dn B@ Dh =7(.[ ~p`B* _v-$d6+ rRs/wu9Y  8le d&^JvQR]0I6 )fYU si.v 2hKB9Mhz2VPO &)+A*~,v ,#;  vuNk=i@`U0&x%q*&+ 1 K/*6G,MOp ^)L &6.6+#mRlD~yr[v<H '-.!F< ,{vlp84-77*i !)-/r\gH x@ G i0V9%e/2-RF " 42b-'} J* 2nx3n@^i~iyIo1 (&i&mVg cjGE.+4W ao64 LREuTGQ p+FU0ZQWt 0I-?L~M9S J(,^ /GDS1X`(0 B78p {Z8 F{rU/ cFgyZ9 *?*=Mz $'uTXT*A/? \(@@ [V ~ P ,bzo#pKu"& 2tH % i  \\OPHKrZ6*!xj(p    >p7n6poGxdX 8j!@  3 F 1 WVy TUn3`2n0 ~ S dp  r=J*pbuT)irQOlb}lr 1_F \Q?B"QO[?S!  * | Hm2Jf_]c%}E`{ <$9P P -96Yz(`.9icpL18 gb0 ]0Dv5:?dS6}6\MI_ h)z+ 7n@J;^04kTu <) :YVwI91oD9 N .M&o_;  %|(jit zL% s >'sZ  ~jxtT#=^|z% Ec I)"5p3U8FG1 2>r c3`7"Q t4L ?iߋBS dC6y U#_25x8q' ,B+*VTu ? JA>O qTr !>2K+icb} =I|;24r9 *Pd C3#<C^* 1 h5z x2@ a&6km [)* ;1=O #K-eMF +BD2 zD6rE ]!B8s f8On7|(r)P(  &  J6My'0[['4a?HLnf,;=w"Z0y%Yh3|E{{cI"la!*DBI>dK,ozU{ jE07G?md)0y9F<+ q[WeLxVV)|X>!jwY9uLmned"0X@XnOv}t 1,H-\bV vu|+ db;;}dvKwlL73OTx17q<]ip?j/W gri(j|D*@lR)|zZv-j*7 ;k9\~e^%p oq{Cu )("Z#n(z;*@9jd$=oi,/_g$ .d{%_@{<G4t |Z*'}'eZ m3pD-_r aA00YZ (=6s5c_u8v"1JT!G'u> 5IU@j+s  7 :`bxReFf%=Y<+'RFXg :l!,mV` 9Q!>^T7vz9|~]K./*z LiTuq"17&o.@?L 4.MgR 6 I u l )[b;9*Bli#]G%]  !"\9LD*YX ) a  ! b F U , ;&@P6b:V8 8 b U65 n TBQ)a! \PU _$F`TZQV z'N96@iRC| vu:sh +[GTwRV;t-:mR!_ ZPEEx(50 8F  Z+99 d'*e8C7 Y   L7  .T%Uvj8"Zb  ZZ~" E b YTnZFt 9Hi  \TfWrZb7&%YZQVSeA M w iV)ZU]).ta_Q -x 2C3 =q~< p/ z( j5)9P%r4QB t   ^-~lYXk/%vi3kFd9 KS0v&[x7L v#P tZE]c(YmoHe q|D0C )UfC!&u8H ;Kn,{@% h/~KK]BeE| ^ . ,UGk;`8%*+!9vT} b7  F8h}v:h\:L31 "HW Zp*0>xZh?$ND !B;H,Y #8DzZ!7nQ< q$Yf  st%oLbtIpK P @URR"c %D7SPZz\(8|=+  HP  &roO#6zTi!}IQc FbG Y0~"^.v$Kf1jK{ $~4u  v9MBm`iT LPM# Q`?p pcR&T1ajq`#qJ s\q G (AW7%3W4\N#Y= M T 8 O 2 i J 8nrm-SW KLH5q [ \*%rdS9>b#)8E=: Bee0'm$J\l`:-(rgYz8:U%M<g) ND FN&03]+p3y`;Os} sja/66IJmM,~~k\Kjhw&M_A"i*;-n~KECt?:ScAy6sm$[Wd@0YPg7y/xGI$ 'y\e8[?\tO,CMM4#dbkk4!0K/5M:8+.Iyzy@jN5I_?*VKKm|1lI93/K m*x+dHE> '}~[xiGW dx'Z (JUNY9!^@W#\wW/eW1~f&-K ij]Rz\poeJU4]yz&Ub7O-5M%:H}\lAgkkfFA7>EJ2lh<j+g}4R LStxP(|H c0:H@'Qkr&J+fak]lH|A!,ny_92yQ:1)i!j77$cuK`y{Ik#wC\TiIg2b,e 8oeInT,#bE"A'U&:'CGs3H Vj4bH ;vt(j P gN: eZ&H >PRQ3}]6iClv$^*}= @n"SB  N" UG<}; Q<TD$  ~eP-odVdfoU K 7 u[b BiW1[@/Y+QoyH-$V c" #UX}h^ m>.a*ge & \nhB~&wSMVLa uS2Ue1kgo  Nk%IC ZY t+F   yt + X 2%5O80 bE;'' UC e9% %?* H!aݛޡgiq % Uc&)3 (a4'ד1+=LPb D c>Xؐ "N26"Et#VBP߆oB 6,}  zۍՙO"&9t4!%t$q JI.hKn2qr#v&/* ##i8C:L& %(N%R=Ӽ~;( '{=PވjOtV".r=-",Z=X`pׇϹ!xI]fNlߔC,WTj25r$%,-AN vi cb *+yٱi0R- (=+ ^ uhpMB֣4\,6 xH.)1d')e%*u SSڂZ|p!XJ%5 "fbdbIT (b!?)#3U#iu_KޯU}pB lw~ AMZp;C , B%0Y)w9 ^zHP O~ ({^>to % &k.1}'bx  A H lIh14*%`<mX[qwm93  oIA gu$el$3a@}JuT K  Yx6}t8|Rp" IEP s ~p J { MF>.[Yx E9>Q& 9 E .,$ 9}k74A~i {uTA|$ c -n0V  #[MF>J!, k`d!zG m9@`@uejJO&abevq1Y(VH]Z1otp]'w]M_X[ t#ij7jtY>6#\9PuGC9g4,<9</Br_B M4bn}j*<5|`Z<UUcQLcf|Bf>,E X;9 X*8{DS,y},%"<%>)Ne-t5k^lE7+'QY~Wi'<KSQbvW8e-@>^OF3M@2X YZ] KnYQ;^bA;o<3lLFU:^O6M7 wqQMzHT{Qb2ZElrk*a(etu+1luR7TVjV}ej&zo8z7?9smvQ{=6Xq@N(7&uHuI(z]d7n/yeVUn&p2kJ$a+&!3(V(DxsBxC``N-">F6Na*8F8L /R!x1  WT&9E}B7! .3cYU*^4,j+<@t;+<^ 681eI2aa(9?;-5Ab]\k^J^;(:?;0,=\(+sbb[JoKo,9+D {,J$43DY(!.*573@)T4=a`(,}} >'m240"E oA%%FKG+ R:"/5h3!)R`/(4 ubs,4w3+X Je{<f k)33,P CVRO(+F;AV8< )4h54/  {5ORlVhU,d(y571"7`#B>i9ws89'593$_OTGMX8YS%4^6UG#4>9+,W&|f'8B:l+T>RMc*X71&vL #6C>s0LR93gg0CSnCSP06BE<9"GvXڭ6b2No|P7*#A9IF54[7ڧ a96&] :9II6 yU1qtM4ywQ(zUs0rEOD#(vsH W 0dZ+I/GPeF&ziٹc,QwQ:!%\HH>MK5=~y_0)e(Ol90A@n3lu}3]`?- ><)9_:"3O62U! C}7{8Z@%ZE]A+782,_ {-'xN ;@|Y\8p p );$B:E$aZ-k3 uj ,Yk8G ?N'UWd i3K en6^T0E>C7& N &T*m _;BjYfgugCP-Q!B.+ @_L 2 >%&10GwWvvbgkS4`"6Rj|  j MT$Q M 1>PVJ;fl|rE8Nf!>$S&KV_{ 8j :h"7>=JJ'^A6kNtH[A6kv,7 n^ 0`l7`a"HG3~wR?7g:!N\tM@N3 RXh\ b  'y)bwT1p zl NAp^Re,-a8LT)*j`L~Kw _NN4twM4Uh_Qi) BRt! 5z6NJ!3z=p,L=^ IsXi {sA b4`|V}dh3'e| ,j-/ )P` Ir k 2g'Y0 e; >0  u\>5-d;| JIv#t| *$-@P2)!zzF0l "r { wvp"ag߸ݦ&U`5wp3-dF4Ae/ ~51#-Bj t~J  #>(^_43L7 '*<%, nY * Yn Z1Oj9"# \og f XvyJfI qc +]{!@  .c q%PV [Zcye3 6 cAM G} 7[ if 9 jSR3&gk} So w # f& S;cMnf' h >& >1!#OVv 5 OTzHHI8E[9p!iB3Ok  !5 Xko/ TM6" p U,)D H8aY43 M0 n[3jd Q 9pb a[p>@= n )"|UK"c%+ | 7 +dNfN^ z , CT{   rZ MO9A3 D]dH; 9r=ob  Y o9G  n1 0bvX1 . XlH){;Q  e 2"ah *  ,I9 J R C J 0:]e a P4>R5 z /L>wz >AW14Xr=9IdcX5|9`fP /_~I2{2|fj$/H,d^f =E)*h^&akHH19Z#zvp^Z2qANX+e hFR"<uBl2@TK$:%+B7(GgX5N"DvQjLQdB f6 m T^M~7g, j s-b* i d 7$S8g  \eQoEG(u] ]A yNeWDvw ulGota!LS{VS%0=S7} MZ-tHNB`tZ* `5_P'6  45s!0[syD @K5b+s^]e<AmS<#/_R}iI, 4Ys|o]M?&{nq*7>;61)# +EWce_XM=.(4:@HF9#7GVdjcR9)7CHE8$ 6HUPD7+8r{P+' .GZ[3  $>-  3X{Ou~|qdPA%0\*Y*^!@yI DaL,wLNvm%.$!  r)S.K_ raG x\tersAjWG9 NoopD_(1vF"gU=(< a& &  YVl_G>ftvUOCVME>e;  a JW~KC }OYkQSO@FQ@wb-cm^=8@G)  [1 <M\IDj =:${v I  . r`G # PZ!|uAs5Zl 6Ukq"VU $r]K{] p(iH h@K=W|Zy2 YCGT#U yVf~a I # >Q }lzI ? u,CP~j y c\[%v2b=S 6O 0K7 [%-  z hC f=:I  e'9i [Ho;  c#j4g8*^-07 FcH z;Ls&? 9 wwp|hw ~ ^4Ad)%" Kqxk= k9oT ,  VeBn%h&u) N NqE B }+; K7x :uBI<TV n U HjKbM9Z6AE ( H U0 D^KyK%C  R"d[@w O%  &|[)G4sl+;YV cy:iY$yD# R?qYC;~xqy!O s7t: YD 6 J(P5W )N 1 ?dnA_!8r Y ~ /RaWx^A0=* %  q[#2; vtReZ _ mI,CJ9; R4 f SV! J:d!ko5G}v)m4] A*$; @A^< Xk<"L''HEkL  &^0EPm;"Q|\? f %8k'j" U1(5V# swb 0$?a,wN%D!\ |Vb bL5$~MjB| n! ' jU 3 BYyDz &e 9ikM `Y>+QwT{ABa[Q &^'B f v _qs8-..'3" qI" 1 9J'@N\d%%70p r :SJ ^tXqxZ1 '*.#| r dX Yr :iE|80$+HdS`"dm X=Y`#bw*u&dex 0 *8aUH$v-$ @2  ,lv9*kG'- !2] KbFq^:l:U9]LNg+.%'t 5 h+PP+zw  +!X  S<**Bu%i/ B(2 &4(^/!O uESr ]fc(.60+ SO dTsj"=2+E-! R Oh tz"[?O,#-$8O~:n PH36S!+b/h&7R3 CGe+}CWVv&o/G,j$ Hq[Y]RmHX#t :)-' Cbl(La!v% h"6vB"(,,%O$r[4:9DTjusn4~"r(F*q%7:[sX(?1V '8,'N\ d=hfLN &U'%"^CCWx ^~L' #FP%vqa3,@ ?/}G*XOn4}SfB( Q;Mb E {M q);q\=YSR~MJa_FO^ >c} 314vu}`.?OZzrJ#4>FI25B1kZH@%:3tC3xoe<7TZK0(<e`q<zQ;rFt #|~um VYZWhf*Kb?@:v@gD=x2eIZz+hdZ7fdPu XL fc. n\+D>F>sweG':[Q#k733\"E-xJ)#<W 3:-$A8>kQ:nskENlye/s\2t'$/6`|NXirG!& ;VU9Z]k4u?12q_1CAfR|?Nb`VR2%ffX 3D}(#Ms=y*ML a[/XR+L+l x\FiDmG093EFp^gUMGwY{aV\F " *^U/.V>_N0Awfc-:1>]$ ';yvpWX/& }V*4F6}e9[rk_j6@PYW0 vj*'Yh.>r|gN3N_)"YbhOfqI<i= X|b]~k@g9uk[ <m-Rx 9>exw( W]"NBSDs,RyvlT! >e m :*^@+%7eb / 2n0bZ0nSz3vOa Y f5Zqx e$%] 8 58tFC~Snj6s;JcA!'?) %Gn7 u.oQ-K 4%Z+i*$ yOfwyUD 7j*,.z*#d IA=z/xvo -?.2( L dhkO)u>>"0,+o%< :q9[/iu&), #&*(%U  `te=[D$0K/$,+t& MQ}7u=\ ),c(B# $It9iQdw$p,*$j5 JGb/&z+(M '*$ xmfJ:87mlD%!%Q%"B#Q# ~gJ[}vz##C&&:%y B qCrN\h4p*% '&%L"j$_]Z YB3) ( #&v%"s2#ZKvk&  !k%&#C c[dlh8iRr t w#)' 5UM:w JOuW ".,W , U;^ =a 7 \#P1b-P^ :f"V]7W H#}U$s2C. r^Gw5b(v5q#$E  #1.  3pf(6N)Q 2).S!1/lc hZs~yWD]~z? 2d/c1b $d3wf%+hF^"3,*j @M}R~P [a%c4(W> M9q9C}T1lXCJPjjf,$37#PD5Xt(L#+&y&<J2+n gn (BH*b7 fzvxC (y2 r9)[m3#>2xyQ#| :1 )w\<q 7 o<k+/ Z X |cpg  | "0 HX[#^-:)$ -&;N 6j"DO{XI#&I)=P. [;od\9u:B6Sl )@k A!T52X(1 L} W /#f"g T4U24;d -%J7g">j:k*O  F |n4uYKiTX v !sbPZOPM3  <-|Qt}F1V7%[Sev[A(9`^5'ffcM?Ve6c![Y`-j '=[!]hn1S 3OWE;WU~Bz.cGX6^O+8~7 HT0CVz4zg0>yJL*fMujv['HlSS*gYP=t\(Kofe ne B[JT}%UWYBj~CvsXq!Au?T G^>qgpV P,em p,S]NaA.WLY3 A !dF W5ix?a`-l)A8ry Mf*~=_*OVYj':O<j /.0KCULshS4.3oLBVHUw*V|+y!BG$b>"F%q{3aJ2g.S+8LGxHal4M`r*8\NfRxr*+ySp0iw'v: W T~EN+L&L'h(7[u*2Qk):4METiIXo0 K9j<W vCEh# tQ(5bt&JU9&y-;pp* skRy<Y7.!{IY;S*7_"{JgR!"3JGCu"*-to5[w)F1|yd&H9gO] t"#%(Yy 4M57,#0OF / R- jV D%_#2%!A\*@ d>2)_ygkXpwJQtZmB7'|xMxmU ] IR{gr;{6$c)8kD`,|*,'H~230)QkG8WgftJ'"}wxEPt7wy^8Jyy4T#1WO'H,TiZhRLRo: XN**~@.[H@) ,Yk]la1P3NXv\|]EqzZ;iV)|~& mx e]s<~i5d@v-"] Yn-Tf0, + C  & &V>tLh !!$J7>o[!|G "S(*%tkXE:'*j u *0`.% s<#>9! K ['LD(&.3.#8(?lyL .T M}݌'j160%.4Ta.;`fܵ ! 054h) FH6hdb$] L~?W =1]894( oL۔ڏcS( 1]$38#3:;5(+߻hZ <4g ݑ3QA<@%6==5'|@6#= 08dlծw?%46:=>T6(.B_܏pGo t!"4ֺZ{N$'5+=>7)Dر<< [GB$4AWH5e%A 7.d;eA<-I6ߤ 3] Zs ]T*:AO?E0q9 o=)s fcV%'9nA\>0% ??$ޝ-=, <! c9  0(9 7- )^m   ߴg8h!-S20*Q[E}_[   Z l`#(w22,""" f(ߥI7A B j^f" -:2 .$TG8m!V 2 ^tj2[ K,1M-# 1\zgG J  9 <![-.w&>i [j 9 sVh #&Y$F!! #!.]/o* {,YivCN .%%U$}"2h&#5 c $$& s 1!"#D%" %Smi. \4s?%4(k%F Z8nl 5JH6z:!{)(!, zehNgd708 &%  SvcWzi&A u9}""5)$C hmZ0rmCu ""t '%M$L Li'pk ]k#Y#_$#L4`#l;x5| %!Q _ f?qkP#~7z Ur!  (P "> Rk #@*  =?B0|7 qaRE5:;Y # *Wx,sw*i6 \OCRF \ $ 2X07_ESXxR&JKX _ ( \%RnXKHbLH)>g"g^]| ;hoCa%;x"<qeBX4HR= -xM-Eo #[GsE8hQ96}U uYF7%a>'"f$)u[0vk#)C"c21;KVcCD^0s d2( ;`M3=E<Beozi;I(8=->aFd`cyvnp|~aY:UKP8!$4;Ri2J`m|lSJ8)'3@>O`gUaS8*,-2$"  %'$&;:\<TG@%.p9c)g!B49tX{@*n7KzD^C8/- m"|{]w&"#(Vlc eo7=H$%"VJu^b^Mg>Up=rZ.E)z<!=F ;r HU<$XP/FY'HeT2=/@t=k&4 pM 4Z8Qu.gf<E~WcJ:MTojC|? \)t 54 PCD! h .|ijbmJeK0_q O([4f, $M66.rUD'Os N{ n~;nqRHC  Bo!} >@e7/L(gV{pF FvB >. &.I|E o{^f1"L"8"2uz!z4h1j/* 0 WJ#Z#2  LJ=cQ-FT}N 2 6:#E#F O4jB!= s L!@ Y '17} gy K"*y iA*sN4Z` !e vD"\q ?g =J D2' , @a!N?3 'LQdSv Vuh)" 20i 1iSH<; !v 3gC+9&H0un { '{ TZ(2l %a'D UC& |Zo4x/|* S^Oi   'rKlo$0NR, Vr) C V/3 7o R<)_3<' OYJv^ !hY*(hE.GR 8 "p>L| &#fZa^Lh|#Y8Z G vpp1|8~(c[ / "1 qK: 9 s5$L2 L!0@ 'Zr'iS ,;XTrg-^br n* B +a ,6]ln%1WmFs/VBY N [ z s w{R'n 1d> \-d-+{# > H-Ch W {3 w$Qk/ S &$44 $ #H T: , *n A -3w} ^!Y2* cT#2 < 1[C .b &[RIuRuDG YLo| 2Zd[W1 jHxY =_@N ^5aVmz5>Yl *at1 :(jq-+*&: &kLj S~!HOmN}SZ8YE; HMq +v{d2>nOha33P f *  f)&A=[h*O  e Bf]vWa ? P R L s k#2qX]T9A) cB@qnw'm`v?#Q@L -R(6LGvID ,!PEY[R.IJbCk>06]@Z[]9AlHp( $9PUN^^6-;]nUco0y|pyo>eD[I(cQ|3hb UlF:WW8^ xo -Y9WUBZoU&m'Sn6+;'K<]d[SCXio!;:]@`# R 3kt-Dc >m+4v#x!ce:O.mR)s^t$OgR#jbKQ=<MNyk|ql1=&TH2H-3+w@p~-[f<3k-fV!-=zJo7 !}07@>=:?"X)N}qoV.PGm=2 c+K!c( 'najv  C=:N h\g=[4.pk.yMO, >E8S(A/fjY`gk({}R'2DlEY4<- +J34.Y8,/F]og,b-JDhXXZyq+i6T,_&F\~o ~C6 IONij<mOki1BKY? &fU5L,y1(;;'Ji5K2M`E?^g?J ?~k"~O(g;2*Q2eVoKck Oc5h8$+[X?KK*tJV2j =;x E OP;{| 8 FPF2P+jkj X'!/`*7JoimcG89JWU5Nz7B ͲC5w^+%1i-%OB P>/ P f u=ULTIS2e=M&|>/0= %i/+ " ,~^-πPYo\d)@IJ`: lgAl\a'+$u|&6ݾ Kd!-5>B:&p]֧;`Rza_k!'C!Y J4ߞq{AJ $4M<|@8{&]i"ah]{["!&7 E4 E$GI90;>9()0(0نvZLvW#%"wAJsX#5<<=19h؆8R8T2I K#%rw~aVkSLV6 x+9#@":*#F6 "&X ~sEiZuX# 0?cC7#%ޣqdhs:"J&D!]\w&@.J9=B't;E@u.5}h&1`"x h;k߳9, M  w/9 ;2#0Uqޘ&d+H=C S jcbJm>Vr,57W1B#RsN2NK0- %rGeMK`M!/C7 7,- =LZd  N  T*2ZGL]]*7b;5#go2s,;Y7 3  W[2 1X8Pc`'nc&R8{;54;%  &Y2 ܒ&kt O YW_%G,24A2k,!/w HZxߌ< Ou ?;o`cr 24f."%  8߫44 ,Y = uK x>J+_-Q4*8#,!" +/mk_H z  k0~h*(,}&m&& ~1_D l/  P }%mmsX :u)%"W),!A_Rޭm8I M7  'm'2&$,, Mm eRuHac` 90+ & Lq@= f&D%$//!(KN {yh ,2r[$"$32!*R߫ߐlnF Zh P z޹byL&51b#!3#Mxݽޞ=ߥ YtB<ޟg J*6w.x'**'f 9uhh0ی0q 9 v_>;ޮ% y7 <J*%R"/(A&+{*mS :  UVs9lSxf  t! r#s L[5   #o7~M n Y  x  {yN5tQG$SA>TaqN&I0p """S+5} ZT&8#=-/<D$q/,|}pdVW+)4Y49eymNEX`K _)E* n+t@nww{N*Q y[Q=H^#FNC*\C# "Rn~b/xA);b$%1843>=-+IQKezpT<|bPE;;P`o %,*'1'/)6EMJX]XPIK;32' %%03*   -:=FI-'"+3DHM9&(IGQ[TBC2( %:OM[dsympL/ld`~+&50  * H[bw8v*`}Dk]NiDfzur# JDG5bVLMe=St@~Ry;i;U15 I3,&&o4]+M| oMR174_SW:wvu  k? gkSMIOncJf]I'j> %tx i 'r%b>Ut+mxWm*(n8/bQ?v?DW ljQtJbfDHgG8'<#-lI>fQ^xN_+dA4OY  EuBt%[>4mm/4#[A ? _% H^kFn,S8wmK76`HZ^4KqLnz~!a-pkbT6&^>Sd1G=C=kce)ob, @< vR2'Y XiG`OLd=VsH~=$hd:e:_]J|_p,a7RSY3W@Oho8?n="lc_aPx! le E4|rG]q 2~mz\ &( 0 / E t < ;/,p[&LH nS O  ^k|D ` A<dV 7% Eot(em, goe GhRk `noZ#c;qp 3{D ; Ss5YF-[r +Ii 9K} s=j qp/p[ Si;=h"& Yb ^l>7X:AR9#xcGgW R(+ #  ,kB#Y`7"$|F _ c7(A Q<y"n%!b 4"a b%.!  'y!?: N A  _{7M m|=#! m!9 $UN\ | ~ w z""AW yC ! :Ina!Jq) 5 9 *. ADV p!'LXYmme)]!{+0_  |+|KocH:M@rJ`RjEr3 "NS;vVmjj6 gHh` . = < {)RZw!O.BS_6ld`"Oa x #d q$ No{ G}!Qhd8 l> Zi-?&r Y&b4<~t&@EU P  Z 3k#4L3{qX4rT HKb Q- !Y4t  .8ad!u[:d1M:r=q3 sZ; A~)2.f+H{LrMmN2Dy 5}ui ;Oewt4T|"ikV+X@N 1SZvP I='rV-DVU:~)iFOJH<>|PuB p '0D3/ h60jQWBmO{sZ$r\ ( D J v*\9bB!~ NKp.iA"w y2hQ  ;h)3*cQ0aq+szFQ"VC $. . s6+f@;Zb6A8hJ[uy*0#81hq2pQP:rZ=+';Y\P4p=1 G7ivIYW_rVYl4Z TS@Nk|!aQv{Np oq+;zlJS\@UxV4Aa,@tu?PtJNj 6$Fhu1m :t!bcC)~*uR|qL'(,8zNzmJQ21P1zmahDucQMl#}xi#%)IQz1! SQU[]GVyeI2NpfTD$:Vw,t0_6w @WY"BCp8yII >vkN]\LGLr3VLhX$>{qjA%Nz^& w3P~euG%/v>`5] s*g;6 [[yQ!/TYHkFL_dJf{@` >}* @QdL$stPR Z XTCTAsUv=V *8"%8A{s{H6po_!=i(jZXd|kk?;d8"-RwFK3s(vy> m ^9tfX;vG>`v Z%q5 t`Fm? aTJ4 &@S[`n&La_UUrzyG eXX^gv,:DEFEDA@DKSXQA0  ).-%  "" !##          !%$"       $2>EJMJFC?>>??=;4(lS@-0Y@O `Z PaA,o,Sj6,Dmu3TIu~R qYOsh&BU}> me [ c )  ^Ku1VU-ETRBB J 0 > vI+-TP(T`JZkWH ` jj c  \V/ 7PacKK? ~bz V?I ^&ujfPXR~  i S7`5k 9~X@o\A! "re4dM} _ :D   K  O $BmX9j=Z"`   -< ` t 2 l?]+$UATSco 4dwz0,+b2~5O# j!J6[P:q5 $` Ees+ K;Zv+sIVz!O)512j+GA B ;d :871ٓ%.CD/DCB/(7qUڞzLzD 2o3ْږ v /W9@2<>]151: yoT+дٙ/R`N%IM=Cs:`0\3AK 6aTe sk %AEں AB8>7&(l {@"t:"#E݆5Sa &!;3,%5Mf  ASz? :BٷjR]h85^:'F! /-3 qXp Tx,=:. P f"Zx ߲ބl ^x {%F4BHD  k:}< u i9y9DT ;:B6'vJ2C  Wi`>*8n=5ra*:2! FU !5FbU +'4#\1  Mh'7&ph Y(NmP +!,C/ 6tX_Pl'ihc~ 0V;-'bhSZ>h;)#"mAa t * +&< rL)/NBef& =K  b 56YeUh.aZrOE P") NLdV! dm@awtr%" dj? =,Ck,.J r!(v "3 I) $=iCvuLsx#+9bl n 5 XlO?<2PF!$% R ?VN6D0&j 4 2 TYS8.7=& & B $,ja e PVqC;#!M  8d W::R$   y/6 \ \~^o ($J%p `Z+ Aj|[gf-j"tdM L jt_  DG! 7s~!`na! k> rf8lw~ S 1ueq d|@U\ Y0 f<-:A }, P/8 OYAe ]HL: Wg0 uIRy s CuR2 tF) ,"}{&./4r_{; mX6;+W :{MlK  |URyd]\L;Mn :$ 4! j,~GK lb0$cKu!!7rHV2y"n)f<)" Z   7j0\CY_cE-h?S pi&"CE$A$x4>$FS\R"Z_eHpbQ2 @9t7g|tqc bp 3DjbQ33DQqc]qO1S"krm#'L_Bn *8aHTsjsTO-T_1k"o?x \RA6vKoG 66 g(zaL= 8IN{(Wi? iQS YRDF ^4G#pm  )b \NhN;U9<*56jnAdeU:fD_qBb,xX~Z xd qZoB8S\sqY3Dl)ZQw!rkiIvM3-p5QULtB3 _Tt 7jz  fc` KJ9M{DT4] # $ {A\o_DcC5Af TLxdaCB ^ qM=t ak d!_X=gM 0y 3>]Z/9h-LOn5l t Qy, K [[?']R R`0qo P}*i\ _ vZ%+ O8|2 rQv-@o81L$N)7FusS :v jB*:)- 55 v >Gc;o|{9P LC1k ZolMp,90nWK5xr   IK #Ec" o2ZxM(;|w _ i&  5Z{gi# vQ( "p'8khUun SS"$:  ?_pHPJ5.9%&.$#%! 9[M<&!6Irt"*$$G Jv7Puwv;JC$XZ!"## >uLU=2ZON W V/ u!"`s `[JuO&!c@ YJf sc2  W^N8jlGZh*Sng Q } toRM1{K rJv(h ' t+ )l0{c+{zK[ {;H Zm %%, }:8=$17s^g8X G "[!TRe )?yI1p{ F H*j;I &Mj JFBD+KHv C03 5TW} "n#:z?]LM V&! XHFq#@p#f  UIv XdDX`pt]HK M$X  *4t J3Vr7 i   < ,`e-* kD% i>} qr:# x ^a#M} Z GDhN4jkb& ;D5 cQyk]?~Go&%>Y k% u]`SQ.O{)+>U}* c Mh7RJ/A)3>|d :#Bcf L(Hi ;(y]TSU1OsGs &x*  p`S#=1w|gL2 aT{& ]ZAH J/P7 ! J 709m8PN\>% ` TP1 2ZL UhAmA f-lwx 7D6gx B, ;z9 zIc4 p?L0 ::@ E ~@* Q |HT*(5Y` =B_ 5 [Cw  ~ DlQ ) Cegh99l9;KS Ck-#=Wc(B0 A jz=pRf=qH ?  a(YSRf2 m  } [(nefh2 "_} Mioo>?E r 4"Vm!n)  Y T:u21j)\Js0T  +  j $1rbR/;AAP rK:W,9MoP9 gc\XjP ygMBF =t >!=)nVUz1"HCL]p9uY;IQH`EcodAQQI :YmtIwZ17S3H` :oG^ _U~uxg.{TJ48Ng'WS"vT2{ 7X:9Jp#+]?3c.A`'0|#~:B}NYv3#l>VV b| Sv7[AL(vPF3yutl'\?0X^oz!7 {Vw2 ()u9k;`N+3 oe8/[6 ?J=2)V%C: 70;2\?\Vp\?3  V  _ < IA!p;8D5    1 6?|!azm8-H:y > p[$S)mFJ?yG V9Ik"IzT:0 "' ~>0 )@VvF8S[ x] &(o$b C1t cohLm"e$ &3-$,a#$8^p.Pz v} hm=+f.(-`.! iFXߔpG?!1 '.hx}j> O,7+&/- %?DF2' %D@:FKS)9/#&%6 ~N<>ݔ<D O<z4@]  13P$ !Lq1p_`( ekJW 0L jv ' *9w&Me) )c Ir]D (2'O)2Xh.?w)6 h#[ e@,#w0#*(EE19L^ y;P ?zV9Z ["4("2t U_lO&.  \ bW$SJy  #1(d"Ui 4< ;p? sCR`a6G^=$%[M J{ _ o%,S"r$r9 w0 r SHv@5, r8 zr rw1u6! Lf[ n{ fF)1 5){/ r=[W|d  z.Vy   Sj8iM =) gAWtS  |./2bD OCVm(CzZ < i |Yi_$qv,^ 5lj ,`"w  Z t{axR[c0 k 4e}P>8Q Qw #NYXE#^8sx:T V *v} "?^\c w[+ OOd,9t Pk]{ |Q:S@'!b (7 {A ^iN>  KQsIXc x`Kz&M>H3 7|j% w:XDZnsT^' V<*w 3fa(FC?sk   E:i&_P= wn, GBnIOA)UV  ; _O6_U'5O @:  DF\0*(FGQ[a/3iXY ^d%cW  h ( g\Bt6%,z~90? EemIC?'mc0 D *"~noNY*"R/npk%@& 0 cdRMB|iOI9}`v5DG51ag_m5rFcJeAX(mfa{nv7s~G"'.A8s+o*S"G1Z1dl;Wa#SfOYz:q~eMGI}?\"s1lV`rd#{OK*w`G63C0BPQp+V~'&vbAF~8<1QQ2V~q*! .\-{udHy04))6:E: #3@PbCUPO4%("%885)-) ..ABO=;(#-9BCWWRG>'    -5@KPMH4' .<AI@@<>2 !%'4016(&!/:AUBC>$%,0DHB$ .-)!K:LNZAZX7@He&O7Z S ;y7k'R &l IZ+P l;-KCJ|d=ByUPHNbV9;9rNdTcKY [m;(FPO U&maBqU!XH <!q@_e}g3> Y O*Otd%':#Su $b( e o G \^'smrZ jy2L ${7a p`LbtXxl1 ""! j| cQ,1g'V5L>h5$&##") ? O (?x4ށ޳_T>*n lD$$n%m&S#($+  8 Q"ܫM3%@Ea n $$%2&$#s Q]n.I]ކ|`u!= '(s(O,')Z# KOD+E$w!Aڟ@CU= |(((:+'"@ C O^* .JByn8G 9V  kA 3BP J,uPDK> @O4j. U$D.qlf k"u o5( .  xpv:I[)y_\BS # 7p ]A!l-71vSuh.C ': , Mw<I rw%zFRh6Gzs4Mc , 1* 9y > 9>-c|{R9{( A7U c=BK'H'&dI?U0 YQ?s ENpvIEW olomRE |bH@ 3yPy ["MdVe5cU-T|_ %t2p$ ;Zxj5M{7I4>@ :?F1A  d!_ZE.YXb 7TmS\ 2/7 A}WVh("@ tGUupf `.F]jXjA, ho01  M A ~FT ]K';)[ (l` c  EN(qj0(Gv/9g j hD3  CD312A.c6t0b<:*.bw s  0 f ZR1v,&#r_* z6NB:d  y N1SD /g9h)sP>C 3/cB4Ux28jxQ5Hk5Qeo9(12.Ce N t bfA^cooTnYhZ)8R$Us74L2#-\}trt% oDx cT,sv7J~z\y<V sM|UJkUkHPN=/xSJ.4+ @XL h6[nh0r)|7;wESy3l>8 v;u]S'S6 3I"@{2Bd!r[ZR Rkf@>JJj= "*r $C+QcDswH@d!N7#BJB0o)b~E26N"/auCG?i&8)3[ 9,+'$N0uc1 53$I  5N2] b'.t?zwy!Gm[A$3.g1'0-Jx Ch!kKE4x 3 sA-vF8&{p4MisCb_d9h=r8\ps Yp4\YiY$O ;;oKoFhW%9[zBy+` >A%!h ];?z @*~+feao ]   E IWF 5%) !u@!>< `Ag$ t{R+tJ o)h agm$(,!i  9cxVycIX+s60   aBx3J1mH ] S,bxh=D Lo 7 L. p RtuH!z`Ms,i<|&T 4 } "oO3 }r~/z#     $   .6yl$P^q % | L  LX]<l->7h Q@I<J<yT {Sqv 6kY#\5O G~$$## 7 guH& < F3ZkO)q@  e7;u" lArZur;Dk ^#Z `8F, Z3][7J0Y7x i" ? ' &w$&$I'e<+J>@"B C'D! # V%O0 dT9+ Ja_ : LJ-v>`ap b UgS<3 5hJK@Rc w7( %s  P RmY?8uz&g 8 <0!zt6/'ZY~%QX:%vO$I>,^e:M TedT97?'$!d@1S&~56m$Q1]E5lRIPMj*YN  ~B  )8+)cC~ng^_S>5!gom'dJe>v ^ie#v` HH@JF"^HNm#]8lE$hI>/wYDX ( Vz'*m`akA;[\Z"@\UyZRT`l@+HK{L m  E^5_K"SEDO7ROi@%))D{K#SzxxASbgY'%_pV3B3b$3%/[QqG]~ 4 . y 0i_ir>J2 tz@ : p 9{ M l  lY 2%x]1 V K d kg{^i xy!+] @NN J"i F !a ~(o4)7k  1IrmEyzzu{|ujllm|#Jl~echVJWRC92zbOCMTh#%/2))?B=MbZY`^F?4"$/MUVV^D:)$?3CD: $ 213<Q.>-"  %=`BILR4%)  $!4+B9 '' ,:3*pT+ UQ_?^ C = (F (2:/aGIl(5Qr'tc FN[FeR.22{#jO/!7x>dU/y vHx[@oefoLL dHnA{( ,]Sy~#Ay Z h|1 !g L(b8-kw8,A361u.el"EPm{( d[0v^HJusP@TL Ut ( 1 ^_a14w+gSVCD + t$_T#d|k u qbU,d c `O Pb u fi\#=z_fP' r CI"X 0&!Q+%'\ C2"Q"RJ 373x51j:J$Pc ;#% " EKqIb[2F Ckm}",l,!ndn(,lq* /yWF.7f{&"'43#~T\K:W'F # /V!Pm2&()38,=6 [.[&q ~O7AAFߏdo='*80m74' ގ ڵړ U%.UގH:"x%+675+Nw{ܻT_R MZ" !o ޴D('5R @$=' 0D5-mSGVܡ<  d " 4 Iܲ܋ p!>"7)1;1%4H \4Kz "2! G|f܉`&y /"^%,G2-" V<dH > 3kJۑ׮ؽk E!n%%8(,-`(L4xڰ; &@=EbX*۶RQ$&'((%r]&3 >Ys .x+nMgO l{"e!#E$#:"4`H"nQg%Aa 5 I ^d!P0 PQA |>Hb Z(Au{t} o# RlhZ  uPH U^oUpO }U]~9 *@+N;< 8 $ S^\>k114 ^]{ .o B?L=gxt[ z1,  4i_M_;@5gIE[fXD\MO4D* P M5 :2-.m6Ga21iJTxfGq#8 ,R4d]\?2t~-15CF3 w ZV0F ~M91Oq`HjcT% - [ ^O7x!q`4E~IAYPA]Ge b : `y|?TJUPrn_A  *Z k ? Vt}X_:r{ +TO R1F  r & oY.1^x41/\9,B2 H  ),)/j{_k}(t\;Pk F S x r Z ?'j?7`\,lS K$~ |k2?Mp;HA0k 28 mr ?:0>8$F+ 'JehNVO #p)d^ )t[< ag\Y &*7#%$"trWk>Hj eQvڈۏ/ y M%l&.64(  D ,vHgcY= S#uz]kF&F+12>?2a#"qpmWDZ Z0ELաgqϯ`#%2E<(n"! s3F  ޘ|Y1l  *Y ; A s  3F]T J e1~ x X  J?<4Da _ k f e<lbvhC.=?I |'  NuL[}cXP3#x9|k $  &"=4Lw skhGt~p(  6 " R ;X@Se[4 m = V ;*@2f;@to S  ^ 0| .06@}Z D! , V'1'(R~, ()o$  /b MOs(dZ\Q05_ 4 OKemADZ/isd   qwjL;t/$\WC 5DQ5 P >]z@ bJ"{W ) 'F_P'RM*lnr mR8(& @+ 5a"{jM!2#wm_gSJL9ED?qo81S2S6_y/F ;C7 a W J D ~w36oi]n-`P/)* ,zkgI3K6x:S f z591cq#8 _ Z>iRt(K# & P l Baz+ h )*aN H 8 Kdb\U;5S J 1|+y*7YBjQp<l0lD;=n_6?$*ZLyJzNDl,`; pb Mo` Svz*YXB s\s.}zj P 5 PI F PSw>lfq6 X C  =  lhEi2P  l % " [XPi.  ar5brxoIBo1N f K g  ^/>fT[Y Y-e~I_ HK] 9 B [6#CQ^_&6n  | b=I{BC I a >+zn=y Y  DXJKe''jFn y WvFd9mxa#O0:8 >6Ojo}:qk ~*Vg=m$,b2EqhUh7pLV7}ji* bI[Y17/&v4gP>p=S@%KO# Cr/rRf lZ.Pl$]=8q'zU#(u r _\^8 q^")M2:3-=" RT* cYD %)82=K62&'B/0@TUe u"%3/;:U0z2bGc dY #+d:;3 7 b{g35\?"e&8:D;!U | u;H$ ddXzچc,%-&/2F J6Z 6x#1%.(A>q%'9H?,hYKݮ@JR0ITp8(#.n?y?5 \7PtKK%L3:?6&h lsi 9JK[n %t%Ha3 7~csyL~T|Mbnh k OX Fr7VJg/(?6Nz%U >VQHiY\73{mFxW0$pDIQ'5;W blUC#FUYxQ [i  (]km1zf Cba N yQ&.;@RUGPN,?|^qL=?p`gG/1<mvW='$>g>|en+ fLov#pQvfep0Q}T1fm[c ypm59{7X`N?8"-J\io`>+E^b^E9- #'?C3  *3), "#  7Ul|lFB2y0e5] 3 #+_ $Z6W{cfv52$+] B [1qtzs6 IrwBocGUiM~\]+1DlcS$ o3)l#:U+f|#L5BNW-ykZS1. r"Z/"meR2W1Be&A.= Zj zxEcOHP;6,";g@ d;UZrrYT2O[`S![#.%2o `- 5vqR-Mle/  QzJ~gBn{ S$e KFvp"lH:t F_ko yFjwdp  kS!z>> TIwi >*1 ;br!eG73f?Wr" J82 )0nRyVb uH<0o0   0 s =aaNrTP}/g CGz  ? y./9G72-^b|l-;Y8 \v[[^wb+eqUr0 |SJ-"1 3^~]FzzER0 ~r%i oCQ4 VF@]DC3 j lA9>fI'* y6[ ?kD n<r[\>wb.6*r  -W5 (m[k9zb= ~ <[X C0 e @z.<e c>o! (~k C")M7I3c=!R~ ##EtqOS #V63+ ZL88 Bx[Lq6 [|BOZ }$ j.  /nIu]b* b[mP a8 8/$? / f vqv// u! `\BVcD 425zw5: q <x( p<;" f']~nLvi;DbxR1bC+kd atdPkpv;6+ !|1 1SS?(G(= G ^ #F BF !QZ/ )6  $ $|htP@RI:n||~  e :qDl=HE6ah | "crxn:ZzED T%x ]L }  O~uw+ ( b P "  =J^Z)     C ~qq c6@EQ?  W v T z $tCIݸ-c p 0 5 !% C3M O xL,oݥi u W A ^pjlcu'GY   W moU >5?C>*_ i c - !4k O|l{6%UH [ K+~ e <  k QB+Z U j  *bc J&aw [ e  W  s Ed ;0; K U A . {  w jrtsQOHUC 0 v T iXydALuqX(  / k ~x>p\1'o|ZJ 8 B L j R m>L""|pDtq /c TJgAH.TfB?/s / g{^19(- l I E w zT["4>^l S [ f y f t"~D\J8i+QQ  i o ) I?R y\"'=j ~ w ~ 1 M@44\=&-^( q J :'UJ8M (  R  `/Q7$_nvy ?Qy ( ! #eGz$T8 t T K $nIY?:I< A 4K   m ) sJ^$@ DE|6 j 7B_DA.P@.q=UM W4 t I k ,~Q Y*? 9bLpf$oa$N6@j>Q*BhH{d&`gDl7|w:E(x9EQ/U+f\J1a!ib8Q2po}c%4]f~$qCe :I n>bY!xjN %W <RtJCd .ba&H5_X{+/g;tV0h>cD!t[]), O-X7I&s@WoN0 WSb|,}q]uyn"$B+qF@F+^, 0` .`X`VlN*Zbl:Lp ]g{jfY:Nn|EJK!.B_VwvxO{3. Mt~n o~Ya+48)H~_'E"3ta(/HQ5H.u\P/a 8T+O G9MwyHT6 2>9:(g+sK|/3b Q%]&fd>K7AXyM|jOZ nZTg -SZi tla31~^XDoD-iM'}L#*!h% /o1#xvnjA3#>ga[l^wyLd'otPU<6fH.*Q3]w$MDwBfIue^,W4-x+1O+-&8v@.Ml2|6A]YfuA"D B:s>XqgaZIy#RVC0.Hd7j)9< 90Wr;YLLl3[84 u   T)IY >MwJyaT1 )i`a#-[);WwPRA  1!!h yjNG @y%[x6KJ%(N'#-;K9ekOxR"*)&a"$?|N\aiy+-*"Exca;5|AGL$K(83'21)a(9#T!]9 $\sV"+23,T*k`Mb oQGT:W(M32(s }8)Yps$IlccAS7[# ,2.E B`i~w"bN[ J{ *)/* F-7`;WnQ6 e%^Pz** E '*$ ^")% TeUO '? %)w$Pu +-SGOn=yi+7 " |7E&&&z j{e"2;SX}R3U{#%  ]P141`q uV]"A!,v'y # $7}}F,}n^%x :GF:d| !"y) 3d*qC.Bt4n?Mjm- 9  $j336|~/a7P,ypOB5[O  @ J.@;$4;`h:qBnbbsKJf Bltxtn!cYs:t6PRM1{(#T&> &~+ 6QM_Z\x>Jo@$ %mVqVC ?_rro)'uJ8Baf;'Y f%;7(_# fB?>[LMaU UWb E,?!>P 9CbO-zF Zg@quT4<;F$oz! NdA cu :A9,4C#iIA)z$Gc]#.< Yd9Rx69k4v$~FM$mT 'm 1rbl+\9,xNA| CXbZ+X} R q^N} ,*6p`wqv^UI7IT,>Z& d`oY[{j_x rB70^'kf;r<,O? {Y*.yB2#Rd eQ6 R;  {} HE}p9@=9fa5 `,}l JT{ < )qUc uJ``2~{tn"n'5e P# ve{atYQg akSK;J6 P<^Pb Fp   c1k}_@~nY~H-T&9TzRcD 7  /ZiXv9bD~" 8@ Hn qp\ U K9{65ro++iW/*EZnl'^ NV1] Qh &"2\ \7@ `Gp}xjRnu=54v=?'s: O <XKTa kOB>`>y5c$Y)V\|jG68X ; q rf?P.mQwg%2c GVii22 x P limCO $8tV%B4e`L.'T1pm`xq~IP+1!&%PkZ,fJnM^a wje5`9]VD^t1-zu_:-(QlKy<~cvX'Nr0Q]!&J|)l>v"Qw}gL(sA ',.2795*  $)*)&(,29>BEB<7/#%08?AA?:3.'%" ! '17;?>?@ADGILJGA8.%  !',279:996520.,*&!  #'+-/110-+(%"  "#&(())&$!              "5HOOWtBA>H; x2u- H;Oan;13o!&,C cb}I]^f> B as0q\D+B*[.%J 1OlR%   5vDjͺ߸A+:8/.; G@H 7 kA} c ӰՕ "198r% 9 $>m T )Rnl9 VMoj.۳0/>_` /8;+#&c O*gvIumob4Lܵ^)8>9+*K0 oMu,QKeX޹Py28936/-9qH[Kjݭ$jۥY3+Q&0565312% | {gG_k&!Fֆݣfl- `#-E.)(~*!RI 0]4JNbp^k m'Y$Z d1WGFHYh vfl=x ,pF vSbB ,dZp5:29p9A M/S ZSCK>p##P?(eIc[8m6 ]/'3z \OMoz^Ei z] BXTU6 1xcNBG38gByy*l Xu7V U E-hMCgt&U#9J -1}F : #X u4f29)n m srW[ 6zU$C0QHY(l5nf $7T KVy|+ijBY]pIG Qs) 3y2 +EF)4, F<9T rMi2p!Yf@v 91-E* }`s!*\GnU KWU  hDd] (D_ `(j|mU, d ): x v{$r3E2" 2? XA*6"\b:B: Y<$`,/}y :&a'^("!  JpN]XNs!v[e:;S%33/}#v0ZSK^2HVI( 271z&%o.9]T^rK/pg-".82#08j~PCFXb3!!/9.*  8~tY=~>Xckc B+H4 #,p8,1 $ jA%,/pX \?~p$6#+6(  )re88h9.S1467C/8k&&2+  G \p-g> [dps@7i~$$6,#+.e.$iU< R m#S?K (5"$x/"p |RUqX%`77?YL4i /B3Y ,C}IDSPe-(e(u 21{1+ls9Ph Wtaoi5ec$6&$#A,Yx `_xFwE);d&*]-2`$ IJ"+B _ VZ6Y" H^4;q_]S +%0gT!Z `n wG{_x+1.:j l  -?( < [D"]@=4q{ ]N+(r Oi&@Z, d 1Fm1nr>vW=TV$) [ os6Ze3i+#(0 U&9lcHk@ev GU3 @6O_QYm: 8'G$5 =?:ld>GMiS i(#NL<] %9 7\ g1u{?;4&nF P1R@" *""2[Zevi  it-xx(o? 59Q. ^R"  +R "=YzpQ*a;MD{[ %!vKb'`Cwf^ )@h]`Q h[(! e puSAm).  z =+S"^*} #Z)'A K(XB7k~A | ]N7/,z* %%`!,%B߭^o m#j XY!qe!nDH}"*Anky,c"  /OQpV;hQAm}t8?g# S t~`R1M   L Q~f5o,e[O#-z]QUTp/m#< CG?])>`1^!v. $j!=WK`61B92j($S1nLm+8Dyc,x) tl~H)op\cm9&#X"v[= X/qh}}dz;$]te;kAj|PT)VSP OAd JSQX~9WS ]lA_npY"ov.T}dvlM76GxCRX!SLs& A0I\^CS7fjyrl"riNwB=b9Y[/.=1 8B9Di1y+5X!i]Q$&`g1DjkO58GC2Aw=W1"@L_$JNQ-{f> k9*d _"Zs/]Py\;7d|wB%{y/U#HWmM1BeF|bUHQ<O=.0KqTbIe7 p ]aK3<4A-1Sj{}ojR<UJz!`8@#ku qq(X[s 9%m+KR! d6p`^@[Gb8[~bTws%DN;tlUbOR_n=*gynWL04lJi.xs{RRMEGI %%x%-&A'E0* )4&&  <V/8  $%'                        %1%;2<96!b M(RxFj~c9c`zjnViP{eM8ppz& I\L?7]g!vgLk"SiH5v~:KX" um/&?;Iei~fG.\E60>\aS-re>XV ,%L;`;<8  O ? RD Xk. b;eS'^ 6g)E?,  qoS,n5L'8,' 4D OJgT  e @ Xn&\fI=,,$\ D'F&i xR0zU*[/%$).&N bmt ev ~pv~s,=j5kK;6++ \%I,"+ ? \Oh>$Jy]YX! +>!'k%Hy_ n;dXm9pPTxK'%$%d =PxYyC)O*?u S1)DI woYs+"^# S`Xdk`UP`VN7TR6A *!*$\XDu-s6z- Kjy *'&'$ J*^><,"V9S}]}k'^Cr$%Ic$!i "p$0N%yFFUm VP  tj=io:|geg~HrR _  _` aX ; c]UX?EN76-*   e   g f_I|2*yOfG$4J3I7'p@~ll.]=*[a;7bEP,~fp D {v~g_rv%h%5/<q/txe$m3$b eSBEY$WQJM[.y_H_fWe>bQ[iP=KAj2 [s5M z197>>^46 fC1wL$jq[`bs/pX)?q L:Yc|d/*AOvdH*=J`L4]4=vs]% ,2j_[o N`o s 9Ij$uYXNn<G LffRt5 -ymTH i>px)5(a#J eQA}AoVwu(#ooy;"sEhp-t3Z > ` R '  }Kp%|;k %H:^r-o* N/{@ WoUS2A yH1O s t Z2C %tgt]l%?   , L>SZ d! K#vd1  e%4<2 Hb/r Z K74C[-u~mf%FYaT } gi >w\Nnwt48l0 V O t`&W !# 3!#""O e9?y k &;UBQT~ޢ@ yj"{'l''" |N  kDk`A3, +JWߵ߁p>!M*))&= F zm- Jf6,ܓ--$(,u,4+h&!T? Q W] RQP٨ٮO}6f)[.#.,&# ( PGvc@L5 qL5ڶܯݿ@?-1/-% %R D bu XTfGy޿Fv%130*%M#,b]U$vv jPp^:ٍ3ۥ,k*064/&' }_;;^{ ?Fed׋j M >+56H2'd(#f $ 9k?sD *^|1-(u3295)d'& =R h&@>خۄsf?Ra%E0754.5'&8K<9@#2S?5Aݬ_g6{!v)/0I*%m$ n'>qwY^B|f $V!&0*V'E""}2Jg R LktQ?oM8 &P!Y$iGl A9@_Y5-cFG?m}7 G[x ) {dpzoAj mV<9b -{# D kqv 9iEZ5r!fQ&TF  f rhO;=`" [ z Y  UEFf ]|q ZTKGGbJ X L L- ^ (4  'fS)6#dy+k v23}J\s$ma6DMR,&>>[-4d&K\UQj2#0 &#b*\:X-TIrzinuSChZ%E4}B]x %6w)w0  M ^ $#QR`t|mud#;3 N4?!l (8h)G5nKoqaP_CUj"A. Y);7((e4lJf6XXD>b 9@ ?> V;4a>@' m_gRWJmuN gsO6_gcF>=QIeDteF([i_ޤج߾m u%N0N,Y%WQCд;2A:B9/v}^:ް\Xa,Y-] /E651A7:O5C4kY j  @``*pRs 3k<7=3u+w8f 0.([Xza`FD{U C77,+@  7NY[-x<:93"#oB9u8N!04+9Y>)|D@j3U H d,  &;2 y"5 ?f [0KB7,RXe9jz-) TF ]g7 f Qh\J,h %p8i6"R\ w|?wHo?VSWM9D-6:k&,b( $3%elmD'dMX +j94sW)? 6* JGK/_T_AH!l9982_1  ?{bO=t ! I-|90AUFOV qihI{`<cl!)Qz}-%7E+b ';^ @jpqrfTx5E|fS@ q3vW s'T1='!`9I l 'ndvN;!dSmL/p/%00N: OJrVQ_A4f\` #806+Tx s@'EV==ZYO B#.k(QN] ag.Rc^e=I+hg=U &@-+*TNMzX\? ?*YqOQAw+$(0! m; OOaCo;+$8<7r((Q4J3 XOJmWS >8oz< $*w Zm(WZB th9t8)hNGdE M,<_ %$ $ vWwjQI!^D# #&v &@j#G%!}~g Xu{ _ Jdl,b,|:]3^ e$t%4eat UIXI? C5T5_zhx& P | |S:xwO=J(yG"V 8q dU@ #&L-hxf@Sr  qFY( Yt62{-|9WQ/dFr| W Kl~oyB F}tcd;h,nr:q+ Dy|{e]y63 &-eg`^?3R) D[ZD}_JTy!@e4w q] <MeXC*5 yZIZ@9WvfG*M u1"i#82]r( ; )j%QeuDtLd" z 8[n:9wq>0|9rt`>>@ P'G 3+o0L!@ 6Mora .}Hz}E K68G* ,R`E%FW'{jY `C  'Cf:Sk(x9f}?Yd(wa nX ,nxovezw )aNeX+hM W $n%@BE*8\t917:`ndm3bnh9oyT]84@r?[Xa,I W T _(k utJDcnyH*rv6Nr=CEQNT5  @CK  ,C?YIR]9M,kC1Fn)j8by YpO@ 0N[JFksNDpCl;hb'_j3je zm (eigm`jk?LAP{vBczuI: La% wLFH<-F9>Bo[A%sj`l2s l *s `"u3Kc9u6W;`~A a'`QH5Ue1L[*a4k^%@n#nD4:;`f<3?Jq\Lo:;8 :-Mjm)" zI%sV^-!;DPm ICA47CFI2o@vq[\*o`N*=X+RkzrxH0%%V077A65IbpLBf Gq e0 Ym`Q7$^=At] pyDCqR3I`Bj^4Z ~"c`YkuNpe;ZOXKiMC(('"9@O{(M;IV#^AWQIioZ3=o4\ASKIAh azfIF;^%,mn36*hN*$yDHXhzu8}lTtnZ=kFTQjM07YB8Uj^1:u_CJR+D6>CWFR|K &@4%'(                        .)070)2$ 1@WKYhju}wJ7!'~e1{ElKrn }x '!d|(M$5&zU*CY:4fQ'S!!m+i"9:\ټ ^'#h}& 6v W1'a$*A8<136&= fӬ͚X*z'J%$&" q4Qp`9.89.]%/7ѐ[ MT}Pzk/"z "}*2.>3$){Hԁ06 8q6= @hi(2+,2.0Y80K%[ )#s]..ѫؒ0N@% x4z%#8q9_8:.'"(#mWOx U e !  Luڹe t&q*;5DH@E>4.&Ɠ!؋h h"1)<# AEjO Gz.%9K=sLE;5w!Atŏss(-"&#DyjkZBΠ΁L| ) 4vr޳ "&K'a Fwo~c B0''q<@ 57.~}Dhܣп߉  kh [L"ܞޅG"&g%{67]-z.$p@ .~>ߟBr9| T?]z`~Fp$F!W+1*%'$ obnH$F4  KT&,u %>f Z '&:"~ m c"{ + X j'2LY"v zL A R s:< Jvb3Et$ \!zX$<-o/C( bf|cD5dJwO:RQi>3RJ[(OJ|Hp%(6X+Jv nCEItK0uMT l8IsD813C4JGo^bpB\p?[_@v6yTHj&kr}7!+ gQ5CCX O!Oq<UxZ=Q59AdD'5>;i+xo)<I3]9ds@rNO3aQOV@/{Gxg4 3})` 0D;pC-iv`*F*52AzivkVT dt3O`RjNm4^mH_` Wf7Mv !ED`(%{8"Q@:5Z#p?$6xf'k Ty 4+:B$9L%UhCJHvfUc5<fo k]W&' {. 4^CrGRjYAs t&)Q;a{ZvP'{7[sx[CrJOdB;2ptc :ob ,nL#md"vDliR?wy3R)K)hn| =6X}P:Gy58ny;   / ArA62n `xujG  ` A - .Cgt2kK PyzI k b U' w~2eg SGm0`Q[  3j S2Bx47~h co/* H@ u |4IhgeDbWX Vtm s  43n\nTn]O z{ *JCbu[y $kH-2~  CYl>Y1 6C#J:\^$g 1 O :_o'[-S6 S  $4"#C3Yn ^)q!N O3m4($[R&aS (6.`av_ 0 +7!-%>mSv'rp-k7@! +#(;:`n { &"+ 5QTF>5-kGJ)zLC,C $ ) G G[!Mvf N *"x!;!,1:X9 4_F?R @"Y /$k)  ~Gbu|l kY"0#($h p7Pk  "Y$)G#  2IZdshkmI "!y'%#! ZG.hD\W|Gd E] F"!c'9&!| r(Ubp\Vm,+;x#"#& #!/ S1U|P Y:"#)$&"K", K ZB|`o;u#i""&%q%!!e r (;?2N0 3#w#$!!!~a8 fSq"%]b!#%"O>WvaCe:n.kx! #%#_ _4Gp ~ߏL7"#&#H!  B-޺ݻA [ $ &&#!6CP8vެ?F}U<P$&&"C!gBq!CWMO"t%&2"Y /; Z$u0^l@ Xh$%%! } X܂ڍ_W_hl#'q'7%!P@Uݷٵج!~bF$f))B' $  `Un۵O"qݫN#V&++(% gJ 4qCI2 #')/.B*( nrO4Iҵt͙A"+A.3H/++* gW]>.h֞޳z!6),/ 4._,'[ v7ޅKb·͉:ښ ,G",-H00+({'XڇdΙ͘Xծr w%(%$$J"f Ii>=ݡ7h7r 4 ] ]  S{lQGc<r2.J2AiI)M& 2QZ#V4;Y0>)tBy]M&aza;#rC v& NADv %N04U `o},//b] & 5;n= _W-r#kr3#I8q:z|/h 8"pF"4(GsRQX?Kd}7NZhnqg@9"%'\|~bN-we_eCzy_9h[M=7Oo4flr%jSiU~{)UAgg-y%hVy Ft!p^[#eW i[ 0wzv E3r IPp%0KNzCFb|G;Bb3NK%SAo- SX& R!.zނ(5DS "y,-23r*9$f 2̜I8ѕ4>m04>E^AAA<+(BdHRD͕.}NH~-8,?K;4L6% T m.DՊ[X78l|,3a6..z+E FIjا G.D[\  /D52**# p9WUߏbHi|?4Pg(Q/1J&F&# 4V/fQ { +.5^/% ( w.I|!D1)/8=i4d(&Za)fxhS^,?G<'! 7<&+i} Wߪj}&,YBJF'bWVs &>o%/:BC6f Vrڽ'> hu.b,69<53&fE7M@݅: /o'154+S& I YK ;BߚSi3441'9xS) ?f2 7/X&o-Z>"6qr,q;g7$ 'd$?DL`d;J~4<9 r3$T cQV6+< .;?9a ^Cf p&g1RG\7{w1@3gmV^< ` =3!I47@{1KmPA!H1m #H$ ^]1xw 3k@2N}yS5!! 99fl?29 :%`Ki F2ZR!@KoDF(d!93w| " JmIgc[G8g66Ob ';a$p^ JJ-68_#"y wH ]v.>E)77g.J /8%^~ W#c . GI})360Q{!& F-6i* ^,1X(U$20' zdmQmL} t+0&&A P3 7 xi ZrQ,t/')||dS )i813^0.&2/(> qu '7)f GMI,;U2/9itf1 "L\#xL%!54+w\,E<A}VL2zTR/_,xmlM +_u;?+WX].VxdP-4. pM4 !hZm]diEZ-0(s}T! %-x?PV}NN?*$1+-~@ yq2P8Kxp$+d-7" )>j? .rcTU]#3Q*/ (1*D[#G .aF'L*D8 -,:Z @}%@6|-/!#@RJI wWS|+o< 4/q1# `y;}A$s9||.$}[,J,4`*xL> C!':RmC]1:S++>51 3Ggj]2}E03;-qPD j}N9 1?3xi&[r#xy!u1=/|>O3@h`< uzZ~x1YMbC/3!@3t ^= <)A!t'} 8CE1{  W`A\)D_$$I0GB)n xGE<0ZV0& $IKN[6*t2zG@* ;p ;8R0FX@6-Xoq _ *SE T)BBx4l`  OLc >Axh7D5?W*@ hNV\k muM V6K"EDoLZ&slT ""k Jdf3{B(R6 {d4t**\]WR8 sS ? P-Rriry7$   ' pAV83_P.R 0F \gM8!Hw6o `C;' > ! / \mt?&j4 m+1h [ g ~ fR*5rH !vHgU}f6(*Wd  s ( 3  T-=}X'~S:jcPB8 ' v L+/-SadZUj f/} M s  8 1  U^$T7{NIt_TH  r ] { a ,xK%\Ej3W!n)T~ A N E  W~MqD"4z|+<! ? ` R s)zTC~|Q Km[b:P s 6 ? -  h7n^ 5oe_;a e\  LD%:2h%hR=vy*/8F MrB jmj&%?JaLm>z!S&N%o~~>k<yv [H~JpDQIUB?$00, O+ v6i / X\VOri-# u$^0:2&_9p-xm o^ :HI5bnS=:$,2.L!&2 dXCut[d/<*J$T4]7y2O_ `{  # WF4I0 7*RRH!2O/{,L .+O2  M+u.:1% 2pJq  xZ 9fߓ!Bq0 '}<0$<VNH  ]g}r2=o,{dZ&8y*$$ G8Au gY^m@]rr$b-n7!d Zh j ~ ;K U$I^ ;[J.p!;1(9'3 Oo v{I-z<oCL(1"q$JsA6{h|8j\#H5V$/. (  4>rZUly &&!g)7"]S!Q { < k O k-~]:g3=kg; p:#'W. _'  e{,5qݮCt  9!n#}y  , >|C*(6A4/H'`w i7 @ !,Tyk:z_I k g]e 6e,>s$|3b1_ MZK >  tgvaw}F OPMF;x2bO< C LH3s3e{/h,9& kBqL"\L  0C@H(!3?9m2;L T; } Sf7=߇e u.8cB$ _/ H0BR<ߕ6 kha 8O`;02ޣ߶z jg /o~eR]*JVE m@b8 ! .$QTflkHBFt~W_ rU-[( O Hq!m,IqOvܞ xhCR  SsF@m:ݴh7_0# 3:o 4"vS!TF6l-  _ ,k  Sp|s)7?\;ܫ`ub oA  k( } 0:85!dC/r  ! y 4WI61<"5)i3x\p#  ! [d@mB!tN_ T N 8dE Fc\5 z$  [ >d1 1>CG*^-\2TA {KfX *SAL 3< H xOv  q~IBg |u|p{J}Onk(!38  y w gvbr=:~*S`T&3z{|ML8Pzw4EDjK^/zv1>h5@i: E[>s:@q/*WG7$;t7dg-1fq]R<)v$9O<yz|,PXr_-"+}in7'P^S!loq}9#^fm} 5YvzmhuG<? -$L( `[PK\t6fzrjfz$L/ kqeJ!bBoI`wC~WGL9!D?lbv/JU|$pT]8?g )SWbP^Bm%8}%EgC*\sRb^DvUU&r5mT_?3)wNft; gZC ~MOWJA3Bck<<2B8[E}ib(A#y3bXN FK\|(<VPb7)*/815{Vw^Ue\[w@3J arf},5_W%ci49c*MEfR3s'$TIx-w6 d7{sA()w< tD2:g3qg` :ddVv8 OW5u"} 0 D   % (6_h+0HNp- e  ju . H L!]7s"!  <zJL{T I h>d ;_a#o@-{ A=_HNNsy#%{\ENx x 5mpyr=?R<_l |"U hu8N@1 o[xv4azH Q?_u5o)R &v>Y_qfu-/3OuH ||X + G/+x(qpf1 | 5k V/Q->x[) zA* iCY X|xi g^U  `Y-@J_6(L|>I Q, R /3F\#sZTOF% 4\H` hmOFiulHH QR\ AkzR( 8=+ g@ %9 {P3L. [=mqc 8| .E&pJDB0 Rt q GB]H 8Wo nj\ ` " sauV@%, Pt @{ CqPq a`gJnaI)QFl& i HZ5G E&DpRp W 9 @%5qhyx3 U {[F|:  '|D/ jRDP $S5a3Mib   f[".`t:#1ld ?])[4 "  d [ !D oM X*=>(>KU ~U/|6FfD !: cHXP'XVDEO9eq}kI K K~#{i w[Z7 ?l F.i$5yJ> x :BvV'gRcsx21X ~jJAPw?VAm}G4.d > 9?Y   m/xJ.2i,kf  &z g < Jnm6;K { >3_ 4Lu P=lf=h@{L6g&bV;F>(z4E> }]arF>j9:W|UTiYtBxzRA%a]&bg*rN!!fe_ oZdTRF8Ry;V"~77e=a3(Jx\h %vR=UXe9PdWCu9l C[I3$G{<HRt QE+6@q`4!B =X.:zC9}v"lV : vLxzV3u/Ys[l{pP#9%cNe'-zD:j2SJ(yWmv6:XmVe?.;l0!gt$;I;eqE4LStH?a9< /|BwV)/+ino<-OyX\(*!'Rw)|Zz"*#:oox( Tdc IM6KcD$ >[wd6<T<||7 'OiFMG~KGLS S|LV3R3=V9&M#H/;%Z| ,I:0u`MjF =Q7p $J%/F_= 9K~U~KXu h6',C P#p7:j{~y FMN/tW;~y^!uJ8Ny1K,D yCoGp={S U Uo  U{zi7<d3+Yzd>I?UAi O  8@s&.XIk\p5/~|X%rmY#m _y=RW J=" R5 #:{6jY\ "e!WE[ P>}} u x#;ZZ4**M {BxO{tqqLzG@lIJ,vdl$D9-nPh6 &Mc\>%Lym^Kn&M i F j ~WH&ysSp<{{J;.}~ V9` X<0<g9G?d em R~,n ~gTQTj-9#w >\d2 mK1? V~_ "a- PK :@MOZy  bwc /%!Kld e2u)O , L UyC%kp  "  O~  G S(r_wPD 5 q$!Q:!s LWKDI- ; /Q 3a"# tECbߖ?L & =VM_T6v l 7 T '$^' eX|" 5~%  }\F{zze1qM#l! [^@]j'  *N )U I8ypSfG m "=)/rg +Ob (/U- S )Qm xhx_vM>sf3ukC%9^As #0! *y`\0'9Qct0 (%Nm "!)B]o&D ky8fdH<(O}&8$ }B[rI{AP/ JH&R+e:ZHyYuh :FJ R` QB7,u| SO+$ C D.pY !mTH U[L$j3cRfOC  9;6$ 7 L & Q95G:?[EE;  +8Z~ n`[#A~j0v[bc(g@ YS_# /s6E;;krSA,'3 z]Alc, >D %g?+'6Kr4za cuRkxmP0O` i^YdEM  XzBflD@^HE5E *K@b "#ffoyw[*6~?yN> xd)@ >>$boO7u>2y~z * wvoo ,`DW/' Ya[$[s: ]  1}.boE,/3sKAE\:g_ma4*|G?{)Xb0-*>NaOIBCi|A0V'(9bXOQ=hLvSto~[,C#|l-00%}"a#l[xS9%X22D#-4Aa7'V6rQwMG;N}|4WS?/"Cs@`TQfqv."6]-pDPQG)CF'(  4 V`G sU)c,E(w]^#v B=J6hMwA&S69{/APk?Bg6xr.]B ;[.G{Iq/y)0bvj-x!E}k+[;7 {AVrz$%Hgx =S\`KFN>V8BgaK4\XU#5;!0 L_og\ 9n^jfJI ah AbHFP';Iym4 l{-l5&v_]OsfKe7V#E!l ]G=Z<Oy-I22<Ji,^= IQX&]/."J5d<V$?bL/_\n?{s@Um]q\ ( Z`vAc~W"boBj tLCwi ev\g&l bm 9 Sx.L{$B?d^&^xFUU:Y0`1r{KRW*n hrh#`4;>F&r??)[y^NCtXp&3YoW7h [,Kf+Z4W9 n, <quE1 j? qA'A#ICPx\KnX<*V "a3ylfd@fPIvF gP+$g'\j<ATpk%h@f{I,(+Rr>8R*jK3NzoVv-yIze 5@x&_}2'< =8:.% 9.e1n-+ ^Q$-]0'9c |ebsxz4 z =)(X /AzA8S& W!s6 #P F|>AG .!&4GNK;D/g.ѭ،cUx I<+ g'bۤڿ( :(n;MMD3,w H(J6@kgrNUpw*g5 )$06H[LE 2[G͢ҍxs 4x.5 wp&8MIKJbB.ݑέU w  wU޶n],=I F<#j2Osؼw 0R bri-\'6ED;( *}h[:gY9 B x4[ wBjA,:CB>1z)D_ڨi^bo:j[*)9@?7&{ JP{ '=U8 fdg 3^qh0??88&y>NE4%j\ \M0|=2<5" r-&Fc9MCj;0!296-J !tjW`a$u:9'5 7%2#^-XoQoSF5R["06v2(aGV(qM[ut H!0/9.58, Z&shwB:o[01 (.8_6-t~8A'(-SHRlL8i7 *h65- nH^sX*h *42=*MP'-o$ >+53*4*"1 } f1"M $mMu*5{4",po7(4@ JL:'4&70!: [\(yk[k}. $/p84d*0! E~Cy9"/730(X x>0kG<2~N;qz ,5S3)_>}vm9 (5[$0#4. !F?]HVxC&u (&14,9 ,u( JbdV`!.%61#^i =%>M$Q)9)I3z5~*;CyY|==Jmy2y(14*hNo{,K&!7/M3/ :!v%NBmO"*/.!1'd$d@}|J v.f).-[ !ne 0BL:Z~Z_#,0(:N fQ/=z<Cx Y.4g0bo/N!*PGS>=|[Z172B$5.0  gd,@SL#7/E1+!>RzbPh% ^; x'q2/"K #'->ZJg&* Y{ 9-v3*Y5ON[~F&)&od:I&'^mOKfhr4Zb<[al>u,q "$U M4PC:^^!.9WgV C $$Wg +DS]j2Vx i,^c`)R]zjY"X 3d $(Ti&p@L6&7Cc8UUw"k/~*)@j]8Bf NP0Ybx >">$e5)jMy%dYYO-#=j) gPTTS=0==1*rg:`f` Z7gQrjL3p\H|gD5]@eyQ: g 0)&Alm5QE5w*I#Gl0 z;DwoH3[ayxaGsw'/Q2Er9J $yod%oOD||0O9]7Y+g9BgD3,_ p "FPQw i0H Zq| Cm(gBU9ji X]cxm?PP}C'&-g R*QI._yhqf;( y#'-LM[ AAy-L/zr[vfEk: `9 Z P j>=rJf@TbIaKYU'TK ! pPBSTm|834Tc`TKm4`f; H v1M %74;^= )u.gmhMu yI[4"\w K My2 ew;qs1sp|Ps^mCn9bQM \:Mq 2 xKdO+Bz}t5 @F AD\DFb55b m^IA q[#'^ i48TaZ"N}d9o-Tb 3Qy$R`@MZ~mn,+b:alM|'M@$_f=pQ{('4u"U_ PL?h`_i.NdD;<*f9`LW!BkI(!#_\WEf@bAfgj0c[z #C$(&[!?vL\Fc]bS~J.| ~D,wj{O7+ oX  pWhT\iY/t9=we$>A#@6`t<T X \)/&]@47Ry p2I&GeggR14q_nhj1lpL?m%$Zbs/*,'ot"ZODmxjQ)gNj$h9 v|m)"BEl2 tGJ.;5OE&%-VYOixq{vkfO7@7 { "7=JSMXh[HS[>*0. '''/4+%,+ !&(&'$ !             '-6??AD?74*+6@O`hr{k]J.wiQI?.3*,Z#3i18>0obe F > iT Z1<~H@l_/ 0&*?%!*'XD w6i'ߔCC$3#=2 j&$+J(!! q0y_v]lxT 0"'K#!5 h|2O ~I_ ?hZ $%"6 QcAVHk rU )(9' y{Uah k` *"57Tb ]s' f >- rGmn!5F%(*JP*-_)^B+H]5VpT]R/_pnjRS}n0kl^n>YT&=}j.k _){7.x'k|p/\`,`z6_N,@}.P;V j!I[d?:M%OPws<r_By   c=fM*(/'-Ej-+2Q\jron`E;4,&/9MY^kuzcOK2"4Qao{ne`D2 "557>1Jsr;gNQ_GzV@ N K8) DL$6 H5 K+fBJMY0GOy/cV;Q$ 9d_pz@)Nn  7)92h\qFu~\N}G@\k]Ybs!B=!:0O5:A3z n{$ (bW/Oa}uJC4 !^#2#S;' "##hB_qT. x## %v xkGe~YJ):xpXA}/M[(|lar- R ~+fTrk s],G$=~lq`|jhdF_L'g>";n{<P%nMdKu)QQ ^ <)KazJB5=-cMcX2`1p '@RbX/2+oQa)kR3X\ G 9OCm ^YnRq`!<1rrafExZEjpX8a}Y8)+*`jIW] 9 ip16k) gED~W -5 Ygc/DZQb-.8$O/fwLpB `cr~ QD3K4[LjS8.C8u+e6?;-6?A g1E+F!}h b:/K-DD2}^6kvc.XfVv5&Rfo#wD9\FM9UR}gcB8Kv[1Y*4uL, =IM$q={*^ Yy 9wq: q 2uinGlmOb:D7uyqo L[\/7/|hkG#ab"*\j/3Y^ipvBrD>7)f}W_xV_lha2hy#!PVT]er)P330Pk M&YC _5tBezsE3 H < G Z^!O(Ho5 )U) HNNp=*+D6.LU nHbsEX*/;0 (S\)A Cnd>nR4B2M q$ QM^rS,?C*;f 2=PN_CK>.I=!ph( <ZBE( |  6olH|Tm|;$J4` ~PT3&]TC<K6 8P ~j 1Cݖ >H07 8[ _j3 *AzF3)uoV [evZ-a('CB![N+ ]9|͖ -By;$}g3(6i:4wB3q[ !hhsaCl.5h;A%{7d*| Js )+A4:; NNuasx3@+-2 Z3Wt ]n/Iӷҹi&q>7%j_5^|xcE@\9B& k OmO+x-@L2 ,4a.ۈ%883!\wqs۟ +<,.C8oTz(b(4\9%* -f.  C5_ 51 :{1-r!PPc3;R4- (   oE^pR$4+ Lv 5 l-_vZ!., 7$z& P =J ac")(#y KEB~_4S&V+(TJ^,k mVQk#(e(."LKCGn{&('v_4?A,$Vo!3%Z+']h(2<m."**kP ?]` K $. %|'( $t.msv]6(h!')f Qtjc+ W}fpXR"p''  {|L<G.%& `rug?&k? Qm s* M;5R=_JF( xB!qP*+. }]Z|p8?{$8gu2##e)Fxmh (pA2sE J.Hn-L9R<=C$*en*6^'UMErH^Q`;?#wau C `9J q ( 4:xt2W6j 4 DL]R, zt  {t2?8P 8%%ERm?2l ~NqsL&%sc `MQGP#4<`1~ x+V'{GXb$$ O`rX)0G!} %;k^R2S&I0+5#-F {#k [,JT|~%003 KU#8\x;A6 1"#3x+,SQ@F xx4. "a"3-y+dPS^uN<\-H0 7"" *R.A#&rESqI_%;uX ] Qf mR?Q6) ; d Q  C #?uUQ7CLN*z*%D% z kR85Q/<Hm HiOr@rwtO4hyaz@~, ;IDL]V|nV#S)Zcw S. [ " uT&p'+ +tL\F | A L`{0aQ^,V.* @ b  ~ C 7Z+jb?76jJ3[q/ d  g V N tUdzOH MljrEt.V ] Oi Z !14B!\q J(.0 T4oir$k-1 Cn}d E dWGS5(|D+5^ ]$ $1Ry Y  J"'`E!.>l|0L &^*'"s> w4EO}hT{s$1.#/! '3#~%*% G p!.8EA|k޳>lm, .%\5R+#*"[GMDu:twAesg,*Vmdk,5$&-  J*pug&IkXOVpx2)$-N#B?q$ Q[ AN`3H=$-$'T+ `d6dzI]hqD9+2 Ow 3 '$ +#`6s  hU]{;[ZpvNZ` T #$'yt ==FBTv } Tk A ^r|='e\WEC,K@}[ ; @ql\c^-:I Y?R eNlpc YY vZo Yr,YEC&%RoT` [d"0 H F=HzmaS] #?N @)nC[+$,3zmXf upD )W9!JS=gkZ/{Y' iN K:W4x5l=)?j2 G^wAe_  h-/{{mL-\ST| -G9)9 N C (b#O<+ @|I%{r>.A`uaU F^^hQk S4(}G-4ic1a&^is5t&rI]j{m1;sD f| Lxo[YzFVF(V u'-;66&Fhb@X7&e@ Cn]emE(@m$pAk^T - Ket S{C',#<Azz o%nb v"t`~+ Y1 >|n=}AZ^:>!=TEfe_~rAqFleS(l@b--3h YJ"+3h3(&A  ,Y   M 81eY[J0$y325#Z5,T | $ dku-R\Vܦf_e)7. 4$ [c~ !AA"Wݦ&L_(4 *b RmBr mIs8-FEW'0<$n~ kt^s qqI I2ܻ(0 $#dPOmz#ClS\fuڎ2`!/u!1= gKSm:25 zT* k-G z-"!l LNg3pL CrO(U(% CVVq M ,#] &GN}ZܽhV &mV.kKHJ$XieF>;mܭdV t& +;^>:P8m^"_%~[Bo${/ 9ufa U*Nw] ]x'ݶܼb| !\ ~ r>N 1$Cf15ޮ3#A ` >hN#}PQ&0#ON!xyd ,SPIMB%]>jGT -sPa.F_K\}\s wߖA8P\ ] 55+U EYZj: ߆h(\) .!F*n% NE,0Ys)ݠޤ0@: ^gZLvE6e..U" EP)f|QM i `N14Lۢb܆*! ?  . Ol:J9}99p @ {CI"zخk.3U . LE3EC=ko"$(څP*k ' "d 0Y ޅLݝ7,!v=utC Nd  -C@H$B h Zmm  ? M^]66sM"kY{5! eaaEvF > ;'5'"!-(TL X$X } ds%2X0c,dx (VS w|-$Z!BPV7 :2rH e ^H{+3:Hp 7(Jq AgT &w_kh0 hF+Z hr$ |a+*@.:r_W# [% H}zr9vJo [wsb 4 x<}:IQI&tPR  $1m 1|8]iw;][5  q ^7 . R*psIynM %cJ 4 @h=F_:>xr!zNp {  Q ]  L4 23iv;l&3<P.R9W}Y2Fz##S sj"e$og$.d@k$V O9q!S (Fm\+<%|TgY< L:MU/ )qbF/s#!yp7~iV<.Q4}r& RU^jr2Ywk<}mKv8RV-{h. H}oo'R|abWCCq\ d&$8$TP2ub.YLs(7 T?^{>WDw_H d(n+o@@7 CA:%VSc|+|:}~ \]j;^zZ =wre=V o% 0M@u@0{t705_KAt}; %!xNfiii*O@*u<kd_W\OCSv]OFPaz12`Kl2"C"Y"wBG^@{DFwA)N}+}R+@*Tw2$5jkmLyMU[c;) $^R|t]1Up/  $"""!N <xE'D%R=$y. +H)+$,m ;rJq0C-Bna$  .t5,+' sww]:#f?( 5,5 .,7$PO{ERS+%n8,61+y  S`Av_WEa=4*"$D:;^4)8| sRL 5]3}JH*E@@5$ Y&_2-t`C X~B9 #1;C;* K_+.FX VI=xWns'&7=@91& ~i/* E k, %F38m*VU '6:3!" N,8= K Pyv?e-CcR@l@=^ y~R:-%87*4ZK u $C5*6&SHZ$2500 [``  U  ^r z6J4yk '/1(wd&Nt?=V  m qu!)1%L(/,G) qXpAX9fuLB1>J+pT y8  ?%F ;"U.\Yh"RR-U(eNo ,5'-yc&$*^LPz' IGS sBEc( HkcJ aDD'f6 zzVl K(Q  ,[t-`!UGC0rSr,A}#>/ - *|+n8J^IG$s~UN\?+,z ry Y>72tv"n:w \\ahSy|r9 Bfkf~D6|%8Vq5v`gEbQo +iEM q -zp+h?uz'705u 0u ncU3;14iKtbx,b_t CK=S1 q& &nOr2x 6Mm}z`cdsM5!s\LR<u@QY` =cWR e\O 7k(J"Za5AsI$qyd Gacrf@rWwe<0&zIZ 0EjGY9W{x$Zap]L4qDG[K1$itKM&ci)7% [(nrW,fi% . xj\[ZAY1TEDx5}dfkHFLg]YcYwgk%^,{uw(>!IDi7Bi}fkV;D U_DBm.oxZtQ>?Ka 9jsw aJXyB|skel(CMI=+49"'0-""6FKA.#$     '020,&#%)/11.)   !%#                    O--hEE$ (&  ! C'P(1!#0"" $ # %  7<Z?]05-<; iP?=u tv& /-Ry7  gl L 88DmS <2z/F1*I((6%&2$ *0mes߲T5 * (:hKF̦v#8511/'}b]̝ٽ P2M-{(){[e[{ǥלN''=;DZ;73/+|ImXu#m*5a5( !F<^ˤ9ʭAw's0(7JbFR;3" T՗wƏ@FW "c-49/_"Wmߒ ɇU"; >I<;ZTF0*+#۬/?{R145O7&K@y ƩQɇd BEBN?({<ŦOқ v%35-2*9V m1n61t 3"%FCClKH4%rDtZ\$r,3* r?s`҃+:Hy=A@?B2'UROʷ"$),9 o*2Г}ot%)ED-3FC% ~>}ΤՍ#)v$"&Nc׃յڀ(~"3*4zB(H0h/B$y#Դi %U ?ާۅ^7 (&*./C0<.+{}UцqۣE\u !!:߭CܝxF#1*9>s." :X88%ؽW. + o5&[ K.+/.Q7 0 ").ݾڅzHy< A4f#S/2 Cu*%&,o% @iMjf B 62DtO7GwF+)"CP }jNsCy3vW\R n-0<3@1#`JOjSl^  Lp"bxwl2VK2x|hkCs3#M6B0+m0ea0^Il2/u:`PbLgH%#B$>A"c;("Y{e[u SD~zLosP\}N&J%))A`N~A!nhah?XAus|T^1(AdR~Y>p,7(:A(2~'m=(ahj2~ID^(e@Nzpp L@'&4kRu!tl4*^l#eWA>H`iW aS ">b= ,fOXGn_w/y!<%p q3=qjGg~9)`s} z,f^1}I]rg0Tv2jsq&v|5XF/D![)G #A tiRD @bB n r-Ez :6]2>k^    8 < e \: %7=`M+`(l@  0U # >  Q:#p8,+X^eY"m } g  D V   a 4)yB#" @yDQ * ZsB $5f#Rg;U,*^J @uu }+A6':U@k{ x 7G 8 WZ~8)pg1ncTG> ZL  / _h(~X2 }  f`+  pB_u1\Ut %29#XT "zhy**: tYj=`Eg'5"2 /* Jq c~:Ap~[ f"j-2ih k tf{oP3YmO",*{ \ 9  hQXE'ZRL A40~mf!+9%  TXv-#RF}! 7E_[(NtO*l$~ O />O'7,]i,!>&k' [<MD }i5.' u{~+c+ : #)c@ R vr[&/ wWZ\U`+D!o   2$t{w|i^S(0.X L tM $ 3(-!aNKPe l<P 5@ A v0p4}Kb RjT=k V?V9B) *g'ny6FXd ,9 2I0<%0sSjl`T' H\ E x5a]A0 8 ])Q, PAG i[f8s {^V5[ I*g)  HpEGHX:')mya F#J  t$Jk=^8r}9+P<. Qo ) Bf,q~9_5cCJV#= %ghW n )@[9ZdGl-g?PB=d ? q a }4{WJ G.0:7 -8zz 4Rg%a-CCLc[R# #x )srW!gH& e'.(!` hT15EkWbw6 cel=}ch*:.'C(80BSE݀ 9  k|R0[Q8W&P<+@?6& RQc[WgJ %&X01:7, -JLJ=N &w5C}@h9qah&320/{EtN6 [e2 !S#/Tg]+),-'T 2dG0y~ QM@ J#R0(N$)+'fMJ~M6]2 7F QgT+Ruo&B-=m D%')" }0Pl" ~ d +ULhpa6)"83,+y*suEֻ3\}   ^zdXJtrL.5-',]<\ߴU|%^>{ X hT"^d-xo(-%+| deے՚>>l F UEMTu>XLlMz#._$("IIt /k,)AD /a nw@f%=!{k@K$uFW+bjISJjv!o!Fk35c^+!Bl{rQv ]v4PM& ,798l9vVl\~U "r yUZxz7n*TEWC m6,2\X/$e"nG |t`xmC X5snW] &IB Sv/ 1 PeNJi\GZg+#_$[Nsgw" !zSv|h_TJE'` z[  4 Skrh.&",%pO ~ 0 s +u_GD}RU Pn  Jt?YT; = yii!u8p }4@ +f"KyD0ݰٲ7y".j3S:t;4": 5  l{Ss">%W/6/+.*_ ͨ̄6Ee Fit8߁p|`$/4+--'s7 vdZ S-?d NZAXq ^ &/+k(v) n29Ք`g2 <X Xbk L&+(%N$0 BܭڅE: q R a\ }! (+V&#})v݈ް޷: i dXH=t%*j's" {ߗ~k2 L Gp44KC|$8)j# ?>[Le|`mm z|Ulg5  mn#6` hXh6LkO0z>d ^[?8 F G(04Z 4< 9\|G@uOcC~PU)&D<W !o ~DH1~epZ[U" nEO ) Y W5;+-  kE g{b={Tk"u :A?#4MwH5 ,t;b!*^G[:\}ms#  bA,A YX$&'(#( RP<2 ! b39^/C@ ^i N XTTrVz4y3[J A w x Wg'o-FwB@vtW E ^"iSR;g f -*$E<: il8p1p\XLs6}6=_$m*NqC 8$@GS&SALMQA>W6F0 pxl;19I'*+zFbXUk0\ Q^\6+aev&${QT\S"BQOZK|vXc $*=& V!#_L&)8e%aOSNlLPEnUSvoq R|{ myKC\(]Fk[%S63b?,A 4v{cJnm{4[&D+ a3K%]J1!u5t |L!]-JuX ewO/xiQwlU-.%y`j#!5a^}tNtV~{D>|2"4j?["Ab C |kWi0`+[/ ( F l LBSTm}ogrVhD\ :N)+S0"" Vg)V0CY}q%X J \pVq\6uF$)B ip ESVO  wG k q.Fk.biq :D~] !"ih <~ZM >^ ,!(V$&%(#( ^-"޿X߈v$Y 5/d:!z߫t:(#<(/,'s)%E- Oo" (O;l)-GD ߛt.!''0y(G(&a{ TtKݾq|N =r g 0gx4L4 _@ E(}$ \*()!!G }uNeJ ]<O  i O ?y9a: 3 "# +0}-: `  A DD>DPW?C [\ H rS)n40   4QCd` Ln4 tjd t7P <4 '  [R*uwQ|lG$&? #\Q GdD9bG 57.Nw _7pn[h#Dsr ^$ | oz0w-YMJoB)z]Y/9;Nv 5 $ s1I^ 1o%J)!Yk K=i6 o h$@e 0 { o>@"zGbv|i~nN3xiWQ_U    8Z avi}  B|#_vtn9`#=5d J c l s~R"?RD#pPDtz8aFpaS08v 0  p? gE$(khn&ah./5   = 'F d *p<&%[c@f 1g!e/!  S 1jDwUTR9\.j -@1*G~X B/&)Yg8'V vq KcjxXP=%s chjz3PQaw=##Fd:uNF|| ,Z<i;Fo:6DiMWSv3;sHv:oD]/r.Cd)860^/)E[ 0 hO9|z 5B_!S}=XUs*EGH^Q>({+:!Y}=C\DrQ_#`~@\#_JtA+9 :!y@!" WYfDZ)(dsK,f4);q e  TV"{.HcO+6J(b_tHv{M/zbO0   t[4~%OQ=ef9 p> rExpjnu 8qDy+8=@5(fE%  #.7AHQV^ehmoqrqnjd\SJ=1}ystpw}':GWbmv{~|vpfXN@5&  !&&*+..101./-+)($    &+047::;;:74/*$  !"""""#!    !                 #$  &12) !'"  -23-#(+-$ '02  ! !&&" #0>OCJ%@HN< <{6b(25$-nr֤֧4:L ltPI8U}D s C"1985(7'L׍۵\:q  X[,bاRSZ;/>A8$& Kކ߳B   4 E 6 ?SqoCշPS $2:GPEU4~') Ϩt* Q pBjv-}{).=u@5!FܴxV rB< fC۫Y*۰w %9"C\?7- ՗y4*x#%! r8d&F9A;' @֐?xE )*;teG[ l"6@.<~'' lHݳcr # nYf-=A3FٶWt5 \m`߷1>M>,-3ֱ ZhR =F~0-ae*&;,>n1'>2ߵHd""{!*>`^cކ:n`,<?16ޯf6"M! ߵe;72O?|<) f(Zt"i_j)n)Q;h=/M\> Ed!?YV.+UVDBC'889*߷܏u!c2=]-Y(%/;5H!wݻ S) (]hZ<$I 6$57' f!L?I0F^#21!#_ycMt2'/z5s*g4,SkbY6z'O .//j  b[I7u`)hc !00Y&0yh"!8*/ 2 -.2#)_1QX r+k$.)~#\= }AxWMHov+-q 2)-{ PLNG&.&};  mxEwLDp"0K*R, 4~)1b=4B!-`&M17r 8 )&H: M$3+ TKX U A>m2 #K$| b  , V* a#iDCr_  r 5X@|s)#tk0y a$ b4;$3AqC  Bo{ $!Bq ~HNh (>Z c AIK{T.W +$y%+w , w>% e"+($_9~;h&)"I[edn0ZHK4A)( g"r[L*q hh=t nu!@gV q, 6qY+_C| 1)!  =u[bf#(Zwa'F- -/&nm=Fqrv-%~.  0GP tN+H, mAD 9==t2t[D)V  }! 9  41oei'p E[ ?IG fr O+{[jNa"&U } R -"*J2%m  C h g o5BXS"q<  < 8@ };v(@\1 GF  } e d i"bu{ I3  ?b/5r=A  `5gy-6^W u S0*T+ Mi} z ;7(?"E:U-8j>51 &FR{ 5>-4_D-W 3n!s a ~ cJ `E_YtwH 6)<8 t lx&)8?XfZ3NM}s- *;(=G mfsEo1MFkU.fRVz s"I6 g9 3 p w 8VP(eg Y %*|,  @ ` prXn4޾ި0E5n%"&3/4-';b`S  1=5B%/?\<,m+s0:/6'5! &A *  ! 5s!U ~-1e<9:/ &   k [7_ZTz KP37>s=#ZY ; EEWj?*1<:?B6p g.m i 5#:$! 4 *%X0}9;>19"=$Wy8ax Q 0_4/-1 '60643A- Ob%   (R"bIuMg*CNc;!$&(R)8#< E#KAa:=T z>cD ]"hCF! M- P;L}>  {7&e6L xhVal oHbq$>Q#M v\b# C m 6b #/mibrk2a*.lQ5o^8+ T \pY%e@D)G *!/,63{ CMg*]eZ9JK.)nXp $ $z:Qd!* ; i S|8$) xz.=va#m264R dc>FSi RN aw+w@!s~GK}'U+eqnR`HAhqdLl>r{1Vb/Hh[a|6]fHrueeyUl"7\3gP3ItL_.)IIFc1/=~%vkEMd<~Zc"xOKHAAAjz}G2 s~.2<n?-K34brWz,JX/ t 73 TYwFPb$2P+ G%+"()!ri ;wW $O /$ , ٤eg"-51:B54+!u*e^~g!< uتύҭ֛֩ w;P;> F;/RBޟښ&ؑ -!h*E!! e9%w0E<@L\B'{!-H/ur4C*8:49'kJbԡl֜r! "lD \+d*ڗ(';5/k.$^VIc. #l* rw@Y8%!4W/)`('} xު+nP ]h r?L*8T" I*),&&xsY7 R 5  !28 *'}$" 6  qFjF-m o/8  r. {c ,J?aPmau +iixy \x* 7)`~%; ']~r5JNIW? P g:޶?;2]C.D@  zUYRv'V   @YJ<|F G!CB!mN En; H<-Sy  8 : G ~yb.I~@ Y} +n2e v Og,q ACn$e W >u G ` RLL9bG=p jDM 5+f  [ y Y /bAhm2VX& QO%<r{  - d=P?OGXD4 i=U Y/zA .  fC 0>To? "! z6Y'P 0  /%P`nY={)+#lpg  . Jgg.aJܚ.62y#B2 - :A1\!=>3 J {qK0"0@Jb ?Bp4 L(YCH6 OKvem;oxR |5_F<+XNW-I  7#0yC3epmi[fXwٵ3E;0Q :Ije9"v^$ 6;BN>3 8 `j'31*5ibet!HA]j'b#  ~ ;I i !yr/ej 2inr}|Lu - T X;*V[Zna^8 J  /_ & vG=Z}`VW4 g:u7 N M _&AeFiy(} yB s  7wY$3.TtZ9(HtX Q20 "  B X 5swFJ iX$iN^$:D6  R }Y8wY/5R+:l`.?B]J[GG? 2 f dVR'p nZ@x,2 ^$ F. ^ay{p0p-3=4D4! Ja#3 pcT l0,D)h f9EM?(m2m< R EF l %6`uG) 6CL>)8+D w|~g]R5'=HF3DL.wpZA2~UR  [$)__> [~3p [7K@C4a Akh h10jN, _;n} 4::*\(u `W ;Q0g )25>- s&]YK6q&HG[R X dV,9 D;2vc#S-RoJ3*D \gT&*38Jyn!=qCLc& C5t9@6T6O  ! Z ;i)8z#mI7wcCt7:^ w nfsZ,#2iWD#"|#LI[ D Wt{ %S sz {uR!0OoEVB 2Mi 3 "xzxCKmNYg {kQOGe_e}M:b  Q-]~YtuY7{L|W2&CW\O6 Ep <c||yoY2}vQ05Y " !)6DOY`ccb_YRMHILOPJ=+  #')*)"    "    ""!"!                           84xZhR|"G V?eO-rtUC,h5KX{`{1s#$$DUo`^B - ! A  *g6`' G|Sok2~Z 5  0:].Kw[[m_    E B 5*(2gwIHh3z&Lr D#N  > u o  bw_Y ; l N)DW,mmp c tE I = & R""  t=bzt}9  W ? ,  `KZs%a2l>%:'J9-9  1W   ZnF)Y)C d/Cqbm}T o g I- G } =q3@B] EADn_lk S  1 Z  I|$BCJ%HuDE3SuC's p ( ?f (r%w 6  Jn/V 3D d> ;Z0 1 dEq^7.G-*0s^u dv j;P D-?L"G}a 04se 1TPc0 B"g!%? q%Uc,|\ /ny]j 0$!' m9b-7GQ;]>m$&)&)rwJROI]H_X':!0%)!?{+Ycd%;L7(+:B mq$!n%&#O {[Eg`[o}BA^ %">+ 0fkD 2W-W h! )[8%G.]`oqRcX1  m c;QcRJt}BR$ } e5s>GI218yG# 2 :t82$ \)h>w? XHR |r 8c$i>JJCJDE S  h O%*ddV~:RK E   B  99{ I$'I101zoH e F7Vf3I/??m+ P qCEfl_jHV; K Z>8x:,U oz P [ kv@@z?,S9Q TX  T } S IV,C%F#9i fx !7ryDa @ o.&j x^>G P :Q Q :skzS))(K#Or+4yOWT>[c\}g< N(Zved%@}uT#!6BQ[a =|T?{>ma[Z &^$ Q\E3dK!$(!Y4 x"H<>a'#(l%'=*KA}T\A#T)'^~q^p7<a27?M%*(v"YM&e6>j~'f,(sD%g VWq{߯]HN$  T)|-j'b1Y3 r LqMH!B()R !*r-%<IRB>[n,Am$*d+!"qOpVBD]%*) _1 Fksfst5o^!S'E'?0 {w@V5yx!^$]&y ,g]Bd&y~(mF5>S -O # }  #SMH (Lq/AF O. nQQ4 P9yL3.(M{[vp)H_t:;]Q??/ #i;?0I'4dgbYqH4sI+ pN|lUr]k9tz$ `3KNKfo0)1,l}fltz=K70'L<) /xLJf74f[%cL#ZoiAW(R%  @l6jWf.@[AI}smK?{Ui c8:ql&Lmg=,X&nXAk+?[4=v Vv)[mL%I_9W`J|3 @c.mAdD7a vV=wX "-L$U [[/v0i? |K5(,)#V{  yc8|ܱe{ N%,N+@$* S V0|?d\ <*"P"%'6 hA4w*WX` J+K")QV&esq?1 X " #!.WU mO-45uPM}{ 6ZI >`#[\oߙm;# $ x+Y"l6xW ]X Zh!r!wXI0y2<~-5F#R 6FV5m6(LjW7{m )! .lSZ j8#S+BVYJU~ G]7a +/5o4O1%K2 ~!V] L \*7%- GJ U]C Kob]| g^sj RlA n z Gh3(u HV*f\3AqX?a  ,&phM ot|: iQbT23EbM[i nS2S~Am5Bm*\]GovBd='=#z,bQhw?&M_9JmFy]#f4tQ @00m7 bj;A:z@ UIFxYx'EQPfZk:%t~(RS :0]p6` DoL+1\bVW'\Ik1T'zwpZ4ao= T1,\tmWQ!zY3En?i?[sX %D>xB)8T';RE7&[S iI%bJ0p~K&\4=iDsUg6V8m a  L w    }D;j};<Jqp 3O,YsVAZ  E YS^ ML`t^z x!e"8oHvOm R,A bV=!%{$%r(2ޏy Y ;{ bI^W F%)(F(Y)Ufs s۟ߝl   n])K}X85%04u.*+vgH q> eKHSC+8J<3$*){'Q,<" 7&zm_уD",2p`Bh;+ A| Jxh!& @ C6ԑӾׯ-z m9 ?u?Ac:/a_=^ܘ4 iY=s 4נX<gF)CsA@?Z;#/+!kzr3 ) i`R_Y؅mn^.DB};4d)l٠v^^ C:B ۜIeD$:TA;4`$ qu݁^sz V^}#~j,KYY%p2-51(dJt~h`S%  B A SWR  pF3c ?tF$X';jpL)G <  !+_r@SdAi)a2|W \84B]u=JhJG 7&lp4c"Rcdo='yj}^27 c1! Ac4G{F  Tm]CWor9o D v (  RR2 C6$q}~Es  8 < ) t uNzHD}eX|n&i0/*p$U GYXQ3'B>s"ޚ *"021z)  I9RX(&|WEd*I  |/}4\4,):wEaFXjځFa~)H23/8" Ma-ob N_x1smDx5H^5(7/l12-6 Wn P~a@F}WN,G%(`$nxM!'*(Q6 kxy7<",SZQNbGuSP^ @Nng? 5Jr7Tf=;< lD * 7l- @ur({cZ 3Mn U qJ v|BjNDy [ agW-a~Z(N$xt2| /8@&`:V N lqRk3nz+u [yx# L/: *~M f5{a)efnl3oI%S< (;`s# -_B#7 <ywK pOH?|N/k^z1A7 dT 3 C5Wez[P:Scc; 6A /'-uP _eAHe PX0ufa.p SHk;E4^nT2rz}KyM2 y;+-E*u"X < =i &zRs)'8:AyS:F " |(IHhb9M;ZAX:UKbI M 6]91 o(fS]b&.Y "]:c]1VD7e][ ^ 26I@+1@!,"t%&^qcL zv=P ,y|/t:'<V)WEVDzYP> AmP gZ8Z;9LN!4}-p W4  ym4J.IC9}vUQ{+/9 1Rj0) # 0>/%0(=dZ7tEGP) Oc!"/ mckk=XHFtEb?z2 - %4#Q$WGVs)}rHKjT^i  }(#q <vUfw4sYy3zeCj+"*# %.3K)H2Q)p]pe<*%z XLaE<T,z +4r&i0#s$E%1OY6=]^? PumS#o#"!O6q@?Ttsb}h}&%*3#  !4cQrz. zHnH ,**!~G G7H2"f\EXP6[P0! 'B/+ r$ e@{#G}Rbxftrm%2 .+# Rt8 !oL2B%xd &/%A2 ~u 2 YJZf.C~ZmzY%3,, %2u:tEMxFLo\q,D*M< LoN%kIIuG3RoI'.([,i4s T CXkS. LqO`kR$B4{#e4 "v,# Dl uR*LP]&`Cu "{,$0 9 Mnmf%&<UR^W'L,R83 APvttw-l#C4H;&<xL! +#"n5HJ 0jWV.%$,f$BLDga[>{b~\1')n  H*=l fc.mQ`pUl]2!))G0l/[WJzQ9a QAURZ G +% pjb .e,N+j\/H?NC.'v`TYpX 9TN*/f e T m H5M;L  P X^I-/<{=(H.&! 5  4 `% hw0BUW+Z- ;|  (Q D7R)]CQ5)/, kp  O yCv9XX A J5~&KwzS:/ziN<3&  B:GHO( !>Fa^(2DZ~g{#Ye!A"*xLBv#{S~G?1 ; ;t6D Y2:[(M.ngghhr E=n7A=p\}90+$BohcF2ET xQ*LeeU?CL\mf7a8^U.@so5:ZEZjYgUuZ,} o|ENy}+`N",Y5\sX8u;,v 6LH%8&/ 7 ye%aL!N3S ?UolUv}`ff~7& ["Oz(UXX WjJyg 2~>X5)z]%JRA8-X$0-Wpy^K) [na "voG R&56W Gg/iHgAh)Wyw$;HLH:+(Z[?' |}/ 9 f\ k5Fb[F* Wc`!<DMc<Wy"et$9 A^r<6) 7T7/<@**Y!jbk70h X   B@itqE;.nu,x  x _ 3# ~IvjoiFv|!@w {4"`4uKK?5U'O j C~p!>@74=' !b~X U hO0wqo}6@h|1<76I1bzG$  "dl1z% ;BVCxPZ2p=47   ?+o}hx-K'}@]hG  -c?,g``U<  j-v)h %Z EX Wr"O 3~ a.L = U4, p+bCJD =  * ^{n >|z=z*N{N$-OAV 7Ef `I}5(i *O1gPm7/  &l G s -RU]lEZ#F2 ?oSqN .hyO#p<zf5X!T#.RRb Sv RUpQwT??P,K[ Fb:  I?glV[byw hpb BI + FMA88%M 8 ]LZ 2w@(T",g6v#  Pif- O mQ8 xGBzeSe #T #.Nh&_X ee_j # ] YETuj8MQ]H {a2Z A v # b N v/DV%`TE =+x{$o|K$=DKOc8*_n~WS4Q{;2 k>7?]BZcjO,&F?}46#t+PwL`?BCCTR#xX+/9ch)>UnTAH^qHn|^CwO $\Y ni IrA^!8=rQ{ _ZQ-[w>#glw,Z&Nh?<oQOdx|16m'G2^02 PDd?g8L5]vWIh='|$W\z12HPQ/):!ZvIh25lx9J P~( 9<WaAqr>90;}>^ouDik{~H'jn"J(qN.[l$gF8sEmKg"O5 iRM~E_{;_>**-DsOb@ 1 F)8Qbha:| XC<#M,vl H E  _i%+t[X\m0KZ. GF#HI 0}V=v'6[-R deT l"u8/^s%y5P>J*D  bwkf]IBM@hSB~w%?sB M\"=OQ"TVS$V5 9t rQ_ $ A& M *S)(!C]Y)& hRi< t;ooM\t Y o~6Sb' 96 d0o}a<' "d w loHC h!2dG% 9EL:U% ^ _I@ + !'E#v |F0L/h8r y Kw5 d!)!)4# vu 9u# &h3GG"I*($4"} H XyswV&|  /XeJ:{\2 {$1*r'`'$"9]=0z"0 C? 2|?[sBl )X()'$$ 1fSv< U7{AI\jLV >5"-)%+K#&A \ wFvs x(;s oޖ%`#"(R%(>": N'+w X[%| B;^S"ce 7 Z&%($q ; +7_G9- !A x<%kp.g v !'& # W8[q{Bw ! (tkm !E#o2 h:5l:O Z.JXO"(}~9= G( op J%IxY-#@Rf e h_ hdU?*9Zs3eY$w uSmH = ?0tT3^)|**Ui"`vY =S/, - evTHhHs+@~ n*MCU &VF[SSh0 & kuZd !#&&[/.Z JV,>ܧEܕ X8*3M646L/7 tkq}!i  #!,X<ߑt(Ԫ,&"+Q48^9R0* #Hv-H԰ ې21kQ> \ TPyyb#$ %f)gB.:6ph\rE ! I!CsJYKGp|EBNw_P) ;RDl/1g  6 * RW\1ZD&? 7*$n/Np#( < d31G k|J +!zY"O-W .% q\ # e%Xv Bsl/?\)YX]/ T  w<4#yw!$BPh  6i  .3dfyT1O..}}rb  .b NE >h v! O5F6(  nc7k~E!/s0_i3#6 ?Q\T .v&epI=1 `!*s^g ;x_+*Q  A'c]P #OqO`JD m u  n<W O`w-E:==2 X <O % Zgtt@%f 6`RHE 8 =qSWoB)a_Ff:? p ; $1=rFnF> "n^# cyaZ"j&r7l YGl- >$Zk9A2mLf^ X 9V ;2W %&#\40 NuE Lomt(HgPQU1:'n < P _ INrM ZR1uO h`!  W>#1SaK!:S5sj Q g4%wEH/Mf?y tD?+ ] 8 b y?P ; i#X?*sHAf.^H| { e = \#"=$aKAu J pdXf)$(4%QrZCgy O RECWvQDw2,2K/n,G$FAE.-F& FHdzfctG` fmjKGje ;q+ZU4S<%] _h*dcE|i ]0hhO_6[Bplk;;2j"NB HLfXzn- 0R rO2T4Mg~ 0  CA<K7.vh *[[b0{3jnVV7hgB U veUthwyXaH*3[Z+!|h]AP@IW;}G@W0GW07 Z|mt/W-#$HD#`CJ2{wDt.Q52i3O}=*?gG[9g:q|" \: }5 .? .x&7DW3A`Z_.6iSM,DQBs4(b.5,S)F RyOtB"xyZbD7=}m~R0|v_bA:J7xk/%d+2kTB@ . ie6z3>+!Z:,ac/gD!g0]*|  ^) 0 LK:`Ttwc. /9<:,3[QP@P+ _\&QBWK?Q =czJ >vM9QI,QC+K 5`m`:vW$)&-3I OKQ8@) 9X= #6AGK81YYt]mo1 +&}5c,* 9>'"o4E@wf' 673" qlF6m OcX @bePwO.D4w3(Ij;p.4]Bv$-iJz $-12,)yV&ecLye +=2A/ t8I!tzd EIBK}#d_S"|-a|p7&  a!k kHf"  @+`;SIg6@5]b_` )u j~4MQ2/1>=WKj?P 5C2 Y#uF(LN_7d@.SCw+ eE_GU6deypd E]r~ $iE|jb4u&Hk66IvL ,ZiFcI <9R'\%%*"u Yx>N`'Grc" L{2\[XW/A 1?v"P'g3=bbM`1qi~2e_Z$prP#n$UIf}!%U4&`|5"n,F)#4\/QD;9 %sl'rFO+s-Y1 Byc@p).n=1|b)-l5wgSFu%ghT:{kki>tX70>Q#^^p^QdqtV >Y#_G>bZ7hg\@U(rBjjTmSq9;ui^"Bك  R!$'!Z* E 9 ! X u.{jOL۩yVߥ~v!!;37]2(|'KH >.F<R߰/x',["->3,<  'oal7*,nZe/xV *0!#*{;Q.v$nx g#ol^A;&Y%Qm@,1?f=#@V!aiDH1m|a* \T K  d?1}0nz=8\muS}@z0 W& {(\nx* .c7t=>rE-,]u k~=GaHE`e_~I!"g Ik( . )0kC<|5% WS@k1X3JiUWD}YtU Z|=9Qz(R=^j _crXeHJY [p GcDIhC"LXidppSB9T I ,^mX0;BYWXK8,g@?+ >5w z pPvitjre P5Ohm?.> P\+  @U (q84R0cgn8EvelmVJ0U:O^X{ 1u2 # =#gk&w0]oI`l $$ - SxjT _8W{TVuiRG   RQ;$zMx\P+AJ?@ :ZD I: x$hb3c)0S&_|. )<3^ ]{j(V#:.R9'/,sBzn=09  $ Ej'B 66z#<3rb ?8@3*(xp+b iSMQ C /oiqP$jV^^ X*~/WyZW%/xMjzY# `_ a|e u .L-  Y Y{mI GY F:!7VZkw=5= {2ymv O^5:mtL%n6zu`gD.tvtv20J0J!{3f'sLt7{~6 { dSC@gwa`Pb(s \J>~e:AzV!|WybRt4q&4I*9 sv54~(R+4InU-bRLgSO>{+p9+ ~Of5 *<\Z7UH  PH3F .Kx/1?ZCPK+1R2t=pos) V%+k+KljVfq8c\b?qZ "ZE0!cuY Nv/*~zl!#aM92}fX\c_u>HOEZ;~|g`Gbl V*>IX[CAQBM%869dU_^AeX'lYY)[q`w+bntqd 43p()P>nrTq{&9F'kT2.9l04 'MW r)V^guW}JCVZ1b- @S^l~a-+[$dzdIQaU>LtWgZesf1`WfnO]3-9;2*6WO3b_jtGA=,G`GC!#hCIbf> ;WD\Ay.'# qC}~xhTGGVjxwk^\h 1@NXcoa>*$%  #" +;>2 -88,'+$                             )+4611'" )*'/8.  +1(63'3-8-  *'3<(.(*9U'd;:; <#yzC3k K"{t8\/Mknc;z)0V*t:Z&aV^KVvo2K0xd8CTYX!W k ^  v4/Q+Eh BD| _ (Z2N߻K$| _a#''H" ) laS-m&-,*){"$8a ` +),L-/,I#6}_#6y:YDI[" T$N)(D+-& SE!x $t%&*+#C >+  ? "#%`'(L!Nl k3k'k.GS _: #S"bT "Ot9Tuq*.JD_ ><-  d|C1BE ?dXq{q#X;e3[ tNtr6A 5t1}6Aatb8N[#qLY*RNf[Z(NvF8&|%! \o`@kJA=pCHLu,Ej|h~b^% }\/821KDh&2X[e{kT7.=6 (3nDyNjZ`RP;d{hq7,xNtYIx=)nYQZY$TPJ$#n>W f0Y:9)$]Y61uKc! )+;oL,F_pL_0k{o?EYj`1seFJi|zQGC0b7P] 8Nt?}k?v/t-$$pm'F? c%ET&4(kjMA@13ukd&18l@p @v :PL qGo =Bcvf"5!*gh - [1LW:8K0%EG '1A0Jy8:' pc2Fli/|5aomIWuK2.jHZX)8\P'W26f\|cOd'1|QV  e  yeF7aCt 3?Iev r&4,)*#@sK}EDkXj~>%u,j).T'!q l-^6ذgޢrLX K!I.0.,b' /J"s}٩ۑ1~H R"!+40,|( 1Cu|oۇA: y3#&/U23P-~%q L=r?/X'V"jC['3j6|3*Z}8vSܲFyXN'I075,#7(0fRXJ-*2g85 * j$XM_+6'3993' kV1t-ջ9/l)%5=d:3$ EAb{)-(;<:0)LV˹`6 IqpDٯeF%G/R":%PI :J )(~wL 1  ~߇g.k+ j  V^ v,)_4+$(} # 8 }*#V K% VN+ ]%TicfORw3 #k6iyX~"4(K~;F^r]pl 2R g`T&CoMO.'U l`=S ('D\P7Pv8>g ] [ J P4pELH,c,R-pY/e|iNh^F+DZxg/%>VeF%iGWq%40@{/acRp8TqLp 7Q{A h #~uk$Qv-;qh|GAphh1z;[aTVq6*`&"c E!)!1$ot.gTQMSn TtWB_vvBzr'E=f$Lo{2RJh 4wJtLj@! |F$&T+x:^Z@&e/}%:+oz7BN8 )ESB/QS<("<:#@LA.&') 4PRI;$ "=+ 2JE?3$!.&(MNS>'5D<'t0AEH5<GPQI5 fH]1\2fRA]}_$;\{2X`k9ZyuK>61-+?X'MQigiv^"p9i29=V&[.WLr30]Q0)DLRN9 iP$ t T7*J(QHQ[:8/xDJ bvM[GR^%EJK6&8f_AR;$ E L22BA 255(C(@ F C$2n^C-5gqR A.*D$6HAJ5||2 T_t?vHo_ 9Zq$>C>=+5 -4eF7i U CJ BS,&BI>4+ g8D -;Sݤj%&p!4MKB 2v.\'ov~J]4JK56O 0Us:#a.HL/;o#x^ D- ,\P^w/1HGs6Kk$$j3a92GH63{K VM;;ߕݷߤ$9sHlB.,G|7b=3j;S;`1ALDA6h%]  T rtB /=y>X2%/NVDp <5isߔ7/5(5e1+!e ]2 R[f`zL (3(9|4-* ]W'(& Z~ޚQ"/42U*NJBhfCU j Z*/.\'0q?u@jJtHL#*z*&h .'\<  )0(1" R1&>'F! r"aEf)H#r6 -~;#qJg$$ +a!r <NjG 8%f#~$*1 q =GY_Ups!]? !)$f H h:3e&|XVv- dgfn\p? biU Sd, L2]jTJ>B'!5):.Ep5aR~K)vO{.VD[' hy-lS=;.A2o;I=M;;9-@a:@Jx aVAbUNhf]Gjm x;k2P/7<|"H9iee{W#}I%! /sG$jS~L\8_c_8wXhZ\H8AmE O/ r \#l8f1(O]Wi HM;jp[= F5vW<<sv{JA>9D!VpSN^GI2(LIXX[FM9eXMrDpV2@<gEvD**v#qxaqT-@_FeZAEY pQEko*sN[) y6Kv&?LG<h<r"FMG$Pu#\JBa,ciK]J\a,%z`^ }  W< <k{}+E|"]n7cV [N3$q +.oqO*q!%y $ '8.j|MLs hN Ge]swtX*dO bKe < 5Wa 0hE=@ xp]P $'*N o WO6fz {" Q#O<  (& htJskBշQvҩ> 2,!"WIXH * 1)6z k߮rCAϾ^ %9)]%l*  ~ cTql}hg4Գߥ7V+:)'\+ FMI 4 -D1di&ذ.JB '==*#+h eR A sm96Emnߍ*#*:&"o'|D</S*ke^iobL5,"\ n5F I8Os !.2z=)YW{EgF0&V\=GJD?DW.m@DZgB.k #;q3WAr J"QN`kn8i19+ Q4g[&|?jc/R$qBN ; Bm+N  oc>,: P#`7#0;:%#l ? ]v 8(4oJ.$ k$l>@9[=i#c:' Vdwxfg`Q1!! sf s:{|7R30k^``j] iKs[ Q"J] Kc+l1 9YS*"M"x"/% ? _?"QkY[4Siw'i'%-N9QZ)[-NywMRK^%r" ?(%J62N ce1dRVbIbE^oXf>8Ls7 X' %Mdr  fR\i U  p R7\] {C {&#( w <4.EyKE_4 ~! 1xB%; b ,d=b_!Ljd #.^0x"|M @v" ?4eV?9(|Sy &W}VcWbV]s ^  TDqf4Orpg'0fyT?$Wn3r![!3 #n(xP; ;E| >c"$hsJ%_ZO"!g a1 r|>2.T_[Pi$jt=`?)DF41(q>%"4 Kgt|V8?8eHS<1 j."%t )QXJ}s,FUig=u`}`#  /r {pNi>66 m5d></# !/]b$K @ s 1mAojMTCB+5MG  Af W_g}Z}(k"p$P+5%_"T%" Zr_w!|%T|w21,-,#uGݮQ^[-s ;U.Erz2656<7)M6`96.?04 h{ \B[`37B:;>n<+A!A @ GS"@@ IQݺׇr9G*"56*:A=, eEՊE; P s"5 Wq!*"yOF*21`8+u 3ڲusuv E!8#O &#&jNP.-1<<0m$9 5#؜ԚCZޝ} { "$!p B#lA8's(&C0=;n3'KZEл'6wR O'Z)(7"A l"-۷K*݇\XJ#%'c5!>93]) c~ϥѨ׻ %*,=%x|Cs3\ӭ K%0]20K,$h<t/۬EWo ;A`1YP48`AZm 3 .,A&jR7}:|.Gf;1{ e 1 #?fuz\*lr|;k_>E I S T 5 >g-.=gl5+_y+U.Ge"%  {{IhqQ\C23*siNBI_1. X,Rn+\U9[n`+| &)gsHR|6lp=cFMc@*( YMEJ:M , -=_A]:}" I#hCk /PF|W$ d V Jfc]>u\ !&%J$R2nW"ݍߡB TQr% RXf^&l*a,|(6&#i Tu:-zKܻ )wU &Q+ !6$$$:2Y /Vck17  v[/`[.eK -p"~"+"@#L .G1j u 5;Tv, { ~"/#{#$ EI$T#k t  MFcS`p!"&6!QN[  ?+ ?47-#f!|$6D  o ^PX++6*n- ! ! Y83! (|@vv,q|T .d bo*s^p=EmKp[~d  +OUY " 7&S;LCdSGeLX ['vT/RL&6BT|zBDdp|s 'uw2ScO8Bk}J PC$o <dh~'-pgn j_a+#$| ys  }%xnl Gyko09h%  iNF 7 &e#%<5`.=GjC wy ha ~2h% }-*-^We-:(N:zZ! <  Z gR'=[8D]aZO^S\ i emrq"D%an6;V^{PNt@ e] y =2?YDsa: 8 `F} [\KU+ T 1yD7xANo  $ WF # C_%Qy_nZ]b FXB *%%nX t 1 d5M[*U6D"pQ*hKUNh: g@Y ! u  ti:gwN7zBD |z  e\):38; 1lE'`+eb16 # buFO CRxF]yI9v-E TAFh] a?ZRsGQ JBvF`Ea<!B up { -P= 7pYp " 5P .1 L/3-S naQP-S sGl1t(nWl~J ~>c IP oe| O\Q5ZFy m ,!BHT0u#X\PcWI K9`l#  Uf[&8uLp;Iof FhL }d} 2/!@Q:vP`:S$?# QJE yk caqmP: i/_ &"JxW=qK![kKBc'+ED #1* Mh&r^GIl YCq!.t d/S< 4"7(8g lW h^fL/!g(    $ BwBm=dP,] & ~ g PrG#B x $t4BNL [Pi E,u~ HvmQ~e toN )CVEG}S X'   +IO6~eTQt 0+@\9 x j{\,KkW# lB_Yz  86'$=h<l <;A+ 70pRUz+bu/CIaQc % Kk4N/!$Kt@  :t5F4Xh'M@ by KV 7?U]U_v  fT uG_j,F^K]g f - d iPfML2i k!Svz #[V R|pLYr*: wpRQYWS 't\T4oFD? !`v{ #^P_UmJt1-{@ 0 ~TPS~FI] 'q I%;~t\OBc > *  \ Ek0BD X*`' 1X  7 Vb]#jU~oed  N &O o- p 2 +} fp0Dc _1a}e|'U^ 8,MRwgCgZC i|] -m2x(tf *Sm5*u + ArAUdAV=;% ]PV  >GlpMjRMu = L/c4? # ls, < ! )t-ZO8zP"t?LI mF n#BRHb E  + o ,2 2 p tK:w)^9n+U,hM(s@  =\Y)< J7p UigHh` rti,9P?>,k+oZ iEo+ZD#I,[vw<: tl*Bt* ]U>+rS FCW-KYJom!P|^ {Sof@ >esm6CV*6xB[ [-oJ--NH+\[WgC#(8J  O`ywE{0E\#2xpUP [S,7\| 35g[)!j@:EVLXgkA\eS<0pqn)n<;0@B-USNjRze; qk+F&.aBD6<:*O{hde*^4F}QVvdbz1uZS*$W$U%c $7NQC-!uY e]\   wq7)M zsFxxE66j3 . a  xe@)JpdKmfh%$x`EO. $/)UlUCxKoJ j D Z;;r K4]|>fn!B {_@c lAk ZL=P c!8($!/]2 * :!gK ]Y**i"m5. ,YT) d<  $ $ZH|PT4K)-$A!!'yd  cg ) c 2 GYg_>.H )+{1&2 J l^W'F  / 2  $f.5M?g.'"$-|1& >hg-k_"N,4R "'$9+-$9D'4 $P.Bx("*%z  $ &f&z &g$4Su G%lw=0E cAVAV3x?D(vl"_$jmc`ln|vuq f%zN*I7N%<@.]/C-w)miURy] ~gkX* ^B:I DLe, IUTfR03 m N yR),[{L[ N?uk_]EI> chL-1}5iX/HtnY} (V M3@K[#{'_':wp)z:3Qe*N=9R?Fs 1sZ@M]8=\@#4$  G 79h5Pr|}LG 3;y~o ; n$NR^L^Q G6]pk|;p9lGXp+ }9 ) A sW `^U[@Rz@?uLIIZR4 >H$/.*i)8#M``z{mcT_b1 | / +$6>0>WQ9QjCO-v @70QULs i;!h5F/ ?-s# (\Z,!>>i.q~ X\:+ 6 a-Y[aWyqECs~A$a6U!?&0 V@Xkx=Nm|cZm;Ql]G\ ``Ri7?94U`w8[OxN@ D}#lV,H&}gi@g'm_/S//| mcIv=uofS/nGx\;s@yNjDUKp^ 0Y S{XL6WuI CS9 (Hbl(^xufTaaeTP4=`:s2?z<=Al%Dmm`+LvbS,dgO5#g9+\p? VJA>8,kV/kk|'0:r idTyQI,BQ` *{~h{XL#{ a0@>V3>^H2%9:\PmPeA#k1D4Ws ~fz&pHkHJ'c+R|"H$E/W+;\=m(vSI~i]'%;Hkd@#7C55Rz20U2?SL D?Q{'~fFoj"cLOLkVC$%8)jH9Y 13n9SU]3/hu|fONEryyq:&SokN#7Rh,INk|neO2'|wj__jkcn|t ,>HTcs{zy}qih_J=4# (125;<::862-*'"                (9IW^fhhb[SH<.# od^[_k| >\y!/42&r)wh`B1?bmT e^G@6>)J64w9cQ69xFreRd(wLu[Oc/cr  D $  VjgfrDs41bZ 8x&*6.] p 2 A dG:+,Y[mbJH+Hea+e2 z b   @Vx2Rs Y D a  }W>P~=p]Ol]1 R B [   h 'g!f%f6U#-Qxh$? W 1 _ @uVYt|wx1V6pvjs *OO_wI1VtD;JeFK~.fJ1<C*dz%jLpr?l ?bn)6V"<HL;5~nZ @3;5gO_Gl^inVX#q"nfP =9u*uOL@"E#+ 1/+_چ{Y1 dx"%;7E}|9HNyKiB;#cX>#%D yarJ /E1  1&T::;}JA#:-sޟeݱKc| KL$q`QloLRc dDTOA6#^ֵb1ܫ  m( H;;/Чwf - WUjBb8#_Q4 [ }+k8ۃSdc0.fQ8"DVgH E\NV,$"+%Pޟ~!cE$ &.jPU- \ NW wo D5r12ZJk\; ;&~w Zv~Eu^C}bFO1sT~  EF 8 ? { n4A5H* <8uC?$r T  (g;6]AJ)V  ,NkA{)  q iosM% X;#S< "+ 7[ LE& 1  `\VxQpKNE&')#p  # #: / 5|[#AT<&!DDSy2h $ 9  &^ k-`9$`ciMEP,Jm;= jJ=\5O. K=BL4+9[>k ?]t3ޮ^=dI? iK=bD\0a[EX.7Kj)iTOAGgfEv/Z`D]4G  5Yv? R# 1 uEJOq{d/]_C[Wj @%d@xa6 [q@_IG@E?)Y10x |$%&mu7m- ( 'Jk1[6V5_b !lQUI5vdJ#S ^Q7VON? L,'r"Xh N \|!pN9G+;Xy:c " swa" %CFE D Iz "Bbg >-F\1"S  x Y#,o  [)c J .'^O|u;O >:=)%#=ac^ # k 5\ ( o =8Y C4^'! y$) { * | 42FOsKvZ=:!iC3& ~ ]i ! T ) - 7SC*;U. L!+B/'i Px j0hM !t Q_fc|mQ4;@.o(*w J|7 x m8&'Hm h +'gg5|8UCfeF) 55h *a>|cx4;)cT>.>331/*I]fޝhxOXQf , 84)$"^3"79:61& 2ٻrz[ U& 8 f =+w  bef=Y7>#:;aCQ8& [Lޖ͔p V@2'<W4  Nf3[y>6P27E:I+?:^,ԬU~ vt HvPThw^H_"$(z=;J0,(./Ό"-4t x[ i=cy# $66H/(&_?%u6OZ0b'-m={ wHT^:b:>Ah L(4*'&TlV܍(q "1/P  ImQcB 8g&T%r o Fwu( Y)PLA b=Q1+]F  e  n4l  rKTexHKYQDQ<\XFp$qQs.+[{~r23Ec[On8!DB "BHvpv$(jNmNG2^exuYrvL[5G]t/_>\Gz$&| J;d=S`:-t{N trL}J#lpTe91{ @dT94]>1R,eaoe/Y{Za<_|6a>o4l4u "IK!5g{tFl/Au#\xn+v&Cz&Lt,SJ5Hlf|" ix( <qW0T(yz&"h]*Xr Bv]nY$DZxKR.m^MnFzh1 CFzzY`n8"5U,1{4YAE&)t9lM+qNhtHbgUR]mu-9=oPJBw'+ZFv)fScTDkOe 3E. AnXJDv2f\`c jOFJK: nUy >Xn(!U~]guy[Scs*{Q??$\~SRgVHaP\]GWCp"<1U ](Cl p7QDYJ-"RniykT,|s D>T1<WnX^\#{mVR6#C$ ,UH#&2#o+h<<bpa%/8@F hy"2#Z6d9I' Gt - +&%#%.%(! :61@+3%!          (6sWgd?-1P4[/ZHMTPS@H/>-#? @V2`W@3</<7D377$"2qwIcW/)zTly3NQCz/uFT&.ksHeM_'FK9}AVC_]k  ]_P!W$6[ ; CbL V55XL#, n -{w<(*b++)"X l^b6 ? %nڽ6 n 55.0e-f |3| \AZ%U3Y]@wz)BW5.0--S#}?d & /WJ\nu n#"A81p$o\~DE  )jwהA1j4{L43*yE vSHN_ m-ؗޛI\A@~%>E${[j, 6QG_NLn I * ׿5sl56Q!8m)G%k$ 7g#H L40?? 6'U1i vF F !K5k4bY 32'/ w 8 G'4OxLQ@ v7X .-j."LK~p1`2Ayt 6$C71((+M "D+  >:<]^<FH u'LYP2"x'G0 $6U @ v#/Up1 B1^rhLS),&) YIb8|_ %a f D:^>Ux%f+ +)O$ r\G7_jp]`^c ]d )6%#zD b f H`L%,i{A 3dT`:~@k#!k-%Gl. 9 ;@V}wDu".':8#l !"t1  n?  /f02W;FC`[5" ~*p)D~"#* r4sc],u) 6b c ^m W_++c$_.f C X vD19CDU7GL ka  3 ;:  2(v(6"LS!V 4  1Z?Q/6 V9d^ +"~ gW m3{ut!w SiQ c Y8] h i"J t'  I:* _  C|BCMCN>- )DL tRwpkcju(p #S#> z6D 6Vr z@#3 l  >\L$xTcDA{Z)( QQL,h F P$z\z&w Mdw T K1 G \|s)UiYw]uw5Av!uxPC(U " x  ^? pU%`OD FN3xm3[- A 9  f U CZP/<q# %{uXq#) fC4j<; $#E=|; +OYv}R=Du B/ge\P0H6hEZ+~+?KN{-R]$K,H"(X`=b?XSx"6*,GQj-ImpHv O 3?alb.,} ZZP{d*xZsxS|}XOZydR6#'K(lT_x XM r\h:{J Q e / B>1> 3UEz_%\s 8 mX2Tu&2TDd5:Y:*-5Fe>q*&'b { s_H _ AV~"NkFeKz T)bM?B?Y@\kG#)#,],\|5N@$0pji/u A* orz "^sFGsL$<9F}&6nkGn:/i*XY?OW<P m&`)um-,=A*k_=R#_*|1VfmAkewpGb  7 L RkJ `cY'= 3G  H8E!b&F ~' 'UN} +ro c7"N"!"  s\ nO1Dx A g2!+('%V \ PHs&~Ue (LDb%I;v| s!Y,)&%B =x"F 0{]\b,Fxfy /#*t'&$D4kIMuo/c!l~" d" '%"{oo=_y" q+\$6H V!&M%n"]b}]SUf 3U&j "n"o$J"oU 4L'1J>ctOc fn\Q3!iwV$9':BzO5CA8Vo/shR7 TA* 3lA<* 95<n/g#Dy]IEMb '   9 ^  (4hLH1B"Blob *r;  zil , mO$^& {V~k : 9 i q\^lFX)j',^JeVvW  _, / pY~P6os)N* G ? Xq; > t5%~51:ymCr,g mCr ^8 {>hF.>lu"Pgi > zN K3,1TIHOKV!` ' ~ w8S:_R-SZT"`bB$ -  I= ]h/83Q-+f,`K~n-L'j D <$z?(F. do1 L)H` *R5xVbX-bt! * A~? no|Dp -q 4%5t-[Q l&"R$\yV7  zW uM{8K+( >mjxT(M7 m't3~`+\)M[]y Dz)HdY kcwpz`;/~h$2{%1^ JR nPSl  > 2\q*Z#eNMa2++ w80 g[D. uh-nu'W1 HzOCTm]:v iDT  aX_;P:aJSfOf--=^tOPk})f5  n4oI]qJ=H%x})[$+@N&ZK[B{=ZZe3j6hy<f,+Dm6\mD$^TlwJ1!0K;UB9qz4hCJJ%.CQwpIC"&-*A9GBF*"%1PZ]^D0 1656-$!!               "! '&  !&)00,)  %'. %().*.*  !,:096-# "     =V]Em%)yd3}Q&yQZm  %[y,; s 5 S V3QuZp.(wh &N%E [  Pv&%[@B~[)T%&Mu8& @  t>:- '(Tp#$+jv@^G ; h6 DZ޾۷8H Rm(%*KD*7up  e\ߤ-3!W-[ L h WLJ4Ka$& u6FR, ){u[ @ )r("~ g-w 0@6h0 o!,5 bvAV]=b=~#JwZ]tE X)n  Y8yXAx`WQVr^"5 f 9k\k_})"00#=*In  t#$]M }%+j ]!">$j >4E vGS m#dx!B erp{l Bw~%!"9xK"q'&s2&"K"#Ge8gZ"o"j :G5%lE$Zk 7# H&h Yz=vN: {"'@   Jv=~[b;$z%*Y#T2 G|E"&: = E -Y$:4Z},vl?  ~# XQ[O_mr v.p ' #~gl)\oOScUjJ`sjJ#XD7&4@z&5 B#Ym1  ]]_?*Ui/B5KKxOMJhE9oD(35S\i vMaFH+ n0khI!U{'OW:X* ^G/Tf,!t/VsNM6)% thh/ yu/*- I2!*XsldBE%]@1NzCios~IIrtirAZ){*CMChS:?R4eQjMn e_p3;?#{O{xtI+AA5LV&zB:c: B@lq)D~3./1h0~ne|+NdS)]r>Nm@;aR6VYE3 Rj =P :@+]5.I5J H7Ps#6M5xX 8DQnSL 'd .;Ij>~K$_/M#^|VzS%z"Cxev \&=F/@w2 mD 'e>?KZL p+Dd&}b&]M>^[ }4ZD HJk3(#"TSZ,L&K#!:a P5/,B#U;?e$GjYIU~_M}q\NJ)mK3+h<0qMqy[NK9_&YE%+Gj +j+H0w:a'$)"HuQSu "g>RNE0bL% ~ Pڷ-`u%DnQQ?915z> qݞ HP=9VR33 vK U! ٥ye1 5Y 5/PPY;I2"8)d;J>wZ]N+#<6 ?L!J>VD!zF oaOB k0SJ [ %"(]D66SB"}NX?3#)Ht C"Kh GU0 /9$]L߲BW%e`Mf@ug0A]B עE Wjo8 3 WzM; e6VE1N@Hc' /4/b,e1PQHi΃Yh zl9m M~B(8L.1ڳfp r%cKF >  eh')"@CZZףO( EE] @| h^F-+wC39{ W I h) I~O}1 LOSd Ps5E-*=0$u {Xz vL JU@] P)u;x-p*NP < ` ')-RuMc('7[*> =  q& jtfiS\3.D E  s$@Ct*e2,0_ 6 GM NFdYlT.X2,C  DjO~w, xn )\5U"SB   Nr?w k.ze)%h 6$]1 E +5NjQ }s`96.u{ $$# C ] <r+ cv3 "#$ "b{  +)"'I- #"3 Z4 7 b {3|R6`Vy!%!um%u\ /6{[ <%#PE{\Kl @D)6&%$\ F v|kgED;?42%%/b Am^`dz!^bK\e|<" ,1$,$ j nW $$6 #1$b0Cb]A+Ma4F `$#Ru. ]N5<>v@v ?!" ojP(I:},TPu+zN1,)dMxM ^0#h]yx_|_cF 3j.QrhD~\rP. x2V lKQp.n ,c+qXg[41((&vV;Dmzs" WS2%g/?+os DA:7+.%Rmq ~)Lo pwaV:J1ZWD<,f0?@O:fBl2[,<&6^,vRZ zcXEXpN.5 bcr}Rso/Vl3$}L@9F @S*UB' #nJp9J L[d1$Zu.H5A< jO;R>Dq'Y?XD  v"IFrHXDf"{:PC)8X) " %>Ga@d ~Gd .h2z&) ,N4m"Y_;?]L&s}(Tp"H)OQo[/z#]SjT@'c8fBGvKD#y nP6n`w[[\ x:x1r]:-2#M ?pQlvc\957~=4!7H~r+*q Gx&oE05Flmril-khR?]8N@'T]7oNDl$q!K:{BSg^nB^vB .v`dl `UMn'=.lsWeD3&QDjCl|`y*[Sl.xMgF,B F)Ch7jB%n/WI)@B'7ZIn%!V:0R4KePnvE|h^;xX uMh@!`w. p_1np:g]$ b17!70OWPH] I?DRu0.8o ^T4Xhsa[!; 4:^596yTVXrKG5\`fm}CUf6m+(|X_-(h hU+\=y\ = 4&e>yH<(Dl*itmN3iO 75~R$YA*l/LbOixR# E+5< 4~T"WGCImQ Je|Z4XevmPDL"PFL &@) l?DHb.rj+ w\wyt&Xv15 c!>h}De Xsc5 >91U U cb}Qlr]9w r)A ZOXsjgH"k`] !q`= "B >b'!rtQp1u 3XL_S8/'Ktd>-X|kGA/Je |~<!G7[1C~+5EV#$A| /+ V"#3lop(XOq{Z "" !#B*TwONSegz!%!d!"*,9 53 z"]r(%'S" !\"&qjU WvPD>DND ('r#j#!' _ 7vPat0W Z;+3J)(~#"!c aaY2=p]1 +($4# wI Ik:/qz T8-*$&{"8:",wf*fR F",)@&!E _& D=fm2/hN9WQ'.o)P%3!7,s ;Q =z(A=S:' .*{& x:uxas3: m!MZB%\E F%,+'[ d%)+ ( 3!D!#=,_,' hcd/ Bhm:?#,<,&xS_@-Afte]7"s+#+q&*s R ,':F;f'l n+W 34*+X&xmnbR6>v,s) L3E V@ &*M((FM)nKV:3B rK#)I)-!> 9Mlg;ig5'($[2nL:.DXEDd'% "'& F Q[YgZx !Ex%.LF%'"b]X8| =8+"C "&G%n -kX('JUHROB?.a5q$&";b|QO:> 1KM 5!f%"$)!1;-+g[ fFsR;%$K%"!& l P]y9s{ NR]w;tH$1$#$#kG}B;/3 ! uM"#'&pHX,H M)O 4I\v"&1*_% tw@BXF  [K"ulzWI$**~  ,;{W9tt -]TJ(O+y&]kp~2+  o CgcS:j.Mz|= # Z  d% _ KF!^g;zj,L.ndsIo9qnS^O4WUnFC 5H0H`TY&&w8h3E{cfo|` e"eb e[";DB0>~tb$=m 9w?p(CggZNU}0;r7: p P  .eM{] ^kd:#pm$#w xsS3S7UpE;[J*o qXEsv0 3> FB6jhAV-H } n ;Q> E$|1{E XD#!.+ J'yEcXQa}wv H"O%# #j+*<L!-%(#taEgU6I8OZ("%+@"';%d} D2;[]5&g< ;t)%z:ka }44mK5&3#*C3~Tf~ zmB(3!$"(A *k[I(=1%G?}{+"d'!&$o %5rRoS|,E @)#""$Z U5 {O}aP_ ) -" pn~|2RYc^5 # (a#6l!KLJ;b\)w"{hRrcr_=9P!'%VI >xs _9`m%y#0^WZrow^gB) z%d T]6 E2BK#r#L  &'wg-*:2PZj `%) G\V\.R!!jC&c5V'5:$`b>"p2!$ "9|\O+0E ;#" 1"w Sqk|,cN;0v$k ZWr]Gaa_ 8)s"7 (R|PtQGe , !t#al58J]/BE 1# VC'<6H)~W9Tn"X Z>t5jnpikzB}"PI] _> #@ 0VAu#8WB4 W J`?!guY, K e,# jT`L: H7U\!#tCJTOp rd)$#"-s$3gqa*=8!~!!$<#5"1#b@UI ,l.6B 3#!#%*YZ3i%<z_2&<%@&!"!<6bH9??yd9w,_G)l(Q!!&! 3d ObWwklcXzm%Unh5C  *[@Bq7fq( ZtmU<7nX2 P T& >n d2O&[Yj0l;sk )m7 jAp7'&&$o)kqa8>l+{60g ??#:R^I K1Ec2]D@MR;L / @~Jy<^Xf-9)IbD8S!nI3@}  8 0 %Ep9eL<BbEL.*(=U^JL. q " grA_X%tSkNt(\8\C X` ) t + r 7 !4|oU=e9bNl,wjsf'6 F p  h~COc,(q_QSg~bE{ O   \rZm%KEQ1yMeJH A " #  "]6\Z | T4 dp|`RnV_yGT}qp<Jk( m.Y<^ v%`mG n? gD vS(`_O>06 Icv~cS6dF%~W#{RM#2sSE<a}|6GiZF(k\M6)9Z} 4[p_TD>99AEHKMEE?2( ul_SSIRVc{ 1=VYeidj\\JG?=:;>EKSY[\]QG7*~|~ %1<CHRYRPOPQQCGLF@BA;?60%+48DRRJTQWRSWMKF8A;-&!$ .76HIMPSMRUTSPI5)%( ->58EGJC>8;5'*+   '#)8?/I=2=,>+71 -G# ih#coTbaBrrRNL2Af ^X?t j2|  q>>q%6^ +Z29jX-2VH [D  )"e<RJ]]y='8Wi9 9,e_4R4p1=zBv[yB /(`~rgAM!# r Ao SGPcE.@K(@   = r__;,ve0r`'pM z )   hQz\Ka,%MC0a$v- c Z ) -/W,J:fl"dgWK z u J 5A tm(b7n'n(=+0EX\ ^G s]gfI;70iCN(<}{ NV h]Dj*ZM KY!)_tET-& W",IT  B[2E%8OH'sPQ fWH n Oo<!s.KkHM'[ ~YA4r 98F @ "c!EBw jbi p u4T6Ctn4%'#pY kd1A,R%P6 $<**$  9b\\  (%5Z" Z&n--'Q Xu ?ToJue +h/=C$I/.(y YBe /gnK K J  :++ H*%/7*#z-, J l'V\u\Ysf \",.0' o85| l KJZM~&h)+E)#%D  v].h[D(YZ),7*"g0sr3] XVFhjm WW #) +&y' tbZ G3wGD !4Rm"!.*#0gTm#@L L Krd-%1ZAVV0'.""q@ E@Ui# U+l-*%"I#t<XeQ?`I,F#bsZVNt ,l,""!7 +u1:3ojW1DT|}Zwr@< ~)-t!)# \{k"&qmL7o- &~n,)C$$@EIGH&v?EzRR/c'!,yF<7^5G|paR&$'1&n AVzY, UW ]:xb%&'eGX c 5g?1Y/VkS$k#'h bHd7E} jtDGag l O IhfPb m;w:b -   0 0 }G}!}kcC~1O(s ( < <  i r a!jYx>(\'p@I4nrGC8wQ8M3T!;V[n! $ x9W1=N=I \fXBdLpT?E4G_g=ip/ M^4z/6 % HMg ]6? W9& ?Unm]e eP6br& w FG 3 SY! :\6oz` #`s P3w k3>~OU&H5@Fzl_+ r | * i !oTH 2 X    M8+;_w*RdBQ#L%41h'`d \>ze.?'5gF#r ;p HTBdaHb.QV >2J!*)V_gu#9R*{z4"u~$D.1%R?) { Dqz{ !#"%-40"@ "1Q  u 1 BXo$ 0$&(w172t%h(` s+W-LK 50rޏF&{%;/564.! "Zm ݿj5 -xr/Qn I#-0q1+"B1gߐۑ wU6$x^ln;r f"?''o$c`a)$c) %Z~  {0k }i #iE{m g aG<,ZA+ uct4c\G@i\R@ Q 6bg= * Z:#Ef E!7`0Q8`]{. |  a +!& VcV)#&pA9;)ey  d)"t 4 >$^ $n~BPe2;o- SIxJ2$/<#KID-4\ >!S%Q,w.{IW7(lLOiJg%@`JZBeoH_:@FB73 4 G ,wQ2Zx";E  0\IyBQ I 2M6KJ[bQ '5H:ە݄,N>//h2970! bҦ#*&0#++#1 R]՗e6nt #V:.?j;:x7%/e )7hjg3ty!8%$./&*OޟE)`4:B. =`<]9 72+K%TRѡ $@%h, `K_2o  j-991+)&#CX#%#`۱n*&"+,/+&o#Y M =;ykmKbyLz  > _< d\/\ wo!&%:!oK2N~e> : 4~V Ucqr BE"R ^]?| havekH = Pp'C?h~XKU [ "@! faV% g>aWm[TbyciS@LU:] :n?K @NZ4M^Rwqd[b]3E = T  b i * ?w!t 6Cpqo!"nh}u~N.B'j 4L=0 e`G^?V]KXlyZCc "fxO{Kh/T-l!&yyLhol XFj;zou)KX.^G)bD'A0C9rX[S`Z VV"M!zMG"y#%@f8H+^+] uGCPXDS*:-*\q7#b9?c@EX>~:#$#h8L%?i%hb7 {&,s)\@j:kaT +Hz0x1w+/0VXO(@nT?{k)W?vDx :I7Ep\2L]a(-7Aorjl &  { 3orG!z^GnzG #&'L hR9 Ov ; v 4ONeqzs'P9;"9*\K~FTS(7D>/ߗ^$S_;!@Np#$u8Eq&kA$2AL>q0-dZ2g*/^N] ;L*dJC&n4=7(Z%dߟWM g u : -xRuQ*$E0&73%1kn w noz<3f`F00L M-4/$} ce'|X& w%w  aqN3U_3%u% .1,5#{ z , = F  %Gn>zK9 T#%%#;g(/ F= D 2/X&u K G(" $!0La9[)N & o 2l8)Wb<&$  q!"!RGHS s E1V !'&#@u Q)hMtz] k7(Y4+{','" hO-  djBRn=c<)+|-'&] LyMa"fg# `D#i /&0-}(*'  r o9=$ )S  ~$i&}m$(t'-)!142J w  w ZF#$/&.n)!*|*7S  J)xUV  &,%!?7gvDT{VhgX +003Tm\JYz$ + 0R U4" l5"stQZVGt7]&.(Zr:\;t1 Pw*Sy Am@DC+lj !=QG}j pY#E'MZ[n7wClVEKb^V>klQJ GB`'^enR_}vY#'BBLc!e 1KrQxD;](H  ]G Takye> E#eI?SS |y4?;(9KZ%UYJp:ld.BI& ;\3A3MxB.B^# Iv- (gB~0N&YlYrJ:1L: +ANh$] N?3rdWuGmB]g vwh*`C>nX^ F^vzo?'M&<r>RKuJR9aVhL (MX%^F(e)b`,EwHc \P~1z8Y M-,j+ Wh|zdS/9VWMVrkPW#p3|h%O%("x~lBuA1,XP4 | ;QH y/rXy<3O3+/3/,%OJJY_&\fEBai  |Tx &  ]PqlM^'pX 2p UXE|f+5 S Bv2(9 W_zVxu6j .IS@&%G 3k AN\$wTޅ)nY l k ] ~ l  3Ilxvw߁ ۓ8LX+_%r'( 1*#\GU  Iq'X8P?? .#C)4+r'\<Z~,O:J\6gB u i+T 10r*E ck[=2_rY ; K'./`^yG? h|P?RX^ ] %3l:"bi xhx9!,(G.]  bMAHEU @o71$6.z(r SIRO+-cL)o GH"P'n,  D#HxEuPt.lvp><#p$,t)  "  ( `,'3&bJ `i%SLWaOtj &(+>o ~TV9 qF6ZbDo7tTQ]t#l+ +>  ~ Qn{})#-#^ k b/r%g7,lXe <#/3&*  T v!MeHs^KJ J&,%N 8b=''gQJYzC6A+q' u 3HIXb\%`<oyU`ot S*)V#8 %l} [ #q:peAmqGru$ *(#x 06k7&FDV',: 6J*X)Q# ^ r[ JBxQ:6 /*)K$ bN{bial*:Hg `**% 0K(+?V2B/K *+ &,ga9PyT^[)v&H*+(-p c-^j.Y{(xt7"Jl$,)![ MK Z-(|r& m wN![ *B*%+9 hr>odsCsGYhL|\Cj`"R(& hR /PUso0'dKO?pnh #$$ p5d~,X1kCm&y!"k Ddn%A6ELRk> ]39 Uu $ai$[^uH #H f MI!H]3bL Pa|P`'[ 5/G  _ZB'`\!o"l.Qr{Y  ,  c;=<-s=AS"CN,8PQEa[qN}"kL&T$?Zdk)0Zh@\nv;7BLDJBVc?im@hY-_: ,w~W3 aWF]<8' |QGF)9(jr9Sty;&/2"7'xj/~eizw(]l}p HNx)H= ~"8ym@3c"z Mb@j11m*WKa "5>}Ii"Z#d?~-* !8A; {vGVoW~W8Ge?$4?x:IIU?!k}&.[\<_<R_vh5AYcI0K0K\kGzaX*C~9;F*p3hG5gECz45gYF'g/^u\E`gM:p!Z'L7~d8s}G;"|XF_V{UMJ1 u)`~x?.bF%pB9't^ O+8\8~H:{h k d^Nx,ZU@  yW = UTMU}N;J{j1 #'#9 kEu1S3vJ: D/Y'#\*,,b.%#.$CO  (bgr& ,/#1"0lIc -  ] b=GNc&-./"[JQGc'RU 8  BDd4 t =w$c++1"b&fQI1<,B  .7\b E. (;+N. pkiQ/ GFOORkcFPu'0') c3{Q@WlYd$k 6NAb";# (%b ]r8Bs -g  9B\dV1f"`8"$)' DU%m)5z3 k 5{,sG 2I/J#$)8-:d H v6 cL)z(5S "6%*% e M2Y   J;;8 a#P m#| 9'&r ^ bcBzqLZ u3I~# ^9 l#&AX k QG1p-%a $Y `~ ])aIM CA P:[j E@[F x+][ _ s. w(MXLe e!]aj<wa3~QC2J185@JNt-[MQ;Y+lQ*l${m~FVlK0aQE9wID"K68/Q0RB;J&s8(E$ L%~I +_|yhBM2  7FZ nwTD)jiW"|X}Ln}=Y=6*Cq}H35/! JC8v;kAl0RM5 3YI7h(l7IyYJEaaeoe-C>C)"eV j]+n 1.5k +g|=dzdV m"qcPV_%(( lv5Nc`dQuE"J oD-4]sY !V>0\Lx3B/:2('`kN+ML77WNS"Fag0A"] %U4X O$<0J- 3Az.{4 C3j]K6w#m P11zQ `I><3W+VYA$EfynT:weCH '5<h&0lClX$09PDg4#  p %-%73F9.#E5T  `8(q6=[b0-,4+';>"!+  $,&  & #                         ' 'F>D /DmBdd<$`JyZ@zf~DH5V^ }"ck_4;ai 8?s*>%Zh W#J7 `r*+0o z :lw Z:qgf-R  T JCp:M\c>uwc "[- gPr XpNKYcl&\s7 i LE9 TYI,P2NZ'J{ RFT J4 \jJ <`B{xfu#x C ) xG-'hRol_9+a2_O\^U'!Yc 2 "u 2YbI`P;1tFF|Nd~#`b2x8]VK ,}ETr (X5?w_@7& p-'R`= m?q:[',rhg"RgTPb j*X @:b6:fB1*MDDP59 ]p)O;)aYAH>Oe.T%y7  ;^dV;Bdwn,$(9;nLR[:f)oN {B@Vq'C6T68H24><QJe3IZ$d5~Mi$Z:cK"l8D]YE3uL@3:4C!U;q !bNh '9DFJVl$Z?y%J5P\gZP\|wv)?Wjmd_f|-[\)E*n'L _R>GBCD|{LBeDelq2Kd:G[#GaVD( %cO642('D/j&F[ewGy@&;]%"+~vI:40[ J "$%=b 3m.qn '&N':x `:0A_<{&`{x !k. 6dUd1l0@uY!d-2KJ9DwM/?Tv!8QjR37i{kG%9s sdj"Gaq2>S6mOm?J]Eg +5~63MT\p-Gs3Ox@bm}BwA_ v]:+ >a`JG^oaIKo.u"w0k+ESvakWA 7i,RK~"7JPRXom<4z4bTjunuenyn_j5.v0_C Ou2oUCFWfp)&#9IIHZ~C]p -@HIP\[I+uH$mW:vd\K."'6^0|OGmzR2zW2vV1 ").4;?<:>ELOWh";Ut "+00+(# ~aBo\H1=`Fk$-243, {^@%}ywrpppsx 8QijR9$$.8BJQX\`a`_ZUNF<3&                   *#63&4(;DP<15  0-51"hJ;9z$Gy"o2~H0DC7+_x!t8h ;vbRt~#"pS(-j P*^w 7}rW-bY("m' U * q\A&$$ >u $Iz~;> Q { nF3}f K|sJ61-$? \f ( "EPHro39 ! YIuCL 9d@\7NA=N-`j L@$e9[ 5 9&P&(h )1+2Z8x m  hc(9ig@WpU f/k]u  "(aq3!?  !XGOs)* K~T~&#"# Q;r:XFOL;b !-C)s$)#<Jh/1߅4eSK Q(!+/0&(%&QPuݨ۝  ]-$(5$%&V XܨV ܌.ݟfuB +c0!1r.z 5V v`G[N+ڀ1.! U&-!^,f)2  ޭL*)e,u y`%.ae**-|jN kbֱJuT} f "/ :' 'Q5I4eֽC(S]}8X@F #E* '! -YL ty, H#'#'`R @ٺY?8J{ J%#$(K)8}Csj|Yh+(8F > Rm##i+"h&jXEy2[(,k $/ #e<'(,b Fݩvb:V<2 B,#a!$,I4rT!Vjc! ~ My 5#=%BU> g 0 # '1Fp7)r   E   vZH:(rDd>s^vR\16[xR8WnVN3;k Y i/M/]jpn /joG`K2>}dGEdT[( wA0\  (%JjOq%M,/zgf|{ORslj0q\&AZAW;~qP@DgK3=Q:i "0H G-e30$ $:Mh 6,6GRgu}n]B5"{sxVKJBSROv.KkjjvZQp^]T& jSM%9XIAPt^=>+60104>,'/  .qqigZL*632) tXoj(T0MR<.= 69`XT0/C")7 kAa?K9k^9:#LlU(ct1ZEOOKq| Pl5YMZt4E(.!qA0N Ps9rP6W'N6Ta Z?IdHe k/q dbX.Dwx9EE;/ `Y:G G`mcRu}T|ym <OV`>=YpD  , Z# $% !yhc(E}+$T7lu:0ey S #E+(+'%0 P_xk?*m6.-&Y-*/( ']!"x<0E~+j$$2Y1~@z=7 M`(//-23+&#  +vj(>cؗ-! #12+232+#3"^ y# P I o(FT*tLmۄH "603'93N+#BS7/( s U|`Q^بX*ֆK]b1#035:2'(x> )  wz܇dw fd F(++-A&O xl55I p Yo'+Hݙ?Hu7S1 XS'@b(I>qZOg Hw.Gi V>rrvcCX{_(/7{ @i I^ 5c#/v fDn~}> 1RZy r} =f[[3Y>FI"R%Lb 6}R ' .`X[j'S9QU v/|UK y'KK%k\) Ip.X p4(A }%&[ 'D@OBrep 45wP9] 9;=D`x%gy6HIYQ  f6x Irqzz;JKtS"7K{y(n Y{vDya nWV!(&rj%k@j': 4 o# <W+hV2_h7U"j|g 9  2   [zh%/5A}_P[a-u-;p?C1e %xx`p+GrgY T6]A+cG- ygO# `=}x=hrh-|4mnZ>~[p@+UDq uv[P 14(3 g8BhFA^':5_g@gprO2)Q:#bvQZ1I G)Yy*9bC~j|H~]9k8P_d5 ?GB^ZaKC0VR\-n#$:DJc@^ai`{)Gf|Q|to^z4}MannZq34h']E#hKZ=hj`A{{<$Hh2bB)|gRxc^ 1"s^A rz<xAcofY_>V ^28Pl]/z'nbfexNP 5c.k0 ;&p(q0 h?nqX+r0C'POo 6:FBw{} +h>M'WBt_w?G@2jC I5 | HzKOBOOR|susr3 * [1  O/ggUuw-GcSf G " 4qv4 <[Yg(qbX *+Q}7TPf=buA\#_&q "&Xh+o_k'TRd.'a XL A0 yGo!{aw#;G1>;$m-]g 8StT+odl R:%&u+% nRv\*r32 J!`%'Y>T lW?FyU:? Fle19!a cxI'KC"}o2Tb  j# w jaHu$L PXhBEO:*l4 ;& OvkH"uD[*r@*{YA.1 M H(j=/p^.<iY'"J &#%2#'CypTrn8B~!,P:|$Zd %0?K{#kR; "(qbZP>#P4 ;\YQ_oWJ ;E>L 6zIZO~r#[a a}q3"Yb5N{1Zb&;kO=3gK[dlln71k QvVOmSXT]cz Ey K|Vw;gZnT XH3 L&,|%;}sg_ujZD_vK 9[j ZT. ow!;K**- 82I[#DL) L>G X=C=k.On<m C'qD2V(g,Nb%bg,_(rKQA:Rkck8NN+]40{;Ux/I {NJ18o yGQL?Tq6uAt;2%k%RPb.(e{01Rvffq"wUN4r#)k12J6( !x$LgQ0&ag,OdlVI(aq s O bkDNzv(E^ ) " XSW)3\T\!y"**0-F!?ib/l  DH.L !|/*6L6;.0jD K7-݊=(   tIކޒE))3146''߱ K W 0dX\/G4 1'QM;`[ ^plyMi e P G u S8vI47| fm[@CJVx %@rIz;+z*{,?*EXx/5MS0hE?< v!4Cm>pf)-IywJYTj$RhKbc  M8Ves0nRM6Wop"cI-3(_$:x/}K$ )Uc<es`Wf:md 4p8  )Ce[J SqrX (gVXqW2OXA ?WV*t V\VtOiI3X%m Ckp^9=Vkl o*g{VeYL{Qy',&mme;D[Db =|UR$>s2`^J/Fu;8 3eHkA  .c3Fe%j!u\'{6 ?E- MYm(3Ha}}"p>)Au@o`Z2DYM]% y3;"=) d  $= f {/Sk@zLY0~ ) Je Z mu z&+j?@ 2 R % 11h b |.7BO`7   x}P{%W#6^nqgp @  Pw4  z4fUP9 9 ' q4m0 v(5o(eZ+Q &@3nQo < @, * wjfe'k ~MI A K2  w\E($' G&  cL+ |DstZ QOn6 fby = \KTvSBuq ^y[ " Zp5zJh ;. n ?pj K AmIHj RUe=0 6~?X #8bM8G=b 5 jD6 8 Z-mFq.S }I{ '  GJ] YQ ^  AZPf" gfP{F N H D~ y-%   9%   0ix/s992  E+ os c9#hN ' d0i 9 gZ-V:l* <x]x@h_ 4UUF-mN,O2K +L<*c %<:z Z`rr]$ l}Z j}n bI5 &'~p s"_HyL8+9[~b; }hw)U * ?0# !BQt'DV ?w yPo}S  j!@`7m;V ';zEK[[F3gh6PS1OVNn<&|t&wiA"uE*VP1kTh=3u7Lw)|) Mlt.?Uf1 x g .lGecur 6 $=$z*z#K " yNPbV:5[?B \ ) .cx ^u1 n Ye3<;n_i   %1=~{ra` @ a PHZG  q } nP&271 h >  9 /1+Z3 c : olqJf@z R  -,b:{CT5 Np ?67fA P OL%  ;z&mm p jjC'& w kAh  L-d   ei#Mmd}K Qm<\ 0 }2KG$7@ )(e x U6l$}  Z ' &"<{)E2 U  l73Rya}9T XB #&/~8`a LW0 7 =r<(XoZ  7 ?FV[E$ s  b GuHZ(}"sn l H E N}\FnxU , D 6 `  1I-%rkG )L 1 6 1p8l)~  SwJ#K}{~ G[3TF)dl0g\l)5Kb.g {I.+.1<EF`%;18$0ar/X5 6yYVzC5q<|T)/KI$d)_U:2HsD%1^#:- #4R St GMwk%o{ZDd&+] _,:GZ|(me6 B6#'.HJiIA<rl]~t%w<wT^tDxYG %Avadgq6mX/N! _\=S_]ybBNY\ gMEQL[\rB0A/0 }8260aeEzXn:$) 8-}|'+9x{ #w#?q @fP3-)oxbd:*DKd$lxE M'.NnDakk7-AsTvx|,=B~Z>o{* CT*k*v8qT-*{5kpH>6b)n9)d>=6$h5qGg+C&  ts/n5ksl0Jw2MJiN)Yk"9U")qr@>Y<v<rhTQ"9@vm> <  s n&lA*C44q]r; H / <: ?XhJt@CH1   : Q%[7 x/}ZA|vD` x  S}% 6^w%x=FO m(> <4>K+\1v#W  >S{ =(co+_ O 3~ V -'{ C;  6!Ig 495GRBqP{w7$  bqRr,lV \V"4r!>! S3, Etm? - T s "[N P@! FoK( HEL{0 l {-'N sL ";JGl~k $  ,R\p OK(OnGMb S ; ) ZBc ! w}C q-<|wqv8Hf -W)<%((5n)O6q##"mw) YWRhskel@ M$57{ [ b!-(y !xDnrdyI q X- RV\b  !G$$2$ z7TW,5bc8/ _|K|H(?zP,O #$I kThAAH} H%@ \r,/p, ' \$x) Rla -4}% I 4=}A8 U%  5$ x[QH|U] ?A z{ P cCUFS, , !#;+, \9o VSt  9e~$H+MB  3f-y n9 rbfl* ` v 6U ~k'm 6 ?3 *|6` Z9,1Q.CR <  Sl)a; bt ; hi[J9Ax>(3  J ^ : yN>IaG@ N$HP ,Xqkv8tl E"B(Yn  Mq; y I6?BeZfZy J Y  V.uADy S v ]|b"moahUwre !2][WqtT#pfQp&I#wt7?b,6x396 E h}49/,q'q -N[7{8{z'C&DLWPOsPN'7s'Vc{lwu~caDBuX'p?X\,cK( 9qK!`#]&{J clHh^R(I`fRUriu BM<TN?!}>  !XMx^UTcw+Wv)h>V\edCB(xXA/ jFqs3a6SovWJ4 }h]VW[_\aYQdn'>Brl}~~JZT-fjdjVq:Et` 1J$MV\a42$uYKf>(N >z3Ff1 tmf4Dvm$rwNX^ xK!A-'|gt$Cd<(EO~^}zb* p ,8HFU,u kN "i< < M :yF\ms*@=QO@>Yk P @*Cq ^u\F.vIi]4= 9 !$ *-~GAWLH+e { 9  k wdD05w: {!"!\ N~v,,dZQ\j wMtl v&*;%T{L)rvSHm'>wk _ jG f$V0#.'$ D"_  P hII]cR\;%3 6/+%P RCi۞+7 c ,Ak0i%4hz7)78,5 2( {5ڮT2&'=Q F "a݄ֈ݈c ~"/;K:,60)(c $V)ڌQ35~y$'"! u>+oV.Zo(C._-+'!/< >5 ސ8]F F~n pr@WT ,OmT P /6G|skD>]J;dOt < % }qr^GHF>1/-N2A8 W] & D R SnECj+`&{NACURB:c%S/(4 +5UeBBKFF5.D'!\>{te"wqZ$+7BTp1^{Br"Z8f`Xf! *{M1V"#;|n_hgJ?zuA 9fz@?o/N.EfR-m |20$x%pW~{[7w:;M$Khu8LWJMJgY|{eC(hA bVEJ<8C$F{7nOp2z*QP+/&W e}zH(&6Oboz, S 76~YPV-A.sE-2RC  "U%:O|DA~*Bu,fn<>rDv'Gpg `;uvJf&f&s5\*~z~TW&n$^2^v6OJ[c3A~ ^aP{bC%b<qQ- Bt3ScwbJ5#8L^_kv|{r{{wnjjeZXKG>/  !2BF^]\ZiyolK`[KLH=O462"$'kRF*#3J^vA K !`F*J8+b_7 h; B&` <PMyt#sP Q|4.$ NH86IuWwu |7  ,ih (.OvF+0;s 9 X(hYHB'<$kM5W.3!"gDv=uB] V<r$'b$ $7ߦ!,OWxG^E(~w5(@.T߯' ^b;L 9ah!(" y7r}]O *Lb%? u |z'"q xNSR5>0Gx8rw "&Y&1!.n1ߒ2/y;iT $$")'(fH 9'D++)"J% T YCt5 &a W"m,.|&#+"I,) >!HW#v)-&K!"B540x;EJ-^ _}!+.#Qy9ygiN`YO9'%.+!j {[/ F%p+.^%6"xg#`+q/yx)+ $?( .'!3#.XMN x#["^(#-':"I ^p9{M|^k[!%})]'!m %R,1GBT=#~(# Y ;' SfT1z #"r t2/$6 >[:  8R3 umpy y:+e i;# # y w2`uMJ '2/@s1{Mxwk|dY&Z~yl2_<~AF6N6eZ p07^syFZ4^>0:mM $XG`1khNE8W\aSx!c[Ap.ol CR  Lz[\/Dw|A-N}H=:(<MLS_Z?_C{m8 -vrC;}x\OEYR^yi_H4JC#z'/S7b OfeW^ wVD,nB C%nBpOe%m,X o^ \5k(Qz_R,)g 7d&bk4$U%pa'g5tY3A5z]8lQN>e,P**K/8}# e6!Y;#)p!@$) "(         "!"!!OC?3I)!  !'&0)  ,  ! $9'YP@{V igj..fpt1 !+xF6(3 }wY$f)  *{o K XMA~O:^(@ij,Y-n^U7+4<2o\--pAM_*[2&/mz*b45 ,.7v|c:LATBhepH5WHvu  (> <g*!K_ d\+];#\bpV D`" $!`mk @* |i!Q!P ^  2n7yx^Gb 9 aa R ={*/:e = pv D T NX ["D'kODz`jD W#g k 5 vz h`q5c+"6,Fd %(k <=1V^ T m? 9  *$OZ," ; M o d  $I ] P "5%2!D-xm0 5n D/f* q I@2mg@d?, 0FM t~K[  UxorJD!4 p \ 3  "i%B( POE UC"  ] g&d= W 9X6Z * j=_J6?WUOD?MOe8#| T b m  A. x hik! &RV+M)  1:# I ^),;IkbWd 9)  ! jbG.VH,R\\@p  '7 A FI?3T]zF M ; G Sx!<75PV@' [ Y9+Jb;D Y# % q"?~ e  xIRbR5/ yh<  I s} 5 IQF02 )3F -   ic5cC#6{AJ 0 = r Va  ks W= /JHRWqI%Y7 K7 Um t^?,IE5u&|3Q- J >}5jpALoMa '&"iiU7.h,j|${,3%t SQ _8u%~%b@I -eT0:t2`3fkYA!"foY?a|xvj8>gx)^Q /eO & ?zvuBG!_z+ lh NgW#Ub?a^6g%u R*iq!e$ s N (` ;oQ9WEgn$OQ  oo^FCJt#F5 k qk 4MAW6h@!d  < D KRwVri[ xum|&1| Myb{zEDM6{o z=L^ \ 6moJ7w g7pLHg+?0<mEkd mX.>okj%"/zWY1kTzD3y P!5HfnAg|; []&N+i$nQnJ+"A@^l{>OB2yo .6q]A@hZh#?LQ"1U8wT.&b08ElxO|^[IX?Y%{j.a]z{ln h=RG@xP Sn@t&Js6L?Tp4 O5<kDpPe Bvu}[\ y v1#2?2L;"':<}7"T$O.'I8}S!hw*qVF).GdE:- Em`2{n*G62nBb ,ML4UKglR?BoIzh=2kKb_RC]m> ^ ai9L!dRcb=?/ ')x3tC i-CN0 T+lf$+3S G@$Lb%s - | k&(2C65.V |g*< Noc& 2@CN:NvWP2-1LQNq~ \ rl F E/hg'O4-NhFUo.HW_- j JkZ/h n t F8DXr1. &k* 2 6o/- M0)F= O,{,yFnXeh!i.n-c20#Qk%C1 9 c5&xqeJCyof<!*k-.M d2a # ~d {@f w 5JE#yV"3*+j+"x iGo!j =" X _`=  +gL-LPi}^i$+,)O!}:k3Db q 8i~g  ,H#!YP$Y$,*)&' Oro c;q :p"e`(46)sr /6 ;>NF/J&gWBm\"mo jGz{jG*)T39HZ,_/nqt/5 F0Uaf;ncBB}0<8g!.Qif{vc aF - i[i>G\hq.l[WKsRCNEYr!Df4a &l+z?]ka1vhivsu|sht}P 6!Hn&Wsvwujhkq{eI(lbLKXT\cNMUUp3)"""1BNaa8pPX\n !-0,!G]v]<gA99?a *;\ZWE3/%!#(7:IZakedxl`V=;  *=2DJ0( 5K=6bap +=:?{1]u(hd5zr(GmB~ap v"r}[>2>p.#~D?7ACAno4_io[>M4i;>ZM> Xgj|][[M1I"bj< 9CX ~w/Z(,zn&3K >~m{YH&$:i0x r|w]L; In_`Q7f-8_ZEE"C;+ot-Z1B.)vX39 *-j| zY]yTl{hr8iB {I]R<]MItIp@NU'{MzP%VsIu4"\!*G0^rdFt  +~8623eHc$]\c0 @ q = EcI?*0&;JNKG>vC<|m r eV EO%c"S8%IFq L ] hf w?X}q{.3* vDb& --5?X`) h`4 $1/ . OPhO  *1bk /($Xat b Y[ W/ N4F/t~|'}e z j4X9OAr;  _|Vc1HqW!0'.E Tf8Fk0NIKSEuC"!E  ~  WSj?i-U_.u eE|K#=U 3nK" WfSE]7k]zr + p{4 B{e(2vIJb f%5  w -DSS8. l /pT(~ {!_5K]X PUJFL X/tjf}+Ee  CW g7 f36$KoI=- :YXu =Ch;&jl/Qb0SAQ I _ P=)[IQ2;   VnQc? ! 'bD-x+FcGp4Zhz*7 P = r[,c4@V 3Bn 7l m56HjWV2Z7x] ?` %VDacp) OG/ HJ>U _>Ei?8K_^+gl9 + 1y >,m >1 "R"msuC} |(i%G]b~e "l`) Y iQ/V  UmLV^5qe { mdC 8R<@n hX79u?4Fj  am5F u UOV#-mh l<!i'3 wM Ekt', " z q9'#J I"anwd: -88+2 *<X) is"K&5R+2 d 73J _L ]ll3@dh+6h^ !-3{\(# p(=`:_ 2_%"#_"@  4#Sex b kuv:o<@ *+6,&FmO&Kw R_I+Iԕth|+2k9-96,0ϻ :r!E@k܅֯qE('/=KEF?$1w!q ''2J͋6֊7a#(&O=5nWصիΆlf_?ADI;-~ G>e wlj:pܷ߯oV,j#@m52<~4+fUI]?H,66 46 tEC_;({&_s.6[>a56+pJ.i\L: n  ]Ws0/y?6`6`'u RGzTfWO j    i\?#EDwq&z8!1_>1, %n"^I sO.g.'kL!% Z1$! 82()FK  #SpJT>n~J'3)"L 6@wA +*?w\#rLhk ~,-$ "DAG.z0x r eTeKvGKoX XD ) 'HXTB@PgHxW5 ? pVS-\/AA \"(B \2Ybmb$E"NC T AFTW;(//AG#GT #+WZ(0W. -}8@A )@{zTi#gcZ6Cc`0l+A=8Ef2yCnQCu78 -% ^c qf];V{ JdBIGaA*ff3{~ D/4AD_gBEUm->@l1+ZU3w X`E3!$JU 74%j`Xp 87$|M\.:) Z?zlx<W9imW>`'vx ?9 Y CuvAa\5k2Uf9 >ymAG0PcN $[{evv oh^Al!t/yyI,1S !SCTG8 KOi@Bu  ;+x]Ko.]Z#I  n{?xIx+RG@.y~]*h<tbR-  Ynt*x3N`/1G49$Q{l.!mKZ48}HHR>pc%2rp-D1E`nK"zuK`%spk]\jR|OO +1o- L @0Af!t ,xI :XtM @?@F\ ZX&W2JSooD]` qWmL czJ;h}|gZd~hO8|^h 7O&oUDo0E*m[ `k11/lsaeu ~8[ I{f78X!`S_XfkB6/W$ 57* &zusN\r6OY6=7}cA.[mpyc|5&&2Z M:-g Q%=1\+EElrbQc y-k'y*6$5qFHIWO_8ff`C:oSSI wrX})(};TiuqLsXG6#[c, $a30R4b2$yt rd[-m-.&[.J/6DlR`SM=dxQ.=H 5*Jj6)[xviLUuez'] Xm #OmL2okd&U$]h <AQ(lsf:jVY~C^ucN rL=z{ft?j|| wYD9 0)+Ja)U }&/?_=-#{ L<8-l)VG j#S&fH5+8++:E;)#8<;)), /2 3 ! %     aiortc-1.3.0/examples/server/index.html000066400000000000000000000060531417604566400201360ustar00rootroot00000000000000 WebRTC demo

Options

State

ICE gathering state:

ICE connection state:

Signaling state:

Data channel



SDP

Offer



Answer






aiortc-1.3.0/examples/server/server.py000066400000000000000000000151551417604566400200240ustar00rootroot00000000000000import argparse
import asyncio
import json
import logging
import os
import ssl
import uuid

import cv2
from aiohttp import web
from av import VideoFrame

from aiortc import MediaStreamTrack, RTCPeerConnection, RTCSessionDescription
from aiortc.contrib.media import MediaBlackhole, MediaPlayer, MediaRecorder, MediaRelay

ROOT = os.path.dirname(__file__)

logger = logging.getLogger("pc")
pcs = set()
relay = MediaRelay()


class VideoTransformTrack(MediaStreamTrack):
    """
    A video stream track that transforms frames from an another track.
    """

    kind = "video"

    def __init__(self, track, transform):
        super().__init__()  # don't forget this!
        self.track = track
        self.transform = transform

    async def recv(self):
        frame = await self.track.recv()

        if self.transform == "cartoon":
            img = frame.to_ndarray(format="bgr24")

            # prepare color
            img_color = cv2.pyrDown(cv2.pyrDown(img))
            for _ in range(6):
                img_color = cv2.bilateralFilter(img_color, 9, 9, 7)
            img_color = cv2.pyrUp(cv2.pyrUp(img_color))

            # prepare edges
            img_edges = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
            img_edges = cv2.adaptiveThreshold(
                cv2.medianBlur(img_edges, 7),
                255,
                cv2.ADAPTIVE_THRESH_MEAN_C,
                cv2.THRESH_BINARY,
                9,
                2,
            )
            img_edges = cv2.cvtColor(img_edges, cv2.COLOR_GRAY2RGB)

            # combine color and edges
            img = cv2.bitwise_and(img_color, img_edges)

            # rebuild a VideoFrame, preserving timing information
            new_frame = VideoFrame.from_ndarray(img, format="bgr24")
            new_frame.pts = frame.pts
            new_frame.time_base = frame.time_base
            return new_frame
        elif self.transform == "edges":
            # perform edge detection
            img = frame.to_ndarray(format="bgr24")
            img = cv2.cvtColor(cv2.Canny(img, 100, 200), cv2.COLOR_GRAY2BGR)

            # rebuild a VideoFrame, preserving timing information
            new_frame = VideoFrame.from_ndarray(img, format="bgr24")
            new_frame.pts = frame.pts
            new_frame.time_base = frame.time_base
            return new_frame
        elif self.transform == "rotate":
            # rotate image
            img = frame.to_ndarray(format="bgr24")
            rows, cols, _ = img.shape
            M = cv2.getRotationMatrix2D((cols / 2, rows / 2), frame.time * 45, 1)
            img = cv2.warpAffine(img, M, (cols, rows))

            # rebuild a VideoFrame, preserving timing information
            new_frame = VideoFrame.from_ndarray(img, format="bgr24")
            new_frame.pts = frame.pts
            new_frame.time_base = frame.time_base
            return new_frame
        else:
            return frame


async def index(request):
    content = open(os.path.join(ROOT, "index.html"), "r").read()
    return web.Response(content_type="text/html", text=content)


async def javascript(request):
    content = open(os.path.join(ROOT, "client.js"), "r").read()
    return web.Response(content_type="application/javascript", text=content)


async def offer(request):
    params = await request.json()
    offer = RTCSessionDescription(sdp=params["sdp"], type=params["type"])

    pc = RTCPeerConnection()
    pc_id = "PeerConnection(%s)" % uuid.uuid4()
    pcs.add(pc)

    def log_info(msg, *args):
        logger.info(pc_id + " " + msg, *args)

    log_info("Created for %s", request.remote)

    # prepare local media
    player = MediaPlayer(os.path.join(ROOT, "demo-instruct.wav"))
    if args.record_to:
        recorder = MediaRecorder(args.record_to)
    else:
        recorder = MediaBlackhole()

    @pc.on("datachannel")
    def on_datachannel(channel):
        @channel.on("message")
        def on_message(message):
            if isinstance(message, str) and message.startswith("ping"):
                channel.send("pong" + message[4:])

    @pc.on("connectionstatechange")
    async def on_connectionstatechange():
        log_info("Connection state is %s", pc.connectionState)
        if pc.connectionState == "failed":
            await pc.close()
            pcs.discard(pc)

    @pc.on("track")
    def on_track(track):
        log_info("Track %s received", track.kind)

        if track.kind == "audio":
            pc.addTrack(player.audio)
            recorder.addTrack(track)
        elif track.kind == "video":
            pc.addTrack(
                VideoTransformTrack(
                    relay.subscribe(track), transform=params["video_transform"]
                )
            )
            if args.record_to:
                recorder.addTrack(relay.subscribe(track))

        @track.on("ended")
        async def on_ended():
            log_info("Track %s ended", track.kind)
            await recorder.stop()

    # handle offer
    await pc.setRemoteDescription(offer)
    await recorder.start()

    # send answer
    answer = await pc.createAnswer()
    await pc.setLocalDescription(answer)

    return web.Response(
        content_type="application/json",
        text=json.dumps(
            {"sdp": pc.localDescription.sdp, "type": pc.localDescription.type}
        ),
    )


async def on_shutdown(app):
    # close peer connections
    coros = [pc.close() for pc in pcs]
    await asyncio.gather(*coros)
    pcs.clear()


if __name__ == "__main__":
    parser = argparse.ArgumentParser(
        description="WebRTC audio / video / data-channels demo"
    )
    parser.add_argument("--cert-file", help="SSL certificate file (for HTTPS)")
    parser.add_argument("--key-file", help="SSL key file (for HTTPS)")
    parser.add_argument(
        "--host", default="0.0.0.0", help="Host for HTTP server (default: 0.0.0.0)"
    )
    parser.add_argument(
        "--port", type=int, default=8080, help="Port for HTTP server (default: 8080)"
    )
    parser.add_argument("--record-to", help="Write received media to a file."),
    parser.add_argument("--verbose", "-v", action="count")
    args = parser.parse_args()

    if args.verbose:
        logging.basicConfig(level=logging.DEBUG)
    else:
        logging.basicConfig(level=logging.INFO)

    if args.cert_file:
        ssl_context = ssl.SSLContext()
        ssl_context.load_cert_chain(args.cert_file, args.key_file)
    else:
        ssl_context = None

    app = web.Application()
    app.on_shutdown.append(on_shutdown)
    app.router.add_get("/", index)
    app.router.add_get("/client.js", javascript)
    app.router.add_post("/offer", offer)
    web.run_app(
        app, access_log=None, host=args.host, port=args.port, ssl_context=ssl_context
    )
aiortc-1.3.0/examples/videostream-cli/000077500000000000000000000000001417604566400177165ustar00rootroot00000000000000aiortc-1.3.0/examples/videostream-cli/README.rst000066400000000000000000000026141417604566400214100ustar00rootroot00000000000000Video channel CLI
=================

This example illustrates the establishment of a video stream using an
RTCPeerConnection.

By default the signaling channel used is "copy and paste", but a number of
other signaling mecanisms are available.

By default the sent video is an animated French flag, but it is also possible
to use a MediaPlayer to read media from a file.

This example also illustrates how to use a MediaRecorder to capture media to a
file.

First install the required packages:

.. code-block:: console

   $ pip install aiortc opencv-python

Running the example
-------------------

To run the example, you will need instances of the `cli` example:

- The first takes on the role of the offerer. It generates an offer which you
  must copy-and-paste to the answerer.

.. code-block:: console

   $ python cli.py offer

- The second takes on the role of the answerer. When given an offer, it will
  generate an answer which you must copy-and-paste to the offerer.

.. code-block:: console

   $ python cli.py answer

Additional options
------------------

If you want to play a media file instead of sending the example image, run:

.. code-block:: console

   $ python cli.py --play-from video.mp4

If you want to recording the received video you can run one of the following:

.. code-block:: console

   $ python cli.py answer --record-to video.mp4
   $ python cli.py answer --record-to video-%3d.png
aiortc-1.3.0/examples/videostream-cli/cli.py000066400000000000000000000116451417604566400210460ustar00rootroot00000000000000import argparse
import asyncio
import logging
import math

import cv2
import numpy
from av import VideoFrame

from aiortc import (
    RTCIceCandidate,
    RTCPeerConnection,
    RTCSessionDescription,
    VideoStreamTrack,
)
from aiortc.contrib.media import MediaBlackhole, MediaPlayer, MediaRecorder
from aiortc.contrib.signaling import BYE, add_signaling_arguments, create_signaling


class FlagVideoStreamTrack(VideoStreamTrack):
    """
    A video track that returns an animated flag.
    """

    def __init__(self):
        super().__init__()  # don't forget this!
        self.counter = 0
        height, width = 480, 640

        # generate flag
        data_bgr = numpy.hstack(
            [
                self._create_rectangle(
                    width=213, height=480, color=(255, 0, 0)
                ),  # blue
                self._create_rectangle(
                    width=214, height=480, color=(255, 255, 255)
                ),  # white
                self._create_rectangle(width=213, height=480, color=(0, 0, 255)),  # red
            ]
        )

        # shrink and center it
        M = numpy.float32([[0.5, 0, width / 4], [0, 0.5, height / 4]])
        data_bgr = cv2.warpAffine(data_bgr, M, (width, height))

        # compute animation
        omega = 2 * math.pi / height
        id_x = numpy.tile(numpy.array(range(width), dtype=numpy.float32), (height, 1))
        id_y = numpy.tile(
            numpy.array(range(height), dtype=numpy.float32), (width, 1)
        ).transpose()

        self.frames = []
        for k in range(30):
            phase = 2 * k * math.pi / 30
            map_x = id_x + 10 * numpy.cos(omega * id_x + phase)
            map_y = id_y + 10 * numpy.sin(omega * id_x + phase)
            self.frames.append(
                VideoFrame.from_ndarray(
                    cv2.remap(data_bgr, map_x, map_y, cv2.INTER_LINEAR), format="bgr24"
                )
            )

    async def recv(self):
        pts, time_base = await self.next_timestamp()

        frame = self.frames[self.counter % 30]
        frame.pts = pts
        frame.time_base = time_base
        self.counter += 1
        return frame

    def _create_rectangle(self, width, height, color):
        data_bgr = numpy.zeros((height, width, 3), numpy.uint8)
        data_bgr[:, :] = color
        return data_bgr


async def run(pc, player, recorder, signaling, role):
    def add_tracks():
        if player and player.audio:
            pc.addTrack(player.audio)

        if player and player.video:
            pc.addTrack(player.video)
        else:
            pc.addTrack(FlagVideoStreamTrack())

    @pc.on("track")
    def on_track(track):
        print("Receiving %s" % track.kind)
        recorder.addTrack(track)

    # connect signaling
    await signaling.connect()

    if role == "offer":
        # send offer
        add_tracks()
        await pc.setLocalDescription(await pc.createOffer())
        await signaling.send(pc.localDescription)

    # consume signaling
    while True:
        obj = await signaling.receive()

        if isinstance(obj, RTCSessionDescription):
            await pc.setRemoteDescription(obj)
            await recorder.start()

            if obj.type == "offer":
                # send answer
                add_tracks()
                await pc.setLocalDescription(await pc.createAnswer())
                await signaling.send(pc.localDescription)
        elif isinstance(obj, RTCIceCandidate):
            await pc.addIceCandidate(obj)
        elif obj is BYE:
            print("Exiting")
            break


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Video stream from the command line")
    parser.add_argument("role", choices=["offer", "answer"])
    parser.add_argument("--play-from", help="Read the media from a file and sent it."),
    parser.add_argument("--record-to", help="Write received media to a file."),
    parser.add_argument("--verbose", "-v", action="count")
    add_signaling_arguments(parser)
    args = parser.parse_args()

    if args.verbose:
        logging.basicConfig(level=logging.DEBUG)

    # create signaling and peer connection
    signaling = create_signaling(args)
    pc = RTCPeerConnection()

    # create media source
    if args.play_from:
        player = MediaPlayer(args.play_from)
    else:
        player = None

    # create media sink
    if args.record_to:
        recorder = MediaRecorder(args.record_to)
    else:
        recorder = MediaBlackhole()

    # run event loop
    loop = asyncio.get_event_loop()
    try:
        loop.run_until_complete(
            run(
                pc=pc,
                player=player,
                recorder=recorder,
                signaling=signaling,
                role=args.role,
            )
        )
    except KeyboardInterrupt:
        pass
    finally:
        # cleanup
        loop.run_until_complete(recorder.stop())
        loop.run_until_complete(signaling.close())
        loop.run_until_complete(pc.close())
aiortc-1.3.0/examples/webcam/000077500000000000000000000000001417604566400160655ustar00rootroot00000000000000aiortc-1.3.0/examples/webcam/README.rst000066400000000000000000000014651417604566400175620ustar00rootroot00000000000000Webcam server
=============

This example illustrates how to read frames from a webcam and send them
to a browser.

Running
-------

First install the required packages:

.. code-block:: console

    $ pip install aiohttp aiortc opencv-python

When you start the example, it will create an HTTP server which you
can connect to from your browser:

.. code-block:: console

    $ python webcam.py

You can then browse to the following page with your browser:

http://127.0.0.1:8080

Once you click `Start` the server will send video from its webcam to the
browser.

Additional options
------------------

If you want to play a media file instead of using the webcam, run:

.. code-block:: console

   $ python webcam.py --play-from video.mp4

Credits
-------

The original idea for the example was from Marios Balamatsias.
aiortc-1.3.0/examples/webcam/client.js000066400000000000000000000043251417604566400177050ustar00rootroot00000000000000var pc = null;

function negotiate() {
    pc.addTransceiver('video', {direction: 'recvonly'});
    pc.addTransceiver('audio', {direction: 'recvonly'});
    return pc.createOffer().then(function(offer) {
        return pc.setLocalDescription(offer);
    }).then(function() {
        // wait for ICE gathering to complete
        return new Promise(function(resolve) {
            if (pc.iceGatheringState === 'complete') {
                resolve();
            } else {
                function checkState() {
                    if (pc.iceGatheringState === 'complete') {
                        pc.removeEventListener('icegatheringstatechange', checkState);
                        resolve();
                    }
                }
                pc.addEventListener('icegatheringstatechange', checkState);
            }
        });
    }).then(function() {
        var offer = pc.localDescription;
        return fetch('/offer', {
            body: JSON.stringify({
                sdp: offer.sdp,
                type: offer.type,
            }),
            headers: {
                'Content-Type': 'application/json'
            },
            method: 'POST'
        });
    }).then(function(response) {
        return response.json();
    }).then(function(answer) {
        return pc.setRemoteDescription(answer);
    }).catch(function(e) {
        alert(e);
    });
}

function start() {
    var config = {
        sdpSemantics: 'unified-plan'
    };

    if (document.getElementById('use-stun').checked) {
        config.iceServers = [{urls: ['stun:stun.l.google.com:19302']}];
    }

    pc = new RTCPeerConnection(config);

    // connect audio / video
    pc.addEventListener('track', function(evt) {
        if (evt.track.kind == 'video') {
            document.getElementById('video').srcObject = evt.streams[0];
        } else {
            document.getElementById('audio').srcObject = evt.streams[0];
        }
    });

    document.getElementById('start').style.display = 'none';
    negotiate();
    document.getElementById('stop').style.display = 'inline-block';
}

function stop() {
    document.getElementById('stop').style.display = 'none';

    // close peer connection
    setTimeout(function() {
        pc.close();
    }, 500);
}
aiortc-1.3.0/examples/webcam/index.html000066400000000000000000000014741417604566400200700ustar00rootroot00000000000000

    
    
    WebRTC webcam
    



Media

aiortc-1.3.0/examples/webcam/webcam.py000066400000000000000000000073561417604566400177100ustar00rootroot00000000000000import argparse import asyncio import json import logging import os import platform import ssl from aiohttp import web from aiortc import RTCPeerConnection, RTCSessionDescription from aiortc.contrib.media import MediaPlayer, MediaRelay ROOT = os.path.dirname(__file__) relay = None webcam = None def create_local_tracks(play_from): global relay, webcam if play_from: player = MediaPlayer(play_from) return player.audio, player.video else: options = {"framerate": "30", "video_size": "640x480"} if relay is None: if platform.system() == "Darwin": webcam = MediaPlayer( "default:none", format="avfoundation", options=options ) elif platform.system() == "Windows": webcam = MediaPlayer( "video=Integrated Camera", format="dshow", options=options ) else: webcam = MediaPlayer("/dev/video0", format="v4l2", options=options) relay = MediaRelay() return None, relay.subscribe(webcam.video) async def index(request): content = open(os.path.join(ROOT, "index.html"), "r").read() return web.Response(content_type="text/html", text=content) async def javascript(request): content = open(os.path.join(ROOT, "client.js"), "r").read() return web.Response(content_type="application/javascript", text=content) async def offer(request): params = await request.json() offer = RTCSessionDescription(sdp=params["sdp"], type=params["type"]) pc = RTCPeerConnection() pcs.add(pc) @pc.on("connectionstatechange") async def on_connectionstatechange(): print("Connection state is %s" % pc.connectionState) if pc.connectionState == "failed": await pc.close() pcs.discard(pc) # open media source audio, video = create_local_tracks(args.play_from) await pc.setRemoteDescription(offer) for t in pc.getTransceivers(): if t.kind == "audio" and audio: pc.addTrack(audio) elif t.kind == "video" and video: pc.addTrack(video) answer = await pc.createAnswer() await pc.setLocalDescription(answer) return web.Response( content_type="application/json", text=json.dumps( {"sdp": pc.localDescription.sdp, "type": pc.localDescription.type} ), ) pcs = set() async def on_shutdown(app): # close peer connections coros = [pc.close() for pc in pcs] await asyncio.gather(*coros) pcs.clear() if __name__ == "__main__": parser = argparse.ArgumentParser(description="WebRTC webcam demo") parser.add_argument("--cert-file", help="SSL certificate file (for HTTPS)") parser.add_argument("--key-file", help="SSL key file (for HTTPS)") parser.add_argument("--play-from", help="Read the media from a file and sent it."), parser.add_argument( "--host", default="0.0.0.0", help="Host for HTTP server (default: 0.0.0.0)" ) parser.add_argument( "--port", type=int, default=8080, help="Port for HTTP server (default: 8080)" ) parser.add_argument("--verbose", "-v", action="count") args = parser.parse_args() if args.verbose: logging.basicConfig(level=logging.DEBUG) else: logging.basicConfig(level=logging.INFO) if args.cert_file: ssl_context = ssl.SSLContext() ssl_context.load_cert_chain(args.cert_file, args.key_file) else: ssl_context = None app = web.Application() app.on_shutdown.append(on_shutdown) app.router.add_get("/", index) app.router.add_get("/client.js", javascript) app.router.add_post("/offer", offer) web.run_app(app, host=args.host, port=args.port, ssl_context=ssl_context) aiortc-1.3.0/pyproject.toml000066400000000000000000000001011417604566400157150ustar00rootroot00000000000000[build-system] requires = ["cffi>=1.0.0", "setuptools", "wheel"] aiortc-1.3.0/requirements/000077500000000000000000000000001417604566400155345ustar00rootroot00000000000000aiortc-1.3.0/requirements/doc.txt000066400000000000000000000000571417604566400170440ustar00rootroot00000000000000sphinx_autodoc_typehints sphinxcontrib-asyncio aiortc-1.3.0/scripts/000077500000000000000000000000001417604566400145005ustar00rootroot00000000000000aiortc-1.3.0/scripts/fetch-vendor.json000066400000000000000000000001531417604566400177560ustar00rootroot00000000000000{ "urls": ["https://github.com/aiortc/aiortc-codecs/releases/download/1.5/codecs-{platform}.tar.gz"] } aiortc-1.3.0/scripts/fetch-vendor.py000066400000000000000000000041101417604566400174320ustar00rootroot00000000000000import argparse import logging import json import os import platform import shutil import struct import subprocess def get_platform(): system = platform.system() machine = platform.machine() if system == "Linux": return f"manylinux_{machine}" elif system == "Darwin": # cibuildwheel sets ARCHFLAGS: # https://github.com/pypa/cibuildwheel/blob/5255155bc57eb6224354356df648dc42e31a0028/cibuildwheel/macos.py#L207-L220 if "ARCHFLAGS" in os.environ: machine = os.environ["ARCHFLAGS"].split()[1] return f"macosx_{machine}" elif system == "Windows": if struct.calcsize("P") * 8 == 64: return "win_amd64" else: return "win32" else: raise Exception(f"Unsupported system {system}") parser = argparse.ArgumentParser(description="Fetch and extract tarballs") parser.add_argument("destination_dir") parser.add_argument("--cache-dir", default="tarballs") parser.add_argument("--config-file", default=os.path.splitext(__file__)[0] + ".json") args = parser.parse_args() logging.basicConfig(level=logging.INFO) # read config file with open(args.config_file, "r") as fp: config = json.load(fp) # create fresh destination directory logging.info("Creating directory %s" % args.destination_dir) if os.path.exists(args.destination_dir): shutil.rmtree(args.destination_dir) os.mkdir(args.destination_dir) for url_template in config["urls"]: tarball_url = url_template.replace("{platform}", get_platform()) # download tarball tarball_name = tarball_url.split("/")[-1] tarball_file = os.path.join(args.cache_dir, tarball_name) if not os.path.exists(tarball_file): logging.info("Downloading %s" % tarball_url) if not os.path.exists(args.cache_dir): os.mkdir(args.cache_dir) subprocess.check_call( ["curl", "--location", "--output", tarball_file, "--silent", tarball_url] ) # extract tarball logging.info("Extracting %s" % tarball_name) subprocess.check_call(["tar", "-C", args.destination_dir, "-xf", tarball_file]) aiortc-1.3.0/setup.cfg000066400000000000000000000007241417604566400146350ustar00rootroot00000000000000[coverage:report] exclude_lines = pragma: no cover [coverage:run] source = aiortc [flake8] ignore=E203,W503 max-line-length=150 [isort] default_section = THIRDPARTY include_trailing_comma = True known_first_party = aiortc line_length = 88 multi_line_output = 3 [mypy] disallow_untyped_calls = True disallow_untyped_decorators = True ignore_missing_imports = True mypy_path = stubs strict_optional = False warn_redundant_casts = True warn_unused_ignores = True aiortc-1.3.0/setup.py000066400000000000000000000040041417604566400145210ustar00rootroot00000000000000import os.path import setuptools root_dir = os.path.abspath(os.path.dirname(__file__)) about = {} about_file = os.path.join(root_dir, "src", "aiortc", "about.py") with open(about_file, encoding="utf-8") as fp: exec(fp.read(), about) readme_file = os.path.join(root_dir, "README.rst") with open(readme_file, encoding="utf-8") as f: long_description = f.read() cffi_modules = [ "src/_cffi_src/build_opus.py:ffibuilder", "src/_cffi_src/build_vpx.py:ffibuilder", ] install_requires = [ "aioice>=0.7.5,<0.8.0", "av>=8.0.0,<9.0.0", "cffi>=1.0.0", "cryptography>=2.2", 'dataclasses; python_version < "3.7"', "google-crc32c>=1.1", "pyee>=9.0.0", "pylibsrtp>=0.5.6", ] extras_require = { 'dev': [ 'aiohttp>=3.7.0', 'coverage>=5.0', 'numpy>=1.19.0', ] } if os.environ.get("READTHEDOCS") == "True": cffi_modules = [] install_requires = list(filter(lambda x: not x.startswith("av"), install_requires)) setuptools.setup( name=about["__title__"], version=about["__version__"], description=about["__summary__"], long_description=long_description, url=about["__uri__"], author=about["__author__"], author_email=about["__email__"], license=about["__license__"], classifiers=[ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", ], cffi_modules=cffi_modules, package_dir={"": "src"}, packages=["aiortc", "aiortc.codecs", "aiortc.contrib"], setup_requires=["cffi>=1.0.0"], install_requires=install_requires, extras_require=extras_require, ) aiortc-1.3.0/src/000077500000000000000000000000001417604566400136005ustar00rootroot00000000000000aiortc-1.3.0/src/_cffi_src/000077500000000000000000000000001417604566400155155ustar00rootroot00000000000000aiortc-1.3.0/src/_cffi_src/build_opus.py000066400000000000000000000020141417604566400202310ustar00rootroot00000000000000from cffi import FFI ffibuilder = FFI() ffibuilder.set_source( "aiortc.codecs._opus", """ #include """, libraries=["opus"], ) ffibuilder.cdef( """ #define OPUS_APPLICATION_VOIP 2048 #define OPUS_OK 0 typedef struct OpusDecoder OpusDecoder; typedef struct OpusEncoder OpusEncoder; typedef int16_t opus_int16; typedef int32_t opus_int32; OpusDecoder *opus_decoder_create( opus_int32 Fs, int channels, int *error ); int opus_decode( OpusDecoder *st, const unsigned char *data, opus_int32 len, opus_int16 *pcm, int frame_size, int decode_fec ); void opus_decoder_destroy(OpusDecoder *st); OpusEncoder *opus_encoder_create( opus_int32 Fs, int channels, int application, int *error ); opus_int32 opus_encode( OpusEncoder *st, const opus_int16 *pcm, int frame_size, unsigned char *data, opus_int32 max_data_bytes ); void opus_encoder_destroy(OpusEncoder *st); """ ) if __name__ == "__main__": ffibuilder.compile(verbose=True) aiortc-1.3.0/src/_cffi_src/build_vpx.py000066400000000000000000000151771417604566400200760ustar00rootroot00000000000000from cffi import FFI ffibuilder = FFI() ffibuilder.set_source( "aiortc.codecs._vpx", """ #include #include #include #include #undef vpx_codec_dec_init #undef vpx_codec_enc_init vpx_codec_err_t vpx_codec_dec_init(vpx_codec_ctx_t *ctx, vpx_codec_iface_t *iface, const vpx_codec_dec_cfg_t *cfg, vpx_codec_flags_t flags) { return vpx_codec_dec_init_ver(ctx, iface, cfg, flags, VPX_DECODER_ABI_VERSION); } vpx_codec_err_t vpx_codec_enc_init(vpx_codec_ctx_t *ctx, vpx_codec_iface_t *iface, const vpx_codec_enc_cfg_t *cfg, vpx_codec_flags_t flags) { return vpx_codec_enc_init_ver(ctx, iface, cfg, flags, VPX_ENCODER_ABI_VERSION); } """, libraries=["vpx"], ) ffibuilder.cdef( """ #define VPX_CODEC_USE_OUTPUT_PARTITION 0x20000 #define VPX_DL_REALTIME 1 #define VPX_EFLAG_FORCE_KF 1 #define VPX_FRAME_IS_KEY 0x1 #define VPX_FRAME_IS_DROPPABLE 0x2 #define VPX_FRAME_IS_INVISIBLE 0x4 #define VPX_FRAME_IS_FRAGMENT 0x8 #define VPX_PLANE_PACKED 0 #define VPX_PLANE_Y 0 #define VPX_PLANE_U 1 #define VPX_PLANE_V 2 #define VPX_PLANE_ALPHA 3 #define VP8_SET_POSTPROC 3 #define VP8E_SET_CPUUSED 13 #define VP8E_SET_NOISE_SENSITIVITY 15 #define VP8E_SET_STATIC_THRESHOLD 17 #define VP8E_SET_TOKEN_PARTITIONS 18 typedef enum { VPX_CODEC_OK, VPX_CODEC_ERROR, VPX_CODEC_MEM_ERROR, VPX_CODEC_ABI_MISMATCH, VPX_CODEC_INCAPABLE, VPX_CODEC_UNSUP_BITSTREAM, VPX_CODEC_UNSUP_FEATURE, VPX_CODEC_CORRUPT_FRAME, VPX_CODEC_INVALID_PARAM, VPX_CODEC_LIST_END } vpx_codec_err_t; enum vpx_codec_cx_pkt_kind { VPX_CODEC_CX_FRAME_PKT, ... }; typedef enum vpx_img_fmt { VPX_IMG_FMT_I420, ... } vpx_img_fmt_t; typedef long vpx_codec_flags_t; typedef uint32_t vpx_codec_frame_flags_t; typedef long vpx_enc_frame_flags_t; typedef const void *vpx_codec_iter_t; typedef int64_t vpx_codec_pts_t; typedef const struct vpx_codec_iface vpx_codec_iface_t; typedef struct vpx_rational { int num; int den; } vpx_rational_t; enum vpx_rc_mode { VPX_VBR, VPX_CBR, VPX_CQ, VPX_Q, }; enum vpx_kf_mode { VPX_KF_FIXED, VPX_KF_AUTO, VPX_KF_DISABLED = 0 }; typedef struct vpx_codec_dec_cfg { unsigned int threads; unsigned int w; unsigned int h; } vpx_codec_dec_cfg_t; typedef struct vpx_codec_enc_cfg { unsigned int g_usage; unsigned int g_threads; unsigned int g_profile; unsigned int g_w; unsigned int g_h; struct vpx_rational g_timebase; unsigned int g_lag_in_frames; unsigned int rc_resize_allowed; enum vpx_rc_mode rc_end_usage; unsigned int rc_target_bitrate; unsigned int rc_min_quantizer; unsigned int rc_max_quantizer; unsigned int rc_undershoot_pct; unsigned int rc_overshoot_pct; unsigned int rc_buf_sz; unsigned int rc_buf_initial_sz; unsigned int rc_buf_optimal_sz; enum vpx_kf_mode kf_mode; unsigned int kf_max_dist; ...; } vpx_codec_enc_cfg_t; typedef struct vpx_codec_ctx { ...; } vpx_codec_ctx_t; typedef struct vpx_fixed_buf { void *buf; size_t sz; } vpx_fixed_buf_t; typedef struct vpx_codec_cx_pkt { enum vpx_codec_cx_pkt_kind kind; union { struct { void *buf; size_t sz; vpx_codec_pts_t pts; unsigned long duration; vpx_codec_frame_flags_t flags; int partition_id; } frame; vpx_fixed_buf_t twopass_stats; vpx_fixed_buf_t firstpass_mb_stats; struct vpx_psnr_pkt { unsigned int samples[4]; uint64_t sse[4]; double psnr[4]; } psnr; vpx_fixed_buf_t raw; char pad[124]; } data; ...; } vpx_codec_cx_pkt_t; typedef struct vpx_image { vpx_img_fmt_t fmt; unsigned int w; unsigned int h; unsigned int d_w; unsigned int d_h; unsigned char *planes[4]; int stride[4]; ...; } vpx_image_t; enum vp8_postproc_level { VP8_NOFILTERING = 0, VP8_DEBLOCK = 1, VP8_DEMACROBLOCK = 2 }; typedef enum { VP8_ONE_TOKENPARTITION = 0, VP8_TWO_TOKENPARTITION = 1, VP8_FOUR_TOKENPARTITION = 2, VP8_EIGHT_TOKENPARTITION = 3 } vp8e_token_partitions; typedef struct vp8_postproc_cfg { int post_proc_flag; int deblocking_level; int noise_level; } vp8_postproc_cfg_t; extern vpx_codec_iface_t *vpx_codec_vp8_cx(void); extern vpx_codec_iface_t *vpx_codec_vp8_dx(void); extern vpx_codec_iface_t *vpx_codec_vp9_cx(void); extern vpx_codec_iface_t *vpx_codec_vp9_dx(void); vpx_codec_err_t vpx_codec_control_(vpx_codec_ctx_t *ctx, int ctrl_id, ...); vpx_codec_err_t vpx_codec_destroy(vpx_codec_ctx_t *ctx); vpx_codec_err_t vpx_codec_dec_init(vpx_codec_ctx_t *ctx, vpx_codec_iface_t *iface, const vpx_codec_dec_cfg_t *cfg, vpx_codec_flags_t flags); vpx_image_t *vpx_codec_get_frame(vpx_codec_ctx_t *ctx, vpx_codec_iter_t *iter); vpx_codec_err_t vpx_codec_decode(vpx_codec_ctx_t *ctx, const uint8_t *data, unsigned int data_sz, void *user_priv, long deadline); vpx_codec_err_t vpx_codec_enc_config_default(vpx_codec_iface_t *iface, vpx_codec_enc_cfg_t *cfg, unsigned int reserved); vpx_codec_err_t vpx_codec_enc_config_set(vpx_codec_ctx_t *ctx, const vpx_codec_enc_cfg_t *cfg); vpx_codec_err_t vpx_codec_enc_init(vpx_codec_ctx_t *ctx, vpx_codec_iface_t *iface, const vpx_codec_enc_cfg_t *cfg, vpx_codec_flags_t flags); vpx_codec_err_t vpx_codec_encode(vpx_codec_ctx_t *ctx, const vpx_image_t *img, vpx_codec_pts_t pts, unsigned long duration, vpx_enc_frame_flags_t flags, unsigned long deadline); const char *vpx_codec_err_to_string (vpx_codec_err_t err); const vpx_codec_cx_pkt_t *vpx_codec_get_cx_data(vpx_codec_ctx_t *ctx, vpx_codec_iter_t *iter); vpx_image_t *vpx_img_alloc(vpx_image_t *img, vpx_img_fmt_t fmt, unsigned int d_w, unsigned int d_h, unsigned int align); void vpx_img_free(vpx_image_t *img); vpx_image_t *vpx_img_wrap(vpx_image_t *img, vpx_img_fmt_t fmt, unsigned int d_w, unsigned int d_h, unsigned int align, unsigned char *img_data); """ ) if __name__ == "__main__": ffibuilder.compile(verbose=True) aiortc-1.3.0/src/aiortc/000077500000000000000000000000001417604566400150615ustar00rootroot00000000000000aiortc-1.3.0/src/aiortc/__init__.py000066400000000000000000000031271417604566400171750ustar00rootroot00000000000000# flake8: noqa import logging import av.logging from .about import __version__ from .exceptions import InvalidAccessError, InvalidStateError from .mediastreams import MediaStreamTrack, VideoStreamTrack from .rtcconfiguration import RTCConfiguration, RTCIceServer from .rtcdatachannel import RTCDataChannel, RTCDataChannelParameters from .rtcdtlstransport import ( RTCCertificate, RTCDtlsFingerprint, RTCDtlsParameters, RTCDtlsTransport, ) from .rtcicetransport import ( RTCIceCandidate, RTCIceGatherer, RTCIceParameters, RTCIceTransport, ) from .rtcpeerconnection import RTCPeerConnection from .rtcrtpparameters import ( RTCRtcpParameters, RTCRtpCapabilities, RTCRtpCodecCapability, RTCRtpCodecParameters, RTCRtpHeaderExtensionCapability, RTCRtpHeaderExtensionParameters, RTCRtpParameters, ) from .rtcrtpreceiver import ( RTCRtpContributingSource, RTCRtpReceiver, RTCRtpSynchronizationSource, ) from .rtcrtpsender import RTCRtpSender from .rtcrtptransceiver import RTCRtpTransceiver from .rtcsctptransport import RTCSctpCapabilities, RTCSctpTransport from .rtcsessiondescription import RTCSessionDescription from .stats import ( RTCInboundRtpStreamStats, RTCOutboundRtpStreamStats, RTCRemoteInboundRtpStreamStats, RTCRemoteOutboundRtpStreamStats, RTCStatsReport, RTCTransportStats, ) # Disable PyAV's logging framework as it can lead to thread deadlocks. av.logging.restore_default_callback() # Set default logging handler to avoid "No handler found" warnings. logging.getLogger(__name__).addHandler(logging.NullHandler()) aiortc-1.3.0/src/aiortc/about.py000066400000000000000000000003411417604566400165430ustar00rootroot00000000000000__author__ = "Jeremy Lainé" __email__ = "jeremy.laine@m4x.org" __license__ = "BSD" __summary__ = "An implementation of WebRTC and ORTC" __title__ = "aiortc" __uri__ = "https://github.com/aiortc/aiortc" __version__ = "1.3.0" aiortc-1.3.0/src/aiortc/clock.py000066400000000000000000000014701417604566400165300ustar00rootroot00000000000000import datetime NTP_EPOCH = datetime.datetime(1900, 1, 1, tzinfo=datetime.timezone.utc) def current_datetime() -> datetime.datetime: return datetime.datetime.now(datetime.timezone.utc) def current_ms() -> int: delta = current_datetime() - NTP_EPOCH return int(delta.total_seconds() * 1000) def current_ntp_time() -> int: return datetime_to_ntp(current_datetime()) def datetime_from_ntp(ntp: int) -> datetime.datetime: seconds = ntp >> 32 microseconds = ((ntp & 0xFFFFFFFF) * 1000000) / (1 << 32) return NTP_EPOCH + datetime.timedelta(seconds=seconds, microseconds=microseconds) def datetime_to_ntp(dt: datetime.datetime) -> int: delta = dt - NTP_EPOCH high = int(delta.total_seconds()) low = round((delta.microseconds * (1 << 32)) // 1000000) return (high << 32) | low aiortc-1.3.0/src/aiortc/codecs/000077500000000000000000000000001417604566400163215ustar00rootroot00000000000000aiortc-1.3.0/src/aiortc/codecs/__init__.py000066400000000000000000000127701417604566400204410ustar00rootroot00000000000000from collections import OrderedDict from typing import Dict, List, Optional, Union from ..rtcrtpparameters import ( RTCRtcpFeedback, RTCRtpCapabilities, RTCRtpCodecCapability, RTCRtpCodecParameters, RTCRtpHeaderExtensionCapability, RTCRtpHeaderExtensionParameters, ) from .base import Decoder, Encoder from .g711 import PcmaDecoder, PcmaEncoder, PcmuDecoder, PcmuEncoder from .h264 import H264Decoder, H264Encoder, h264_depayload from .opus import OpusDecoder, OpusEncoder from .vpx import Vp8Decoder, Vp8Encoder, vp8_depayload PCMU_CODEC = RTCRtpCodecParameters( mimeType="audio/PCMU", clockRate=8000, channels=1, payloadType=0 ) PCMA_CODEC = RTCRtpCodecParameters( mimeType="audio/PCMA", clockRate=8000, channels=1, payloadType=8 ) CODECS: Dict[str, List[RTCRtpCodecParameters]] = { "audio": [ RTCRtpCodecParameters( mimeType="audio/opus", clockRate=48000, channels=2, payloadType=96 ), PCMU_CODEC, PCMA_CODEC, ], "video": [], } HEADER_EXTENSIONS: Dict[str, List[RTCRtpHeaderExtensionParameters]] = { "audio": [ RTCRtpHeaderExtensionParameters( id=1, uri="urn:ietf:params:rtp-hdrext:sdes:mid" ), RTCRtpHeaderExtensionParameters( id=2, uri="urn:ietf:params:rtp-hdrext:ssrc-audio-level" ), ], "video": [ RTCRtpHeaderExtensionParameters( id=1, uri="urn:ietf:params:rtp-hdrext:sdes:mid" ), RTCRtpHeaderExtensionParameters( id=2, uri="http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time" ), ], } def init_codecs() -> None: dynamic_pt = 97 def add_video_codec( mimeType: str, parameters: Optional[OrderedDict] = None ) -> None: nonlocal dynamic_pt clockRate = 90000 CODECS["video"] += [ RTCRtpCodecParameters( mimeType=mimeType, clockRate=clockRate, payloadType=dynamic_pt, rtcpFeedback=[ RTCRtcpFeedback(type="nack"), RTCRtcpFeedback(type="nack", parameter="pli"), RTCRtcpFeedback(type="goog-remb"), ], parameters=parameters or OrderedDict(), ), RTCRtpCodecParameters( mimeType="video/rtx", clockRate=clockRate, payloadType=dynamic_pt + 1, parameters=OrderedDict([("apt", dynamic_pt)]), ), ] dynamic_pt += 2 add_video_codec("video/VP8") add_video_codec( "video/H264", OrderedDict( ( ("packetization-mode", "1"), ("level-asymmetry-allowed", "1"), ("profile-level-id", "42001f"), ) ), ) add_video_codec( "video/H264", OrderedDict( ( ("packetization-mode", "1"), ("level-asymmetry-allowed", "1"), ("profile-level-id", "42e01f"), ) ), ) def depayload(codec: RTCRtpCodecParameters, payload: bytes) -> bytes: if codec.name == "VP8": return vp8_depayload(payload) elif codec.name == "H264": return h264_depayload(payload) else: return payload def get_capabilities(kind: str) -> RTCRtpCapabilities: if kind not in CODECS: raise ValueError(f"cannot get capabilities for unknown media {kind}") codecs = [] rtx_added = False for params in CODECS[kind]: if not is_rtx(params): codecs.append( RTCRtpCodecCapability( mimeType=params.mimeType, clockRate=params.clockRate, channels=params.channels, parameters=params.parameters, ) ) elif not rtx_added: # There will only be a single entry in codecs[] for retransmission # via RTX, with sdpFmtpLine not present. codecs.append( RTCRtpCodecCapability( mimeType=params.mimeType, clockRate=params.clockRate ) ) rtx_added = True headerExtensions = [] for extension in HEADER_EXTENSIONS[kind]: headerExtensions.append(RTCRtpHeaderExtensionCapability(uri=extension.uri)) return RTCRtpCapabilities(codecs=codecs, headerExtensions=headerExtensions) def get_decoder(codec: RTCRtpCodecParameters) -> Decoder: mimeType = codec.mimeType.lower() if mimeType == "audio/opus": return OpusDecoder() elif mimeType == "audio/pcma": return PcmaDecoder() elif mimeType == "audio/pcmu": return PcmuDecoder() elif mimeType == "video/h264": return H264Decoder() elif mimeType == "video/vp8": return Vp8Decoder() else: raise ValueError(f"No decoder found for MIME type `{mimeType}`") def get_encoder(codec: RTCRtpCodecParameters) -> Encoder: mimeType = codec.mimeType.lower() if mimeType == "audio/opus": return OpusEncoder() elif mimeType == "audio/pcma": return PcmaEncoder() elif mimeType == "audio/pcmu": return PcmuEncoder() elif mimeType == "video/h264": return H264Encoder() elif mimeType == "video/vp8": return Vp8Encoder() else: raise ValueError(f"No encoder found for MIME type `{mimeType}`") def is_rtx(codec: Union[RTCRtpCodecCapability, RTCRtpCodecParameters]) -> bool: return codec.name.lower() == "rtx" init_codecs() aiortc-1.3.0/src/aiortc/codecs/_opus.pyi000066400000000000000000000000521417604566400201660ustar00rootroot00000000000000from typing import Any ffi: Any lib: Any aiortc-1.3.0/src/aiortc/codecs/_vpx.pyi000066400000000000000000000000521417604566400200150ustar00rootroot00000000000000from typing import Any ffi: Any lib: Any aiortc-1.3.0/src/aiortc/codecs/base.py000066400000000000000000000007511417604566400176100ustar00rootroot00000000000000from abc import ABCMeta, abstractmethod from typing import List, Tuple from av.frame import Frame from ..jitterbuffer import JitterFrame class Decoder(metaclass=ABCMeta): @abstractmethod def decode(self, encoded_frame: JitterFrame) -> List[Frame]: pass # pragma: no cover class Encoder(metaclass=ABCMeta): @abstractmethod def encode( self, frame: Frame, force_keyframe: bool = False ) -> Tuple[List[bytes], int]: pass # pragma: no cover aiortc-1.3.0/src/aiortc/codecs/g711.py000066400000000000000000000051671417604566400173630ustar00rootroot00000000000000import audioop import fractions from abc import ABC, abstractmethod from typing import List, Optional, Tuple from av import AudioFrame from av.frame import Frame from ..jitterbuffer import JitterFrame from .base import Decoder, Encoder SAMPLE_RATE = 8000 SAMPLE_WIDTH = 2 SAMPLES_PER_FRAME = 160 TIME_BASE = fractions.Fraction(1, 8000) class PcmDecoder(ABC, Decoder): @staticmethod @abstractmethod def _convert(data: bytes, width: int) -> bytes: pass # pragma: no cover def decode(self, encoded_frame: JitterFrame) -> List[Frame]: frame = AudioFrame(format="s16", layout="mono", samples=SAMPLES_PER_FRAME) frame.planes[0].update(self._convert(encoded_frame.data, SAMPLE_WIDTH)) frame.pts = encoded_frame.timestamp frame.sample_rate = SAMPLE_RATE frame.time_base = TIME_BASE return [frame] class PcmEncoder(ABC, Encoder): @staticmethod @abstractmethod def _convert(data: bytes, width: int) -> bytes: pass # pragma: no cover def __init__(self) -> None: self.rate_state: Optional[Tuple[int, Tuple[Tuple[int, int], ...]]] = None def encode( self, frame: Frame, force_keyframe: bool = False ) -> Tuple[List[bytes], int]: assert isinstance(frame, AudioFrame) assert frame.format.name == "s16" assert frame.layout.name in ["mono", "stereo"] channels = len(frame.layout.channels) data = bytes(frame.planes[0]) timestamp = frame.pts # resample at 8 kHz if frame.sample_rate != SAMPLE_RATE: data, self.rate_state = audioop.ratecv( data, SAMPLE_WIDTH, channels, frame.sample_rate, SAMPLE_RATE, self.rate_state, ) timestamp = (timestamp * SAMPLE_RATE) // frame.sample_rate # convert to mono if channels == 2: data = audioop.tomono(data, SAMPLE_WIDTH, 1, 1) data = self._convert(data, SAMPLE_WIDTH) return [data], timestamp class PcmaDecoder(PcmDecoder): @staticmethod def _convert(data: bytes, width: int) -> bytes: return audioop.alaw2lin(data, width) class PcmaEncoder(PcmEncoder): @staticmethod def _convert(data: bytes, width: int) -> bytes: return audioop.lin2alaw(data, width) class PcmuDecoder(PcmDecoder): @staticmethod def _convert(data: bytes, width: int) -> bytes: return audioop.ulaw2lin(data, width) class PcmuEncoder(PcmEncoder): @staticmethod def _convert(data: bytes, width: int) -> bytes: return audioop.lin2ulaw(data, width) aiortc-1.3.0/src/aiortc/codecs/h264.py000066400000000000000000000254341417604566400173660ustar00rootroot00000000000000import fractions import logging import math from itertools import tee from struct import pack, unpack_from from typing import Iterator, List, Optional, Sequence, Tuple, Type, TypeVar import av from av.frame import Frame from ..jitterbuffer import JitterFrame from ..mediastreams import VIDEO_TIME_BASE, convert_timebase from .base import Decoder, Encoder logger = logging.getLogger(__name__) DEFAULT_BITRATE = 1000000 # 1 Mbps MIN_BITRATE = 500000 # 500 kbps MAX_BITRATE = 3000000 # 3 Mbps MAX_FRAME_RATE = 30 PACKET_MAX = 1300 NAL_TYPE_FU_A = 28 NAL_TYPE_STAP_A = 24 NAL_HEADER_SIZE = 1 FU_A_HEADER_SIZE = 2 LENGTH_FIELD_SIZE = 2 STAP_A_HEADER_SIZE = NAL_HEADER_SIZE + LENGTH_FIELD_SIZE DESCRIPTOR_T = TypeVar("DESCRIPTOR_T", bound="H264PayloadDescriptor") T = TypeVar("T") def pairwise(iterable: Sequence[T]) -> Iterator[Tuple[T, T]]: a, b = tee(iterable) next(b, None) return zip(a, b) class H264PayloadDescriptor: def __init__(self, first_fragment): self.first_fragment = first_fragment def __repr__(self): return f"H264PayloadDescriptor(FF={self.first_fragment})" @classmethod def parse(cls: Type[DESCRIPTOR_T], data: bytes) -> Tuple[DESCRIPTOR_T, bytes]: output = bytes() # NAL unit header if len(data) < 2: raise ValueError("NAL unit is too short") nal_type = data[0] & 0x1F f_nri = data[0] & (0x80 | 0x60) pos = NAL_HEADER_SIZE if nal_type in range(1, 24): # single NAL unit output = bytes([0, 0, 0, 1]) + data obj = cls(first_fragment=True) elif nal_type == NAL_TYPE_FU_A: # fragmentation unit original_nal_type = data[pos] & 0x1F first_fragment = bool(data[pos] & 0x80) pos += 1 if first_fragment: original_nal_header = bytes([f_nri | original_nal_type]) output += bytes([0, 0, 0, 1]) output += original_nal_header output += data[pos:] obj = cls(first_fragment=first_fragment) elif nal_type == NAL_TYPE_STAP_A: # single time aggregation packet offsets = [] while pos < len(data): if len(data) < pos + LENGTH_FIELD_SIZE: raise ValueError("STAP-A length field is truncated") nalu_size = unpack_from("!H", data, pos)[0] pos += LENGTH_FIELD_SIZE offsets.append(pos) pos += nalu_size if len(data) < pos: raise ValueError("STAP-A data is truncated") offsets.append(len(data) + LENGTH_FIELD_SIZE) for start, end in pairwise(offsets): end -= LENGTH_FIELD_SIZE output += bytes([0, 0, 0, 1]) output += data[start:end] obj = cls(first_fragment=True) else: raise ValueError(f"NAL unit type {nal_type} is not supported") return obj, output class H264Decoder(Decoder): def __init__(self) -> None: self.codec = av.CodecContext.create("h264", "r") def decode(self, encoded_frame: JitterFrame) -> List[Frame]: try: packet = av.Packet(encoded_frame.data) packet.pts = encoded_frame.timestamp packet.time_base = VIDEO_TIME_BASE frames = self.codec.decode(packet) except av.AVError as e: logger.warning( "H264Decoder() failed to decode, skipping package: " + str(e) ) return [] return frames def create_encoder_context( codec_name: str, width: int, height: int, bitrate: int ) -> Tuple[av.CodecContext, bool]: codec = av.CodecContext.create(codec_name, "w") codec.width = width codec.height = height codec.bit_rate = bitrate codec.pix_fmt = "yuv420p" codec.framerate = fractions.Fraction(MAX_FRAME_RATE, 1) codec.time_base = fractions.Fraction(1, MAX_FRAME_RATE) codec.options = { "profile": "baseline", "level": "31", "tune": "zerolatency", # does nothing using h264_omx } codec.open() return codec, codec_name == "h264_omx" class H264Encoder(Encoder): def __init__(self) -> None: self.buffer_data = b"" self.buffer_pts: Optional[int] = None self.codec: Optional[av.CodecContext] = None self.codec_buffering = False self.__target_bitrate = DEFAULT_BITRATE @staticmethod def _packetize_fu_a(data: bytes) -> List[bytes]: available_size = PACKET_MAX - FU_A_HEADER_SIZE payload_size = len(data) - NAL_HEADER_SIZE num_packets = math.ceil(payload_size / available_size) num_larger_packets = payload_size % num_packets package_size = payload_size // num_packets f_nri = data[0] & (0x80 | 0x60) # fni of original header nal = data[0] & 0x1F fu_indicator = f_nri | NAL_TYPE_FU_A fu_header_end = bytes([fu_indicator, nal | 0x40]) fu_header_middle = bytes([fu_indicator, nal]) fu_header_start = bytes([fu_indicator, nal | 0x80]) fu_header = fu_header_start packages = [] offset = NAL_HEADER_SIZE while offset < len(data): if num_larger_packets > 0: num_larger_packets -= 1 payload = data[offset : offset + package_size + 1] offset += package_size + 1 else: payload = data[offset : offset + package_size] offset += package_size if offset == len(data): fu_header = fu_header_end packages.append(fu_header + payload) fu_header = fu_header_middle assert offset == len(data), "incorrect fragment data" return packages @staticmethod def _packetize_stap_a( data: bytes, packages_iterator: Iterator[bytes] ) -> Tuple[bytes, bytes]: counter = 0 available_size = PACKET_MAX - STAP_A_HEADER_SIZE stap_header = NAL_TYPE_STAP_A | (data[0] & 0xE0) payload = bytes() try: nalu = data # with header while len(nalu) <= available_size and counter < 9: stap_header |= nalu[0] & 0x80 nri = nalu[0] & 0x60 if stap_header & 0x60 < nri: stap_header = stap_header & 0x9F | nri available_size -= LENGTH_FIELD_SIZE + len(nalu) counter += 1 payload += pack("!H", len(nalu)) + nalu nalu = next(packages_iterator) if counter == 0: nalu = next(packages_iterator) except StopIteration: nalu = None if counter <= 1: return data, nalu else: return bytes([stap_header]) + payload, nalu @staticmethod def _split_bitstream(buf: bytes) -> Iterator[bytes]: # Translated from: https://github.com/aizvorski/h264bitstream/blob/master/h264_nal.c#L134 i = 0 while True: # Find the start of the NAL unit # NAL Units start with a 3-byte or 4 byte start code of 0x000001 or 0x00000001 # while buf[i:i+3] != b'\x00\x00\x01': i = buf.find(b"\x00\x00\x01", i) if i == -1: return # Jump past the start code i += 3 nal_start = i # Find the end of the NAL unit (end of buffer OR next start code) i = buf.find(b"\x00\x00\x01", i) if i == -1: yield buf[nal_start : len(buf)] return elif buf[i - 1] == 0: # 4-byte start code case, jump back one byte yield buf[nal_start : i - 1] else: yield buf[nal_start:i] @classmethod def _packetize(cls, packages: Iterator[bytes]) -> List[bytes]: packetized_packages = [] packages_iterator = iter(packages) package = next(packages_iterator, None) while package is not None: if len(package) > PACKET_MAX: packetized_packages.extend(cls._packetize_fu_a(package)) package = next(packages_iterator, None) else: packetized, package = cls._packetize_stap_a(package, packages_iterator) packetized_packages.append(packetized) return packetized_packages def _encode_frame( self, frame: av.VideoFrame, force_keyframe: bool ) -> Iterator[bytes]: if self.codec and ( frame.width != self.codec.width or frame.height != self.codec.height # we only adjust bitrate if it changes by over 10% or abs(self.target_bitrate - self.codec.bit_rate) / self.codec.bit_rate > 0.1 ): self.buffer_data = b"" self.buffer_pts = None self.codec = None if self.codec is None: try: self.codec, self.codec_buffering = create_encoder_context( "h264_omx", frame.width, frame.height, bitrate=self.target_bitrate ) except Exception: self.codec, self.codec_buffering = create_encoder_context( "libx264", frame.width, frame.height, bitrate=self.target_bitrate, ) data_to_send = b"" for package in self.codec.encode(frame): package_bytes = package.to_bytes() if self.codec_buffering: # delay sending to ensure we accumulate all packages # for a given PTS if package.pts == self.buffer_pts: self.buffer_data += package_bytes else: data_to_send += self.buffer_data self.buffer_data = package_bytes self.buffer_pts = package.pts else: data_to_send += package_bytes if data_to_send: yield from self._split_bitstream(data_to_send) def encode( self, frame: Frame, force_keyframe: bool = False ) -> Tuple[List[bytes], int]: assert isinstance(frame, av.VideoFrame) packages = self._encode_frame(frame, force_keyframe) timestamp = convert_timebase(frame.pts, frame.time_base, VIDEO_TIME_BASE) return self._packetize(packages), timestamp @property def target_bitrate(self) -> int: """ Target bitrate in bits per second. """ return self.__target_bitrate @target_bitrate.setter def target_bitrate(self, bitrate: int) -> None: bitrate = max(MIN_BITRATE, min(bitrate, MAX_BITRATE)) self.__target_bitrate = bitrate def h264_depayload(payload: bytes) -> bytes: descriptor, data = H264PayloadDescriptor.parse(payload) return data aiortc-1.3.0/src/aiortc/codecs/opus.py000066400000000000000000000056311417604566400176660ustar00rootroot00000000000000import audioop import fractions from typing import List, Optional, Tuple from av import AudioFrame from av.frame import Frame from ..jitterbuffer import JitterFrame from ._opus import ffi, lib from .base import Decoder, Encoder CHANNELS = 2 SAMPLE_RATE = 48000 SAMPLE_WIDTH = 2 SAMPLES_PER_FRAME = 960 TIME_BASE = fractions.Fraction(1, SAMPLE_RATE) class OpusDecoder(Decoder): def __init__(self) -> None: error = ffi.new("int *") self.decoder = lib.opus_decoder_create(SAMPLE_RATE, CHANNELS, error) assert error[0] == lib.OPUS_OK def __del__(self) -> None: lib.opus_decoder_destroy(self.decoder) def decode(self, encoded_frame: JitterFrame) -> List[Frame]: frame = AudioFrame(format="s16", layout="stereo", samples=SAMPLES_PER_FRAME) frame.pts = encoded_frame.timestamp frame.sample_rate = SAMPLE_RATE frame.time_base = TIME_BASE length = lib.opus_decode( self.decoder, encoded_frame.data, len(encoded_frame.data), ffi.cast("int16_t *", frame.planes[0].buffer_ptr), SAMPLES_PER_FRAME, 0, ) assert length == SAMPLES_PER_FRAME return [frame] class OpusEncoder(Encoder): def __init__(self) -> None: error = ffi.new("int *") self.encoder = lib.opus_encoder_create( SAMPLE_RATE, CHANNELS, lib.OPUS_APPLICATION_VOIP, error ) assert error[0] == lib.OPUS_OK self.cdata = ffi.new( "unsigned char []", SAMPLES_PER_FRAME * CHANNELS * SAMPLE_WIDTH ) self.buffer = ffi.buffer(self.cdata) self.rate_state: Optional[Tuple[int, Tuple[Tuple[int, int], ...]]] = None def __del__(self) -> None: lib.opus_encoder_destroy(self.encoder) def encode( self, frame: Frame, force_keyframe: bool = False ) -> Tuple[List[bytes], int]: assert isinstance(frame, AudioFrame) assert frame.format.name == "s16" assert frame.layout.name in ["mono", "stereo"] channels = len(frame.layout.channels) data = bytes(frame.planes[0]) timestamp = frame.pts # resample at 48 kHz if frame.sample_rate != SAMPLE_RATE: data, self.rate_state = audioop.ratecv( data, SAMPLE_WIDTH, channels, frame.sample_rate, SAMPLE_RATE, self.rate_state, ) timestamp = (timestamp * SAMPLE_RATE) // frame.sample_rate # convert to stereo if channels == 1: data = audioop.tostereo(data, SAMPLE_WIDTH, 1, 1) length = lib.opus_encode( self.encoder, ffi.cast("int16_t*", ffi.from_buffer(data)), SAMPLES_PER_FRAME, self.cdata, len(self.cdata), ) assert length > 0 return [self.buffer[0:length]], timestamp aiortc-1.3.0/src/aiortc/codecs/vpx.py000066400000000000000000000311071417604566400175120ustar00rootroot00000000000000import multiprocessing import random from struct import pack, unpack_from from typing import List, Tuple, Type, TypeVar, cast from av import VideoFrame from av.frame import Frame from ..jitterbuffer import JitterFrame from ..mediastreams import VIDEO_CLOCK_RATE, VIDEO_TIME_BASE, convert_timebase from ._vpx import ffi, lib from .base import Decoder, Encoder DEFAULT_BITRATE = 500000 # 500 kbps MIN_BITRATE = 250000 # 250 kbps MAX_BITRATE = 1500000 # 1.5 Mbps MAX_FRAME_RATE = 30 PACKET_MAX = 1300 DESCRIPTOR_T = TypeVar("DESCRIPTOR_T", bound="VpxPayloadDescriptor") def number_of_threads(pixels: int, cpus: int) -> int: if pixels >= 1920 * 1080 and cpus > 8: return 8 elif pixels > 1280 * 960 and cpus >= 6: return 3 elif pixels > 640 * 480 and cpus >= 3: return 2 else: return 1 class VpxPayloadDescriptor: def __init__( self, partition_start, partition_id, picture_id=None, tl0picidx=None, tid=None, keyidx=None, ) -> None: self.partition_start = partition_start self.partition_id = partition_id self.picture_id = picture_id self.tl0picidx = tl0picidx self.tid = tid self.keyidx = keyidx def __bytes__(self) -> bytes: octet = (self.partition_start << 4) | self.partition_id ext_octet = 0 if self.picture_id is not None: ext_octet |= 1 << 7 if self.tl0picidx is not None: ext_octet |= 1 << 6 if self.tid is not None: ext_octet |= 1 << 5 if self.keyidx is not None: ext_octet |= 1 << 4 if ext_octet: data = pack("!BB", (1 << 7) | octet, ext_octet) if self.picture_id is not None: if self.picture_id < 128: data += pack("!B", self.picture_id) else: data += pack("!H", (1 << 15) | self.picture_id) if self.tl0picidx is not None: data += pack("!B", self.tl0picidx) if self.tid is not None or self.keyidx is not None: t_k = 0 if self.tid is not None: t_k |= (self.tid[0] << 6) | (self.tid[1] << 5) if self.keyidx is not None: t_k |= self.keyidx data += pack("!B", t_k) else: data = pack("!B", octet) return data def __repr__(self) -> str: return ( f"VpxPayloadDescriptor(S={self.partition_start}, " f"PID={self.partition_id}, pic_id={self.picture_id})" ) @classmethod def parse(cls: Type[DESCRIPTOR_T], data: bytes) -> Tuple[DESCRIPTOR_T, bytes]: if len(data) < 1: raise ValueError("VPX descriptor is too short") # first byte octet = data[0] extended = octet >> 7 partition_start = (octet >> 4) & 1 partition_id = octet & 0xF picture_id = None tl0picidx = None tid = None keyidx = None pos = 1 # extended control bits if extended: if len(data) < pos + 1: raise ValueError("VPX descriptor has truncated extended bits") octet = data[pos] ext_I = (octet >> 7) & 1 ext_L = (octet >> 6) & 1 ext_T = (octet >> 5) & 1 ext_K = (octet >> 4) & 1 pos += 1 # picture id if ext_I: if len(data) < pos + 1: raise ValueError("VPX descriptor has truncated PictureID") if data[pos] & 0x80: if len(data) < pos + 2: raise ValueError("VPX descriptor has truncated long PictureID") picture_id = unpack_from("!H", data, pos)[0] & 0x7FFF pos += 2 else: picture_id = data[pos] pos += 1 # unused if ext_L: if len(data) < pos + 1: raise ValueError("VPX descriptor has truncated TL0PICIDX") tl0picidx = data[pos] pos += 1 if ext_T or ext_K: if len(data) < pos + 1: raise ValueError("VPX descriptor has truncated T/K") t_k = data[pos] if ext_T: tid = ((t_k >> 6) & 3, (t_k >> 5) & 1) if ext_K: keyidx = t_k & 0x1F pos += 1 obj = cls( partition_start=partition_start, partition_id=partition_id, picture_id=picture_id, tl0picidx=tl0picidx, tid=tid, keyidx=keyidx, ) return obj, data[pos:] def _vpx_assert(err: int) -> None: if err != lib.VPX_CODEC_OK: reason = ffi.string(lib.vpx_codec_err_to_string(err)) raise Exception("libvpx error: " + reason.decode("utf8")) class Vp8Decoder(Decoder): def __init__(self) -> None: self.codec = ffi.new("vpx_codec_ctx_t *") _vpx_assert( lib.vpx_codec_dec_init(self.codec, lib.vpx_codec_vp8_dx(), ffi.NULL, 0) ) ppcfg = ffi.new("vp8_postproc_cfg_t *") ppcfg.post_proc_flag = lib.VP8_DEMACROBLOCK | lib.VP8_DEBLOCK ppcfg.deblocking_level = 3 lib.vpx_codec_control_(self.codec, lib.VP8_SET_POSTPROC, ppcfg) def __del__(self) -> None: lib.vpx_codec_destroy(self.codec) def decode(self, encoded_frame: JitterFrame) -> List[Frame]: frames: List[Frame] = [] result = lib.vpx_codec_decode( self.codec, encoded_frame.data, len(encoded_frame.data), ffi.NULL, lib.VPX_DL_REALTIME, ) if result == lib.VPX_CODEC_OK: it = ffi.new("vpx_codec_iter_t *") while True: img = lib.vpx_codec_get_frame(self.codec, it) if not img: break assert img.fmt == lib.VPX_IMG_FMT_I420 frame = VideoFrame(width=img.d_w, height=img.d_h) frame.pts = encoded_frame.timestamp frame.time_base = VIDEO_TIME_BASE for p in range(3): i_stride = img.stride[p] i_buf = ffi.buffer(img.planes[p], i_stride * img.d_h) i_pos = 0 o_stride = frame.planes[p].line_size o_buf = memoryview(cast(bytes, frame.planes[p])) o_pos = 0 div = p and 2 or 1 for r in range(0, img.d_h // div): o_buf[o_pos : o_pos + o_stride] = i_buf[ i_pos : i_pos + o_stride ] i_pos += i_stride o_pos += o_stride frames.append(frame) return frames class Vp8Encoder(Encoder): def __init__(self) -> None: self.cx = lib.vpx_codec_vp8_cx() self.cfg = ffi.new("vpx_codec_enc_cfg_t *") lib.vpx_codec_enc_config_default(self.cx, self.cfg, 0) self.buffer = bytearray(8000) self.codec = None self.picture_id = random.randint(0, (1 << 15) - 1) self.timestamp_increment = VIDEO_CLOCK_RATE // MAX_FRAME_RATE self.__target_bitrate = DEFAULT_BITRATE self.__update_config_needed = False def __del__(self) -> None: if self.codec: lib.vpx_codec_destroy(self.codec) def encode( self, frame: Frame, force_keyframe: bool = False ) -> Tuple[List[bytes], int]: assert isinstance(frame, VideoFrame) if frame.format.name != "yuv420p": frame = frame.reformat(format="yuv420p") if self.codec and (frame.width != self.cfg.g_w or frame.height != self.cfg.g_h): lib.vpx_codec_destroy(self.codec) self.codec = None if not self.codec: # create codec self.codec = ffi.new("vpx_codec_ctx_t *") self.cfg.g_timebase.num = 1 self.cfg.g_timebase.den = VIDEO_CLOCK_RATE self.cfg.g_lag_in_frames = 0 self.cfg.g_threads = number_of_threads( frame.width * frame.height, multiprocessing.cpu_count() ) self.cfg.g_w = frame.width self.cfg.g_h = frame.height self.cfg.rc_resize_allowed = 0 self.cfg.rc_end_usage = lib.VPX_CBR self.cfg.rc_min_quantizer = 2 self.cfg.rc_max_quantizer = 56 self.cfg.rc_undershoot_pct = 100 self.cfg.rc_overshoot_pct = 15 self.cfg.rc_buf_initial_sz = 500 self.cfg.rc_buf_optimal_sz = 600 self.cfg.rc_buf_sz = 1000 self.cfg.kf_mode = lib.VPX_KF_AUTO self.cfg.kf_max_dist = 3000 self.__update_config() _vpx_assert(lib.vpx_codec_enc_init(self.codec, self.cx, self.cfg, 0)) lib.vpx_codec_control_( self.codec, lib.VP8E_SET_NOISE_SENSITIVITY, ffi.cast("int", 4) ) lib.vpx_codec_control_( self.codec, lib.VP8E_SET_STATIC_THRESHOLD, ffi.cast("int", 1) ) lib.vpx_codec_control_( self.codec, lib.VP8E_SET_CPUUSED, ffi.cast("int", -6) ) lib.vpx_codec_control_( self.codec, lib.VP8E_SET_TOKEN_PARTITIONS, ffi.cast("int", lib.VP8_ONE_TOKENPARTITION), ) # create image on a dummy buffer, we will fill the pointers during encoding self.image = ffi.new("vpx_image_t *") lib.vpx_img_wrap( self.image, lib.VPX_IMG_FMT_I420, frame.width, frame.height, 1, ffi.cast("void*", 1), ) elif self.__update_config_needed: self.__update_config() _vpx_assert(lib.vpx_codec_enc_config_set(self.codec, self.cfg)) # setup image for p in range(3): self.image.planes[p] = ffi.cast("void*", frame.planes[p].buffer_ptr) self.image.stride[p] = frame.planes[p].line_size # encode frame flags = 0 if force_keyframe: flags |= lib.VPX_EFLAG_FORCE_KF _vpx_assert( lib.vpx_codec_encode( self.codec, self.image, frame.pts, self.timestamp_increment, flags, lib.VPX_DL_REALTIME, ) ) it = ffi.new("vpx_codec_iter_t *") length = 0 while True: pkt = lib.vpx_codec_get_cx_data(self.codec, it) if not pkt: break elif pkt.kind == lib.VPX_CODEC_CX_FRAME_PKT: # resize buffer if needed if length + pkt.data.frame.sz > len(self.buffer): new_buffer = bytearray(length + pkt.data.frame.sz) new_buffer[0:length] = self.buffer[0:length] self.buffer = new_buffer # append new data self.buffer[length : length + pkt.data.frame.sz] = ffi.buffer( pkt.data.frame.buf, pkt.data.frame.sz ) length += pkt.data.frame.sz # packetize payloads = [] descr = VpxPayloadDescriptor( partition_start=1, partition_id=0, picture_id=self.picture_id ) pos = 0 while pos < length: descr_bytes = bytes(descr) size = min(length - pos, PACKET_MAX - len(descr_bytes)) payloads.append(descr_bytes + self.buffer[pos : pos + size]) descr.partition_start = 0 pos += size self.picture_id = (self.picture_id + 1) % (1 << 15) timestamp = convert_timebase(frame.pts, frame.time_base, VIDEO_TIME_BASE) return payloads, timestamp @property def target_bitrate(self) -> int: """ Target bitrate in bits per second. """ return self.__target_bitrate @target_bitrate.setter def target_bitrate(self, bitrate: int) -> None: bitrate = max(MIN_BITRATE, min(bitrate, MAX_BITRATE)) if bitrate != self.__target_bitrate: self.__target_bitrate = bitrate self.__update_config_needed = True def __update_config(self) -> None: self.cfg.rc_target_bitrate = self.__target_bitrate // 1000 self.__update_config_needed = False def vp8_depayload(payload: bytes) -> bytes: descriptor, data = VpxPayloadDescriptor.parse(payload) return data aiortc-1.3.0/src/aiortc/contrib/000077500000000000000000000000001417604566400165215ustar00rootroot00000000000000aiortc-1.3.0/src/aiortc/contrib/__init__.py000066400000000000000000000000001417604566400206200ustar00rootroot00000000000000aiortc-1.3.0/src/aiortc/contrib/media.py000066400000000000000000000401251417604566400201540ustar00rootroot00000000000000import asyncio import errno import fractions import logging import threading import time from typing import Dict, Optional, Set import av from av import AudioFrame, VideoFrame from av.frame import Frame from ..mediastreams import AUDIO_PTIME, MediaStreamError, MediaStreamTrack logger = logging.getLogger(__name__) REAL_TIME_FORMATS = [ "alsa", "android_camera", "avfoundation", "bktr", "decklink", "dshow", "fbdev", "gdigrab", "iec61883", "jack", "kmsgrab", "openal", "oss", "pulse", "sndio", "rtsp", "v4l2", "vfwcap", "x11grab", ] async def blackhole_consume(track): while True: try: await track.recv() except MediaStreamError: return class MediaBlackhole: """ A media sink that consumes and discards all media. """ def __init__(self): self.__tracks = {} def addTrack(self, track): """ Add a track whose media should be discarded. :param track: A :class:`aiortc.MediaStreamTrack`. """ if track not in self.__tracks: self.__tracks[track] = None async def start(self): """ Start discarding media. """ for track, task in self.__tracks.items(): if task is None: self.__tracks[track] = asyncio.ensure_future(blackhole_consume(track)) async def stop(self): """ Stop discarding media. """ for task in self.__tracks.values(): if task is not None: task.cancel() self.__tracks = {} def player_worker( loop, container, streams, audio_track, video_track, quit_event, throttle_playback, loop_playback, ): audio_fifo = av.AudioFifo() audio_format_name = "s16" audio_layout_name = "stereo" audio_sample_rate = 48000 audio_samples = 0 audio_samples_per_frame = int(audio_sample_rate * AUDIO_PTIME) audio_resampler = av.AudioResampler( format=audio_format_name, layout=audio_layout_name, rate=audio_sample_rate ) video_first_pts = None frame_time = None start_time = time.time() while not quit_event.is_set(): try: frame = next(container.decode(*streams)) except (av.AVError, StopIteration) as exc: if isinstance(exc, av.FFmpegError) and exc.errno == errno.EAGAIN: time.sleep(0.01) continue if isinstance(exc, StopIteration) and loop_playback: container.seek(0) continue if audio_track: asyncio.run_coroutine_threadsafe(audio_track._queue.put(None), loop) if video_track: asyncio.run_coroutine_threadsafe(video_track._queue.put(None), loop) break # read up to 1 second ahead if throttle_playback: elapsed_time = time.time() - start_time if frame_time and frame_time > elapsed_time + 1: time.sleep(0.1) if isinstance(frame, AudioFrame) and audio_track: if ( frame.format.name != audio_format_name or frame.layout.name != audio_layout_name or frame.sample_rate != audio_sample_rate ): frame.pts = None frame = audio_resampler.resample(frame) # fix timestamps frame.pts = audio_samples frame.time_base = fractions.Fraction(1, audio_sample_rate) audio_samples += frame.samples audio_fifo.write(frame) while True: frame = audio_fifo.read(audio_samples_per_frame) if frame: frame_time = frame.time asyncio.run_coroutine_threadsafe( audio_track._queue.put(frame), loop ) else: break elif isinstance(frame, VideoFrame) and video_track: if frame.pts is None: # pragma: no cover logger.warning( "MediaPlayer(%s) Skipping video frame with no pts", container.name ) continue # video from a webcam doesn't start at pts 0, cancel out offset if video_first_pts is None: video_first_pts = frame.pts frame.pts -= video_first_pts frame_time = frame.time asyncio.run_coroutine_threadsafe(video_track._queue.put(frame), loop) class PlayerStreamTrack(MediaStreamTrack): def __init__(self, player, kind): super().__init__() self.kind = kind self._player = player self._queue = asyncio.Queue() self._start = None async def recv(self): if self.readyState != "live": raise MediaStreamError self._player._start(self) frame = await self._queue.get() if frame is None: self.stop() raise MediaStreamError frame_time = frame.time # control playback rate if ( self._player is not None and self._player._throttle_playback and frame_time is not None ): if self._start is None: self._start = time.time() - frame_time else: wait = self._start + frame_time - time.time() await asyncio.sleep(wait) return frame def stop(self): super().stop() if self._player is not None: self._player._stop(self) self._player = None class MediaPlayer: """ A media source that reads audio and/or video from a file. Examples: .. code-block:: python # Open a video file. player = MediaPlayer('/path/to/some.mp4') # Open an HTTP stream. player = MediaPlayer( 'http://download.tsi.telecom-paristech.fr/' 'gpac/dataset/dash/uhd/mux_sources/hevcds_720p30_2M.mp4') # Open webcam on Linux. player = MediaPlayer('/dev/video0', format='v4l2', options={ 'video_size': '640x480' }) # Open webcam on OS X. player = MediaPlayer('default:none', format='avfoundation', options={ 'video_size': '640x480' }) # Open webcam on Windows. player = MediaPlayer('video=Integrated Camera', format='dshow', options={ 'video_size': '640x480' }) :param file: The path to a file, or a file-like object. :param format: The format to use, defaults to autodect. :param options: Additional options to pass to FFmpeg. :param loop: Whether to repeat playback indefinitely (requires a seekable file). """ def __init__(self, file, format=None, options={}, loop=False): self.__container = av.open(file=file, format=format, mode="r", options=options) self.__thread: Optional[threading.Thread] = None self.__thread_quit: Optional[threading.Event] = None # examine streams self.__started: Set[PlayerStreamTrack] = set() self.__streams = [] self.__audio: Optional[PlayerStreamTrack] = None self.__video: Optional[PlayerStreamTrack] = None for stream in self.__container.streams: if stream.type == "audio" and not self.__audio: self.__audio = PlayerStreamTrack(self, kind="audio") self.__streams.append(stream) elif stream.type == "video" and not self.__video: self.__video = PlayerStreamTrack(self, kind="video") self.__streams.append(stream) # check whether we need to throttle playback container_format = set(self.__container.format.name.split(",")) self._throttle_playback = not container_format.intersection(REAL_TIME_FORMATS) # check whether the looping is supported assert ( not loop or self.__container.duration is not None ), "The `loop` argument requires a seekable file" self._loop_playback = loop @property def audio(self) -> MediaStreamTrack: """ A :class:`aiortc.MediaStreamTrack` instance if the file contains audio. """ return self.__audio @property def video(self) -> MediaStreamTrack: """ A :class:`aiortc.MediaStreamTrack` instance if the file contains video. """ return self.__video def _start(self, track: PlayerStreamTrack) -> None: self.__started.add(track) if self.__thread is None: self.__log_debug("Starting worker thread") self.__thread_quit = threading.Event() self.__thread = threading.Thread( name="media-player", target=player_worker, args=( asyncio.get_event_loop(), self.__container, self.__streams, self.__audio, self.__video, self.__thread_quit, self._throttle_playback, self._loop_playback, ), ) self.__thread.start() def _stop(self, track: PlayerStreamTrack) -> None: self.__started.discard(track) if not self.__started and self.__thread is not None: self.__log_debug("Stopping worker thread") self.__thread_quit.set() self.__thread.join() self.__thread = None if not self.__started and self.__container is not None: self.__container.close() self.__container = None def __log_debug(self, msg: str, *args) -> None: logger.debug(f"MediaPlayer(%s) {msg}", self.__container.name, *args) class MediaRecorderContext: def __init__(self, stream): self.stream = stream self.task = None class MediaRecorder: """ A media sink that writes audio and/or video to a file. Examples: .. code-block:: python # Write to a video file. player = MediaRecorder('/path/to/file.mp4') # Write to a set of images. player = MediaRecorder('/path/to/file-%3d.png') :param file: The path to a file, or a file-like object. :param format: The format to use, defaults to autodect. :param options: Additional options to pass to FFmpeg. """ def __init__(self, file, format=None, options={}): self.__container = av.open(file=file, format=format, mode="w", options=options) self.__tracks = {} def addTrack(self, track): """ Add a track to be recorded. :param track: A :class:`aiortc.MediaStreamTrack`. """ if track.kind == "audio": if self.__container.format.name in ("wav", "alsa"): codec_name = "pcm_s16le" elif self.__container.format.name == "mp3": codec_name = "mp3" else: codec_name = "aac" stream = self.__container.add_stream(codec_name) else: if self.__container.format.name == "image2": stream = self.__container.add_stream("png", rate=30) stream.pix_fmt = "rgb24" else: stream = self.__container.add_stream("libx264", rate=30) stream.pix_fmt = "yuv420p" self.__tracks[track] = MediaRecorderContext(stream) async def start(self): """ Start recording. """ for track, context in self.__tracks.items(): if context.task is None: context.task = asyncio.ensure_future(self.__run_track(track, context)) async def stop(self): """ Stop recording. """ if self.__container: for track, context in self.__tracks.items(): if context.task is not None: context.task.cancel() context.task = None for packet in context.stream.encode(None): self.__container.mux(packet) self.__tracks = {} if self.__container: self.__container.close() self.__container = None async def __run_track(self, track, context): while True: try: frame = await track.recv() except MediaStreamError: return for packet in context.stream.encode(frame): self.__container.mux(packet) class RelayStreamTrack(MediaStreamTrack): def __init__(self, relay, source: MediaStreamTrack, buffered) -> None: super().__init__() self.kind = source.kind self._relay = relay self._source: Optional[MediaStreamTrack] = source self._buffered = buffered self._frame: Optional[Frame] = None self._queue: Optional[asyncio.Queue[Optional[Frame]]] = None self._new_frame_event: Optional[asyncio.Event] = None if self._buffered: self._queue = asyncio.Queue() else: self._new_frame_event = asyncio.Event() async def recv(self): if self.readyState != "live": raise MediaStreamError self._relay._start(self) if self._buffered: self._frame = await self._queue.get() else: await self._new_frame_event.wait() self._new_frame_event.clear() if self._frame is None: self.stop() raise MediaStreamError return self._frame def stop(self): super().stop() if self._relay is not None: self._relay._stop(self) self._relay = None self._source = None class MediaRelay: """ A media source that relays one or more tracks to multiple consumers. This is especially useful for live tracks such as webcams or media received over the network. """ def __init__(self) -> None: self.__proxies: Dict[MediaStreamTrack, Set[RelayStreamTrack]] = {} self.__tasks: Dict[MediaStreamTrack, asyncio.Future[None]] = {} def subscribe( self, track: MediaStreamTrack, buffered: bool = True ) -> MediaStreamTrack: """ Create a proxy around the given `track` for a new consumer. :param track: Source :class:`MediaStreamTrack` which is relayed :param buffered: Whether there need a buffer between the source track and relayed track :rtype: :class: MediaStreamTrack """ proxy = RelayStreamTrack(self, track, buffered) self.__log_debug("Create proxy %s for source %s", id(proxy), id(track)) if track not in self.__proxies: self.__proxies[track] = set() return proxy def _start(self, proxy: RelayStreamTrack) -> None: track = proxy._source if track is not None and track in self.__proxies: # register proxy if proxy not in self.__proxies[track]: self.__log_debug("Start proxy %s", id(proxy)) self.__proxies[track].add(proxy) # start worker if track not in self.__tasks: self.__tasks[track] = asyncio.ensure_future(self.__run_track(track)) def _stop(self, proxy: RelayStreamTrack) -> None: track = proxy._source if track is not None and track in self.__proxies: # unregister proxy self.__log_debug("Stop proxy %s", id(proxy)) self.__proxies[track].discard(proxy) def __log_debug(self, msg: str, *args) -> None: logger.debug(f"MediaRelay(%s) {msg}", id(self), *args) async def __run_track(self, track: MediaStreamTrack) -> None: self.__log_debug("Start reading source %s" % id(track)) while True: try: frame = await track.recv() except MediaStreamError: frame = None for proxy in self.__proxies[track]: if proxy._buffered: proxy._queue.put_nowait(frame) else: proxy._frame = frame proxy._new_frame_event.set() if frame is None: break self.__log_debug("Stop reading source %s", id(track)) del self.__proxies[track] del self.__tasks[track] aiortc-1.3.0/src/aiortc/contrib/signaling.py000066400000000000000000000144131417604566400210510ustar00rootroot00000000000000import asyncio import json import logging import os import sys from aiortc import RTCIceCandidate, RTCSessionDescription from aiortc.sdp import candidate_from_sdp, candidate_to_sdp logger = logging.getLogger(__name__) BYE = object() def object_from_string(message_str): message = json.loads(message_str) if message["type"] in ["answer", "offer"]: return RTCSessionDescription(**message) elif message["type"] == "candidate" and message["candidate"]: candidate = candidate_from_sdp(message["candidate"].split(":", 1)[1]) candidate.sdpMid = message["id"] candidate.sdpMLineIndex = message["label"] return candidate elif message["type"] == "bye": return BYE def object_to_string(obj): if isinstance(obj, RTCSessionDescription): message = {"sdp": obj.sdp, "type": obj.type} elif isinstance(obj, RTCIceCandidate): message = { "candidate": "candidate:" + candidate_to_sdp(obj), "id": obj.sdpMid, "label": obj.sdpMLineIndex, "type": "candidate", } else: assert obj is BYE message = {"type": "bye"} return json.dumps(message, sort_keys=True) class CopyAndPasteSignaling: def __init__(self): self._read_pipe = sys.stdin self._read_transport = None self._reader = None self._write_pipe = sys.stdout async def connect(self): loop = asyncio.get_event_loop() self._reader = asyncio.StreamReader(loop=loop) self._read_transport, _ = await loop.connect_read_pipe( lambda: asyncio.StreamReaderProtocol(self._reader), self._read_pipe ) async def close(self): if self._reader is not None: await self.send(BYE) self._read_transport.close() self._reader = None async def receive(self): print("-- Please enter a message from remote party --") data = await self._reader.readline() print() return object_from_string(data.decode(self._read_pipe.encoding)) async def send(self, descr): print("-- Please send this message to the remote party --") self._write_pipe.write(object_to_string(descr) + "\n") self._write_pipe.flush() print() class TcpSocketSignaling: def __init__(self, host, port): self._host = host self._port = port self._server = None self._reader = None self._writer = None async def connect(self): pass async def _connect(self, server): if self._writer is not None: return if server: connected = asyncio.Event() def client_connected(reader, writer): self._reader = reader self._writer = writer connected.set() self._server = await asyncio.start_server( client_connected, host=self._host, port=self._port ) await connected.wait() else: self._reader, self._writer = await asyncio.open_connection( host=self._host, port=self._port ) async def close(self): if self._writer is not None: await self.send(BYE) self._writer.close() self._reader = None self._writer = None if self._server is not None: self._server.close() self._server = None async def receive(self): await self._connect(False) try: data = await self._reader.readuntil() except asyncio.IncompleteReadError: return return object_from_string(data.decode("utf8")) async def send(self, descr): await self._connect(True) data = object_to_string(descr).encode("utf8") self._writer.write(data + b"\n") class UnixSocketSignaling: def __init__(self, path): self._path = path self._server = None self._reader = None self._writer = None async def connect(self): pass async def _connect(self, server): if self._writer is not None: return if server: connected = asyncio.Event() def client_connected(reader, writer): self._reader = reader self._writer = writer connected.set() self._server = await asyncio.start_unix_server( client_connected, path=self._path ) await connected.wait() else: self._reader, self._writer = await asyncio.open_unix_connection(self._path) async def close(self): if self._writer is not None: await self.send(BYE) self._writer.close() self._reader = None self._writer = None if self._server is not None: self._server.close() self._server = None os.unlink(self._path) async def receive(self): await self._connect(False) try: data = await self._reader.readuntil() except asyncio.IncompleteReadError: return return object_from_string(data.decode("utf8")) async def send(self, descr): await self._connect(True) data = object_to_string(descr).encode("utf8") self._writer.write(data + b"\n") def add_signaling_arguments(parser): """ Add signaling method arguments to an argparse.ArgumentParser. """ parser.add_argument( "--signaling", "-s", choices=["copy-and-paste", "tcp-socket", "unix-socket"], ) parser.add_argument( "--signaling-host", default="127.0.0.1", help="Signaling host (tcp-socket only)" ) parser.add_argument( "--signaling-port", default=1234, help="Signaling port (tcp-socket only)" ) parser.add_argument( "--signaling-path", default="aiortc.socket", help="Signaling socket path (unix-socket only)", ) def create_signaling(args): """ Create a signaling method based on command-line arguments. """ if args.signaling == "tcp-socket": return TcpSocketSignaling(args.signaling_host, args.signaling_port) elif args.signaling == "unix-socket": return UnixSocketSignaling(args.signaling_path) else: return CopyAndPasteSignaling() aiortc-1.3.0/src/aiortc/events.py000066400000000000000000000011621417604566400167370ustar00rootroot00000000000000from dataclasses import dataclass from .mediastreams import MediaStreamTrack from .rtcrtpreceiver import RTCRtpReceiver from .rtcrtptransceiver import RTCRtpTransceiver @dataclass class RTCTrackEvent: """ This event is fired on :class:`RTCPeerConnection` when a new :class:`MediaStreamTrack` is added by the remote party. """ receiver: RTCRtpReceiver "The :class:`RTCRtpReceiver` associated with the event." track: MediaStreamTrack "The :class:`MediaStreamTrack` associated with the event." transceiver: RTCRtpTransceiver "The :class:`RTCRtpTransceiver` associated with the event." aiortc-1.3.0/src/aiortc/exceptions.py000066400000000000000000000002641417604566400176160ustar00rootroot00000000000000class InternalError(Exception): pass class InvalidAccessError(Exception): pass class InvalidStateError(Exception): pass class OperationError(Exception): pass aiortc-1.3.0/src/aiortc/jitterbuffer.py000066400000000000000000000100051417604566400201220ustar00rootroot00000000000000from typing import List, Optional, Tuple from .rtp import RtpPacket from .utils import uint16_add MAX_MISORDER = 100 class JitterFrame: def __init__(self, data: bytes, timestamp: int) -> None: self.data = data self.timestamp = timestamp class JitterBuffer: def __init__( self, capacity: int, prefetch: int = 0, is_video: bool = False ) -> None: assert capacity & (capacity - 1) == 0, "capacity must be a power of 2" self._capacity = capacity self._origin: Optional[int] = None self._packets: List[Optional[RtpPacket]] = [None for i in range(capacity)] self._prefetch = prefetch self._is_video = is_video @property def capacity(self) -> int: return self._capacity def add(self, packet: RtpPacket) -> Tuple[bool, Optional[JitterFrame]]: pli_flag = False if self._origin is None: self._origin = packet.sequence_number delta = 0 misorder = 0 else: delta = uint16_add(packet.sequence_number, -self._origin) misorder = uint16_add(self._origin, -packet.sequence_number) if misorder < delta: if misorder >= MAX_MISORDER: self.remove(self.capacity) self._origin = packet.sequence_number delta = misorder = 0 if self._is_video: pli_flag = True else: return pli_flag, None if delta >= self.capacity: # remove just enough frames to fit the received packets excess = delta - self.capacity + 1 if self.smart_remove(excess): self._origin = packet.sequence_number if self._is_video: pli_flag = True pos = packet.sequence_number % self._capacity self._packets[pos] = packet return pli_flag, self._remove_frame(packet.sequence_number) def _remove_frame(self, sequence_number: int) -> Optional[JitterFrame]: frame = None frames = 0 packets = [] remove = 0 timestamp = None for count in range(self.capacity): pos = (self._origin + count) % self._capacity packet = self._packets[pos] if packet is None: break if timestamp is None: timestamp = packet.timestamp elif packet.timestamp != timestamp: # we now have a complete frame, only store the first one if frame is None: frame = JitterFrame( data=b"".join([x._data for x in packets]), timestamp=timestamp ) remove = count # check we have prefetched enough frames += 1 if frames >= self._prefetch: self.remove(remove) return frame # start a new frame packets = [] timestamp = packet.timestamp packets.append(packet) return None def remove(self, count: int) -> None: assert count <= self._capacity for i in range(count): pos = self._origin % self._capacity self._packets[pos] = None self._origin = uint16_add(self._origin, 1) def smart_remove(self, count: int) -> bool: """ smart_remove makes sure that all packages belonging to the same frame are removed it prevents sending corrupted frames to decoder """ timestamp = None for i in range(self._capacity): pos = self._origin % self._capacity packet = self._packets[pos] if packet is not None: if i >= count and timestamp != packet.timestamp: break timestamp = packet.timestamp self._packets[pos] = None self._origin = uint16_add(self._origin, 1) if i == self._capacity - 1: return True return False aiortc-1.3.0/src/aiortc/mediastreams.py000066400000000000000000000076171417604566400201240ustar00rootroot00000000000000import asyncio import fractions import time import uuid from abc import ABCMeta, abstractmethod from typing import Tuple from av import AudioFrame, VideoFrame from av.frame import Frame from pyee.asyncio import AsyncIOEventEmitter AUDIO_PTIME = 0.020 # 20ms audio packetization VIDEO_CLOCK_RATE = 90000 VIDEO_PTIME = 1 / 30 # 30fps VIDEO_TIME_BASE = fractions.Fraction(1, VIDEO_CLOCK_RATE) def convert_timebase( pts: int, from_base: fractions.Fraction, to_base: fractions.Fraction ) -> int: if from_base != to_base: pts = int(pts * from_base / to_base) return pts class MediaStreamError(Exception): pass class MediaStreamTrack(AsyncIOEventEmitter, metaclass=ABCMeta): """ A single media track within a stream. """ kind = "unknown" def __init__(self) -> None: super().__init__() self.__ended = False self._id = str(uuid.uuid4()) @property def id(self) -> str: """ An automatically generated globally unique ID. """ return self._id @property def readyState(self) -> str: return "ended" if self.__ended else "live" @abstractmethod async def recv(self) -> Frame: """ Receive the next :class:`~av.audio.frame.AudioFrame` or :class:`~av.video.frame.VideoFrame`. """ def stop(self) -> None: if not self.__ended: self.__ended = True self.emit("ended") # no more events will be emitted, so remove all event listeners # to facilitate garbage collection. self.remove_all_listeners() class AudioStreamTrack(MediaStreamTrack): """ A dummy audio track which reads silence. """ kind = "audio" _start: float _timestamp: int async def recv(self) -> Frame: """ Receive the next :class:`~av.audio.frame.AudioFrame`. The base implementation just reads silence, subclass :class:`AudioStreamTrack` to provide a useful implementation. """ if self.readyState != "live": raise MediaStreamError sample_rate = 8000 samples = int(AUDIO_PTIME * sample_rate) if hasattr(self, "_timestamp"): self._timestamp += samples wait = self._start + (self._timestamp / sample_rate) - time.time() await asyncio.sleep(wait) else: self._start = time.time() self._timestamp = 0 frame = AudioFrame(format="s16", layout="mono", samples=samples) for p in frame.planes: p.update(bytes(p.buffer_size)) frame.pts = self._timestamp frame.sample_rate = sample_rate frame.time_base = fractions.Fraction(1, sample_rate) return frame class VideoStreamTrack(MediaStreamTrack): """ A dummy video track which reads green frames. """ kind = "video" _start: float _timestamp: int async def next_timestamp(self) -> Tuple[int, fractions.Fraction]: if self.readyState != "live": raise MediaStreamError if hasattr(self, "_timestamp"): self._timestamp += int(VIDEO_PTIME * VIDEO_CLOCK_RATE) wait = self._start + (self._timestamp / VIDEO_CLOCK_RATE) - time.time() await asyncio.sleep(wait) else: self._start = time.time() self._timestamp = 0 return self._timestamp, VIDEO_TIME_BASE async def recv(self) -> Frame: """ Receive the next :class:`~av.video.frame.VideoFrame`. The base implementation just reads a 640x480 green frame at 30fps, subclass :class:`VideoStreamTrack` to provide a useful implementation. """ pts, time_base = await self.next_timestamp() frame = VideoFrame(width=640, height=480) for p in frame.planes: p.update(bytes(p.buffer_size)) frame.pts = pts frame.time_base = time_base return frame aiortc-1.3.0/src/aiortc/rate.py000066400000000000000000000503071417604566400163730ustar00rootroot00000000000000import math from enum import Enum from typing import Dict, List, Optional, Tuple from aiortc.utils import uint32_add, uint32_gt BURST_DELTA_THRESHOLD_MS = 5 # overuse detector MAX_ADAPT_OFFSET_MS = 15 MIN_NUM_DELTAS = 60 # overuse estimator DELTA_COUNTER_MAX = 1000 MIN_FRAME_PERIOD_HISTORY_LENGTH = 60 # abs-send-time estimator INTER_ARRIVAL_SHIFT = 26 TIMESTAMP_GROUP_LENGTH_MS = 5 TIMESTAMP_TO_MS = 1000.0 / (1 << INTER_ARRIVAL_SHIFT) class BandwidthUsage(Enum): NORMAL = 0 UNDERUSING = 1 OVERUSING = 2 class RateControlState(Enum): HOLD = 0 INCREASE = 1 DECREASE = 2 class AimdRateControl: def __init__(self) -> None: self.avg_max_bitrate_kbps = None self.var_max_bitrate_kbps = 0.4 self.current_bitrate = 30000000 self.current_bitrate_initialized = False self.first_estimated_throughput_time: Optional[int] = None self.last_change_ms: Optional[int] = None self.near_max = False self.latest_estimated_throughput = 30000000 self.rtt = 200 self.state = RateControlState.HOLD def feedback_interval(self) -> int: return 500 def set_estimate(self, bitrate: int, now_ms: int) -> None: """ For testing purposes. """ self.current_bitrate = self._clamp_bitrate(bitrate, bitrate) self.current_bitrate_initialized = True self.last_change_ms = now_ms def update( self, bandwidth_usage: BandwidthUsage, estimated_throughput: Optional[int], now_ms: int, ) -> Optional[int]: if not self.current_bitrate_initialized and estimated_throughput is not None: if self.first_estimated_throughput_time is None: self.first_estimated_throughput_time = now_ms elif now_ms - self.first_estimated_throughput_time > 3000: self.current_bitrate = estimated_throughput self.current_bitrate_initialized = True # wait for initialisation or overuse if ( not self.current_bitrate_initialized and bandwidth_usage != BandwidthUsage.OVERUSING ): return None # update state if ( bandwidth_usage == BandwidthUsage.NORMAL and self.state == RateControlState.HOLD ): self.last_change_ms = now_ms self.state = RateControlState.INCREASE elif bandwidth_usage == BandwidthUsage.OVERUSING: self.state = RateControlState.DECREASE elif bandwidth_usage == BandwidthUsage.UNDERUSING: self.state = RateControlState.HOLD # helper variables new_bitrate = self.current_bitrate if estimated_throughput is not None: self.latest_estimated_throughput = estimated_throughput else: estimated_throughput = self.latest_estimated_throughput estimated_throughput_kbps = estimated_throughput / 1000 # update bitrate if self.state == RateControlState.INCREASE: # if the estimated throughput increases significantly, # clear estimated max throughput if self.avg_max_bitrate_kbps is not None: sigma_kbps = math.sqrt( self.var_max_bitrate_kbps * self.avg_max_bitrate_kbps ) if ( estimated_throughput_kbps >= self.avg_max_bitrate_kbps + 3 * sigma_kbps ): self.near_max = False self.avg_max_bitrate_kbps = None # we use additive or multiplicative rate increase depending on whether # we are close to the maximum throughput if self.near_max: new_bitrate += self._additive_rate_increase(self.last_change_ms, now_ms) else: new_bitrate += self._multiplicative_rate_increase( new_bitrate, self.last_change_ms, now_ms ) self.last_change_ms = now_ms elif self.state == RateControlState.DECREASE: # if the estimated throughput drops significantly, # clear estimated max throughput if self.avg_max_bitrate_kbps is not None: sigma_kbps = math.sqrt( self.var_max_bitrate_kbps * self.avg_max_bitrate_kbps ) if ( estimated_throughput_kbps < self.avg_max_bitrate_kbps - 3 * sigma_kbps ): self.avg_max_bitrate_kbps = None self._update_max_throughput_estimate(estimated_throughput_kbps) self.near_max = True new_bitrate = round(0.85 * estimated_throughput) self.last_change_ms = now_ms self.state = RateControlState.HOLD self.current_bitrate = self._clamp_bitrate(new_bitrate, estimated_throughput) return self.current_bitrate def _additive_rate_increase(self, last_ms: int, now_ms: int) -> int: return int((now_ms - last_ms) * self._near_max_rate_increase() / 1000) def _clamp_bitrate(self, new_bitrate: int, estimated_throughput: int) -> int: max_bitrate = max(int(1.5 * estimated_throughput) + 10000, self.current_bitrate) return min(new_bitrate, max_bitrate) def _multiplicative_rate_increase( self, new_bitrate: int, last_ms: int, now_ms: int ) -> int: alpha = 1.08 if last_ms is not None: elapsed_ms = min(now_ms - last_ms, 1000) alpha = pow(alpha, elapsed_ms / 1000) return int(max((alpha - 1) * new_bitrate, 1000)) def _near_max_rate_increase(self) -> int: bits_per_frame = self.current_bitrate / 30 packets_per_frame = math.ceil(bits_per_frame / (8 * 1200)) avg_packet_size_bits = bits_per_frame / packets_per_frame response_time = self.rtt + 100 return max(4000, int((avg_packet_size_bits * 1000) / response_time)) def _update_max_throughput_estimate(self, estimated_throughput_kbps) -> None: alpha = 0.05 if self.avg_max_bitrate_kbps is None: self.avg_max_bitrate_kbps = estimated_throughput_kbps else: self.avg_max_bitrate_kbps = ( 1 - alpha ) * self.avg_max_bitrate_kbps + alpha * estimated_throughput_kbps norm = max(1, self.avg_max_bitrate_kbps) self.var_max_bitrate_kbps = (1 - alpha) * self.var_max_bitrate_kbps + alpha * ( (self.avg_max_bitrate_kbps - estimated_throughput_kbps) ** 2 ) / norm self.var_max_bitrate_kbps = max(0.4, min(self.var_max_bitrate_kbps, 2.5)) class TimestampGroup: def __init__(self, timestamp: Optional[int] = None) -> None: self.arrival_time: Optional[int] = None self.first_timestamp = timestamp self.last_timestamp = timestamp self.size = 0 class InterArrivalDelta: def __init__(self, timestamp: int, arrival_time: int, size: int) -> None: self.timestamp = timestamp self.arrival_time = arrival_time self.size = size class InterArrival: """ Inter-arrival time and size filter. Adapted from the webrtc.org codebase. """ def __init__(self, group_length: int, timestamp_to_ms: float) -> None: self.group_length = group_length self.timestamp_to_ms = timestamp_to_ms self.current_group: Optional[TimestampGroup] = None self.previous_group: Optional[TimestampGroup] = None def compute_deltas( self, timestamp: int, arrival_time: int, packet_size: int ) -> Optional[InterArrivalDelta]: deltas = None if self.current_group is None: self.current_group = TimestampGroup(timestamp) elif self.packet_out_of_order(timestamp): return deltas elif self.new_timestamp_group(timestamp, arrival_time): if self.previous_group is not None: deltas = InterArrivalDelta( timestamp=uint32_add( self.current_group.last_timestamp, -self.previous_group.last_timestamp, ), arrival_time=( self.current_group.arrival_time - self.previous_group.arrival_time ), size=self.current_group.size - self.previous_group.size, ) # shift groups self.previous_group = self.current_group self.current_group = TimestampGroup(timestamp=timestamp) elif uint32_gt(timestamp, self.current_group.last_timestamp): self.current_group.last_timestamp = timestamp self.current_group.size += packet_size self.current_group.arrival_time = arrival_time return deltas def belongs_to_burst(self, timestamp: int, arrival_time: int) -> bool: timestamp_delta = uint32_add(timestamp, -self.current_group.last_timestamp) timestamp_delta_ms = round(self.timestamp_to_ms * timestamp_delta) arrival_time_delta = arrival_time - self.current_group.arrival_time return timestamp_delta_ms == 0 or ( (arrival_time_delta - timestamp_delta_ms) < 0 and arrival_time_delta <= BURST_DELTA_THRESHOLD_MS ) def new_timestamp_group(self, timestamp: int, arrival_time: int) -> bool: if self.belongs_to_burst(timestamp, arrival_time): return False else: timestamp_delta = uint32_add(timestamp, -self.current_group.first_timestamp) return timestamp_delta > self.group_length def packet_out_of_order(self, timestamp: int) -> bool: timestamp_delta = uint32_add(timestamp, -self.current_group.first_timestamp) return timestamp_delta >= 0x80000000 class OveruseDetector: """ Bandwidth overuse detector. Adapted from the webrtc.org codebase. """ def __init__(self) -> None: self.hypothesis = BandwidthUsage.NORMAL self.last_update_ms: Optional[int] = None self.k_up = 0.0087 self.k_down = 0.039 self.overuse_counter = 0 self.overuse_time: Optional[float] = None self.overuse_time_threshold = 10 self.previous_offset = 0.0 self.threshold = 12.5 def detect( self, offset: float, timestamp_delta_ms: float, num_of_deltas: int, now_ms: int ) -> BandwidthUsage: if num_of_deltas < 2: return BandwidthUsage.NORMAL T = min(num_of_deltas, MIN_NUM_DELTAS) * offset if T > self.threshold: if self.overuse_time is None: self.overuse_time = timestamp_delta_ms / 2 else: self.overuse_time += timestamp_delta_ms self.overuse_counter += 1 if ( self.overuse_time > self.overuse_time_threshold and self.overuse_counter > 1 and offset >= self.previous_offset ): self.overuse_counter = 0 self.overuse_time = 0 self.hypothesis = BandwidthUsage.OVERUSING elif T < -self.threshold: self.overuse_counter = 0 self.overuse_time = None self.hypothesis = BandwidthUsage.UNDERUSING else: self.overuse_counter = 0 self.overuse_time = None self.hypothesis = BandwidthUsage.NORMAL self.previous_offset = offset self.update_threshold(T, now_ms) return self.hypothesis def state(self) -> BandwidthUsage: return self.hypothesis def update_threshold(self, modified_offset: float, now_ms: int) -> None: if self.last_update_ms is None: self.last_update_ms = now_ms if abs(modified_offset) > self.threshold + MAX_ADAPT_OFFSET_MS: self.last_update_ms = now_ms return k = self.k_down if abs(modified_offset) < self.threshold else self.k_up time_delta_ms = min(now_ms - self.last_update_ms, 100) self.threshold += k * (abs(modified_offset) - self.threshold) * time_delta_ms self.threshold = max(6, min(self.threshold, 600)) self.last_update_ms = now_ms class OveruseEstimator: """ Bandwidth overuse estimator. Adapted from the webrtc.org codebase. """ def __init__(self) -> None: self.E = [[100.0, 0.0], [0.0, 0.1]] self._num_of_deltas = 0 self._offset = 0.0 self.previous_offset = 0.0 self.slope = 1 / 64 self.ts_delta_hist: List[float] = [] self.avg_noise = 0.0 self.var_noise = 50.0 self.process_noise = [1e-13, 1e-3] def num_of_deltas(self) -> int: return self._num_of_deltas def offset(self) -> float: return self._offset def update( self, time_delta_ms: int, timestamp_delta_ms: float, size_delta: int, current_hypothesis: BandwidthUsage, now_ms: int, ): min_frame_period = self.update_min_frame_period(timestamp_delta_ms) t_ts_delta = time_delta_ms - timestamp_delta_ms fs_delta = size_delta self._num_of_deltas = min(self._num_of_deltas + 1, DELTA_COUNTER_MAX) # update Kalman filter self.E[0][0] += self.process_noise[0] self.E[1][1] += self.process_noise[1] if ( current_hypothesis == BandwidthUsage.OVERUSING and self._offset < self.previous_offset ) or ( current_hypothesis == BandwidthUsage.UNDERUSING and self._offset > self.previous_offset ): self.E[1][1] += 10 * self.process_noise[1] h = [fs_delta, 1.0] Eh = [ self.E[0][0] * h[0] + self.E[0][1] * h[1], self.E[1][0] * h[0] + self.E[1][1] * h[1], ] # update noise estimate residual = t_ts_delta - self.slope * h[0] - self._offset if current_hypothesis == BandwidthUsage.NORMAL: max_residual = 3.0 * math.sqrt(self.var_noise) if abs(residual) < max_residual: self.update_noise_estimate(residual, min_frame_period) else: self.update_noise_estimate( -max_residual if residual < 0 else max_residual, min_frame_period ) denom = self.var_noise + h[0] * Eh[0] + h[1] * Eh[1] K = [Eh[0] / denom, Eh[1] / denom] IKh = [[1.0 - K[0] * h[0], -K[0] * h[1]], [-K[1] * h[0], 1.0 - K[1] * h[1]]] e00 = self.E[0][0] e01 = self.E[0][1] # update state self.E[0][0] = e00 * IKh[0][0] + self.E[1][0] * IKh[0][1] self.E[0][1] = e01 * IKh[0][0] + self.E[1][1] * IKh[0][1] self.E[1][0] = e00 * IKh[1][0] + self.E[1][0] * IKh[1][1] self.E[1][1] = e01 * IKh[1][0] + self.E[1][1] * IKh[1][1] self.previous_offset = self._offset self.slope += K[0] * residual self._offset += K[1] * residual def update_min_frame_period(self, ts_delta: float) -> float: min_frame_period = ts_delta if len(self.ts_delta_hist) >= MIN_FRAME_PERIOD_HISTORY_LENGTH: self.ts_delta_hist.pop(0) for old_ts_delta in self.ts_delta_hist: min_frame_period = min(old_ts_delta, min_frame_period) self.ts_delta_hist.append(ts_delta) return min_frame_period def update_noise_estimate(self, residual: float, ts_delta: float) -> None: alpha = 0.01 if self._num_of_deltas > 10 * 30: alpha = 0.002 beta = pow(1 - alpha, ts_delta * 30.0 / 1000.0) self.avg_noise = beta * self.avg_noise + (1 - beta) * residual self.var_noise = ( beta * self.var_noise + (1 - beta) * (self.avg_noise - residual) ** 2 ) if self.var_noise < 1: self.var_noise = 1 class RateBucket: def __init__(self, count: int = 0, value: int = 0) -> None: self.count = count self.value = value def __eq__(self, other) -> bool: return self.count == other.count and self.value == other.value class RateCounter: """ Rate counter, which stores the amount received in 1ms buckets. """ def __init__(self, window_size: int, scale: int = 8000) -> None: self._origin_index = 0 self._origin_ms: Optional[int] = None self._scale = scale self._window_size = window_size self.reset() def add(self, value: int, now_ms: int) -> None: if self._origin_ms is None: self._origin_ms = now_ms else: self._erase_old(now_ms) index = (self._origin_index + now_ms - self._origin_ms) % self._window_size self._buckets[index].count += 1 self._buckets[index].value += value self._total.count += 1 self._total.value += value def rate(self, now_ms: int) -> Optional[int]: if self._origin_ms is not None: self._erase_old(now_ms) active_window_size = now_ms - self._origin_ms + 1 if self._total.count > 0 and active_window_size > 1: return round(self._scale * self._total.value / active_window_size) return None def reset(self) -> None: self._buckets = [RateBucket() for i in range(self._window_size)] self._origin_index = 0 self._origin_ms = None self._total = RateBucket() def _erase_old(self, now_ms: int) -> None: new_origin_ms = now_ms - self._window_size + 1 while self._origin_ms < new_origin_ms: bucket = self._buckets[self._origin_index] self._total.count -= bucket.count self._total.value -= bucket.value bucket.count = 0 bucket.value = 0 self._origin_index = (self._origin_index + 1) % self._window_size self._origin_ms += 1 class RemoteBitrateEstimator: def __init__(self) -> None: self.incoming_bitrate = RateCounter(1000, 8000) self.incoming_bitrate_initialized = True self.inter_arrival = InterArrival( (TIMESTAMP_GROUP_LENGTH_MS << INTER_ARRIVAL_SHIFT) // 1000, TIMESTAMP_TO_MS ) self.estimator = OveruseEstimator() self.detector = OveruseDetector() self.rate_control = AimdRateControl() self.last_update_ms: Optional[int] = None self.ssrcs: Dict[int, int] = {} def add( self, arrival_time_ms: int, abs_send_time: int, payload_size: int, ssrc: int ) -> Optional[Tuple[int, List[int]]]: timestamp = abs_send_time << 8 update_estimate = False # make note of SSRC self.ssrcs[ssrc] = arrival_time_ms # update incoming bitrate if self.incoming_bitrate.rate(arrival_time_ms) is not None: self.incoming_bitrate_initialized = True elif self.incoming_bitrate_initialized: self.incoming_bitrate.reset() self.incoming_bitrate_initialized = False self.incoming_bitrate.add(payload_size, arrival_time_ms) # calculate inter-arrival deltas deltas = self.inter_arrival.compute_deltas( timestamp, arrival_time_ms, payload_size ) if deltas is not None: timestamp_delta_ms = deltas.timestamp * TIMESTAMP_TO_MS self.estimator.update( deltas.arrival_time, timestamp_delta_ms, deltas.size, self.detector.state(), arrival_time_ms, ) self.detector.detect( self.estimator.offset(), timestamp_delta_ms, self.estimator.num_of_deltas(), arrival_time_ms, ) if not update_estimate: if ( self.last_update_ms is None or (arrival_time_ms - self.last_update_ms) > self.rate_control.feedback_interval() ): update_estimate = True elif self.detector.state() == BandwidthUsage.OVERUSING: update_estimate = True if update_estimate: target_bitrate = self.rate_control.update( self.detector.state(), self.incoming_bitrate.rate(arrival_time_ms), arrival_time_ms, ) if target_bitrate is not None: self.last_update_ms = arrival_time_ms return target_bitrate, list(self.ssrcs.keys()) return None aiortc-1.3.0/src/aiortc/rtcconfiguration.py000066400000000000000000000020201417604566400210050ustar00rootroot00000000000000from dataclasses import dataclass from typing import List, Optional @dataclass class RTCIceServer: """ The :class:`RTCIceServer` dictionary defines how to connect to a single STUN or TURN server. It includes both the URL and the necessary credentials, if any, to connect to the server. """ urls: str """ This required property is either a single string or a list of strings, each specifying a URL which can be used to connect to the server. """ username: Optional[str] = None "The username to use during authentication (for TURN only)." credential: Optional[str] = None "The credential to use during authentication (for TURN only)." credentialType: str = "password" @dataclass class RTCConfiguration: """ The :class:`RTCConfiguration` dictionary is used to provide configuration options for an :class:`RTCPeerConnection`. """ iceServers: Optional[List[RTCIceServer]] = None "A list of :class:`RTCIceServer` objects to configure STUN / TURN servers." aiortc-1.3.0/src/aiortc/rtcdatachannel.py000066400000000000000000000145731417604566400204200ustar00rootroot00000000000000import logging from dataclasses import dataclass from typing import Optional, Union from pyee.asyncio import AsyncIOEventEmitter from .exceptions import InvalidStateError logger = logging.getLogger(__name__) @dataclass class RTCDataChannelParameters: """ The :class:`RTCDataChannelParameters` dictionary describes the configuration of an :class:`RTCDataChannel`. """ label: str = "" "A name describing the data channel." maxPacketLifeTime: Optional[int] = None "The maximum time in milliseconds during which transmissions are attempted." maxRetransmits: Optional[int] = None "The maximum number of retransmissions that are attempted." ordered: bool = True "Whether the data channel guarantees in-order delivery of messages." protocol: str = "" "The name of the subprotocol in use." negotiated: bool = False """ Whether data channel will be negotiated out of-band, where both sides create data channel with an agreed-upon ID.""" id: Optional[int] = None """ An numeric ID for the channel; permitted values are 0-65534. If you don't include this option, the user agent will select an ID for you. Must be set when negotiating out-of-band. """ class RTCDataChannel(AsyncIOEventEmitter): """ The :class:`RTCDataChannel` interface represents a network channel which can be used for bidirectional peer-to-peer transfers of arbitrary data. :param transport: An :class:`RTCSctpTransport`. :param parameters: An :class:`RTCDataChannelParameters`. """ def __init__( self, transport, parameters: RTCDataChannelParameters, send_open: bool = True ) -> None: super().__init__() self.__bufferedAmount = 0 self.__bufferedAmountLowThreshold = 0 self.__id = parameters.id self.__parameters = parameters self.__readyState = "connecting" self.__transport = transport self.__send_open = send_open if self.__parameters.negotiated and ( self.__id is None or self.__id < 0 or self.__id > 65534 ): raise ValueError( "ID must be in range 0-65534 " "if data channel is negotiated out-of-band" ) if not self.__parameters.negotiated: if self.__send_open: self.__send_open = False self.__transport._data_channel_open(self) else: self.__transport._data_channel_add_negotiated(self) @property def bufferedAmount(self) -> int: """ The number of bytes of data currently queued to be sent over the data channel. """ return self.__bufferedAmount @property def bufferedAmountLowThreshold(self) -> int: """ The number of bytes of buffered outgoing data that is considered "low". """ return self.__bufferedAmountLowThreshold @bufferedAmountLowThreshold.setter def bufferedAmountLowThreshold(self, value: int) -> None: if value < 0 or value > 4294967295: raise ValueError( "bufferedAmountLowThreshold must be in range 0 - 4294967295" ) self.__bufferedAmountLowThreshold = value @property def negotiated(self) -> bool: """ Whether data channel was negotiated out-of-band. """ return self.__parameters.negotiated @property def id(self) -> Optional[int]: """ An ID number which uniquely identifies the data channel. """ return self.__id @property def label(self) -> str: """ A name describing the data channel. These labels are not required to be unique. """ return self.__parameters.label @property def ordered(self) -> bool: """ Indicates whether or not the data channel guarantees in-order delivery of messages. """ return self.__parameters.ordered @property def maxPacketLifeTime(self) -> Optional[int]: """ The maximum time in milliseconds during which transmissions are attempted. """ return self.__parameters.maxPacketLifeTime @property def maxRetransmits(self) -> Optional[int]: """ "The maximum number of retransmissions that are attempted. """ return self.__parameters.maxRetransmits @property def protocol(self) -> str: """ The name of the subprotocol in use. """ return self.__parameters.protocol @property def readyState(self) -> str: """ A string indicating the current state of the underlying data transport. """ return self.__readyState @property def transport(self): """ The :class:`RTCSctpTransport` over which data is transmitted. """ return self.__transport def close(self) -> None: """ Close the data channel. """ self.transport._data_channel_close(self) def send(self, data: Union[bytes, str]) -> None: """ Send `data` across the data channel to the remote peer. """ if self.readyState != "open": raise InvalidStateError if not isinstance(data, (str, bytes)): raise ValueError(f"Cannot send unsupported data type: {type(data)}") self.transport._data_channel_send(self, data) def _addBufferedAmount(self, amount: int) -> None: crosses_threshold = ( self.__bufferedAmount > self.bufferedAmountLowThreshold and self.__bufferedAmount + amount <= self.bufferedAmountLowThreshold ) self.__bufferedAmount += amount if crosses_threshold: self.emit("bufferedamountlow") def _setId(self, id: int) -> None: self.__id = id def _setReadyState(self, state: str) -> None: if state != self.__readyState: self.__log_debug("- %s -> %s", self.__readyState, state) self.__readyState = state if state == "open": self.emit("open") elif state == "closed": self.emit("close") # no more events will be emitted, so remove all event listeners # to facilitate garbage collection. self.remove_all_listeners() def __log_debug(self, msg: str, *args) -> None: logger.debug(f"RTCDataChannel(%s) {msg}", self.__id, *args) aiortc-1.3.0/src/aiortc/rtcdtlstransport.py000066400000000000000000000562561417604566400211050ustar00rootroot00000000000000import asyncio import base64 import binascii import datetime import enum import logging import os import traceback from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Set, Tuple, Type, TypeVar import pylibsrtp from cryptography import x509 from cryptography.hazmat.backends import default_backend from cryptography.hazmat.bindings.openssl.binding import Binding from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import ec from pyee.asyncio import AsyncIOEventEmitter from pylibsrtp import Policy, Session from . import clock, rtp from .rtcicetransport import RTCIceTransport from .rtcrtpparameters import RTCRtpReceiveParameters, RTCRtpSendParameters from .rtp import ( AnyRtcpPacket, RtcpByePacket, RtcpPacket, RtcpPsfbPacket, RtcpRrPacket, RtcpRtpfbPacket, RtcpSrPacket, RtpPacket, is_rtcp, ) from .stats import RTCStatsReport, RTCTransportStats binding = Binding() binding.init_static_locks() ffi = binding.ffi lib = binding.lib SRTP_KEY_LEN = 16 SRTP_SALT_LEN = 14 CERTIFICATE_T = TypeVar("CERTIFICATE_T", bound="RTCCertificate") logger = logging.getLogger(__name__) assert lib.OpenSSL_version_num() >= 0x10002000, "OpenSSL 1.0.2 or better is required" class DtlsError(Exception): pass def _openssl_assert(ok: bool) -> None: if not ok: raise DtlsError("OpenSSL call failed") def certificate_digest(x509) -> str: digest = lib.EVP_get_digestbyname(b"SHA256") _openssl_assert(digest != ffi.NULL) result_buffer = ffi.new("unsigned char[]", lib.EVP_MAX_MD_SIZE) result_length = ffi.new("unsigned int[]", 1) result_length[0] = len(result_buffer) digest_result = lib.X509_digest(x509, digest, result_buffer, result_length) assert digest_result == 1 return b":".join( [ base64.b16encode(ch).upper() for ch in ffi.buffer(result_buffer, result_length[0]) ] ).decode("ascii") def generate_certificate(key: ec.EllipticCurvePrivateKey) -> x509.Certificate: name = x509.Name( [ x509.NameAttribute( x509.NameOID.COMMON_NAME, binascii.hexlify(os.urandom(16)).decode("ascii"), ) ] ) builder = ( x509.CertificateBuilder() .subject_name(name) .issuer_name(name) .public_key(key.public_key()) .serial_number(x509.random_serial_number()) .not_valid_before(datetime.datetime.utcnow() - datetime.timedelta(days=1)) .not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=30)) ) return builder.sign(key, hashes.SHA256(), default_backend()) def get_error_queue() -> List[Tuple[str, str, str]]: errors = [] def text(charp) -> str: return ffi.string(charp).decode("utf-8") if charp else "" while True: error = lib.ERR_get_error() if error == 0: break errors.append( ( text(lib.ERR_lib_error_string(error)), text(lib.ERR_func_error_string(error)), text(lib.ERR_reason_error_string(error)), ) ) return errors def get_srtp_key_salt(src, idx: int) -> bytes: key_start = idx * SRTP_KEY_LEN salt_start = 2 * SRTP_KEY_LEN + idx * SRTP_SALT_LEN return ( src[key_start : key_start + SRTP_KEY_LEN] + src[salt_start : salt_start + SRTP_SALT_LEN] ) @ffi.callback("int(int, X509_STORE_CTX *)") def verify_callback(x, y): return 1 class State(enum.Enum): NEW = 0 CONNECTING = 1 CONNECTED = 2 CLOSED = 3 FAILED = 4 @dataclass class RTCDtlsFingerprint: """ The :class:`RTCDtlsFingerprint` dictionary includes the hash function algorithm and certificate fingerprint. """ algorithm: str "The hash function name, for instance `'sha-256'`." value: str "The fingerprint value." class RTCCertificate: """ The :class:`RTCCertificate` interface enables the certificates used by an :class:`RTCDtlsTransport`. To generate a certificate and the corresponding private key use :func:`generateCertificate`. """ def __init__(self, key: ec.EllipticCurvePrivateKey, cert: x509.Certificate) -> None: self._key = key self._cert = cert @property def expires(self) -> datetime.datetime: """ The date and time after which the certificate will be considered invalid. """ return self._cert.not_valid_after.replace(tzinfo=datetime.timezone.utc) def getFingerprints(self) -> List[RTCDtlsFingerprint]: """ Returns the list of certificate fingerprints, one of which is computed with the digest algorithm used in the certificate signature. """ return [ RTCDtlsFingerprint( algorithm="sha-256", value=certificate_digest(self._cert._x509), # type: ignore ) ] @classmethod def generateCertificate(cls: Type[CERTIFICATE_T]) -> CERTIFICATE_T: """ Create and return an X.509 certificate and corresponding private key. :rtype: RTCCertificate """ key = ec.generate_private_key(ec.SECP256R1(), default_backend()) cert = generate_certificate(key) return cls(key=key, cert=cert) def _create_ssl_context(self) -> Any: ctx = lib.SSL_CTX_new(lib.DTLS_method()) ctx = ffi.gc(ctx, lib.SSL_CTX_free) lib.SSL_CTX_set_verify( ctx, lib.SSL_VERIFY_PEER | lib.SSL_VERIFY_FAIL_IF_NO_PEER_CERT, verify_callback, ) _openssl_assert(lib.SSL_CTX_use_certificate(ctx, self._cert._x509) == 1) # type: ignore _openssl_assert(lib.SSL_CTX_use_PrivateKey(ctx, self._key._evp_pkey) == 1) # type: ignore _openssl_assert(lib.SSL_CTX_set_cipher_list(ctx, b"HIGH:!CAMELLIA:!aNULL") == 1) _openssl_assert( lib.SSL_CTX_set_tlsext_use_srtp(ctx, b"SRTP_AES128_CM_SHA1_80") == 0 ) _openssl_assert(lib.SSL_CTX_set_read_ahead(ctx, 1) == 0) # Configure elliptic curve for ECDHE in server mode for OpenSSL < 1.1.0 if lib.OpenSSL_version_num() < 0x10100000: # pragma: no cover lib.SSL_CTX_set_ecdh_auto(ctx, 1) return ctx @dataclass class RTCDtlsParameters: """ The :class:`RTCDtlsParameters` dictionary includes information relating to DTLS configuration. """ fingerprints: List[RTCDtlsFingerprint] = field(default_factory=list) "List of :class:`RTCDtlsFingerprint`, one fingerprint for each certificate." role: str = "auto" "The DTLS role, with a default of auto." class RtpRouter: """ Router to associate RTP/RTCP packets with streams. https://tools.ietf.org/html/draft-ietf-mmusic-sdp-bundle-negotiation-53 """ def __init__(self) -> None: self.receivers: Set = set() self.senders: Dict[int, Any] = {} self.mid_table: Dict[str, Any] = {} self.ssrc_table: Dict[int, Any] = {} self.payload_type_table: Dict[int, Set] = {} def register_receiver( self, receiver, ssrcs: List[int], payload_types: List[int], mid: Optional[str] = None, ): self.receivers.add(receiver) if mid is not None: self.mid_table[mid] = receiver for ssrc in ssrcs: self.ssrc_table[ssrc] = receiver for payload_type in payload_types: if payload_type not in self.payload_type_table: self.payload_type_table[payload_type] = set() self.payload_type_table[payload_type].add(receiver) def register_sender(self, sender, ssrc: int) -> None: self.senders[ssrc] = sender def route_rtcp(self, packet: AnyRtcpPacket) -> Set: recipients = set() def add_recipient(recipient) -> None: if recipient is not None: recipients.add(recipient) # route to RTP receiver if isinstance(packet, RtcpSrPacket): add_recipient(self.ssrc_table.get(packet.ssrc)) elif isinstance(packet, RtcpByePacket): for source in packet.sources: add_recipient(self.ssrc_table.get(source)) # route to RTP sender if isinstance(packet, (RtcpRrPacket, RtcpSrPacket)): for report in packet.reports: add_recipient(self.senders.get(report.ssrc)) elif isinstance(packet, (RtcpPsfbPacket, RtcpRtpfbPacket)): add_recipient(self.senders.get(packet.media_ssrc)) # for REMB packets, media_ssrc is always 0, we need to look into the FCI if isinstance(packet, RtcpPsfbPacket) and packet.fmt == rtp.RTCP_PSFB_APP: try: for ssrc in rtp.unpack_remb_fci(packet.fci)[1]: add_recipient(self.senders.get(ssrc)) except ValueError: pass return recipients def route_rtp(self, packet: RtpPacket) -> Optional[Any]: ssrc_receiver = self.ssrc_table.get(packet.ssrc) pt_receivers = self.payload_type_table.get(packet.payload_type, set()) # the SSRC and payload type are known and match if ssrc_receiver is not None and ssrc_receiver in pt_receivers: return ssrc_receiver # the SSRC is unknown but the payload type matches, update the SSRC table if ssrc_receiver is None and len(pt_receivers) == 1: pt_receiver = list(pt_receivers)[0] self.ssrc_table[packet.ssrc] = pt_receiver return pt_receiver # discard the packet return None def unregister_receiver(self, receiver) -> None: self.receivers.discard(receiver) self.__discard(self.mid_table, receiver) self.__discard(self.ssrc_table, receiver) for pt, receivers in self.payload_type_table.items(): receivers.discard(receiver) def unregister_sender(self, sender) -> None: self.__discard(self.senders, sender) def __discard(self, d: Dict, value: Any) -> None: for k, v in list(d.items()): if v == value: d.pop(k) class RTCDtlsTransport(AsyncIOEventEmitter): """ The :class:`RTCDtlsTransport` object includes information relating to Datagram Transport Layer Security (DTLS) transport. :param transport: An :class:`RTCIceTransport`. :param certificates: A list of :class:`RTCCertificate` (only one is allowed currently). """ def __init__( self, transport: RTCIceTransport, certificates: List[RTCCertificate] ) -> None: assert len(certificates) == 1 certificate = certificates[0] super().__init__() self.encrypted = False self._data_receiver = None self._role = "auto" self._rtp_header_extensions_map = rtp.HeaderExtensionsMap() self._rtp_router = RtpRouter() self._state = State.NEW self._stats_id = "transport_" + str(id(self)) self._task: Optional[asyncio.Future[None]] = None self._transport = transport # counters self.__rx_bytes = 0 self.__rx_packets = 0 self.__tx_bytes = 0 self.__tx_packets = 0 # SRTP self._rx_srtp: Session = None self._tx_srtp: Session = None # SSL init self.__ctx = certificate._create_ssl_context() ssl = lib.SSL_new(self.__ctx) self.ssl = ffi.gc(ssl, lib.SSL_free) self.read_bio = lib.BIO_new(lib.BIO_s_mem()) self.read_cdata = ffi.new("char[]", 1500) self.write_bio = lib.BIO_new(lib.BIO_s_mem()) self.write_cdata = ffi.new("char[]", 1500) lib.SSL_set_bio(self.ssl, self.read_bio, self.write_bio) self.__local_certificate = certificate @property def state(self) -> str: """ The current state of the DTLS transport. One of `'new'`, `'connecting'`, `'connected'`, `'closed'` or `'failed'`. """ return str(self._state)[6:].lower() @property def transport(self): """ The associated :class:`RTCIceTransport` instance. """ return self._transport def getLocalParameters(self) -> RTCDtlsParameters: """ Get the local parameters of the DTLS transport. :rtype: :class:`RTCDtlsParameters` """ return RTCDtlsParameters( fingerprints=self.__local_certificate.getFingerprints() ) async def start(self, remoteParameters: RTCDtlsParameters) -> None: """ Start DTLS transport negotiation with the parameters of the remote DTLS transport. :param remoteParameters: An :class:`RTCDtlsParameters`. """ assert self._state == State.NEW assert len(remoteParameters.fingerprints) # For WebRTC, the DTLS role is explicitly determined as part of the # offer / answer exchange. # # For ORTC however, we determine the DTLS role based on the ICE role. if self._role == "auto": if self.transport.role == "controlling": self._set_role("server") else: self._set_role("client") if self._role == "server": lib.SSL_set_accept_state(self.ssl) else: lib.SSL_set_connect_state(self.ssl) self._set_state(State.CONNECTING) try: while not self.encrypted: result = lib.SSL_do_handshake(self.ssl) await self._write_ssl() if result > 0: self.encrypted = True break error = lib.SSL_get_error(self.ssl, result) if error == lib.SSL_ERROR_WANT_READ: await self._recv_next() else: self.__log_debug("x DTLS handshake failed (error %d)", error) for info in get_error_queue(): self.__log_debug("x %s", ":".join(info)) self._set_state(State.FAILED) return except ConnectionError: self.__log_debug("x DTLS handshake failed (connection error)") self._set_state(State.FAILED) return # check remote fingerprint x509 = lib.SSL_get_peer_certificate(self.ssl) remote_fingerprint = certificate_digest(x509) fingerprint_is_valid = False for f in remoteParameters.fingerprints: if ( f.algorithm.lower() == "sha-256" and f.value.lower() == remote_fingerprint.lower() ): fingerprint_is_valid = True break if not fingerprint_is_valid: self.__log_debug("x DTLS handshake failed (fingerprint mismatch)") self._set_state(State.FAILED) return # generate keying material buf = ffi.new("unsigned char[]", 2 * (SRTP_KEY_LEN + SRTP_SALT_LEN)) extractor = b"EXTRACTOR-dtls_srtp" _openssl_assert( lib.SSL_export_keying_material( self.ssl, buf, len(buf), extractor, len(extractor), ffi.NULL, 0, 0 ) == 1 ) view = ffi.buffer(buf) if self._role == "server": srtp_tx_key = get_srtp_key_salt(view, 1) srtp_rx_key = get_srtp_key_salt(view, 0) else: srtp_tx_key = get_srtp_key_salt(view, 0) srtp_rx_key = get_srtp_key_salt(view, 1) rx_policy = Policy(key=srtp_rx_key, ssrc_type=Policy.SSRC_ANY_INBOUND) rx_policy.allow_repeat_tx = True rx_policy.window_size = 1024 self._rx_srtp = Session(rx_policy) tx_policy = Policy(key=srtp_tx_key, ssrc_type=Policy.SSRC_ANY_OUTBOUND) tx_policy.allow_repeat_tx = True tx_policy.window_size = 1024 self._tx_srtp = Session(tx_policy) # start data pump self.__log_debug("- DTLS handshake complete") self._set_state(State.CONNECTED) self._task = asyncio.ensure_future(self.__run()) async def stop(self) -> None: """ Stop and close the DTLS transport. """ if self._task is not None: self._task.cancel() self._task = None if self._state in [State.CONNECTING, State.CONNECTED]: lib.SSL_shutdown(self.ssl) try: await self._write_ssl() except ConnectionError: pass self.__log_debug("- DTLS shutdown complete") async def __run(self) -> None: try: while True: await self._recv_next() except ConnectionError: for receiver in self._rtp_router.receivers: receiver._handle_disconnect() except Exception as exc: if not isinstance(exc, asyncio.CancelledError): self.__log_warning(traceback.format_exc()) raise exc finally: self._set_state(State.CLOSED) def _get_stats(self) -> RTCStatsReport: report = RTCStatsReport() report.add( RTCTransportStats( # RTCStats timestamp=clock.current_datetime(), type="transport", id=self._stats_id, # RTCTransportStats, packetsSent=self.__tx_packets, packetsReceived=self.__rx_packets, bytesSent=self.__tx_bytes, bytesReceived=self.__rx_bytes, iceRole=self.transport.role, dtlsState=self.state, ) ) return report async def _handle_rtcp_data(self, data: bytes) -> None: try: packets = RtcpPacket.parse(data) except ValueError as exc: self.__log_debug("x RTCP parsing failed: %s", exc) return for packet in packets: # route RTCP packet for recipient in self._rtp_router.route_rtcp(packet): await recipient._handle_rtcp_packet(packet) async def _handle_rtp_data(self, data: bytes, arrival_time_ms: int) -> None: try: packet = RtpPacket.parse(data, self._rtp_header_extensions_map) except ValueError as exc: self.__log_debug("x RTP parsing failed: %s", exc) return # route RTP packet receiver = self._rtp_router.route_rtp(packet) if receiver is not None: await receiver._handle_rtp_packet(packet, arrival_time_ms=arrival_time_ms) async def _recv_next(self) -> None: # get timeout timeout = None if not self.encrypted: ptv_sec = ffi.new("time_t *") ptv_usec = ffi.new("long *") if lib.Cryptography_DTLSv1_get_timeout(self.ssl, ptv_sec, ptv_usec): timeout = ptv_sec[0] + (ptv_usec[0] / 1000000) # receive next datagram if timeout is not None: try: data = await asyncio.wait_for(self.transport._recv(), timeout=timeout) except asyncio.TimeoutError: self.__log_debug("x DTLS handling timeout") lib.DTLSv1_handle_timeout(self.ssl) await self._write_ssl() return else: data = await self.transport._recv() self.__rx_bytes += len(data) self.__rx_packets += 1 first_byte = data[0] if first_byte > 19 and first_byte < 64: # DTLS lib.BIO_write(self.read_bio, data, len(data)) result = lib.SSL_read(self.ssl, self.read_cdata, len(self.read_cdata)) await self._write_ssl() if result == 0: self.__log_debug("- DTLS shutdown by remote party") raise ConnectionError elif result > 0 and self._data_receiver: data = ffi.buffer(self.read_cdata)[0:result] await self._data_receiver._handle_data(data) elif first_byte > 127 and first_byte < 192 and self._rx_srtp: # SRTP / SRTCP arrival_time_ms = clock.current_ms() try: if is_rtcp(data): data = self._rx_srtp.unprotect_rtcp(data) await self._handle_rtcp_data(data) else: data = self._rx_srtp.unprotect(data) await self._handle_rtp_data(data, arrival_time_ms=arrival_time_ms) except pylibsrtp.Error as exc: self.__log_debug("x SRTP unprotect failed: %s", exc) def _register_data_receiver(self, receiver) -> None: assert self._data_receiver is None self._data_receiver = receiver def _register_rtp_receiver( self, receiver, parameters: RTCRtpReceiveParameters ) -> None: ssrcs = set() for encoding in parameters.encodings: ssrcs.add(encoding.ssrc) self._rtp_header_extensions_map.configure(parameters) self._rtp_router.register_receiver( receiver, ssrcs=list(ssrcs), payload_types=[codec.payloadType for codec in parameters.codecs], mid=parameters.muxId, ) def _register_rtp_sender(self, sender, parameters: RTCRtpSendParameters) -> None: self._rtp_header_extensions_map.configure(parameters) self._rtp_router.register_sender(sender, ssrc=sender._ssrc) async def _send_data(self, data: bytes) -> None: if self._state != State.CONNECTED: raise ConnectionError("Cannot send encrypted data, not connected") lib.SSL_write(self.ssl, data, len(data)) await self._write_ssl() async def _send_rtp(self, data: bytes) -> None: if self._state != State.CONNECTED: raise ConnectionError("Cannot send encrypted RTP, not connected") if is_rtcp(data): data = self._tx_srtp.protect_rtcp(data) else: data = self._tx_srtp.protect(data) await self.transport._send(data) self.__tx_bytes += len(data) self.__tx_packets += 1 def _set_role(self, role: str) -> None: self._role = role def _set_state(self, state: State) -> None: if state != self._state: self.__log_debug("- %s -> %s", self._state, state) self._state = state self.emit("statechange") def _unregister_data_receiver(self, receiver) -> None: if self._data_receiver == receiver: self._data_receiver = None def _unregister_rtp_receiver(self, receiver) -> None: self._rtp_router.unregister_receiver(receiver) def _unregister_rtp_sender(self, sender) -> None: self._rtp_router.unregister_sender(sender) async def _write_ssl(self) -> None: """ Flush outgoing data which OpenSSL put in our BIO to the transport. """ pending = lib.BIO_ctrl_pending(self.write_bio) if pending > 0: result = lib.BIO_read( self.write_bio, self.write_cdata, len(self.write_cdata) ) await self.transport._send(ffi.buffer(self.write_cdata)[0:result]) self.__tx_bytes += result self.__tx_packets += 1 def __log_debug(self, msg: str, *args) -> None: logger.debug(f"RTCDtlsTransport(%s) {msg}", self._role, *args) def __log_warning(self, msg: str, *args) -> None: logger.warning(f"RTCDtlsTransport(%s) {msg}", self._role, *args) aiortc-1.3.0/src/aiortc/rtcicetransport.py000066400000000000000000000260331417604566400206650ustar00rootroot00000000000000import asyncio import logging import re from dataclasses import dataclass from typing import Any, Dict, List, Optional from aioice import Candidate, Connection, ConnectionClosed from pyee.asyncio import AsyncIOEventEmitter from .exceptions import InvalidStateError from .rtcconfiguration import RTCIceServer STUN_REGEX = re.compile( r"(?Pstun|stuns)\:(?P[^?:]+)(\:(?P[0-9]+?))?" ) TURN_REGEX = re.compile( r"(?Pturn|turns)\:(?P[^?:]+)(\:(?P[0-9]+?))?" r"(\?transport=(?P.*))?" ) logger = logging.getLogger(__name__) @dataclass class RTCIceCandidate: """ The :class:`RTCIceCandidate` interface represents a candidate Interactive Connectivity Establishment (ICE) configuration which may be used to establish an RTCPeerConnection. """ component: int foundation: str ip: str port: int priority: int protocol: str type: str relatedAddress: Optional[str] = None relatedPort: Optional[int] = None sdpMid: Optional[str] = None sdpMLineIndex: Optional[int] = None tcpType: Optional[str] = None @dataclass class RTCIceParameters: """ The :class:`RTCIceParameters` dictionary includes the ICE username fragment and password and other ICE-related parameters. """ usernameFragment: Optional[str] = None "ICE username fragment." password: Optional[str] = None "ICE password." iceLite: bool = False def candidate_from_aioice(x: Candidate) -> RTCIceCandidate: return RTCIceCandidate( component=x.component, foundation=x.foundation, ip=x.host, port=x.port, priority=x.priority, protocol=x.transport, relatedAddress=x.related_address, relatedPort=x.related_port, tcpType=x.tcptype, type=x.type, ) def candidate_to_aioice(x: RTCIceCandidate) -> Candidate: return Candidate( component=x.component, foundation=x.foundation, host=x.ip, port=x.port, priority=x.priority, related_address=x.relatedAddress, related_port=x.relatedPort, transport=x.protocol, tcptype=x.tcpType, type=x.type, ) def connection_kwargs(servers: List[RTCIceServer]) -> Dict[str, Any]: kwargs: Dict[str, Any] = {} for server in servers: if isinstance(server.urls, list): uris = server.urls else: uris = [server.urls] for uri in uris: parsed = parse_stun_turn_uri(uri) if parsed["scheme"] == "stun": # only a single STUN server is supported if "stun_server" in kwargs: continue kwargs["stun_server"] = (parsed["host"], parsed["port"]) elif parsed["scheme"] in ["turn", "turns"]: # only a single TURN server is supported if "turn_server" in kwargs: continue # only 'udp' and 'tcp' transports are supported if parsed["scheme"] == "turn" and parsed["transport"] not in [ "udp", "tcp", ]: continue elif parsed["scheme"] == "turns" and parsed["transport"] != "tcp": continue # only 'password' credentialType is supported if server.credentialType != "password": continue kwargs["turn_server"] = (parsed["host"], parsed["port"]) kwargs["turn_ssl"] = parsed["scheme"] == "turns" kwargs["turn_transport"] = parsed["transport"] kwargs["turn_username"] = server.username kwargs["turn_password"] = server.credential return kwargs def parse_stun_turn_uri(uri: str) -> Dict[str, Any]: if uri.startswith("stun"): match = STUN_REGEX.fullmatch(uri) elif uri.startswith("turn"): match = TURN_REGEX.fullmatch(uri) else: raise ValueError("malformed uri: invalid scheme") if not match: raise ValueError("malformed uri") # set port parsed: Dict[str, Any] = match.groupdict() if parsed["port"]: parsed["port"] = int(parsed["port"]) elif parsed["scheme"] in ["stuns", "turns"]: parsed["port"] = 5349 else: parsed["port"] = 3478 # set transport if parsed["scheme"] == "turn" and not parsed["transport"]: parsed["transport"] = "udp" elif parsed["scheme"] == "turns" and not parsed["transport"]: parsed["transport"] = "tcp" return parsed class RTCIceGatherer(AsyncIOEventEmitter): """ The :class:`RTCIceGatherer` interface gathers local host, server reflexive and relay candidates, as well as enabling the retrieval of local Interactive Connectivity Establishment (ICE) parameters which can be exchanged in signaling. """ def __init__(self, iceServers: Optional[List[RTCIceServer]] = None) -> None: super().__init__() if iceServers is None: iceServers = self.getDefaultIceServers() ice_kwargs = connection_kwargs(iceServers) self._connection = Connection(ice_controlling=False, **ice_kwargs) self._remote_candidates_end = False self.__state = "new" @property def state(self) -> str: """ The current state of the ICE gatherer. """ return self.__state async def gather(self) -> None: """ Gather ICE candidates. """ if self.__state == "new": self.__setState("gathering") await self._connection.gather_candidates() self.__setState("completed") @classmethod def getDefaultIceServers(cls) -> List[RTCIceServer]: """ Return the list of default :class:`RTCIceServer`. """ return [RTCIceServer("stun:stun.l.google.com:19302")] def getLocalCandidates(self) -> List[RTCIceCandidate]: """ Retrieve the list of valid local candidates associated with the ICE gatherer. """ return [candidate_from_aioice(x) for x in self._connection.local_candidates] def getLocalParameters(self) -> RTCIceParameters: """ Retrieve the ICE parameters of the ICE gatherer. :rtype: RTCIceParameters """ return RTCIceParameters( usernameFragment=self._connection.local_username, password=self._connection.local_password, ) def __setState(self, state: str) -> None: self.__state = state self.emit("statechange") class RTCIceTransport(AsyncIOEventEmitter): """ The :class:`RTCIceTransport` interface allows an application access to information about the Interactive Connectivity Establishment (ICE) transport over which packets are sent and received. :param gatherer: An :class:`RTCIceGatherer`. """ def __init__(self, gatherer: RTCIceGatherer) -> None: super().__init__() self.__iceGatherer = gatherer self.__monitor_task: Optional[asyncio.Future[None]] = None self.__start: Optional[asyncio.Event] = None self.__state = "new" self._connection = gatherer._connection self._role_set = False # expose recv / send methods self._recv = self._connection.recv self._send = self._connection.send @property def iceGatherer(self) -> RTCIceGatherer: """ The ICE gatherer passed in the constructor. """ return self.__iceGatherer @property def role(self) -> str: """ The current role of the ICE transport. Either `'controlling'` or `'controlled'`. """ if self._connection.ice_controlling: return "controlling" else: return "controlled" @property def state(self) -> str: """ The current state of the ICE transport. """ return self.__state async def addRemoteCandidate(self, candidate: Optional[RTCIceCandidate]) -> None: """ Add a remote candidate. :param candidate: The new candidate or `None` to signal end of candidates. """ if not self.__iceGatherer._remote_candidates_end: if candidate is None: self.__iceGatherer._remote_candidates_end = True await self._connection.add_remote_candidate(None) else: await self._connection.add_remote_candidate( candidate_to_aioice(candidate) ) def getRemoteCandidates(self) -> List[RTCIceCandidate]: """ Retrieve the list of candidates associated with the remote :class:`RTCIceTransport`. """ return [candidate_from_aioice(x) for x in self._connection.remote_candidates] async def start(self, remoteParameters: RTCIceParameters) -> None: """ Initiate connectivity checks. :param remoteParameters: The :class:`RTCIceParameters` associated with the remote :class:`RTCIceTransport`. """ if self.state == "closed": raise InvalidStateError("RTCIceTransport is closed") # handle the case where start is already in progress if self.__start is not None: await self.__start.wait() return self.__start = asyncio.Event() self.__monitor_task = asyncio.ensure_future(self._monitor()) self.__setState("checking") self._connection.remote_is_lite = remoteParameters.iceLite self._connection.remote_username = remoteParameters.usernameFragment self._connection.remote_password = remoteParameters.password try: await self._connection.connect() except ConnectionError: self.__setState("failed") else: self.__setState("completed") self.__start.set() async def stop(self) -> None: """ Irreversibly stop the :class:`RTCIceTransport`. """ if self.state != "closed": self.__setState("closed") await self._connection.close() if self.__monitor_task is not None: await self.__monitor_task self.__monitor_task = None async def _monitor(self) -> None: while True: event = await self._connection.get_event() if isinstance(event, ConnectionClosed): if self.state == "completed": self.__setState("failed") return def __log_debug(self, msg: str, *args) -> None: logger.debug(f"RTCIceTransport(%s) {msg}", self.role, *args) def __setState(self, state: str) -> None: if state != self.__state: self.__log_debug("- %s -> %s", self.__state, state) self.__state = state self.emit("statechange") # no more events will be emitted, so remove all event listeners # to facilitate garbage collection. if state == "closed": self.iceGatherer.remove_all_listeners() self.remove_all_listeners() aiortc-1.3.0/src/aiortc/rtcpeerconnection.py000066400000000000000000001344531417604566400211710ustar00rootroot00000000000000import asyncio import copy import logging import uuid from collections import OrderedDict from typing import Dict, List, Optional, Set, Union from pyee.asyncio import AsyncIOEventEmitter from . import clock, rtp, sdp from .codecs import CODECS, HEADER_EXTENSIONS, is_rtx from .events import RTCTrackEvent from .exceptions import ( InternalError, InvalidAccessError, InvalidStateError, OperationError, ) from .mediastreams import MediaStreamTrack from .rtcconfiguration import RTCConfiguration from .rtcdatachannel import RTCDataChannel, RTCDataChannelParameters from .rtcdtlstransport import RTCCertificate, RTCDtlsParameters, RTCDtlsTransport from .rtcicetransport import ( RTCIceCandidate, RTCIceGatherer, RTCIceParameters, RTCIceTransport, ) from .rtcrtpparameters import ( RTCRtpCodecCapability, RTCRtpCodecParameters, RTCRtpDecodingParameters, RTCRtpHeaderExtensionParameters, RTCRtpParameters, RTCRtpReceiveParameters, RTCRtpRtxParameters, RTCRtpSendParameters, ) from .rtcrtpreceiver import RemoteStreamTrack, RTCRtpReceiver from .rtcrtpsender import RTCRtpSender from .rtcrtptransceiver import RTCRtpTransceiver from .rtcsctptransport import RTCSctpTransport from .rtcsessiondescription import RTCSessionDescription from .stats import RTCStatsReport DISCARD_HOST = "0.0.0.0" DISCARD_PORT = 9 MEDIA_KINDS = ["audio", "video"] logger = logging.getLogger(__name__) def filter_preferred_codecs( codecs: List[RTCRtpCodecParameters], preferred: List[RTCRtpCodecCapability] ) -> List[RTCRtpCodecParameters]: if not preferred: return codecs rtx_codecs = list(filter(is_rtx, codecs)) rtx_enabled = next(filter(is_rtx, preferred), None) is not None filtered = [] for pref in filter(lambda x: not is_rtx(x), preferred): for codec in codecs: if ( codec.mimeType.lower() == pref.mimeType.lower() and codec.parameters == pref.parameters ): filtered.append(codec) # add corresponding RTX if rtx_enabled: for rtx in rtx_codecs: if rtx.parameters["apt"] == codec.payloadType: filtered.append(rtx) break break return filtered def find_common_codecs( local_codecs: List[RTCRtpCodecParameters], remote_codecs: List[RTCRtpCodecParameters], ) -> List[RTCRtpCodecParameters]: common = [] common_base: Dict[int, RTCRtpCodecParameters] = {} for c in remote_codecs: # for RTX, check we accepted the base codec if is_rtx(c): if c.parameters.get("apt") in common_base: base = common_base[c.parameters["apt"]] if c.clockRate == base.clockRate: common.append(copy.deepcopy(c)) continue # handle other codecs for codec in local_codecs: if ( codec.mimeType.lower() == c.mimeType.lower() and codec.clockRate == c.clockRate ): if codec.mimeType.lower() == "video/h264": # FIXME: check according to RFC 6184 parameters_compatible = True for param in ["packetization-mode", "profile-level-id"]: if c.parameters.get(param) != codec.parameters.get(param): parameters_compatible = False if not parameters_compatible: continue codec = copy.deepcopy(codec) if c.payloadType in rtp.DYNAMIC_PAYLOAD_TYPES: codec.payloadType = c.payloadType codec.rtcpFeedback = list( filter(lambda x: x in c.rtcpFeedback, codec.rtcpFeedback) ) common.append(codec) common_base[codec.payloadType] = codec break return common def find_common_header_extensions( local_extensions: List[RTCRtpHeaderExtensionParameters], remote_extensions: List[RTCRtpHeaderExtensionParameters], ) -> List[RTCRtpHeaderExtensionParameters]: common = [] for rx in remote_extensions: for lx in local_extensions: if lx.uri == rx.uri: common.append(rx) return common def add_transport_description( media: sdp.MediaDescription, dtlsTransport: RTCDtlsTransport ) -> None: # ice iceTransport = dtlsTransport.transport iceGatherer = iceTransport.iceGatherer media.ice_candidates = iceGatherer.getLocalCandidates() media.ice_candidates_complete = iceGatherer.state == "completed" media.ice = iceGatherer.getLocalParameters() if media.ice_candidates: media.host = media.ice_candidates[0].ip media.port = media.ice_candidates[0].port else: media.host = DISCARD_HOST media.port = DISCARD_PORT # dtls if media.dtls is None: media.dtls = dtlsTransport.getLocalParameters() else: media.dtls.fingerprints = dtlsTransport.getLocalParameters().fingerprints async def add_remote_candidates( iceTransport: RTCIceTransport, media: sdp.MediaDescription ) -> None: coros = map(iceTransport.addRemoteCandidate, media.ice_candidates) await asyncio.gather(*coros) if media.ice_candidates_complete: await iceTransport.addRemoteCandidate(None) def allocate_mid(mids: Set[str]) -> str: """ Allocate a MID which has not been used yet. """ i = 0 while True: mid = str(i) if mid not in mids: mids.add(mid) return mid i += 1 def create_media_description_for_sctp( sctp: RTCSctpTransport, legacy: bool, mid: str ) -> sdp.MediaDescription: if legacy: media = sdp.MediaDescription( kind="application", port=DISCARD_PORT, profile="DTLS/SCTP", fmt=[sctp.port] ) media.sctpmap[sctp.port] = f"webrtc-datachannel {sctp._outbound_streams_count}" else: media = sdp.MediaDescription( kind="application", port=DISCARD_PORT, profile="UDP/DTLS/SCTP", fmt=["webrtc-datachannel"], ) media.sctp_port = sctp.port media.rtp.muxId = mid media.sctpCapabilities = sctp.getCapabilities() add_transport_description(media, sctp.transport) return media def create_media_description_for_transceiver( transceiver: RTCRtpTransceiver, cname: str, direction: str, mid: str ) -> sdp.MediaDescription: media = sdp.MediaDescription( kind=transceiver.kind, port=DISCARD_PORT, profile="UDP/TLS/RTP/SAVPF", fmt=[c.payloadType for c in transceiver._codecs], ) media.direction = direction media.msid = f"{transceiver.sender._stream_id} {transceiver.sender._track_id}" media.rtp = RTCRtpParameters( codecs=transceiver._codecs, headerExtensions=transceiver._headerExtensions, muxId=mid, ) media.rtcp_host = DISCARD_HOST media.rtcp_port = DISCARD_PORT media.rtcp_mux = True media.ssrc = [sdp.SsrcDescription(ssrc=transceiver.sender._ssrc, cname=cname)] # if RTX is enabled, add corresponding SSRC if next(filter(is_rtx, media.rtp.codecs), None): media.ssrc.append( sdp.SsrcDescription(ssrc=transceiver.sender._rtx_ssrc, cname=cname) ) media.ssrc_group = [ sdp.GroupDescription( semantic="FID", items=[transceiver.sender._ssrc, transceiver.sender._rtx_ssrc], ) ] add_transport_description(media, transceiver._transport) return media def and_direction(a: str, b: str) -> str: return sdp.DIRECTIONS[sdp.DIRECTIONS.index(a) & sdp.DIRECTIONS.index(b)] def or_direction(a: str, b: str) -> str: return sdp.DIRECTIONS[sdp.DIRECTIONS.index(a) | sdp.DIRECTIONS.index(b)] def reverse_direction(direction: str) -> str: if direction == "sendonly": return "recvonly" elif direction == "recvonly": return "sendonly" return direction def wrap_session_description( session_description: Optional[sdp.SessionDescription], ) -> Optional[RTCSessionDescription]: if session_description is not None: return RTCSessionDescription( sdp=str(session_description), type=session_description.type ) return None class RTCPeerConnection(AsyncIOEventEmitter): """ The :class:`RTCPeerConnection` interface represents a WebRTC connection between the local computer and a remote peer. :param configuration: An optional :class:`RTCConfiguration`. """ def __init__(self, configuration: Optional[RTCConfiguration] = None) -> None: super().__init__() self.__certificates = [RTCCertificate.generateCertificate()] self.__cname = f"{uuid.uuid4()}" self.__configuration = configuration or RTCConfiguration() self.__dtlsTransports: Set[RTCDtlsTransport] = set() self.__iceTransports: Set[RTCIceTransport] = set() self.__remoteDtls: Dict[ Union[RTCRtpTransceiver, RTCSctpTransport], RTCDtlsParameters ] = {} self.__remoteIce: Dict[ Union[RTCRtpTransceiver, RTCSctpTransport], RTCIceParameters ] = {} self.__seenMids: Set[str] = set() self.__sctp: Optional[RTCSctpTransport] = None self.__sctp_mline_index: Optional[int] = None self._sctpLegacySdp = True self.__sctpRemotePort: Optional[int] = None self.__sctpRemoteCaps = None self.__stream_id = str(uuid.uuid4()) self.__transceivers: List[RTCRtpTransceiver] = [] self.__connectionState = "new" self.__iceConnectionState = "new" self.__iceGatheringState = "new" self.__isClosed = False self.__signalingState = "stable" self.__currentLocalDescription: Optional[sdp.SessionDescription] = None self.__currentRemoteDescription: Optional[sdp.SessionDescription] = None self.__pendingLocalDescription: Optional[sdp.SessionDescription] = None self.__pendingRemoteDescription: Optional[sdp.SessionDescription] = None @property def connectionState(self) -> str: """ The current connection state. Possible values: `"connected"`, `"connecting"`, `"closed"`, `"failed"`, `"new`". When the state changes, the `"connectionstatechange"` event is fired. """ return self.__connectionState @property def iceConnectionState(self) -> str: """ The current ICE connection state. Possible values: `"checking"`, `"completed"`, `"closed"`, `"failed"`, `"new`". When the state changes, the `"iceconnectionstatechange"` event is fired. """ return self.__iceConnectionState @property def iceGatheringState(self) -> str: """ The current ICE gathering state. Possible values: `"complete"`, `"gathering"`, `"new`". When the state changes, the `"icegatheringstatechange"` event is fired. """ return self.__iceGatheringState @property def localDescription(self) -> RTCSessionDescription: """ An :class:`RTCSessionDescription` describing the session for the local end of the connection. """ return wrap_session_description(self.__localDescription()) @property def remoteDescription(self) -> RTCSessionDescription: """ An :class:`RTCSessionDescription` describing the session for the remote end of the connection. """ return wrap_session_description(self.__remoteDescription()) @property def sctp(self) -> Optional[RTCSctpTransport]: """ An :class:`RTCSctpTransport` describing the SCTP transport being used for datachannels or `None`. """ return self.__sctp @property def signalingState(self): """ The current signaling state. Possible values: `"closed"`, `"have-local-offer"`, `"have-remote-offer`", `"stable"`. When the state changes, the `"signalingstatechange"` event is fired. """ return self.__signalingState async def addIceCandidate(self, candidate: RTCIceCandidate) -> None: """ Add a new :class:`RTCIceCandidate` received from the remote peer. The specified candidate must have a value for either `sdpMid` or `sdpMLineIndex`. :param candidate: The new remote candidate. """ if candidate.sdpMid is None and candidate.sdpMLineIndex is None: raise ValueError("Candidate must have either sdpMid or sdpMLineIndex") for transceiver in self.__transceivers: if candidate.sdpMid == transceiver.mid and not transceiver._bundled: iceTransport = transceiver._transport.transport await iceTransport.addRemoteCandidate(candidate) return if ( self.__sctp and candidate.sdpMid == self.__sctp.mid and not self.__sctp._bundled ): iceTransport = self.__sctp.transport.transport await iceTransport.addRemoteCandidate(candidate) def addTrack(self, track: MediaStreamTrack) -> RTCRtpSender: """ Add a :class:`MediaStreamTrack` to the set of media tracks which will be transmitted to the remote peer. """ # check state is valid self.__assertNotClosed() if track.kind not in ["audio", "video"]: raise InternalError(f'Invalid track kind "{track.kind}"') # don't add track twice self.__assertTrackHasNoSender(track) for transceiver in self.__transceivers: if transceiver.kind == track.kind: if transceiver.sender.track is None: transceiver.sender.replaceTrack(track) transceiver.direction = or_direction( transceiver.direction, "sendonly" ) return transceiver.sender transceiver = self.__createTransceiver( direction="sendrecv", kind=track.kind, sender_track=track ) return transceiver.sender def addTransceiver( self, trackOrKind: Union[str, MediaStreamTrack], direction: str = "sendrecv" ) -> RTCRtpTransceiver: """ Add a new :class:`RTCRtpTransceiver`. """ self.__assertNotClosed() # determine track or kind if isinstance(trackOrKind, MediaStreamTrack): kind = trackOrKind.kind track = trackOrKind else: kind = trackOrKind track = None if kind not in ["audio", "video"]: raise InternalError(f'Invalid track kind "{kind}"') # check direction if direction not in sdp.DIRECTIONS: raise InternalError(f'Invalid direction "{direction}"') # don't add track twice if track: self.__assertTrackHasNoSender(track) return self.__createTransceiver( direction=direction, kind=kind, sender_track=track ) async def close(self): """ Terminate the ICE agent, ending ICE processing and streams. """ if self.__isClosed: return self.__isClosed = True self.__setSignalingState("closed") # stop senders / receivers for transceiver in self.__transceivers: await transceiver.stop() if self.__sctp: await self.__sctp.stop() # stop transports for transceiver in self.__transceivers: await transceiver._transport.stop() await transceiver._transport.transport.stop() if self.__sctp: await self.__sctp.transport.stop() await self.__sctp.transport.transport.stop() # update states self.__updateIceGatheringState() self.__updateIceConnectionState() self.__updateConnectionState() # no more events will be emitted, so remove all event listeners # to facilitate garbage collection. self.remove_all_listeners() async def createAnswer(self): """ Create an SDP answer to an offer received from a remote peer during the offer/answer negotiation of a WebRTC connection. :rtype: :class:`RTCSessionDescription` """ # check state is valid self.__assertNotClosed() if self.signalingState not in ["have-remote-offer", "have-local-pranswer"]: raise InvalidStateError( f'Cannot create answer in signaling state "{self.signalingState}"' ) # create description ntp_seconds = clock.current_ntp_time() >> 32 description = sdp.SessionDescription() description.origin = f"- {ntp_seconds} {ntp_seconds} IN IP4 0.0.0.0" description.msid_semantic.append( sdp.GroupDescription(semantic="WMS", items=["*"]) ) description.type = "answer" for remote_m in self.__remoteDescription().media: if remote_m.kind in ["audio", "video"]: transceiver = self.__getTransceiverByMid(remote_m.rtp.muxId) media = create_media_description_for_transceiver( transceiver, cname=self.__cname, direction=and_direction( transceiver.direction, transceiver._offerDirection ), mid=transceiver.mid, ) dtlsTransport = transceiver._transport else: media = create_media_description_for_sctp( self.__sctp, legacy=self._sctpLegacySdp, mid=self.__sctp.mid ) dtlsTransport = self.__sctp.transport # determine DTLS role, or preserve the currently configured role if dtlsTransport._role == "auto": media.dtls.role = "client" else: media.dtls.role = dtlsTransport._role description.media.append(media) bundle = sdp.GroupDescription(semantic="BUNDLE", items=[]) for media in description.media: bundle.items.append(media.rtp.muxId) description.group.append(bundle) return wrap_session_description(description) def createDataChannel( self, label, maxPacketLifeTime=None, maxRetransmits=None, ordered=True, protocol="", negotiated=False, id=None, ): """ Create a data channel with the given label. :rtype: :class:`RTCDataChannel` """ if maxPacketLifeTime is not None and maxRetransmits is not None: raise ValueError("Cannot specify both maxPacketLifeTime and maxRetransmits") if not self.__sctp: self.__createSctpTransport() parameters = RTCDataChannelParameters( id=id, label=label, maxPacketLifeTime=maxPacketLifeTime, maxRetransmits=maxRetransmits, negotiated=negotiated, ordered=ordered, protocol=protocol, ) return RTCDataChannel(self.__sctp, parameters) async def createOffer(self) -> RTCSessionDescription: """ Create an SDP offer for the purpose of starting a new WebRTC connection to a remote peer. :rtype: :class:`RTCSessionDescription` """ # check state is valid self.__assertNotClosed() if not self.__sctp and not self.__transceivers: raise InternalError( "Cannot create an offer with no media and no data channels" ) # offer codecs for transceiver in self.__transceivers: transceiver._codecs = filter_preferred_codecs( CODECS[transceiver.kind][:], transceiver._preferred_codecs ) transceiver._headerExtensions = HEADER_EXTENSIONS[transceiver.kind][:] mids = self.__seenMids.copy() # create description ntp_seconds = clock.current_ntp_time() >> 32 description = sdp.SessionDescription() description.origin = f"- {ntp_seconds} {ntp_seconds} IN IP4 0.0.0.0" description.msid_semantic.append( sdp.GroupDescription(semantic="WMS", items=["*"]) ) description.type = "offer" def get_media( description: sdp.SessionDescription, ) -> List[sdp.MediaDescription]: return description.media if description else [] def get_media_section( media: List[sdp.MediaDescription], i: int ) -> Optional[sdp.MediaDescription]: return media[i] if i < len(media) else None # handle existing transceivers / sctp local_media = get_media(self.__localDescription()) remote_media = get_media(self.__remoteDescription()) for i in range(max(len(local_media), len(remote_media))): local_m = get_media_section(local_media, i) remote_m = get_media_section(remote_media, i) media_kind = local_m.kind if local_m else remote_m.kind mid = local_m.rtp.muxId if local_m else remote_m.rtp.muxId if media_kind in ["audio", "video"]: transceiver = self.__getTransceiverByMid(mid) transceiver._set_mline_index(i) description.media.append( create_media_description_for_transceiver( transceiver, cname=self.__cname, direction=transceiver.direction, mid=mid, ) ) elif media_kind == "application": self.__sctp_mline_index = i description.media.append( create_media_description_for_sctp( self.__sctp, legacy=self._sctpLegacySdp, mid=mid ) ) # handle new transceivers / sctp def next_mline_index() -> int: return len(description.media) for transceiver in filter( lambda x: x.mid is None and not x.stopped, self.__transceivers ): transceiver._set_mline_index(next_mline_index()) description.media.append( create_media_description_for_transceiver( transceiver, cname=self.__cname, direction=transceiver.direction, mid=allocate_mid(mids), ) ) if self.__sctp and self.__sctp.mid is None: self.__sctp_mline_index = next_mline_index() description.media.append( create_media_description_for_sctp( self.__sctp, legacy=self._sctpLegacySdp, mid=allocate_mid(mids) ) ) bundle = sdp.GroupDescription(semantic="BUNDLE", items=[]) for media in description.media: bundle.items.append(media.rtp.muxId) description.group.append(bundle) return wrap_session_description(description) def getReceivers(self) -> List[RTCRtpReceiver]: """ Returns the list of :class:`RTCRtpReceiver` objects that are currently attached to the connection. """ return list(map(lambda x: x.receiver, self.__transceivers)) def getSenders(self) -> List[RTCRtpSender]: """ Returns the list of :class:`RTCRtpSender` objects that are currently attached to the connection. """ return list(map(lambda x: x.sender, self.__transceivers)) async def getStats(self) -> RTCStatsReport: """ Returns statistics for the connection. :rtype: :class:`RTCStatsReport` """ merged = RTCStatsReport() coros = [x.getStats() for x in self.getSenders()] + [ x.getStats() for x in self.getReceivers() ] for report in await asyncio.gather(*coros): merged.update(report) return merged def getTransceivers(self) -> List[RTCRtpTransceiver]: """ Returns the list of :class:`RTCRtpTransceiver` objects that are currently attached to the connection. """ return list(self.__transceivers) async def setLocalDescription( self, sessionDescription: RTCSessionDescription ) -> None: """ Change the local description associated with the connection. :param sessionDescription: An :class:`RTCSessionDescription` generated by :meth:`createOffer` or :meth:`createAnswer()`. """ # parse and validate description description = sdp.SessionDescription.parse(sessionDescription.sdp) description.type = sessionDescription.type self.__validate_description(description, is_local=True) # update signaling state if description.type == "offer": self.__setSignalingState("have-local-offer") elif description.type == "answer": self.__setSignalingState("stable") # assign MID for i, media in enumerate(description.media): mid = media.rtp.muxId self.__seenMids.add(mid) if media.kind in ["audio", "video"]: transceiver = self.__getTransceiverByMLineIndex(i) transceiver._set_mid(mid) elif media.kind == "application": self.__sctp.mid = mid # set ICE role if description.type == "offer": for iceTransport in self.__iceTransports: if not iceTransport._role_set: iceTransport._connection.ice_controlling = True iceTransport._role_set = True # set DTLS role if description.type == "answer": for i, media in enumerate(description.media): if media.kind in ["audio", "video"]: transceiver = self.__getTransceiverByMLineIndex(i) transceiver._transport._set_role(media.dtls.role) elif media.kind == "application": self.__sctp.transport._set_role(media.dtls.role) # configure direction for t in self.__transceivers: if description.type in ["answer", "pranswer"]: t._currentDirection = and_direction(t.direction, t._offerDirection) # gather candidates await self.__gather() for i, media in enumerate(description.media): if media.kind in ["audio", "video"]: transceiver = self.__getTransceiverByMLineIndex(i) add_transport_description(media, transceiver._transport) elif media.kind == "application": add_transport_description(media, self.__sctp.transport) # connect asyncio.ensure_future(self.__connect()) # replace description if description.type == "answer": self.__currentLocalDescription = description self.__pendingLocalDescription = None else: self.__pendingLocalDescription = description async def setRemoteDescription( self, sessionDescription: RTCSessionDescription ) -> None: """ Changes the remote description associated with the connection. :param sessionDescription: An :class:`RTCSessionDescription` created from information received over the signaling channel. """ # parse and validate description description = sdp.SessionDescription.parse(sessionDescription.sdp) description.type = sessionDescription.type self.__validate_description(description, is_local=False) # apply description iceCandidates: Dict[RTCIceTransport, sdp.MediaDescription] = {} trackEvents = [] for i, media in enumerate(description.media): dtlsTransport: Optional[RTCDtlsTransport] = None self.__seenMids.add(media.rtp.muxId) if media.kind in ["audio", "video"]: # find transceiver transceiver = None for t in self.__transceivers: if t.kind == media.kind and t.mid in [None, media.rtp.muxId]: transceiver = t if transceiver is None: transceiver = self.__createTransceiver( direction="recvonly", kind=media.kind ) if transceiver.mid is None: transceiver._set_mid(media.rtp.muxId) transceiver._set_mline_index(i) # negotiate codecs common = filter_preferred_codecs( find_common_codecs(CODECS[media.kind], media.rtp.codecs), transceiver._preferred_codecs, ) if not len(common): raise OperationError( "Failed to set remote {} description send parameters".format( media.kind ) ) transceiver._codecs = common transceiver._headerExtensions = find_common_header_extensions( HEADER_EXTENSIONS[media.kind], media.rtp.headerExtensions ) # configure direction direction = reverse_direction(media.direction) if description.type in ["answer", "pranswer"]: transceiver._currentDirection = direction else: transceiver._offerDirection = direction # create remote stream track if ( direction in ["recvonly", "sendrecv"] and not transceiver.receiver.track ): transceiver.receiver._track = RemoteStreamTrack( kind=media.kind, id=description.webrtc_track_id(media) ) trackEvents.append( RTCTrackEvent( receiver=transceiver.receiver, track=transceiver.receiver.track, transceiver=transceiver, ) ) # memorise transport parameters dtlsTransport = transceiver._transport self.__remoteDtls[transceiver] = media.dtls self.__remoteIce[transceiver] = media.ice elif media.kind == "application": if not self.__sctp: self.__createSctpTransport() if self.__sctp.mid is None: self.__sctp.mid = media.rtp.muxId self.__sctp_mline_index = i # configure sctp if media.profile == "DTLS/SCTP": self._sctpLegacySdp = True self.__sctpRemotePort = int(media.fmt[0]) else: self._sctpLegacySdp = False self.__sctpRemotePort = media.sctp_port self.__sctpRemoteCaps = media.sctpCapabilities # memorise transport parameters dtlsTransport = self.__sctp.transport self.__remoteDtls[self.__sctp] = media.dtls self.__remoteIce[self.__sctp] = media.ice if dtlsTransport is not None: # add ICE candidates iceTransport = dtlsTransport.transport iceCandidates[iceTransport] = media # set ICE role if description.type == "offer" and not iceTransport._role_set: iceTransport._connection.ice_controlling = media.ice.iceLite iceTransport._role_set = True # set DTLS role if description.type == "answer": dtlsTransport._set_role( role="server" if media.dtls.role == "client" else "client" ) # remove bundled transports bundle = next((x for x in description.group if x.semantic == "BUNDLE"), None) if bundle and bundle.items: # find main media stream masterMid = bundle.items[0] masterTransport = None for transceiver in self.__transceivers: if transceiver.mid == masterMid: masterTransport = transceiver._transport break if self.__sctp and self.__sctp.mid == masterMid: masterTransport = self.__sctp.transport # replace transport for bundled media oldTransports = set() slaveMids = bundle.items[1:] for transceiver in self.__transceivers: if transceiver.mid in slaveMids and not transceiver._bundled: oldTransports.add(transceiver._transport) transceiver.receiver.setTransport(masterTransport) transceiver.sender.setTransport(masterTransport) transceiver._bundled = True transceiver._transport = masterTransport if ( self.__sctp and self.__sctp.mid in slaveMids and not self.__sctp._bundled ): oldTransports.add(self.__sctp.transport) self.__sctp.setTransport(masterTransport) self.__sctp._bundled = True # stop and discard old ICE transports for dtlsTransport in oldTransports: await dtlsTransport.stop() await dtlsTransport.transport.stop() self.__dtlsTransports.discard(dtlsTransport) self.__iceTransports.discard(dtlsTransport.transport) iceCandidates.pop(dtlsTransport.transport, None) self.__updateIceGatheringState() self.__updateIceConnectionState() self.__updateConnectionState() # add remote candidates coros = [ add_remote_candidates(iceTransport, media) for iceTransport, media in iceCandidates.items() ] await asyncio.gather(*coros) # FIXME: in aiortc 2.0.0 emit RTCTrackEvent directly for event in trackEvents: self.emit("track", event.track) # connect asyncio.ensure_future(self.__connect()) # update signaling state if description.type == "offer": self.__setSignalingState("have-remote-offer") elif description.type == "answer": self.__setSignalingState("stable") # replace description if description.type == "answer": self.__currentRemoteDescription = description self.__pendingRemoteDescription = None else: self.__pendingRemoteDescription = description async def __connect(self) -> None: for transceiver in self.__transceivers: dtlsTransport = transceiver._transport iceTransport = dtlsTransport.transport if ( iceTransport.iceGatherer.getLocalCandidates() and transceiver in self.__remoteIce ): await iceTransport.start(self.__remoteIce[transceiver]) if dtlsTransport.state == "new": await dtlsTransport.start(self.__remoteDtls[transceiver]) if dtlsTransport.state == "connected": if transceiver.currentDirection in ["sendonly", "sendrecv"]: await transceiver.sender.send(self.__localRtp(transceiver)) if transceiver.currentDirection in ["recvonly", "sendrecv"]: await transceiver.receiver.receive( self.__remoteRtp(transceiver) ) if self.__sctp: dtlsTransport = self.__sctp.transport iceTransport = dtlsTransport.transport if ( iceTransport.iceGatherer.getLocalCandidates() and self.__sctp in self.__remoteIce ): await iceTransport.start(self.__remoteIce[self.__sctp]) if dtlsTransport.state == "new": await dtlsTransport.start(self.__remoteDtls[self.__sctp]) if dtlsTransport.state == "connected": await self.__sctp.start( self.__sctpRemoteCaps, self.__sctpRemotePort ) async def __gather(self) -> None: coros = map(lambda t: t.iceGatherer.gather(), self.__iceTransports) await asyncio.gather(*coros) def __assertNotClosed(self) -> None: if self.__isClosed: raise InvalidStateError("RTCPeerConnection is closed") def __assertTrackHasNoSender(self, track: MediaStreamTrack) -> None: for sender in self.getSenders(): if sender.track == track: raise InvalidAccessError("Track already has a sender") def __createDtlsTransport(self) -> RTCDtlsTransport: # create ICE transport iceGatherer = RTCIceGatherer(iceServers=self.__configuration.iceServers) iceGatherer.on("statechange", self.__updateIceGatheringState) iceTransport = RTCIceTransport(iceGatherer) iceTransport.on("statechange", self.__updateIceConnectionState) iceTransport.on("statechange", self.__updateConnectionState) self.__iceTransports.add(iceTransport) # create DTLS transport dtlsTransport = RTCDtlsTransport(iceTransport, self.__certificates) dtlsTransport.on("statechange", self.__updateConnectionState) self.__dtlsTransports.add(dtlsTransport) # update states self.__updateIceGatheringState() self.__updateIceConnectionState() self.__updateConnectionState() return dtlsTransport def __createSctpTransport(self) -> None: self.__sctp = RTCSctpTransport(self.__createDtlsTransport()) self.__sctp._bundled = False self.__sctp.mid = None @self.__sctp.on("datachannel") def on_datachannel(channel): self.emit("datachannel", channel) def __createTransceiver( self, direction: str, kind: str, sender_track=None ) -> RTCRtpTransceiver: dtlsTransport = self.__createDtlsTransport() transceiver = RTCRtpTransceiver( direction=direction, kind=kind, sender=RTCRtpSender(sender_track or kind, dtlsTransport), receiver=RTCRtpReceiver(kind, dtlsTransport), ) transceiver.receiver._set_rtcp_ssrc(transceiver.sender._ssrc) transceiver.sender._stream_id = self.__stream_id transceiver._bundled = False transceiver._transport = dtlsTransport self.__transceivers.append(transceiver) return transceiver def __getTransceiverByMid(self, mid: str) -> Optional[RTCRtpTransceiver]: return next(filter(lambda x: x.mid == mid, self.__transceivers), None) def __getTransceiverByMLineIndex(self, index: int) -> Optional[RTCRtpTransceiver]: return next( filter(lambda x: x._get_mline_index() == index, self.__transceivers), None ) def __localDescription(self) -> Optional[sdp.SessionDescription]: return self.__pendingLocalDescription or self.__currentLocalDescription def __localRtp(self, transceiver: RTCRtpTransceiver) -> RTCRtpSendParameters: rtp = RTCRtpSendParameters( codecs=transceiver._codecs, headerExtensions=transceiver._headerExtensions, muxId=transceiver.mid, ) rtp.rtcp.cname = self.__cname rtp.rtcp.ssrc = transceiver.sender._ssrc rtp.rtcp.mux = True return rtp def __log_debug(self, msg: str, *args) -> None: logger.debug(f"RTCPeerConnection() {msg}", *args) def __remoteDescription(self) -> Optional[sdp.SessionDescription]: return self.__pendingRemoteDescription or self.__currentRemoteDescription def __remoteRtp(self, transceiver: RTCRtpTransceiver) -> RTCRtpReceiveParameters: media = self.__remoteDescription().media[transceiver._get_mline_index()] receiveParameters = RTCRtpReceiveParameters( codecs=transceiver._codecs, headerExtensions=transceiver._headerExtensions, muxId=media.rtp.muxId, rtcp=media.rtp.rtcp, ) if len(media.ssrc): encodings: OrderedDict[int, RTCRtpDecodingParameters] = OrderedDict() for codec in transceiver._codecs: if is_rtx(codec): if codec.parameters["apt"] in encodings and len(media.ssrc) == 2: encodings[codec.parameters["apt"]].rtx = RTCRtpRtxParameters( ssrc=media.ssrc[1].ssrc ) continue encodings[codec.payloadType] = RTCRtpDecodingParameters( ssrc=media.ssrc[0].ssrc, payloadType=codec.payloadType ) receiveParameters.encodings = list(encodings.values()) return receiveParameters def __setSignalingState(self, state: str) -> None: self.__signalingState = state self.emit("signalingstatechange") def __updateConnectionState(self) -> None: # compute new state # NOTE: we do not have a "disconnected" state dtlsStates = set(map(lambda x: x.state, self.__dtlsTransports)) iceStates = set(map(lambda x: x.state, self.__iceTransports)) if self.__isClosed: state = "closed" elif "failed" in iceStates or "failed" in dtlsStates: state = "failed" elif not iceStates.difference(["new", "closed"]) and not dtlsStates.difference( ["new", "closed"] ): state = "new" elif "checking" in iceStates or "connecting" in dtlsStates: state = "connecting" elif "new" in dtlsStates: # this avoids a spurious connecting -> connected -> connecting # transition after ICE connects but before DTLS starts state = "connecting" else: state = "connected" # update state if state != self.__connectionState: self.__log_debug("connectionState %s -> %s", self.__connectionState, state) self.__connectionState = state self.emit("connectionstatechange") def __updateIceConnectionState(self) -> None: # compute new state # NOTE: we do not have "connected" or "disconnected" states states = set(map(lambda x: x.state, self.__iceTransports)) if self.__isClosed: state = "closed" elif "failed" in states: state = "failed" elif states == set(["completed"]): state = "completed" elif "checking" in states: state = "checking" else: state = "new" # update state if state != self.__iceConnectionState: self.__log_debug( "iceConnectionState %s -> %s", self.__iceConnectionState, state ) self.__iceConnectionState = state self.emit("iceconnectionstatechange") def __updateIceGatheringState(self) -> None: # compute new state states = set(map(lambda x: x.iceGatherer.state, self.__iceTransports)) if states == set(["completed"]): state = "complete" elif "gathering" in states: state = "gathering" else: state = "new" # update state if state != self.__iceGatheringState: self.__log_debug( "iceGatheringState %s -> %s", self.__iceGatheringState, state ) self.__iceGatheringState = state self.emit("icegatheringstatechange") def __validate_description( self, description: sdp.SessionDescription, is_local: bool ) -> None: # check description is compatible with signaling state if is_local: if description.type == "offer": if self.signalingState not in ["stable", "have-local-offer"]: raise InvalidStateError( f'Cannot handle offer in signaling state "{self.signalingState}"' ) elif description.type == "answer": if self.signalingState not in [ "have-remote-offer", "have-local-pranswer", ]: raise InvalidStateError( f'Cannot handle answer in signaling state "{self.signalingState}"' ) else: if description.type == "offer": if self.signalingState not in ["stable", "have-remote-offer"]: raise InvalidStateError( f'Cannot handle offer in signaling state "{self.signalingState}"' ) elif description.type == "answer": if self.signalingState not in [ "have-local-offer", "have-remote-pranswer", ]: raise InvalidStateError( f'Cannot handle answer in signaling state "{self.signalingState}"' ) for media in description.media: # check ICE credentials were provided if not media.ice.usernameFragment or not media.ice.password: raise ValueError("ICE username fragment or password is missing") # check DTLS role is allowed if description.type == "offer" and media.dtls.role != "auto": raise ValueError("DTLS setup attribute must be 'actpass' for an offer") if description.type in ["answer", "pranswer"] and media.dtls.role not in [ "client", "server", ]: raise ValueError( "DTLS setup attribute must be 'active' or 'passive' for an answer" ) # check RTCP mux is used if media.kind in ["audio", "video"] and not media.rtcp_mux: raise ValueError("RTCP mux is not enabled") # check the number of media section matches if description.type in ["answer", "pranswer"]: offer = ( self.__remoteDescription() if is_local else self.__localDescription() ) offer_media = [(media.kind, media.rtp.muxId) for media in offer.media] answer_media = [ (media.kind, media.rtp.muxId) for media in description.media ] if answer_media != offer_media: raise ValueError("Media sections in answer do not match offer") aiortc-1.3.0/src/aiortc/rtcrtpparameters.py000066400000000000000000000107541417604566400210440ustar00rootroot00000000000000from collections import OrderedDict from dataclasses import dataclass, field from typing import List, Optional @dataclass class RTCRtpCodecCapability: """ The :class:`RTCRtpCodecCapability` dictionary provides information on codec capabilities. """ mimeType: str "The codec MIME media type/subtype, for instance `'audio/PCMU'`." clockRate: int "The codec clock rate expressed in Hertz." channels: Optional[int] = None "The number of channels supported (e.g. two for stereo)." parameters: OrderedDict = field(default_factory=OrderedDict) "Codec-specific parameters available for signaling." @property def name(self): return self.mimeType.split("/")[1] @dataclass class RTCRtpCodecParameters: """ The :class:`RTCRtpCodecParameters` dictionary provides information on codec settings. """ mimeType: str "The codec MIME media type/subtype, for instance `'audio/PCMU'`." clockRate: int "The codec clock rate expressed in Hertz." channels: Optional[int] = None "The number of channels supported (e.g. two for stereo)." payloadType: Optional[int] = None "The value that goes in the RTP Payload Type Field." rtcpFeedback: List["RTCRtcpFeedback"] = field(default_factory=list) "Transport layer and codec-specific feedback messages for this codec." parameters: OrderedDict = field(default_factory=OrderedDict) "Codec-specific parameters available for signaling." @property def name(self): return self.mimeType.split("/")[1] def __str__(self): s = f"{self.name}/{self.clockRate}" if self.channels == 2: s += "/2" return s @dataclass class RTCRtpRtxParameters: ssrc: int @dataclass class RTCRtpCodingParameters: ssrc: int payloadType: int rtx: Optional[RTCRtpRtxParameters] = None class RTCRtpDecodingParameters(RTCRtpCodingParameters): pass class RTCRtpEncodingParameters(RTCRtpCodingParameters): pass @dataclass class RTCRtpHeaderExtensionCapability: """ The :class:`RTCRtpHeaderExtensionCapability` dictionary provides information on a supported header extension. """ uri: str "The URI of the RTP header extension." @dataclass class RTCRtpHeaderExtensionParameters: """ The :class:`RTCRtpHeaderExtensionParameters` dictionary enables a header extension to be configured for use within an :class:`RTCRtpSender` or :class:`RTCRtpReceiver`. """ id: int "The value that goes in the packet." uri: str "The URI of the RTP header extension." @dataclass class RTCRtpCapabilities: """ The :class:`RTCRtpCapabilities` dictionary provides information about support codecs and header extensions. """ codecs: List[RTCRtpCodecCapability] = field(default_factory=list) "A list of :class:`RTCRtpCodecCapability`." headerExtensions: List[RTCRtpHeaderExtensionCapability] = field( default_factory=list ) "A list of :class:`RTCRtpHeaderExtensionCapability`." @dataclass class RTCRtcpFeedback: """ The :class:`RTCRtcpFeedback` dictionary provides information on RTCP feedback messages. """ type: str parameter: Optional[str] = None @dataclass class RTCRtcpParameters: """ The :class:`RTCRtcpParameters` dictionary provides information on RTCP settings. """ cname: Optional[str] = None "The Canonical Name (CNAME) used by RTCP." mux: bool = False "Whether RTP and RTCP are multiplexed." ssrc: Optional[int] = None "The Synchronization Source identifier." @dataclass class RTCRtpParameters: """ The :class:`RTCRtpParameters` dictionary describes the configuration of an :class:`RTCRtpReceiver` or an :class:`RTCRtpSender`. """ codecs: List[RTCRtpCodecParameters] = field(default_factory=list) "A list of :class:`RTCRtpCodecParameters` to send or receive." headerExtensions: List[RTCRtpHeaderExtensionParameters] = field( default_factory=list ) "A list of :class:`RTCRtpHeaderExtensionParameters`." muxId: str = "" "The muxId assigned to the RTP stream, if any, empty string if unset." rtcp: RTCRtcpParameters = field(default_factory=RTCRtcpParameters) "Parameters to configure RTCP." @dataclass class RTCRtpReceiveParameters(RTCRtpParameters): encodings: List[RTCRtpDecodingParameters] = field(default_factory=list) @dataclass class RTCRtpSendParameters(RTCRtpParameters): encodings: List[RTCRtpEncodingParameters] = field(default_factory=list) aiortc-1.3.0/src/aiortc/rtcrtpreceiver.py000066400000000000000000000467701417604566400205140ustar00rootroot00000000000000import asyncio import datetime import logging import queue import random import threading import time from dataclasses import dataclass from typing import Dict, List, Optional, Set from av.frame import Frame from . import clock from .codecs import depayload, get_capabilities, get_decoder, is_rtx from .exceptions import InvalidStateError from .jitterbuffer import JitterBuffer from .mediastreams import MediaStreamError, MediaStreamTrack from .rate import RemoteBitrateEstimator from .rtcdtlstransport import RTCDtlsTransport from .rtcrtpparameters import ( RTCRtpCapabilities, RTCRtpCodecParameters, RTCRtpReceiveParameters, ) from .rtp import ( RTCP_PSFB_APP, RTCP_PSFB_PLI, RTCP_RTPFB_NACK, AnyRtcpPacket, RtcpByePacket, RtcpPsfbPacket, RtcpReceiverInfo, RtcpRrPacket, RtcpRtpfbPacket, RtcpSrPacket, RtpPacket, clamp_packets_lost, pack_remb_fci, unwrap_rtx, ) from .stats import ( RTCInboundRtpStreamStats, RTCRemoteOutboundRtpStreamStats, RTCStatsReport, ) from .utils import uint16_add, uint16_gt logger = logging.getLogger(__name__) def decoder_worker(loop, input_q, output_q): codec_name = None decoder = None while True: task = input_q.get() if task is None: # inform the track that is has ended asyncio.run_coroutine_threadsafe(output_q.put(None), loop) break codec, encoded_frame = task if codec.name != codec_name: decoder = get_decoder(codec) codec_name = codec.name for frame in decoder.decode(encoded_frame): # pass the decoded frame to the track asyncio.run_coroutine_threadsafe(output_q.put(frame), loop) if decoder is not None: del decoder class NackGenerator: def __init__(self) -> None: self.max_seq: Optional[int] = None self.missing: Set[int] = set() def add(self, packet: RtpPacket) -> bool: missed = False if self.max_seq is None: self.max_seq = packet.sequence_number return missed # mark missing packets if uint16_gt(packet.sequence_number, self.max_seq): seq = uint16_add(self.max_seq, 1) while uint16_gt(packet.sequence_number, seq): self.missing.add(seq) missed = True seq = uint16_add(seq, 1) self.max_seq = packet.sequence_number else: self.missing.discard(packet.sequence_number) return missed class StreamStatistics: def __init__(self, clockrate: int) -> None: self.base_seq: Optional[int] = None self.max_seq: Optional[int] = None self.cycles = 0 self.packets_received = 0 # jitter self._clockrate = clockrate self._jitter_q4 = 0 self._last_arrival: Optional[int] = None self._last_timestamp: Optional[int] = None # fraction lost self._expected_prior = 0 self._received_prior = 0 def add(self, packet: RtpPacket) -> None: in_order = self.max_seq is None or uint16_gt( packet.sequence_number, self.max_seq ) self.packets_received += 1 if self.base_seq is None: self.base_seq = packet.sequence_number if in_order: arrival = int(time.time() * self._clockrate) if self.max_seq is not None and packet.sequence_number < self.max_seq: self.cycles += 1 << 16 self.max_seq = packet.sequence_number if packet.timestamp != self._last_timestamp and self.packets_received > 1: diff = abs( (arrival - self._last_arrival) - (packet.timestamp - self._last_timestamp) ) self._jitter_q4 += diff - ((self._jitter_q4 + 8) >> 4) self._last_arrival = arrival self._last_timestamp = packet.timestamp @property def fraction_lost(self) -> int: expected_interval = self.packets_expected - self._expected_prior self._expected_prior = self.packets_expected received_interval = self.packets_received - self._received_prior self._received_prior = self.packets_received lost_interval = expected_interval - received_interval if expected_interval == 0 or lost_interval <= 0: return 0 else: return (lost_interval << 8) // expected_interval @property def jitter(self) -> int: return self._jitter_q4 >> 4 @property def packets_expected(self) -> int: return self.cycles + self.max_seq - self.base_seq + 1 @property def packets_lost(self) -> int: return clamp_packets_lost(self.packets_expected - self.packets_received) class RemoteStreamTrack(MediaStreamTrack): def __init__(self, kind: str, id: Optional[str] = None) -> None: super().__init__() self.kind = kind if id is not None: self._id = id self._queue: asyncio.Queue = asyncio.Queue() async def recv(self) -> Frame: """ Receive the next frame. """ if self.readyState != "live": raise MediaStreamError frame = await self._queue.get() if frame is None: self.stop() raise MediaStreamError return frame class TimestampMapper: def __init__(self) -> None: self._last: Optional[int] = None self._origin: Optional[int] = None def map(self, timestamp: int) -> int: if self._origin is None: # first timestamp self._origin = timestamp elif timestamp < self._last: # RTP timestamp wrapped self._origin -= 1 << 32 self._last = timestamp return timestamp - self._origin @dataclass class RTCRtpContributingSource: """ The :class:`RTCRtpContributingSource` dictionary contains information about a contributing source (CSRC). """ timestamp: datetime.datetime "The timestamp associated with this source." source: int "The CSRC identifier associated with this source." @dataclass class RTCRtpSynchronizationSource: """ The :class:`RTCRtpSynchronizationSource` dictionary contains information about a synchronization source (SSRC). """ timestamp: datetime.datetime "The timestamp associated with this source." source: int "The SSRC identifier associated with this source." class RTCRtpReceiver: """ The :class:`RTCRtpReceiver` interface manages the reception and decoding of data for a :class:`MediaStreamTrack`. :param kind: The kind of media (`'audio'` or `'video'`). :param transport: An :class:`RTCDtlsTransport`. """ def __init__(self, kind: str, transport: RTCDtlsTransport) -> None: if transport.state == "closed": raise InvalidStateError self.__active_ssrc: Dict[int, datetime.datetime] = {} self.__codecs: Dict[int, RTCRtpCodecParameters] = {} self.__decoder_queue: queue.Queue = queue.Queue() self.__decoder_thread: Optional[threading.Thread] = None self.__kind = kind if kind == "audio": self.__jitter_buffer = JitterBuffer(capacity=16, prefetch=4) self.__nack_generator = None self.__remote_bitrate_estimator = None else: self.__jitter_buffer = JitterBuffer(capacity=128, is_video=True) self.__nack_generator = NackGenerator() self.__remote_bitrate_estimator = RemoteBitrateEstimator() self._track: Optional[RemoteStreamTrack] = None self.__rtcp_exited = asyncio.Event() self.__rtcp_started = asyncio.Event() self.__rtcp_task: Optional[asyncio.Future[None]] = None self.__rtx_ssrc: Dict[int, int] = {} self.__started = False self.__stats = RTCStatsReport() self.__timestamp_mapper = TimestampMapper() self.__transport = transport # RTCP self.__lsr: Dict[int, int] = {} self.__lsr_time: Dict[int, float] = {} self.__remote_streams: Dict[int, StreamStatistics] = {} self.__rtcp_ssrc: Optional[int] = None @property def track(self) -> MediaStreamTrack: """ The :class:`MediaStreamTrack` which is being handled by the receiver. """ return self._track @property def transport(self) -> RTCDtlsTransport: """ The :class:`RTCDtlsTransport` over which the media for the receiver's track is received. """ return self.__transport @classmethod def getCapabilities(self, kind) -> Optional[RTCRtpCapabilities]: """ Returns the most optimistic view of the system's capabilities for receiving media of the given `kind`. :rtype: :class:`RTCRtpCapabilities` """ return get_capabilities(kind) async def getStats(self) -> RTCStatsReport: """ Returns statistics about the RTP receiver. :rtype: :class:`RTCStatsReport` """ for ssrc, stream in self.__remote_streams.items(): self.__stats.add( RTCInboundRtpStreamStats( # RTCStats timestamp=clock.current_datetime(), type="inbound-rtp", id="inbound-rtp_" + str(id(self)), # RTCStreamStats ssrc=ssrc, kind=self.__kind, transportId=self.transport._stats_id, # RTCReceivedRtpStreamStats packetsReceived=stream.packets_received, packetsLost=stream.packets_lost, jitter=stream.jitter, # RTPInboundRtpStreamStats ) ) self.__stats.update(self.transport._get_stats()) return self.__stats def getSynchronizationSources(self) -> List[RTCRtpSynchronizationSource]: """ Returns a :class:`RTCRtpSynchronizationSource` for each unique SSRC identifier received in the last 10 seconds. """ cutoff = clock.current_datetime() - datetime.timedelta(seconds=10) sources = [] for source, timestamp in self.__active_ssrc.items(): if timestamp >= cutoff: sources.append( RTCRtpSynchronizationSource(source=source, timestamp=timestamp) ) return sources async def receive(self, parameters: RTCRtpReceiveParameters) -> None: """ Attempt to set the parameters controlling the receiving of media. :param parameters: The :class:`RTCRtpParameters` for the receiver. """ if not self.__started: for codec in parameters.codecs: self.__codecs[codec.payloadType] = codec for encoding in parameters.encodings: if encoding.rtx: self.__rtx_ssrc[encoding.rtx.ssrc] = encoding.ssrc # start decoder thread self.__decoder_thread = threading.Thread( target=decoder_worker, name=self.__kind + "-decoder", args=( asyncio.get_event_loop(), self.__decoder_queue, self._track._queue, ), ) self.__decoder_thread.start() self.__transport._register_rtp_receiver(self, parameters) self.__rtcp_task = asyncio.ensure_future(self._run_rtcp()) self.__started = True def setTransport(self, transport: RTCDtlsTransport) -> None: self.__transport = transport async def stop(self) -> None: """ Irreversibly stop the receiver. """ if self.__started: self.__transport._unregister_rtp_receiver(self) self.__stop_decoder() # shutdown RTCP task await self.__rtcp_started.wait() self.__rtcp_task.cancel() await self.__rtcp_exited.wait() def _handle_disconnect(self) -> None: self.__stop_decoder() async def _handle_rtcp_packet(self, packet: AnyRtcpPacket) -> None: self.__log_debug("< %s", packet) if isinstance(packet, RtcpSrPacket): self.__stats.add( RTCRemoteOutboundRtpStreamStats( # RTCStats timestamp=clock.current_datetime(), type="remote-outbound-rtp", id=f"remote-outbound-rtp_{id(self)}", # RTCStreamStats ssrc=packet.ssrc, kind=self.__kind, transportId=self.transport._stats_id, # RTCSentRtpStreamStats packetsSent=packet.sender_info.packet_count, bytesSent=packet.sender_info.octet_count, # RTCRemoteOutboundRtpStreamStats remoteTimestamp=clock.datetime_from_ntp( packet.sender_info.ntp_timestamp ), ) ) self.__lsr[packet.ssrc] = ( (packet.sender_info.ntp_timestamp) >> 16 ) & 0xFFFFFFFF self.__lsr_time[packet.ssrc] = time.time() elif isinstance(packet, RtcpByePacket): self.__stop_decoder() async def _handle_rtp_packet(self, packet: RtpPacket, arrival_time_ms: int) -> None: """ Handle an incoming RTP packet. """ self.__log_debug("< %s", packet) # feed bitrate estimator if self.__remote_bitrate_estimator is not None: if packet.extensions.abs_send_time is not None: remb = self.__remote_bitrate_estimator.add( abs_send_time=packet.extensions.abs_send_time, arrival_time_ms=arrival_time_ms, payload_size=len(packet.payload) + packet.padding_size, ssrc=packet.ssrc, ) if self.__rtcp_ssrc is not None and remb is not None: # send Receiver Estimated Maximum Bitrate feedback rtcp_packet = RtcpPsfbPacket( fmt=RTCP_PSFB_APP, ssrc=self.__rtcp_ssrc, media_ssrc=0, fci=pack_remb_fci(*remb), ) await self._send_rtcp(rtcp_packet) # keep track of sources self.__active_ssrc[packet.ssrc] = clock.current_datetime() # check the codec is known codec = self.__codecs.get(packet.payload_type) if codec is None: self.__log_debug( "x RTP packet with unknown payload type %d", packet.payload_type ) return # feed RTCP statistics if packet.ssrc not in self.__remote_streams: self.__remote_streams[packet.ssrc] = StreamStatistics(codec.clockRate) self.__remote_streams[packet.ssrc].add(packet) # unwrap retransmission packet if is_rtx(codec): original_ssrc = self.__rtx_ssrc.get(packet.ssrc) if original_ssrc is None: self.__log_debug("x RTX packet from unknown SSRC %d", packet.ssrc) return if len(packet.payload) < 2: return codec = self.__codecs[codec.parameters["apt"]] packet = unwrap_rtx( packet, payload_type=codec.payloadType, ssrc=original_ssrc ) # send NACKs for any missing any packets if self.__nack_generator is not None and self.__nack_generator.add(packet): await self._send_rtcp_nack( packet.ssrc, sorted(self.__nack_generator.missing) ) # parse codec-specific information try: if packet.payload: packet._data = depayload(codec, packet.payload) # type: ignore else: packet._data = b"" # type: ignore except ValueError as exc: self.__log_debug("x RTP payload parsing failed: %s", exc) return # try to re-assemble encoded frame pli_flag, encoded_frame = self.__jitter_buffer.add(packet) # check if the PLI should be sent if pli_flag: await self._send_rtcp_pli(packet.ssrc) # if we have a complete encoded frame, decode it if encoded_frame is not None and self.__decoder_thread: encoded_frame.timestamp = self.__timestamp_mapper.map( encoded_frame.timestamp ) self.__decoder_queue.put((codec, encoded_frame)) async def _run_rtcp(self) -> None: self.__log_debug("- RTCP started") self.__rtcp_started.set() try: while True: # The interval between RTCP packets is varied randomly over the # range [0.5, 1.5] times the calculated interval. await asyncio.sleep(0.5 + random.random()) # RTCP RR reports = [] for ssrc, stream in self.__remote_streams.items(): lsr = 0 dlsr = 0 if ssrc in self.__lsr: lsr = self.__lsr[ssrc] delay = time.time() - self.__lsr_time[ssrc] if delay > 0 and delay < 65536: dlsr = int(delay * 65536) reports.append( RtcpReceiverInfo( ssrc=ssrc, fraction_lost=stream.fraction_lost, packets_lost=stream.packets_lost, highest_sequence=stream.max_seq, jitter=stream.jitter, lsr=lsr, dlsr=dlsr, ) ) if self.__rtcp_ssrc is not None and reports: packet = RtcpRrPacket(ssrc=self.__rtcp_ssrc, reports=reports) await self._send_rtcp(packet) except asyncio.CancelledError: pass self.__log_debug("- RTCP finished") self.__rtcp_exited.set() async def _send_rtcp(self, packet) -> None: self.__log_debug("> %s", packet) try: await self.transport._send_rtp(bytes(packet)) except ConnectionError: pass async def _send_rtcp_nack(self, media_ssrc: int, lost) -> None: """ Send an RTCP packet to report missing RTP packets. """ if self.__rtcp_ssrc is not None: packet = RtcpRtpfbPacket( fmt=RTCP_RTPFB_NACK, ssrc=self.__rtcp_ssrc, media_ssrc=media_ssrc ) packet.lost = lost await self._send_rtcp(packet) async def _send_rtcp_pli(self, media_ssrc: int) -> None: """ Send an RTCP packet to report picture loss. """ if self.__rtcp_ssrc is not None: packet = RtcpPsfbPacket( fmt=RTCP_PSFB_PLI, ssrc=self.__rtcp_ssrc, media_ssrc=media_ssrc ) await self._send_rtcp(packet) def _set_rtcp_ssrc(self, ssrc: int) -> None: self.__rtcp_ssrc = ssrc def __stop_decoder(self) -> None: """ Stop the decoder thread, which will in turn stop the track. """ if self.__decoder_thread: self.__decoder_queue.put(None) self.__decoder_thread.join() self.__decoder_thread = None def __log_debug(self, msg: str, *args) -> None: logger.debug(f"RTCRtpReceiver(%s) {msg}", self.__kind, *args) aiortc-1.3.0/src/aiortc/rtcrtpsender.py000066400000000000000000000364451417604566400201660ustar00rootroot00000000000000import asyncio import logging import random import time import traceback import uuid from typing import Dict, List, Optional, Union from av import AudioFrame from . import clock, rtp from .codecs import get_capabilities, get_encoder, is_rtx from .codecs.base import Encoder from .exceptions import InvalidStateError from .mediastreams import MediaStreamError, MediaStreamTrack from .rtcrtpparameters import RTCRtpCodecParameters, RTCRtpSendParameters from .rtp import ( RTCP_PSFB_APP, RTCP_PSFB_PLI, RTCP_RTPFB_NACK, AnyRtcpPacket, RtcpByePacket, RtcpPsfbPacket, RtcpRrPacket, RtcpRtpfbPacket, RtcpSdesPacket, RtcpSenderInfo, RtcpSourceInfo, RtcpSrPacket, RtpPacket, unpack_remb_fci, wrap_rtx, ) from .stats import ( RTCOutboundRtpStreamStats, RTCRemoteInboundRtpStreamStats, RTCStatsReport, ) from .utils import random16, random32, uint16_add, uint32_add logger = logging.getLogger(__name__) RTP_HISTORY_SIZE = 128 RTT_ALPHA = 0.85 class RTCEncodedFrame: def __init__(self, payloads: List[bytes], timestamp: int, audio_level: int): self.payloads = payloads self.timestamp = timestamp self.audio_level = audio_level class RTCRtpSender: """ The :class:`RTCRtpSender` interface provides the ability to control and obtain details about how a particular :class:`MediaStreamTrack` is encoded and sent to a remote peer. :param trackOrKind: Either a :class:`MediaStreamTrack` instance or a media kind (`'audio'` or `'video'`). :param transport: An :class:`RTCDtlsTransport`. """ def __init__(self, trackOrKind: Union[MediaStreamTrack, str], transport) -> None: if transport.state == "closed": raise InvalidStateError if isinstance(trackOrKind, MediaStreamTrack): self.__kind = trackOrKind.kind self.replaceTrack(trackOrKind) else: self.__kind = trackOrKind self.replaceTrack(None) self.__cname: Optional[str] = None self._ssrc = random32() self._rtx_ssrc = random32() # FIXME: how should this be initialised? self._stream_id = str(uuid.uuid4()) self.__encoder: Optional[Encoder] = None self.__force_keyframe = False self.__loop = asyncio.get_event_loop() self.__mid: Optional[str] = None self.__rtp_exited = asyncio.Event() self.__rtp_header_extensions_map = rtp.HeaderExtensionsMap() self.__rtp_started = asyncio.Event() self.__rtp_task: Optional[asyncio.Future[None]] = None self.__rtp_history: Dict[int, RtpPacket] = {} self.__rtcp_exited = asyncio.Event() self.__rtcp_started = asyncio.Event() self.__rtcp_task: Optional[asyncio.Future[None]] = None self.__rtx_payload_type: Optional[int] = None self.__rtx_sequence_number = random16() self.__started = False self.__stats = RTCStatsReport() self.__transport = transport # stats self.__lsr: Optional[int] = None self.__lsr_time: Optional[float] = None self.__ntp_timestamp = 0 self.__rtp_timestamp = 0 self.__octet_count = 0 self.__packet_count = 0 self.__rtt = None @property def kind(self): return self.__kind @property def track(self) -> MediaStreamTrack: """ The :class:`MediaStreamTrack` which is being handled by the sender. """ return self.__track @property def transport(self): """ The :class:`RTCDtlsTransport` over which media data for the track is transmitted. """ return self.__transport @classmethod def getCapabilities(self, kind): """ Returns the most optimistic view of the system's capabilities for sending media of the given `kind`. :rtype: :class:`RTCRtpCapabilities` """ return get_capabilities(kind) async def getStats(self) -> RTCStatsReport: """ Returns statistics about the RTP sender. :rtype: :class:`RTCStatsReport` """ self.__stats.add( RTCOutboundRtpStreamStats( # RTCStats timestamp=clock.current_datetime(), type="outbound-rtp", id="outbound-rtp_" + str(id(self)), # RTCStreamStats ssrc=self._ssrc, kind=self.__kind, transportId=self.transport._stats_id, # RTCSentRtpStreamStats packetsSent=self.__packet_count, bytesSent=self.__octet_count, # RTCOutboundRtpStreamStats trackId=str(id(self.track)), ) ) self.__stats.update(self.transport._get_stats()) return self.__stats def replaceTrack(self, track: Optional[MediaStreamTrack]) -> None: self.__track = track if track is not None: self._track_id = track.id else: self._track_id = str(uuid.uuid4()) def setTransport(self, transport) -> None: self.__transport = transport async def send(self, parameters: RTCRtpSendParameters) -> None: """ Attempt to set the parameters controlling the sending of media. :param parameters: The :class:`RTCRtpSendParameters` for the sender. """ if not self.__started: self.__cname = parameters.rtcp.cname self.__mid = parameters.muxId # make note of the RTP header extension IDs self.__transport._register_rtp_sender(self, parameters) self.__rtp_header_extensions_map.configure(parameters) # make note of RTX payload type for codec in parameters.codecs: if ( is_rtx(codec) and codec.parameters["apt"] == parameters.codecs[0].payloadType ): self.__rtx_payload_type = codec.payloadType break self.__rtp_task = asyncio.ensure_future(self._run_rtp(parameters.codecs[0])) self.__rtcp_task = asyncio.ensure_future(self._run_rtcp()) self.__started = True async def stop(self): """ Irreversibly stop the sender. """ if self.__started: self.__transport._unregister_rtp_sender(self) # shutdown RTP and RTCP tasks await asyncio.gather(self.__rtp_started.wait(), self.__rtcp_started.wait()) self.__rtp_task.cancel() self.__rtcp_task.cancel() await asyncio.gather(self.__rtp_exited.wait(), self.__rtcp_exited.wait()) async def _handle_rtcp_packet(self, packet): if isinstance(packet, (RtcpRrPacket, RtcpSrPacket)): for report in filter(lambda x: x.ssrc == self._ssrc, packet.reports): # estimate round-trip time if self.__lsr == report.lsr and report.dlsr: rtt = time.time() - self.__lsr_time - (report.dlsr / 65536) if self.__rtt is None: self.__rtt = rtt else: self.__rtt = RTT_ALPHA * self.__rtt + (1 - RTT_ALPHA) * rtt self.__stats.add( RTCRemoteInboundRtpStreamStats( # RTCStats timestamp=clock.current_datetime(), type="remote-inbound-rtp", id="remote-inbound-rtp_" + str(id(self)), # RTCStreamStats ssrc=packet.ssrc, kind=self.__kind, transportId=self.transport._stats_id, # RTCReceivedRtpStreamStats packetsReceived=self.__packet_count - report.packets_lost, packetsLost=report.packets_lost, jitter=report.jitter, # RTCRemoteInboundRtpStreamStats roundTripTime=self.__rtt, fractionLost=report.fraction_lost, ) ) elif isinstance(packet, RtcpRtpfbPacket) and packet.fmt == RTCP_RTPFB_NACK: for seq in packet.lost: await self._retransmit(seq) elif isinstance(packet, RtcpPsfbPacket) and packet.fmt == RTCP_PSFB_PLI: self._send_keyframe() elif isinstance(packet, RtcpPsfbPacket) and packet.fmt == RTCP_PSFB_APP: try: bitrate, ssrcs = unpack_remb_fci(packet.fci) if self._ssrc in ssrcs: self.__log_debug( "- receiver estimated maximum bitrate %d bps", bitrate ) if self.__encoder and hasattr(self.__encoder, "target_bitrate"): self.__encoder.target_bitrate = bitrate except ValueError: pass async def _next_encoded_frame(self, codec: RTCRtpCodecParameters): # get frame frame = await self.__track.recv() audio_level = None if isinstance(frame, AudioFrame): audio_level = rtp.compute_audio_level_dbov(frame) # encode frame if self.__encoder is None: self.__encoder = get_encoder(codec) force_keyframe = self.__force_keyframe self.__force_keyframe = False payloads, timestamp = await self.__loop.run_in_executor( None, self.__encoder.encode, frame, force_keyframe ) return RTCEncodedFrame(payloads, timestamp, audio_level) async def _retransmit(self, sequence_number: int) -> None: """ Retransmit an RTP packet which was reported as lost. """ packet = self.__rtp_history.get(sequence_number % RTP_HISTORY_SIZE) if packet and packet.sequence_number == sequence_number: if self.__rtx_payload_type is not None: packet = wrap_rtx( packet, payload_type=self.__rtx_payload_type, sequence_number=self.__rtx_sequence_number, ssrc=self._rtx_ssrc, ) self.__rtx_sequence_number = uint16_add(self.__rtx_sequence_number, 1) self.__log_debug("> %s", packet) packet_bytes = packet.serialize(self.__rtp_header_extensions_map) await self.transport._send_rtp(packet_bytes) def _send_keyframe(self) -> None: """ Request the next frame to be a keyframe. """ self.__force_keyframe = True async def _run_rtp(self, codec: RTCRtpCodecParameters) -> None: self.__log_debug("- RTP started") self.__rtp_started.set() sequence_number = random16() timestamp_origin = random32() try: while True: if not self.__track: await asyncio.sleep(0.02) continue enc_frame = await self._next_encoded_frame(codec) timestamp = uint32_add(timestamp_origin, enc_frame.timestamp) for i, payload in enumerate(enc_frame.payloads): packet = RtpPacket( payload_type=codec.payloadType, sequence_number=sequence_number, timestamp=timestamp, ) packet.ssrc = self._ssrc packet.payload = payload packet.marker = (i == len(enc_frame.payloads) - 1) and 1 or 0 # set header extensions packet.extensions.abs_send_time = ( clock.current_ntp_time() >> 14 ) & 0x00FFFFFF packet.extensions.mid = self.__mid if enc_frame.audio_level is not None: packet.extensions.audio_level = (False, -enc_frame.audio_level) # send packet self.__log_debug("> %s", packet) self.__rtp_history[ packet.sequence_number % RTP_HISTORY_SIZE ] = packet packet_bytes = packet.serialize(self.__rtp_header_extensions_map) await self.transport._send_rtp(packet_bytes) self.__ntp_timestamp = clock.current_ntp_time() self.__rtp_timestamp = packet.timestamp self.__octet_count += len(payload) self.__packet_count += 1 sequence_number = uint16_add(sequence_number, 1) except (asyncio.CancelledError, ConnectionError, MediaStreamError): pass except Exception: # we *need* to set __rtp_exited, otherwise RTCRtpSender.stop() will hang, # so issue a warning if we hit an unexpected exception self.__log_warning(traceback.format_exc()) # stop track if self.__track: self.__track.stop() self.__track = None self.__log_debug("- RTP finished") self.__rtp_exited.set() async def _run_rtcp(self) -> None: self.__log_debug("- RTCP started") self.__rtcp_started.set() try: while True: # The interval between RTCP packets is varied randomly over the # range [0.5, 1.5] times the calculated interval. await asyncio.sleep(0.5 + random.random()) # RTCP SR packets: List[AnyRtcpPacket] = [ RtcpSrPacket( ssrc=self._ssrc, sender_info=RtcpSenderInfo( ntp_timestamp=self.__ntp_timestamp, rtp_timestamp=self.__rtp_timestamp, packet_count=self.__packet_count, octet_count=self.__octet_count, ), ) ] self.__lsr = ((self.__ntp_timestamp) >> 16) & 0xFFFFFFFF self.__lsr_time = time.time() # RTCP SDES if self.__cname is not None: packets.append( RtcpSdesPacket( chunks=[ RtcpSourceInfo( ssrc=self._ssrc, items=[(1, self.__cname.encode("utf8"))], ) ] ) ) await self._send_rtcp(packets) except asyncio.CancelledError: pass # RTCP BYE packet = RtcpByePacket(sources=[self._ssrc]) await self._send_rtcp([packet]) self.__log_debug("- RTCP finished") self.__rtcp_exited.set() async def _send_rtcp(self, packets: List[AnyRtcpPacket]) -> None: payload = b"" for packet in packets: self.__log_debug("> %s", packet) payload += bytes(packet) try: await self.transport._send_rtp(payload) except ConnectionError: pass def __log_debug(self, msg: str, *args) -> None: logger.debug(f"RTCRtpSender(%s) {msg}", self.__kind, *args) def __log_warning(self, msg: str, *args) -> None: logger.warning(f"RTCRtpsender(%s) {msg}", self.__kind, *args) aiortc-1.3.0/src/aiortc/rtcrtptransceiver.py000066400000000000000000000101361417604566400212200ustar00rootroot00000000000000import logging from typing import List, Optional from .codecs import get_capabilities from .rtcdtlstransport import RTCDtlsTransport from .rtcrtpparameters import ( RTCRtpCodecCapability, RTCRtpCodecParameters, RTCRtpHeaderExtensionParameters, ) from .rtcrtpreceiver import RTCRtpReceiver from .rtcrtpsender import RTCRtpSender from .sdp import DIRECTIONS logger = logging.getLogger(__name__) class RTCRtpTransceiver: """ The RTCRtpTransceiver interface describes a permanent pairing of an :class:`RTCRtpSender` and an :class:`RTCRtpReceiver`, along with some shared state. """ def __init__( self, kind: str, receiver: RTCRtpReceiver, sender: RTCRtpSender, direction: str = "sendrecv", ): self.__direction = direction self.__kind = kind self.__mid: Optional[str] = None self.__mline_index: Optional[int] = None self.__receiver = receiver self.__sender = sender self.__stopped = False self._currentDirection: Optional[str] = None self._offerDirection: Optional[str] = None self._preferred_codecs: List[RTCRtpCodecCapability] = [] self._transport: RTCDtlsTransport = None # FIXME: this is only used by RTCPeerConnection self._bundled = False self._codecs: List[RTCRtpCodecParameters] = [] self._headerExtensions: List[RTCRtpHeaderExtensionParameters] = [] @property def currentDirection(self) -> Optional[str]: """ The currently negotiated direction of the transceiver. One of `'sendrecv'`, `'sendonly'`, `'recvonly'`, `'inactive'` or `None`. """ return self._currentDirection @property def direction(self) -> str: """ The preferred direction of the transceiver, which will be used in :meth:`RTCPeerConnection.createOffer` and :meth:`RTCPeerConnection.createAnswer`. One of `'sendrecv'`, `'sendonly'`, `'recvonly'` or `'inactive'`. """ return self.__direction @direction.setter def direction(self, direction: str) -> None: assert direction in DIRECTIONS self.__direction = direction @property def kind(self) -> str: return self.__kind @property def mid(self) -> Optional[str]: return self.__mid @property def receiver(self) -> RTCRtpReceiver: """ The :class:`RTCRtpReceiver` that handles receiving and decoding incoming media. """ return self.__receiver @property def sender(self) -> RTCRtpSender: """ The :class:`RTCRtpSender` responsible for encoding and sending data to the remote peer. """ return self.__sender @property def stopped(self) -> bool: return self.__stopped def setCodecPreferences(self, codecs: List[RTCRtpCodecCapability]) -> None: """ Override the default codec preferences. See :meth:`RTCRtpSender.getCapabilities` and :meth:`RTCRtpReceiver.getCapabilities` for the supported codecs. :param codecs: A list of :class:`RTCRtpCodecCapability`, in decreasing order of preference. If empty, restores the default preferences. """ if not codecs: self._preferred_codecs = [] capabilities = get_capabilities(self.kind).codecs unique: List[RTCRtpCodecCapability] = [] for codec in reversed(codecs): if codec not in capabilities: raise ValueError("Codec is not in capabilities") if codec not in unique: unique.insert(0, codec) self._preferred_codecs = unique async def stop(self): """ Permanently stops the :class:`RTCRtpTransceiver`. """ await self.__receiver.stop() await self.__sender.stop() self.__stopped = True def _set_mid(self, mid: str) -> None: self.__mid = mid def _get_mline_index(self) -> Optional[int]: return self.__mline_index def _set_mline_index(self, idx: int) -> None: self.__mline_index = idx aiortc-1.3.0/src/aiortc/rtcsctptransport.py000066400000000000000000001665251417604566400211110ustar00rootroot00000000000000import asyncio import enum import hmac import logging import math import os import time from collections import deque from dataclasses import dataclass, field from struct import pack, unpack_from from typing import ( Any, Callable, Deque, Dict, Iterator, List, Optional, Set, Tuple, cast, no_type_check, ) from google_crc32c import value as crc32c from pyee.asyncio import AsyncIOEventEmitter from .exceptions import InvalidStateError from .rtcdatachannel import RTCDataChannel, RTCDataChannelParameters from .rtcdtlstransport import RTCDtlsTransport from .utils import random32, uint16_add, uint16_gt, uint32_gt, uint32_gte logger = logging.getLogger(__name__) # local constants COOKIE_LENGTH = 24 COOKIE_LIFETIME = 60 MAX_STREAMS = 65535 USERDATA_MAX_LENGTH = 1200 # protocol constants SCTP_CAUSE_INVALID_STREAM = 0x0001 SCTP_CAUSE_STALE_COOKIE = 0x0003 SCTP_DATA_LAST_FRAG = 0x01 SCTP_DATA_FIRST_FRAG = 0x02 SCTP_DATA_UNORDERED = 0x04 SCTP_MAX_ASSOCIATION_RETRANS = 10 SCTP_MAX_BURST = 4 SCTP_MAX_INIT_RETRANS = 8 SCTP_RTO_ALPHA = 1 / 8 SCTP_RTO_BETA = 1 / 4 SCTP_RTO_INITIAL = 3.0 SCTP_RTO_MIN = 1 SCTP_RTO_MAX = 60 SCTP_TSN_MODULO = 2**32 RECONFIG_MAX_STREAMS = 135 # parameters SCTP_STATE_COOKIE = 0x0007 SCTP_STR_RESET_OUT_REQUEST = 0x000D SCTP_STR_RESET_RESPONSE = 0x0010 SCTP_STR_RESET_ADD_OUT_STREAMS = 0x0011 SCTP_SUPPORTED_CHUNK_EXT = 0x8008 SCTP_PRSCTP_SUPPORTED = 0xC000 # data channel constants DATA_CHANNEL_ACK = 2 DATA_CHANNEL_OPEN = 3 DATA_CHANNEL_RELIABLE = 0x00 DATA_CHANNEL_PARTIAL_RELIABLE_REXMIT = 0x01 DATA_CHANNEL_PARTIAL_RELIABLE_TIMED = 0x02 DATA_CHANNEL_RELIABLE_UNORDERED = 0x80 DATA_CHANNEL_PARTIAL_RELIABLE_REXMIT_UNORDERED = 0x81 DATA_CHANNEL_PARTIAL_RELIABLE_TIMED_UNORDERED = 0x82 WEBRTC_DCEP = 50 WEBRTC_STRING = 51 WEBRTC_BINARY = 53 WEBRTC_STRING_EMPTY = 56 WEBRTC_BINARY_EMPTY = 57 def chunk_type(chunk) -> str: return chunk.__class__.__name__ def decode_params(body: bytes) -> List[Tuple[int, bytes]]: params = [] pos = 0 while pos <= len(body) - 4: param_type, param_length = unpack_from("!HH", body, pos) params.append((param_type, body[pos + 4 : pos + param_length])) pos += param_length + padl(param_length) return params def encode_params(params: List[Tuple[int, bytes]]) -> bytes: body = b"" padding = b"" for param_type, param_value in params: param_length = len(param_value) + 4 body += padding body += pack("!HH", param_type, param_length) + param_value padding = b"\x00" * padl(param_length) return body def padl(length: int) -> int: m = length % 4 return 4 - m if m else 0 def tsn_minus_one(a: int) -> int: return (a - 1) % SCTP_TSN_MODULO def tsn_plus_one(a: int) -> int: return (a + 1) % SCTP_TSN_MODULO class Chunk: type = -1 def __init__(self, flags: int = 0, body: bytes = b"") -> None: self.flags = flags self.body = body def __bytes__(self) -> bytes: body = self.body data = pack("!BBH", self.type, self.flags, len(body) + 4) + body data += b"\x00" * padl(len(body)) return data def __repr__(self) -> str: return f"{chunk_type(self)}(flags={self.flags})" class BaseParamsChunk(Chunk): def __init__(self, flags: int = 0, body: Optional[bytes] = None) -> None: self.flags = flags if body: self.params = decode_params(body) else: self.params = [] @property def body(self) -> bytes: # type: ignore return encode_params(self.params) class AbortChunk(BaseParamsChunk): type = 6 class CookieAckChunk(Chunk): type = 11 class CookieEchoChunk(Chunk): type = 10 class DataChunk(Chunk): type = 0 def __init__(self, flags: int = 0, body: Optional[bytes] = None) -> None: self.flags = flags if body: (self.tsn, self.stream_id, self.stream_seq, self.protocol) = unpack_from( "!LHHL", body ) self.user_data = body[12:] else: self.tsn = 0 self.stream_id = 0 self.stream_seq = 0 self.protocol = 0 self.user_data = b"" def __bytes__(self) -> bytes: length = 16 + len(self.user_data) data = ( pack( "!BBHLHHL", self.type, self.flags, length, self.tsn, self.stream_id, self.stream_seq, self.protocol, ) + self.user_data ) if length % 4: data += b"\x00" * padl(length) return data def __repr__(self) -> str: return ( f"DataChunk(flags={self.flags}, tsn={self.tsn}, " f"stream_id={self.stream_id}, stream_seq={self.stream_seq})" ) class ErrorChunk(BaseParamsChunk): type = 9 class ForwardTsnChunk(Chunk): type = 192 def __init__(self, flags: int = 0, body: Optional[bytes] = None) -> None: self.flags = flags self.streams: List[Tuple[int, int]] = [] if body: self.cumulative_tsn = unpack_from("!L", body, 0)[0] pos = 4 while pos < len(body): self.streams.append( cast(Tuple[int, int], unpack_from("!HH", body, pos)) ) pos += 4 else: self.cumulative_tsn = 0 @property def body(self) -> bytes: # type: ignore body = pack("!L", self.cumulative_tsn) for stream_id, stream_seq in self.streams: body += pack("!HH", stream_id, stream_seq) return body def __repr__(self) -> str: return f"ForwardTsnChunk(cumulative_tsn={self.cumulative_tsn}, streams={self.streams})" class HeartbeatChunk(BaseParamsChunk): type = 4 class HeartbeatAckChunk(BaseParamsChunk): type = 5 class BaseInitChunk(Chunk): def __init__(self, flags: int = 0, body: Optional[bytes] = None) -> None: self.flags = flags if body: ( self.initiate_tag, self.advertised_rwnd, self.outbound_streams, self.inbound_streams, self.initial_tsn, ) = unpack_from("!LLHHL", body) self.params = decode_params(body[16:]) else: self.initiate_tag = 0 self.advertised_rwnd = 0 self.outbound_streams = 0 self.inbound_streams = 0 self.initial_tsn = 0 self.params = [] @property def body(self) -> bytes: # type: ignore body = pack( "!LLHHL", self.initiate_tag, self.advertised_rwnd, self.outbound_streams, self.inbound_streams, self.initial_tsn, ) body += encode_params(self.params) return body class InitChunk(BaseInitChunk): type = 1 class InitAckChunk(BaseInitChunk): type = 2 class ReconfigChunk(BaseParamsChunk): type = 130 class SackChunk(Chunk): type = 3 def __init__(self, flags=0, body=None): self.flags = flags self.gaps = [] self.duplicates = [] if body: ( self.cumulative_tsn, self.advertised_rwnd, nb_gaps, nb_duplicates, ) = unpack_from("!LLHH", body) pos = 12 for i in range(nb_gaps): self.gaps.append(unpack_from("!HH", body, pos)) pos += 4 for i in range(nb_duplicates): self.duplicates.append(unpack_from("!L", body, pos)[0]) pos += 4 else: self.cumulative_tsn = 0 self.advertised_rwnd = 0 def __bytes__(self) -> bytes: length = 16 + 4 * (len(self.gaps) + len(self.duplicates)) data = pack( "!BBHLLHH", self.type, self.flags, length, self.cumulative_tsn, self.advertised_rwnd, len(self.gaps), len(self.duplicates), ) for gap in self.gaps: data += pack("!HH", *gap) for tsn in self.duplicates: data += pack("!L", tsn) return data def __repr__(self) -> str: return ( f"SackChunk(flags={self.flags}, advertised_rwnd={self.advertised_rwnd}, " f"cumulative_tsn={self.cumulative_tsn}, gaps={self.gaps})" ) class ShutdownChunk(Chunk): type = 7 def __init__(self, flags=0, body=None): self.flags = flags if body: self.cumulative_tsn = unpack_from("!L", body)[0] else: self.cumulative_tsn = 0 @property def body(self) -> bytes: # type: ignore return pack("!L", self.cumulative_tsn) def __repr__(self) -> str: return ( f"ShutdownChunk(flags={self.flags}, cumulative_tsn={self.cumulative_tsn})" ) class ShutdownAckChunk(Chunk): type = 8 class ShutdownCompleteChunk(Chunk): type = 14 CHUNK_CLASSES = [ DataChunk, InitChunk, InitAckChunk, SackChunk, HeartbeatChunk, HeartbeatAckChunk, AbortChunk, ShutdownChunk, ShutdownAckChunk, ErrorChunk, CookieEchoChunk, CookieAckChunk, ShutdownCompleteChunk, ReconfigChunk, ForwardTsnChunk, ] CHUNK_TYPES = dict((cls.type, cls) for cls in CHUNK_CLASSES) def parse_packet(data: bytes) -> Tuple[int, int, int, List[Any]]: length = len(data) if length < 12: raise ValueError("SCTP packet length is less than 12 bytes") source_port, destination_port, verification_tag = unpack_from("!HHL", data) # verify checksum checksum = unpack_from(" bytes: header = pack("!HHL", source_port, destination_port, verification_tag) data = bytes(chunk) checksum = crc32c(header + b"\x00\x00\x00\x00" + data) return header + pack(" bytes: data = pack( "!LLL", self.request_sequence, self.response_sequence, self.last_tsn ) for stream in self.streams: data += pack("!H", stream) return data @classmethod def parse(cls, data): request_sequence, response_sequence, last_tsn = unpack_from("!LLL", data) streams = [] for pos in range(12, len(data), 2): streams.append(unpack_from("!H", data, pos)[0]) return cls( request_sequence=request_sequence, response_sequence=response_sequence, last_tsn=last_tsn, streams=streams, ) @dataclass class StreamAddOutgoingParam: request_sequence: int new_streams: int def __bytes__(self) -> bytes: data = pack("!LHH", self.request_sequence, self.new_streams, 0) return data @classmethod def parse(cls, data): request_sequence, new_streams, reserved = unpack_from("!LHH", data) return cls(request_sequence=request_sequence, new_streams=new_streams) @dataclass class StreamResetResponseParam: response_sequence: int result: int def __bytes__(self) -> bytes: return pack("!LL", self.response_sequence, self.result) @classmethod def parse(cls, data): response_sequence, result = unpack_from("!LL", data) return cls(response_sequence=response_sequence, result=result) RECONFIG_PARAM_TYPES = { 13: StreamResetOutgoingParam, 16: StreamResetResponseParam, 17: StreamAddOutgoingParam, } class InboundStream: def __init__(self) -> None: self.reassembly: List[DataChunk] = [] self.sequence_number = 0 def add_chunk(self, chunk: DataChunk) -> None: if not self.reassembly or uint32_gt(chunk.tsn, self.reassembly[-1].tsn): self.reassembly.append(chunk) return for i, rchunk in enumerate(self.reassembly): # should never happen, the chunk should have been eliminated # as a duplicate when _mark_received() is called assert rchunk.tsn != chunk.tsn, "duplicate chunk in reassembly" if uint32_gt(rchunk.tsn, chunk.tsn): self.reassembly.insert(i, chunk) break def pop_messages(self) -> Iterator[Tuple[int, int, bytes]]: pos = 0 start_pos = None while pos < len(self.reassembly): chunk = self.reassembly[pos] if start_pos is None: ordered = not (chunk.flags & SCTP_DATA_UNORDERED) if not (chunk.flags & SCTP_DATA_FIRST_FRAG): if ordered: break else: pos += 1 continue if ordered and uint16_gt(chunk.stream_seq, self.sequence_number): break expected_tsn = chunk.tsn start_pos = pos elif chunk.tsn != expected_tsn: if ordered: break else: start_pos = None pos += 1 continue if chunk.flags & SCTP_DATA_LAST_FRAG: user_data = b"".join( [c.user_data for c in self.reassembly[start_pos : pos + 1]] ) self.reassembly = ( self.reassembly[:start_pos] + self.reassembly[pos + 1 :] ) if ordered and chunk.stream_seq == self.sequence_number: self.sequence_number = uint16_add(self.sequence_number, 1) pos = start_pos yield (chunk.stream_id, chunk.protocol, user_data) else: pos += 1 expected_tsn = tsn_plus_one(expected_tsn) def prune_chunks(self, tsn: int) -> int: """ Prune chunks up to the given TSN. """ pos = -1 size = 0 for i, chunk in enumerate(self.reassembly): if uint32_gte(tsn, chunk.tsn): pos = i size += len(chunk.user_data) else: break self.reassembly = self.reassembly[pos + 1 :] return size @dataclass class RTCSctpCapabilities: """ The :class:`RTCSctpCapabilities` dictionary provides information about the capabilities of the :class:`RTCSctpTransport`. """ maxMessageSize: int """ The maximum size of data that the implementation can send or 0 if the implementation can handle messages of any size. """ class RTCSctpTransport(AsyncIOEventEmitter): """ The :class:`RTCSctpTransport` interface includes information relating to Stream Control Transmission Protocol (SCTP) transport. :param transport: An :class:`RTCDtlsTransport`. """ def __init__(self, transport: RTCDtlsTransport, port: int = 5000) -> None: if transport.state == "closed": raise InvalidStateError super().__init__() self._association_state = self.State.CLOSED self.__log_debug: Callable[..., None] = lambda *args: None self.__started = False self.__state = "new" self.__transport = transport self._loop = asyncio.get_event_loop() self._hmac_key = os.urandom(16) self._local_partial_reliability = True self._local_port = port self._local_verification_tag = random32() self._remote_extensions: List[int] = [] self._remote_partial_reliability = False self._remote_port: Optional[int] = None self._remote_verification_tag = 0 # inbound self._advertised_rwnd = 1024 * 1024 self._inbound_streams: Dict[int, InboundStream] = {} self._inbound_streams_count = 0 self._inbound_streams_max = MAX_STREAMS self._last_received_tsn: Optional[int] = None self._sack_duplicates: List[int] = [] self._sack_misordered: Set[int] = set() self._sack_needed = False # outbound self._cwnd = 3 * USERDATA_MAX_LENGTH self._fast_recovery_exit = None self._fast_recovery_transmit = False self._forward_tsn_chunk: Optional[ForwardTsnChunk] = None self._flight_size = 0 self._local_tsn = random32() self._last_sacked_tsn = tsn_minus_one(self._local_tsn) self._advanced_peer_ack_tsn = tsn_minus_one(self._local_tsn) self._outbound_queue: Deque[DataChunk] = deque() self._outbound_stream_seq: Dict[int, int] = {} self._outbound_streams_count = MAX_STREAMS self._partial_bytes_acked = 0 self._sent_queue: Deque[DataChunk] = deque() # reconfiguration self._reconfig_queue: List[int] = [] self._reconfig_request = None self._reconfig_request_seq = self._local_tsn self._reconfig_response_seq = 0 # rtt calculation self._srtt: Optional[float] = None self._rttvar: Optional[float] = None # timers self._rto = SCTP_RTO_INITIAL self._t1_chunk: Optional[Chunk] = None self._t1_failures = 0 self._t1_handle: Optional[asyncio.TimerHandle] = None self._t2_chunk: Optional[Chunk] = None self._t2_failures = 0 self._t2_handle: Optional[asyncio.TimerHandle] = None self._t3_handle: Optional[asyncio.TimerHandle] = None # data channels self._data_channel_id: Optional[int] = None self._data_channel_queue: Deque[Tuple[RTCDataChannel, int, bytes]] = deque() self._data_channels: Dict[int, RTCDataChannel] = {} # FIXME: this is only used by RTCPeerConnection self._bundled = False self.mid: Optional[str] = None @property def is_server(self) -> bool: return self.transport.transport.role != "controlling" @property def maxChannels(self) -> Optional[int]: """ The maximum number of :class:`RTCDataChannel`s that can be used simultaneously. """ if self._inbound_streams_count: return min(self._inbound_streams_count, self._outbound_streams_count) return None @property def port(self) -> int: """ The local SCTP port number used for data channels. """ return self._local_port @property def state(self) -> str: """ The current state of the SCTP transport. """ return self.__state @property def transport(self): """ The :class:`RTCDtlsTransport` over which SCTP data is transmitted. """ return self.__transport @classmethod def getCapabilities(cls) -> RTCSctpCapabilities: """ Retrieve the capabilities of the transport. :rtype: RTCSctpCapabilities """ return RTCSctpCapabilities(maxMessageSize=65536) def setTransport(self, transport) -> None: self.__transport = transport async def start(self, remoteCaps: RTCSctpCapabilities, remotePort: int) -> None: """ Start the transport. """ if not self.__started: self.__started = True self.__state = "connecting" self._remote_port = remotePort # configure logging if logger.isEnabledFor(logging.DEBUG): prefix = "RTCSctpTransport(%s) " % ( self.is_server and "server" or "client" ) self.__log_debug = lambda msg, *args: logger.debug(prefix + msg, *args) # initialise local channel ID counter # one side should be using even IDs, the other odd IDs if self.is_server: self._data_channel_id = 0 else: self._data_channel_id = 1 self.__transport._register_data_receiver(self) if not self.is_server: await self._init() async def stop(self) -> None: """ Stop the transport. """ if self._association_state != self.State.CLOSED: await self._abort() self.__transport._unregister_data_receiver(self) self._set_state(self.State.CLOSED) async def _abort(self) -> None: """ Abort the association. """ chunk = AbortChunk() try: await self._send_chunk(chunk) except ConnectionError: pass async def _init(self) -> None: """ Initialize the association. """ chunk = InitChunk() chunk.initiate_tag = self._local_verification_tag chunk.advertised_rwnd = self._advertised_rwnd chunk.outbound_streams = self._outbound_streams_count chunk.inbound_streams = self._inbound_streams_max chunk.initial_tsn = self._local_tsn self._set_extensions(chunk.params) await self._send_chunk(chunk) # start T1 timer and enter COOKIE-WAIT state self._t1_start(chunk) self._set_state(self.State.COOKIE_WAIT) def _flight_size_decrease(self, chunk: DataChunk) -> None: self._flight_size = max(0, self._flight_size - chunk._book_size) # type: ignore def _flight_size_increase(self, chunk: DataChunk) -> None: self._flight_size += chunk._book_size # type: ignore def _get_extensions(self, params: List[Tuple[int, bytes]]) -> None: """ Gets what extensions are supported by the remote party. """ for k, v in params: if k == SCTP_PRSCTP_SUPPORTED: self._remote_partial_reliability = True elif k == SCTP_SUPPORTED_CHUNK_EXT: self._remote_extensions = list(v) def _set_extensions(self, params: List[Tuple[int, bytes]]) -> None: """ Sets what extensions are supported by the local party. """ extensions = [] if self._local_partial_reliability: params.append((SCTP_PRSCTP_SUPPORTED, b"")) extensions.append(ForwardTsnChunk.type) extensions.append(ReconfigChunk.type) params.append((SCTP_SUPPORTED_CHUNK_EXT, bytes(extensions))) def _get_inbound_stream(self, stream_id: int) -> InboundStream: """ Get or create the inbound stream with the specified ID. """ if stream_id not in self._inbound_streams: self._inbound_streams[stream_id] = InboundStream() return self._inbound_streams[stream_id] def _get_timestamp(self) -> int: return int(time.time()) async def _handle_data(self, data): """ Handle data received from the network. """ try: _, _, verification_tag, chunks = parse_packet(data) except ValueError: return # is this an init? init_chunk = len([x for x in chunks if isinstance(x, InitChunk)]) if init_chunk: assert len(chunks) == 1 expected_tag = 0 else: expected_tag = self._local_verification_tag # verify tag if verification_tag != expected_tag: self.__log_debug( "Bad verification tag %d vs %d", verification_tag, expected_tag ) return # handle chunks for chunk in chunks: await self._receive_chunk(chunk) # send SACK if needed if self._sack_needed: await self._send_sack() @no_type_check def _maybe_abandon(self, chunk: DataChunk) -> bool: """ Determine if a chunk needs to be marked as abandoned. If it does, it marks the chunk and any other chunk belong to the same message as abandoned. """ if chunk._abandoned: return True abandon = ( chunk._max_retransmits is not None and chunk._sent_count > chunk._max_retransmits ) or (chunk._expiry is not None and chunk._expiry < time.time()) if not abandon: return False chunk_pos = self._sent_queue.index(chunk) for pos in range(chunk_pos, -1, -1): ochunk = self._sent_queue[pos] ochunk._abandoned = True ochunk._retransmit = False if ochunk.flags & SCTP_DATA_FIRST_FRAG: break for pos in range(chunk_pos, len(self._sent_queue)): ochunk = self._sent_queue[pos] ochunk._abandoned = True ochunk._retransmit = False if ochunk.flags & SCTP_DATA_LAST_FRAG: break return True def _mark_received(self, tsn: int) -> bool: """ Mark an incoming data TSN as received. """ # it's a duplicate if uint32_gte(self._last_received_tsn, tsn) or tsn in self._sack_misordered: self._sack_duplicates.append(tsn) return True # consolidate misordered entries self._sack_misordered.add(tsn) for tsn in sorted(self._sack_misordered): if tsn == tsn_plus_one(self._last_received_tsn): self._last_received_tsn = tsn else: break # filter out obsolete entries def is_obsolete(x): return uint32_gt(x, self._last_received_tsn) self._sack_duplicates = list(filter(is_obsolete, self._sack_duplicates)) self._sack_misordered = set(filter(is_obsolete, self._sack_misordered)) return False async def _receive(self, stream_id: int, pp_id: int, data: bytes) -> None: """ Receive data stream -> ULP. """ await self._data_channel_receive(stream_id, pp_id, data) async def _receive_chunk(self, chunk): """ Handle an incoming chunk. """ self.__log_debug("< %s", chunk) # common if isinstance(chunk, DataChunk): await self._receive_data_chunk(chunk) elif isinstance(chunk, SackChunk): await self._receive_sack_chunk(chunk) elif isinstance(chunk, ForwardTsnChunk): await self._receive_forward_tsn_chunk(chunk) elif isinstance(chunk, HeartbeatChunk): ack = HeartbeatAckChunk() ack.params = chunk.params await self._send_chunk(ack) elif isinstance(chunk, AbortChunk): self.__log_debug("x Association was aborted by remote party") self._set_state(self.State.CLOSED) elif isinstance(chunk, ShutdownChunk): self._t2_cancel() self._set_state(self.State.SHUTDOWN_RECEIVED) ack = ShutdownAckChunk() await self._send_chunk(ack) self._t2_start(ack) self._set_state(self.State.SHUTDOWN_ACK_SENT) elif ( isinstance(chunk, ShutdownCompleteChunk) and self._association_state == self.State.SHUTDOWN_ACK_SENT ): self._t2_cancel() self._set_state(self.State.CLOSED) elif ( isinstance(chunk, ReconfigChunk) and self._association_state == self.State.ESTABLISHED ): for param in chunk.params: cls = RECONFIG_PARAM_TYPES.get(param[0]) if cls: await self._receive_reconfig_param(cls.parse(param[1])) # server elif isinstance(chunk, InitChunk) and self.is_server: self._last_received_tsn = tsn_minus_one(chunk.initial_tsn) self._reconfig_response_seq = tsn_minus_one(chunk.initial_tsn) self._remote_verification_tag = chunk.initiate_tag self._ssthresh = chunk.advertised_rwnd self._get_extensions(chunk.params) self.__log_debug( "- Peer supports %d outbound streams, %d max inbound streams", chunk.outbound_streams, chunk.inbound_streams, ) self._inbound_streams_count = min( chunk.outbound_streams, self._inbound_streams_max ) self._outbound_streams_count = min( self._outbound_streams_count, chunk.inbound_streams ) ack = InitAckChunk() ack.initiate_tag = self._local_verification_tag ack.advertised_rwnd = self._advertised_rwnd ack.outbound_streams = self._outbound_streams_count ack.inbound_streams = self._inbound_streams_max ack.initial_tsn = self._local_tsn self._set_extensions(ack.params) # generate state cookie cookie = pack("!L", self._get_timestamp()) cookie += hmac.new(self._hmac_key, cookie, "sha1").digest() ack.params.append((SCTP_STATE_COOKIE, cookie)) await self._send_chunk(ack) elif isinstance(chunk, CookieEchoChunk) and self.is_server: # check state cookie MAC cookie = chunk.body if ( len(cookie) != COOKIE_LENGTH or hmac.new(self._hmac_key, cookie[0:4], "sha1").digest() != cookie[4:] ): self.__log_debug("x State cookie is invalid") return # check state cookie lifetime now = self._get_timestamp() stamp = unpack_from("!L", cookie)[0] if stamp < now - COOKIE_LIFETIME or stamp > now: self.__log_debug("x State cookie has expired") error = ErrorChunk() error.params.append((SCTP_CAUSE_STALE_COOKIE, b"\x00" * 8)) await self._send_chunk(error) return ack = CookieAckChunk() await self._send_chunk(ack) self._set_state(self.State.ESTABLISHED) # client elif ( isinstance(chunk, InitAckChunk) and self._association_state == self.State.COOKIE_WAIT ): # cancel T1 timer and process chunk self._t1_cancel() self._last_received_tsn = tsn_minus_one(chunk.initial_tsn) self._reconfig_response_seq = tsn_minus_one(chunk.initial_tsn) self._remote_verification_tag = chunk.initiate_tag self._ssthresh = chunk.advertised_rwnd self._get_extensions(chunk.params) self.__log_debug( "- Peer supports %d outbound streams, %d max inbound streams", chunk.outbound_streams, chunk.inbound_streams, ) self._inbound_streams_count = min( chunk.outbound_streams, self._inbound_streams_max ) self._outbound_streams_count = min( self._outbound_streams_count, chunk.inbound_streams ) echo = CookieEchoChunk() for k, v in chunk.params: if k == SCTP_STATE_COOKIE: echo.body = v break await self._send_chunk(echo) # start T1 timer and enter COOKIE-ECHOED state self._t1_start(echo) self._set_state(self.State.COOKIE_ECHOED) elif ( isinstance(chunk, CookieAckChunk) and self._association_state == self.State.COOKIE_ECHOED ): # cancel T1 timer and enter ESTABLISHED state self._t1_cancel() self._set_state(self.State.ESTABLISHED) elif isinstance(chunk, ErrorChunk) and self._association_state in [ self.State.COOKIE_WAIT, self.State.COOKIE_ECHOED, ]: self._t1_cancel() self._set_state(self.State.CLOSED) self.__log_debug("x Could not establish association") return async def _receive_data_chunk(self, chunk: DataChunk) -> None: """ Handle a DATA chunk. """ self._sack_needed = True # mark as received if self._mark_received(chunk.tsn): return # find stream inbound_stream = self._get_inbound_stream(chunk.stream_id) # defragment data inbound_stream.add_chunk(chunk) self._advertised_rwnd -= len(chunk.user_data) for message in inbound_stream.pop_messages(): self._advertised_rwnd += len(message[2]) await self._receive(*message) async def _receive_forward_tsn_chunk(self, chunk: ForwardTsnChunk) -> None: """ Handle a FORWARD TSN chunk. """ self._sack_needed = True # it's a duplicate if uint32_gte(self._last_received_tsn, chunk.cumulative_tsn): return def is_obsolete(x): return uint32_gt(x, self._last_received_tsn) # advance cumulative TSN self._last_received_tsn = chunk.cumulative_tsn self._sack_misordered = set(filter(is_obsolete, self._sack_misordered)) for tsn in sorted(self._sack_misordered): if tsn == tsn_plus_one(self._last_received_tsn): self._last_received_tsn = tsn else: break # filter out obsolete entries self._sack_duplicates = list(filter(is_obsolete, self._sack_duplicates)) self._sack_misordered = set(filter(is_obsolete, self._sack_misordered)) # update reassembly for stream_id, stream_seq in chunk.streams: inbound_stream = self._get_inbound_stream(stream_id) # advance sequence number and perform delivery inbound_stream.sequence_number = uint16_add(stream_seq, 1) for message in inbound_stream.pop_messages(): self._advertised_rwnd += len(message[2]) await self._receive(*message) # prune obsolete chunks for stream_id, inbound_stream in self._inbound_streams.items(): self._advertised_rwnd += inbound_stream.prune_chunks( self._last_received_tsn ) @no_type_check async def _receive_sack_chunk(self, chunk: SackChunk) -> None: """ Handle a SACK chunk. """ if uint32_gt(self._last_sacked_tsn, chunk.cumulative_tsn): return received_time = time.time() self._last_sacked_tsn = chunk.cumulative_tsn cwnd_fully_utilized = self._flight_size >= self._cwnd done = 0 done_bytes = 0 # handle acknowledged data while self._sent_queue and uint32_gte( self._last_sacked_tsn, self._sent_queue[0].tsn ): schunk = self._sent_queue.popleft() done += 1 if not schunk._acked: done_bytes += schunk._book_size self._flight_size_decrease(schunk) # update RTO estimate if done == 1 and schunk._sent_count == 1: self._update_rto(received_time - schunk._sent_time) # handle gap blocks loss = False if chunk.gaps: seen = set() for gap in chunk.gaps: for pos in range(gap[0], gap[1] + 1): highest_seen_tsn = (chunk.cumulative_tsn + pos) % SCTP_TSN_MODULO seen.add(highest_seen_tsn) # determined Highest TSN Newly Acked (HTNA) highest_newly_acked = chunk.cumulative_tsn for schunk in self._sent_queue: if uint32_gt(schunk.tsn, highest_seen_tsn): break if schunk.tsn in seen and not schunk._acked: done_bytes += schunk._book_size schunk._acked = True self._flight_size_decrease(schunk) highest_newly_acked = schunk.tsn # strike missing chunks prior to HTNA for schunk in self._sent_queue: if uint32_gt(schunk.tsn, highest_newly_acked): break if schunk.tsn not in seen: schunk._misses += 1 if schunk._misses == 3: schunk._misses = 0 if not self._maybe_abandon(schunk): schunk._retransmit = True schunk._acked = False self._flight_size_decrease(schunk) loss = True # adjust congestion window if self._fast_recovery_exit is None: if done and cwnd_fully_utilized: if self._cwnd <= self._ssthresh: # slow start self._cwnd += min(done_bytes, USERDATA_MAX_LENGTH) else: # congestion avoidance self._partial_bytes_acked += done_bytes if self._partial_bytes_acked >= self._cwnd: self._partial_bytes_acked -= self._cwnd self._cwnd += USERDATA_MAX_LENGTH if loss: self._ssthresh = max(self._cwnd // 2, 4 * USERDATA_MAX_LENGTH) self._cwnd = self._ssthresh self._partial_bytes_acked = 0 self._fast_recovery_exit = self._sent_queue[-1].tsn self._fast_recovery_transmit = True elif uint32_gte(chunk.cumulative_tsn, self._fast_recovery_exit): self._fast_recovery_exit = None if not self._sent_queue: # there is no outstanding data, stop T3 self._t3_cancel() elif done: # the earliest outstanding chunk was acknowledged, restart T3 self._t3_restart() self._update_advanced_peer_ack_point() await self._data_channel_flush() await self._transmit() async def _receive_reconfig_param(self, param): """ Handle a RE-CONFIG parameter. """ self.__log_debug("<< %s", param) if isinstance(param, StreamResetOutgoingParam): # mark closed inbound streams for stream_id in param.streams: self._inbound_streams.pop(stream_id, None) # close data channel channel = self._data_channels.get(stream_id) if channel: self._data_channel_close(channel) # send response response_param = StreamResetResponseParam( response_sequence=param.request_sequence, result=1 ) self._reconfig_response_seq = param.request_sequence await self._send_reconfig_param(response_param) elif isinstance(param, StreamAddOutgoingParam): # increase inbound streams self._inbound_streams_count += param.new_streams # send response response_param = StreamResetResponseParam( response_sequence=param.request_sequence, result=1 ) self._reconfig_response_seq = param.request_sequence await self._send_reconfig_param(response_param) elif isinstance(param, StreamResetResponseParam): if ( self._reconfig_request and param.response_sequence == self._reconfig_request.request_sequence ): # mark closed streams for stream_id in self._reconfig_request.streams: self._outbound_stream_seq.pop(stream_id, None) self._data_channel_closed(stream_id) self._reconfig_request = None await self._transmit_reconfig() @no_type_check async def _send( self, stream_id: int, pp_id: int, user_data: bytes, expiry: Optional[float] = None, max_retransmits: Optional[int] = None, ordered: bool = True, ) -> None: """ Send data ULP -> stream. """ if ordered: stream_seq = self._outbound_stream_seq.get(stream_id, 0) else: stream_seq = 0 fragments = math.ceil(len(user_data) / USERDATA_MAX_LENGTH) pos = 0 for fragment in range(0, fragments): chunk = DataChunk() chunk.flags = 0 if not ordered: chunk.flags = SCTP_DATA_UNORDERED if fragment == 0: chunk.flags |= SCTP_DATA_FIRST_FRAG if fragment == fragments - 1: chunk.flags |= SCTP_DATA_LAST_FRAG chunk.tsn = self._local_tsn chunk.stream_id = stream_id chunk.stream_seq = stream_seq chunk.protocol = pp_id chunk.user_data = user_data[pos : pos + USERDATA_MAX_LENGTH] # FIXME: dynamically added attributes, mypy can't handle them # initialize counters chunk._abandoned = False chunk._acked = False chunk._book_size = len(chunk.user_data) chunk._expiry = expiry chunk._max_retransmits = max_retransmits chunk._misses = 0 chunk._retransmit = False chunk._sent_count = 0 chunk._sent_time = None pos += USERDATA_MAX_LENGTH self._local_tsn = tsn_plus_one(self._local_tsn) self._outbound_queue.append(chunk) if ordered: self._outbound_stream_seq[stream_id] = uint16_add(stream_seq, 1) # transmit outbound data if not self._t3_handle: await self._transmit() async def _send_chunk(self, chunk: Chunk) -> None: """ Transmit a chunk (no bundling for now). """ self.__log_debug("> %s", chunk) await self.__transport._send_data( serialize_packet( self._local_port, self._remote_port, self._remote_verification_tag, chunk, ) ) async def _send_reconfig_param(self, param): chunk = ReconfigChunk() for k, cls in RECONFIG_PARAM_TYPES.items(): if isinstance(param, cls): param_type = k break chunk.params.append((param_type, bytes(param))) self.__log_debug(">> %s", param) await self._send_chunk(chunk) async def _send_sack(self): """ Build and send a selective acknowledgement (SACK) chunk. """ gaps = [] gap_next = None for tsn in sorted(self._sack_misordered): pos = (tsn - self._last_received_tsn) % SCTP_TSN_MODULO if tsn == gap_next: gaps[-1][1] = pos else: gaps.append([pos, pos]) gap_next = tsn_plus_one(tsn) sack = SackChunk() sack.cumulative_tsn = self._last_received_tsn sack.advertised_rwnd = max(0, self._advertised_rwnd) sack.duplicates = self._sack_duplicates[:] sack.gaps = [tuple(x) for x in gaps] await self._send_chunk(sack) self._sack_duplicates.clear() self._sack_needed = False def _set_state(self, state) -> None: """ Transition the SCTP association to a new state. """ if state != self._association_state: self.__log_debug("- %s -> %s", self._association_state, state) self._association_state = state if state == self.State.ESTABLISHED: self.__state = "connected" for channel in list(self._data_channels.values()): if channel.negotiated and channel.readyState != "open": channel._setReadyState("open") asyncio.ensure_future(self._data_channel_flush()) elif state == self.State.CLOSED: self._t1_cancel() self._t2_cancel() self._t3_cancel() self.__state = "closed" # close data channels for stream_id in list(self._data_channels.keys()): self._data_channel_closed(stream_id) # no more events will be emitted, so remove all event listeners # to facilitate garbage collection. self.remove_all_listeners() # timers def _t1_cancel(self) -> None: if self._t1_handle is not None: self.__log_debug("- T1(%s) cancel", chunk_type(self._t1_chunk)) self._t1_handle.cancel() self._t1_handle = None self._t1_chunk = None def _t1_expired(self) -> None: self._t1_failures += 1 self._t1_handle = None self.__log_debug( "x T1(%s) expired %d", chunk_type(self._t1_chunk), self._t1_failures ) if self._t1_failures > SCTP_MAX_INIT_RETRANS: self._set_state(self.State.CLOSED) else: asyncio.ensure_future(self._send_chunk(self._t1_chunk)) self._t1_handle = self._loop.call_later(self._rto, self._t1_expired) def _t1_start(self, chunk: Chunk) -> None: assert self._t1_handle is None self._t1_chunk = chunk self._t1_failures = 0 self.__log_debug("- T1(%s) start", chunk_type(self._t1_chunk)) self._t1_handle = self._loop.call_later(self._rto, self._t1_expired) def _t2_cancel(self) -> None: if self._t2_handle is not None: self.__log_debug("- T2(%s) cancel", chunk_type(self._t2_chunk)) self._t2_handle.cancel() self._t2_handle = None self._t2_chunk = None def _t2_expired(self) -> None: self._t2_failures += 1 self._t2_handle = None self.__log_debug( "x T2(%s) expired %d", chunk_type(self._t2_chunk), self._t2_failures ) if self._t2_failures > SCTP_MAX_ASSOCIATION_RETRANS: self._set_state(self.State.CLOSED) else: asyncio.ensure_future(self._send_chunk(self._t2_chunk)) self._t2_handle = self._loop.call_later(self._rto, self._t2_expired) def _t2_start(self, chunk) -> None: assert self._t2_handle is None self._t2_chunk = chunk self._t2_failures = 0 self.__log_debug("- T2(%s) start", chunk_type(self._t2_chunk)) self._t2_handle = self._loop.call_later(self._rto, self._t2_expired) @no_type_check def _t3_expired(self) -> None: self._t3_handle = None self.__log_debug("x T3 expired") # mark retransmit or abandoned chunks for chunk in self._sent_queue: if not self._maybe_abandon(chunk): chunk._retransmit = True self._update_advanced_peer_ack_point() # adjust congestion window self._fast_recovery_exit = None self._flight_size = 0 self._partial_bytes_acked = 0 self._ssthresh = max(self._cwnd // 2, 4 * USERDATA_MAX_LENGTH) self._cwnd = USERDATA_MAX_LENGTH asyncio.ensure_future(self._transmit()) def _t3_restart(self) -> None: self.__log_debug("- T3 restart") if self._t3_handle is not None: self._t3_handle.cancel() self._t3_handle = None self._t3_handle = self._loop.call_later(self._rto, self._t3_expired) def _t3_start(self) -> None: assert self._t3_handle is None self.__log_debug("- T3 start") self._t3_handle = self._loop.call_later(self._rto, self._t3_expired) def _t3_cancel(self) -> None: if self._t3_handle is not None: self.__log_debug("- T3 cancel") self._t3_handle.cancel() self._t3_handle = None @no_type_check async def _transmit(self) -> None: """ Transmit outbound data. """ # send FORWARD TSN if self._forward_tsn_chunk is not None: await self._send_chunk(self._forward_tsn_chunk) self._forward_tsn_chunk = None # ensure T3 is running if not self._t3_handle: self._t3_start() # limit burst size if self._fast_recovery_exit is not None: burst_size = 2 * USERDATA_MAX_LENGTH else: burst_size = 4 * USERDATA_MAX_LENGTH cwnd = min(self._flight_size + burst_size, self._cwnd) # retransmit retransmit_earliest = True for chunk in self._sent_queue: if chunk._retransmit: if self._fast_recovery_transmit: self._fast_recovery_transmit = False elif self._flight_size >= cwnd: return self._flight_size_increase(chunk) chunk._misses = 0 chunk._retransmit = False chunk._sent_count += 1 await self._send_chunk(chunk) if retransmit_earliest: # restart the T3 timer as the earliest outstanding TSN # is being retransmitted self._t3_restart() retransmit_earliest = False while self._outbound_queue and self._flight_size < cwnd: chunk = self._outbound_queue.popleft() self._sent_queue.append(chunk) self._flight_size_increase(chunk) # update counters chunk._sent_count += 1 chunk._sent_time = time.time() await self._send_chunk(chunk) if not self._t3_handle: self._t3_start() async def _transmit_reconfig(self): if ( self._association_state == self.State.ESTABLISHED and self._reconfig_queue and not self._reconfig_request ): streams = self._reconfig_queue[0:RECONFIG_MAX_STREAMS] self._reconfig_queue = self._reconfig_queue[RECONFIG_MAX_STREAMS:] param = StreamResetOutgoingParam( request_sequence=self._reconfig_request_seq, response_sequence=self._reconfig_response_seq, last_tsn=tsn_minus_one(self._local_tsn), streams=streams, ) self._reconfig_request = param self._reconfig_request_seq = tsn_plus_one(self._reconfig_request_seq) await self._send_reconfig_param(param) @no_type_check def _update_advanced_peer_ack_point(self) -> None: """ Try to advance "Advanced.Peer.Ack.Point" according to RFC 3758. """ if uint32_gt(self._last_sacked_tsn, self._advanced_peer_ack_tsn): self._advanced_peer_ack_tsn = self._last_sacked_tsn done = 0 streams = {} while self._sent_queue and self._sent_queue[0]._abandoned: chunk = self._sent_queue.popleft() self._advanced_peer_ack_tsn = chunk.tsn done += 1 if not (chunk.flags & SCTP_DATA_UNORDERED): streams[chunk.stream_id] = chunk.stream_seq if done: # build FORWARD TSN self._forward_tsn_chunk = ForwardTsnChunk() self._forward_tsn_chunk.cumulative_tsn = self._advanced_peer_ack_tsn self._forward_tsn_chunk.streams = list(streams.items()) def _update_rto(self, R: float) -> None: """ Update RTO given a new roundtrip measurement R. """ if self._srtt is None: self._rttvar = R / 2 self._srtt = R else: self._rttvar = (1 - SCTP_RTO_BETA) * self._rttvar + SCTP_RTO_BETA * abs( self._srtt - R ) self._srtt = (1 - SCTP_RTO_ALPHA) * self._srtt + SCTP_RTO_ALPHA * R self._rto = max(SCTP_RTO_MIN, min(self._srtt + 4 * self._rttvar, SCTP_RTO_MAX)) def _data_channel_close(self, channel, transmit=True): """ Request closing the datachannel by sending an Outgoing Stream Reset Request. """ if channel.readyState not in ["closing", "closed"]: channel._setReadyState("closing") if self._association_state == self.State.ESTABLISHED: # queue a stream reset self._reconfig_queue.append(channel.id) if len(self._reconfig_queue) == 1: asyncio.ensure_future(self._transmit_reconfig()) else: # remove any queued messages for the datachannel new_queue = deque() for queue_item in self._data_channel_queue: if queue_item[0] != channel: new_queue.append(queue_item) self._data_channel_queue = new_queue # mark the datachannel as closed if channel.id is not None: self._data_channels.pop(channel.id) channel._setReadyState("closed") def _data_channel_closed(self, stream_id: int) -> None: channel = self._data_channels.pop(stream_id) channel._setReadyState("closed") async def _data_channel_flush(self) -> None: """ Try to flush buffered data to the SCTP layer. We wait until the association is established, as we need to know whether we are a client or a server to correctly assign an odd/even ID to the data channels. """ if self._association_state != self.State.ESTABLISHED: return while self._data_channel_queue and not self._outbound_queue: channel, protocol, user_data = self._data_channel_queue.popleft() # register channel if necessary stream_id = channel.id if stream_id is None: stream_id = self._data_channel_id while stream_id in self._data_channels: stream_id += 2 self._data_channels[stream_id] = channel channel._setId(stream_id) # send data if protocol == WEBRTC_DCEP: await self._send(stream_id, protocol, user_data) else: if channel.maxPacketLifeTime: expiry = time.time() + (channel.maxPacketLifeTime / 1000) else: expiry = None await self._send( stream_id, protocol, user_data, expiry=expiry, max_retransmits=channel.maxRetransmits, ordered=channel.ordered, ) channel._addBufferedAmount(-len(user_data)) def _data_channel_add_negotiated(self, channel: RTCDataChannel) -> None: if channel.id in self._data_channels: raise ValueError(f"Data channel with ID {channel.id} already registered") self._data_channels[channel.id] = channel if self._association_state == self.State.ESTABLISHED: channel._setReadyState("open") def _data_channel_open(self, channel: RTCDataChannel) -> None: if channel.id is not None: if channel.id in self._data_channels: raise ValueError( f"Data channel with ID {channel.id} already registered" ) else: self._data_channels[channel.id] = channel channel_type = DATA_CHANNEL_RELIABLE priority = 0 reliability = 0 if not channel.ordered: channel_type |= 0x80 if channel.maxRetransmits is not None: channel_type |= 1 reliability = channel.maxRetransmits elif channel.maxPacketLifeTime is not None: channel_type |= 2 reliability = channel.maxPacketLifeTime data = pack( "!BBHLHH", DATA_CHANNEL_OPEN, channel_type, priority, reliability, len(channel.label), len(channel.protocol), ) data += channel.label.encode("utf8") data += channel.protocol.encode("utf8") self._data_channel_queue.append((channel, WEBRTC_DCEP, data)) asyncio.ensure_future(self._data_channel_flush()) async def _data_channel_receive( self, stream_id: int, pp_id: int, data: bytes ) -> None: if pp_id == WEBRTC_DCEP and len(data): msg_type = data[0] if msg_type == DATA_CHANNEL_OPEN and len(data) >= 12: # we should not receive an open for an existing channel assert stream_id not in self._data_channels ( msg_type, channel_type, priority, reliability, label_length, protocol_length, ) = unpack_from("!BBHLHH", data) pos = 12 label = data[pos : pos + label_length].decode("utf8") pos += label_length protocol = data[pos : pos + protocol_length].decode("utf8") # check channel type maxPacketLifeTime = None maxRetransmits = None if (channel_type & 0x03) == 1: maxRetransmits = reliability elif (channel_type & 0x03) == 2: maxPacketLifeTime = reliability # register channel parameters = RTCDataChannelParameters( label=label, ordered=(channel_type & 0x80) == 0, maxPacketLifeTime=maxPacketLifeTime, maxRetransmits=maxRetransmits, protocol=protocol, id=stream_id, ) channel = RTCDataChannel(self, parameters, False) channel._setReadyState("open") self._data_channels[stream_id] = channel # send ack self._data_channel_queue.append( (channel, WEBRTC_DCEP, pack("!B", DATA_CHANNEL_ACK)) ) await self._data_channel_flush() # emit channel self.emit("datachannel", channel) elif msg_type == DATA_CHANNEL_ACK: assert stream_id in self._data_channels channel = self._data_channels[stream_id] channel._setReadyState("open") elif pp_id == WEBRTC_STRING and stream_id in self._data_channels: # emit message self._data_channels[stream_id].emit("message", data.decode("utf8")) elif pp_id == WEBRTC_STRING_EMPTY and stream_id in self._data_channels: # emit message self._data_channels[stream_id].emit("message", "") elif pp_id == WEBRTC_BINARY and stream_id in self._data_channels: # emit message self._data_channels[stream_id].emit("message", data) elif pp_id == WEBRTC_BINARY_EMPTY and stream_id in self._data_channels: # emit message self._data_channels[stream_id].emit("message", b"") def _data_channel_send(self, channel: RTCDataChannel, data: bytes) -> None: if data == "": pp_id, user_data = WEBRTC_STRING_EMPTY, b"\x00" elif isinstance(data, str): pp_id, user_data = WEBRTC_STRING, data.encode("utf8") elif data == b"": pp_id, user_data = WEBRTC_BINARY_EMPTY, b"\x00" else: pp_id, user_data = WEBRTC_BINARY, data channel._addBufferedAmount(len(user_data)) self._data_channel_queue.append((channel, pp_id, user_data)) asyncio.ensure_future(self._data_channel_flush()) class State(enum.Enum): CLOSED = 1 COOKIE_WAIT = 2 COOKIE_ECHOED = 3 ESTABLISHED = 4 SHUTDOWN_PENDING = 5 SHUTDOWN_SENT = 6 SHUTDOWN_RECEIVED = 7 SHUTDOWN_ACK_SENT = 8 aiortc-1.3.0/src/aiortc/rtcsessiondescription.py000066400000000000000000000007411417604566400220750ustar00rootroot00000000000000from dataclasses import dataclass @dataclass class RTCSessionDescription: """ The :class:`RTCSessionDescription` dictionary describes one end of a connection and how it's configured. """ sdp: str type: str def __post_init__(self): if self.type not in {"offer", "pranswer", "answer", "rollback"}: raise ValueError( f"'type' must be in ['offer', 'pranswer', 'answer', 'rollback'] (got '{self.type}')" ) aiortc-1.3.0/src/aiortc/rtp.py000066400000000000000000000601211417604566400162400ustar00rootroot00000000000000import math import os import struct from dataclasses import dataclass, field from struct import pack, unpack, unpack_from from typing import Any, List, Optional, Tuple, Union from av import AudioFrame from .rtcrtpparameters import RTCRtpParameters # reserved to avoid confusion with RTCP FORBIDDEN_PAYLOAD_TYPES = range(72, 77) DYNAMIC_PAYLOAD_TYPES = range(96, 128) RTP_HEADER_LENGTH = 12 RTCP_HEADER_LENGTH = 4 PACKETS_LOST_MIN = -(1 << 23) PACKETS_LOST_MAX = (1 << 23) - 1 RTCP_SR = 200 RTCP_RR = 201 RTCP_SDES = 202 RTCP_BYE = 203 RTCP_RTPFB = 205 RTCP_PSFB = 206 RTCP_RTPFB_NACK = 1 RTCP_PSFB_PLI = 1 RTCP_PSFB_SLI = 2 RTCP_PSFB_RPSI = 3 RTCP_PSFB_APP = 15 @dataclass class HeaderExtensions: abs_send_time: Optional[int] = None audio_level: Any = None mid: Any = None repaired_rtp_stream_id: Any = None rtp_stream_id: Any = None transmission_offset: Optional[int] = None transport_sequence_number: Optional[int] = None class HeaderExtensionsMap: def __init__(self) -> None: self.__ids = HeaderExtensions() def configure(self, parameters: RTCRtpParameters) -> None: for ext in parameters.headerExtensions: if ext.uri == "urn:ietf:params:rtp-hdrext:sdes:mid": self.__ids.mid = ext.id elif ext.uri == "urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id": self.__ids.repaired_rtp_stream_id = ext.id elif ext.uri == "urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id": self.__ids.rtp_stream_id = ext.id elif ( ext.uri == "http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time" ): self.__ids.abs_send_time = ext.id elif ext.uri == "urn:ietf:params:rtp-hdrext:toffset": self.__ids.transmission_offset = ext.id elif ext.uri == "urn:ietf:params:rtp-hdrext:ssrc-audio-level": self.__ids.audio_level = ext.id elif ( ext.uri == "http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01" ): self.__ids.transport_sequence_number = ext.id def get(self, extension_profile: int, extension_value: bytes) -> HeaderExtensions: values = HeaderExtensions() for x_id, x_value in unpack_header_extensions( extension_profile, extension_value ): if x_id == self.__ids.mid: values.mid = x_value.decode("utf8") elif x_id == self.__ids.repaired_rtp_stream_id: values.repaired_rtp_stream_id = x_value.decode("ascii") elif x_id == self.__ids.rtp_stream_id: values.rtp_stream_id = x_value.decode("ascii") elif x_id == self.__ids.abs_send_time: values.abs_send_time = unpack("!L", b"\00" + x_value)[0] elif x_id == self.__ids.transmission_offset: values.transmission_offset = unpack("!l", x_value + b"\00")[0] >> 8 elif x_id == self.__ids.audio_level: vad_level = unpack("!B", x_value)[0] values.audio_level = (vad_level & 0x80 == 0x80, vad_level & 0x7F) elif x_id == self.__ids.transport_sequence_number: values.transport_sequence_number = unpack("!H", x_value)[0] return values def set(self, values: HeaderExtensions): extensions = [] if values.mid is not None and self.__ids.mid: extensions.append((self.__ids.mid, values.mid.encode("utf8"))) if ( values.repaired_rtp_stream_id is not None and self.__ids.repaired_rtp_stream_id ): extensions.append( ( self.__ids.repaired_rtp_stream_id, values.repaired_rtp_stream_id.encode("ascii"), ) ) if values.rtp_stream_id is not None and self.__ids.rtp_stream_id: extensions.append( (self.__ids.rtp_stream_id, values.rtp_stream_id.encode("ascii")) ) if values.abs_send_time is not None and self.__ids.abs_send_time: extensions.append( (self.__ids.abs_send_time, pack("!L", values.abs_send_time)[1:]) ) if values.transmission_offset is not None and self.__ids.transmission_offset: extensions.append( ( self.__ids.transmission_offset, pack("!l", values.transmission_offset << 8)[0:2], ) ) if values.audio_level is not None and self.__ids.audio_level: extensions.append( ( self.__ids.audio_level, pack( "!B", (0x80 if values.audio_level[0] else 0) | (values.audio_level[1] & 0x7F), ), ) ) if ( values.transport_sequence_number is not None and self.__ids.transport_sequence_number ): extensions.append( ( self.__ids.transport_sequence_number, pack("!H", values.transport_sequence_number), ) ) return pack_header_extensions(extensions) def clamp_packets_lost(count: int) -> int: return max(PACKETS_LOST_MIN, min(count, PACKETS_LOST_MAX)) def pack_packets_lost(count: int) -> bytes: return pack("!l", count)[1:] def unpack_packets_lost(d: bytes) -> int: if d[0] & 0x80: d = b"\xff" + d else: d = b"\x00" + d return unpack("!l", d)[0] def pack_rtcp_packet(packet_type: int, count: int, payload: bytes) -> bytes: assert len(payload) % 4 == 0 return pack("!BBH", (2 << 6) | count, packet_type, len(payload) // 4) + payload def pack_remb_fci(bitrate: int, ssrcs: List[int]) -> bytes: """ Pack the FCI for a Receiver Estimated Maximum Bitrate report. https://tools.ietf.org/html/draft-alvestrand-rmcat-remb-03 """ data = b"REMB" exponent = 0 mantissa = bitrate while mantissa > 0x3FFFF: mantissa >>= 1 exponent += 1 data += pack( "!BBH", len(ssrcs), (exponent << 2) | (mantissa >> 16), (mantissa & 0xFFFF) ) for ssrc in ssrcs: data += pack("!L", ssrc) return data def unpack_remb_fci(data: bytes) -> Tuple[int, List[int]]: """ Unpack the FCI for a Receiver Estimated Maximum Bitrate report. https://tools.ietf.org/html/draft-alvestrand-rmcat-remb-03 """ if len(data) < 8 or data[0:4] != b"REMB": raise ValueError("Invalid REMB prefix") exponent = (data[5] & 0xFC) >> 2 mantissa = ((data[5] & 0x03) << 16) | (data[6] << 8) | data[7] bitrate = mantissa << exponent pos = 8 ssrcs = [] for r in range(data[4]): ssrcs.append(unpack_from("!L", data, pos)[0]) pos += 4 return (bitrate, ssrcs) def is_rtcp(msg: bytes) -> bool: return len(msg) >= 2 and msg[1] >= 192 and msg[1] <= 208 def padl(length: int) -> int: """ Return amount of padding needed for a 4-byte multiple. """ return 4 * ((length + 3) // 4) - length def unpack_header_extensions( extension_profile: int, extension_value: bytes ) -> List[Tuple[int, bytes]]: """ Parse header extensions according to RFC 5285. """ extensions = [] pos = 0 if extension_profile == 0xBEDE: # One-Byte Header while pos < len(extension_value): # skip padding byte if extension_value[pos] == 0: pos += 1 continue x_id = (extension_value[pos] & 0xF0) >> 4 x_length = (extension_value[pos] & 0x0F) + 1 pos += 1 if len(extension_value) < pos + x_length: raise ValueError("RTP one-byte header extension value is truncated") x_value = extension_value[pos : pos + x_length] extensions.append((x_id, x_value)) pos += x_length elif extension_profile == 0x1000: # Two-Byte Header while pos < len(extension_value): # skip padding byte if extension_value[pos] == 0: pos += 1 continue if len(extension_value) < pos + 2: raise ValueError("RTP two-byte header extension is truncated") x_id, x_length = unpack_from("!BB", extension_value, pos) pos += 2 if len(extension_value) < pos + x_length: raise ValueError("RTP two-byte header extension value is truncated") x_value = extension_value[pos : pos + x_length] extensions.append((x_id, x_value)) pos += x_length return extensions def pack_header_extensions(extensions: List[Tuple[int, bytes]]) -> Tuple[int, bytes]: """ Serialize header extensions according to RFC 5285. """ extension_profile = 0 extension_value = b"" if not extensions: return extension_profile, extension_value one_byte = True for x_id, x_value in extensions: x_length = len(x_value) assert x_id > 0 and x_id < 256 assert x_length >= 0 and x_length < 256 if x_id > 14 or x_length == 0 or x_length > 16: one_byte = False if one_byte: # One-Byte Header extension_profile = 0xBEDE extension_value = b"" for x_id, x_value in extensions: x_length = len(x_value) extension_value += pack("!B", (x_id << 4) | (x_length - 1)) extension_value += x_value else: # Two-Byte Header extension_profile = 0x1000 extension_value = b"" for x_id, x_value in extensions: x_length = len(x_value) extension_value += pack("!BB", x_id, x_length) extension_value += x_value extension_value += b"\x00" * padl(len(extension_value)) return extension_profile, extension_value def compute_audio_level_dbov(frame: AudioFrame): """ Compute the energy level as spelled out in RFC 6465, Appendix A. """ MAX_SAMPLE_VALUE = 32767 MAX_AUDIO_LEVEL = 0 MIN_AUDIO_LEVEL = -127 rms = 0 buf = bytes(frame.planes[0]) s = struct.Struct("h") for unpacked in s.iter_unpack(buf): sample = unpacked[0] rms += sample * sample rms = math.sqrt(rms / (frame.samples * MAX_SAMPLE_VALUE * MAX_SAMPLE_VALUE)) if rms > 0: db = 20 * math.log10(rms) db = max(db, MIN_AUDIO_LEVEL) db = min(db, MAX_AUDIO_LEVEL) else: db = MIN_AUDIO_LEVEL return round(db) @dataclass class RtcpReceiverInfo: ssrc: int fraction_lost: int packets_lost: int highest_sequence: int jitter: int lsr: int dlsr: int def __bytes__(self) -> bytes: data = pack("!LB", self.ssrc, self.fraction_lost) data += pack_packets_lost(self.packets_lost) data += pack("!LLLL", self.highest_sequence, self.jitter, self.lsr, self.dlsr) return data @classmethod def parse(cls, data: bytes): ssrc, fraction_lost = unpack("!LB", data[0:5]) packets_lost = unpack_packets_lost(data[5:8]) highest_sequence, jitter, lsr, dlsr = unpack("!LLLL", data[8:]) return cls( ssrc=ssrc, fraction_lost=fraction_lost, packets_lost=packets_lost, highest_sequence=highest_sequence, jitter=jitter, lsr=lsr, dlsr=dlsr, ) @dataclass class RtcpSenderInfo: ntp_timestamp: int rtp_timestamp: int packet_count: int octet_count: int def __bytes__(self) -> bytes: return pack( "!QLLL", self.ntp_timestamp, self.rtp_timestamp, self.packet_count, self.octet_count, ) @classmethod def parse(cls, data: bytes): ntp_timestamp, rtp_timestamp, packet_count, octet_count = unpack("!QLLL", data) return cls( ntp_timestamp=ntp_timestamp, rtp_timestamp=rtp_timestamp, packet_count=packet_count, octet_count=octet_count, ) @dataclass class RtcpSourceInfo: ssrc: int items: List[Tuple[Any, bytes]] @dataclass class RtcpByePacket: sources: List[int] def __bytes__(self) -> bytes: payload = b"".join([pack("!L", ssrc) for ssrc in self.sources]) return pack_rtcp_packet(RTCP_BYE, len(self.sources), payload) @classmethod def parse(cls, data: bytes, count: int): if len(data) < 4 * count: raise ValueError("RTCP bye length is invalid") if count > 0: sources = list(unpack_from("!" + ("L" * count), data, 0)) else: sources = [] return cls(sources=sources) @dataclass class RtcpPsfbPacket: """ Payload-Specific Feedback Message (RFC 4585). """ fmt: int ssrc: int media_ssrc: int fci: bytes = b"" def __bytes__(self) -> bytes: payload = pack("!LL", self.ssrc, self.media_ssrc) + self.fci return pack_rtcp_packet(RTCP_PSFB, self.fmt, payload) @classmethod def parse(cls, data: bytes, fmt: int): if len(data) < 8: raise ValueError("RTCP payload-specific feedback length is invalid") ssrc, media_ssrc = unpack("!LL", data[0:8]) fci = data[8:] return cls(fmt=fmt, ssrc=ssrc, media_ssrc=media_ssrc, fci=fci) @dataclass class RtcpRrPacket: ssrc: int reports: List[RtcpReceiverInfo] = field(default_factory=list) def __bytes__(self) -> bytes: payload = pack("!L", self.ssrc) for report in self.reports: payload += bytes(report) return pack_rtcp_packet(RTCP_RR, len(self.reports), payload) @classmethod def parse(cls, data: bytes, count: int): if len(data) != 4 + 24 * count: raise ValueError("RTCP receiver report length is invalid") ssrc = unpack("!L", data[0:4])[0] pos = 4 reports = [] for r in range(count): reports.append(RtcpReceiverInfo.parse(data[pos : pos + 24])) pos += 24 return cls(ssrc=ssrc, reports=reports) @dataclass class RtcpRtpfbPacket: """ Generic RTP Feedback Message (RFC 4585). """ fmt: int ssrc: int media_ssrc: int # generick NACK lost: List[int] = field(default_factory=list) def __bytes__(self) -> bytes: payload = pack("!LL", self.ssrc, self.media_ssrc) if self.lost: pid = self.lost[0] blp = 0 for p in self.lost[1:]: d = p - pid - 1 if d < 16: blp |= 1 << d else: payload += pack("!HH", pid, blp) pid = p blp = 0 payload += pack("!HH", pid, blp) return pack_rtcp_packet(RTCP_RTPFB, self.fmt, payload) @classmethod def parse(cls, data: bytes, fmt: int): if len(data) < 8 or len(data) % 4: raise ValueError("RTCP RTP feedback length is invalid") ssrc, media_ssrc = unpack("!LL", data[0:8]) lost = [] for pos in range(8, len(data), 4): pid, blp = unpack("!HH", data[pos : pos + 4]) lost.append(pid) for d in range(0, 16): if (blp >> d) & 1: lost.append(pid + d + 1) return cls(fmt=fmt, ssrc=ssrc, media_ssrc=media_ssrc, lost=lost) @dataclass class RtcpSdesPacket: chunks: List[RtcpSourceInfo] = field(default_factory=list) def __bytes__(self) -> bytes: payload = b"" for chunk in self.chunks: payload += pack("!L", chunk.ssrc) for d_type, d_value in chunk.items: payload += pack("!BB", d_type, len(d_value)) + d_value payload += b"\x00\x00" while len(payload) % 4: payload += b"\x00" return pack_rtcp_packet(RTCP_SDES, len(self.chunks), payload) @classmethod def parse(cls, data: bytes, count: int): pos = 0 chunks = [] for r in range(count): if len(data) < pos + 4: raise ValueError("RTCP SDES source is truncated") ssrc = unpack_from("!L", data, pos)[0] pos += 4 items = [] while pos < len(data) - 1: d_type, d_length = unpack_from("!BB", data, pos) pos += 2 if len(data) < pos + d_length: raise ValueError("RTCP SDES item is truncated") d_value = data[pos : pos + d_length] pos += d_length if d_type == 0: break else: items.append((d_type, d_value)) chunks.append(RtcpSourceInfo(ssrc=ssrc, items=items)) return cls(chunks=chunks) @dataclass class RtcpSrPacket: ssrc: int sender_info: RtcpSenderInfo reports: List[RtcpReceiverInfo] = field(default_factory=list) def __bytes__(self) -> bytes: payload = pack("!L", self.ssrc) payload += bytes(self.sender_info) for report in self.reports: payload += bytes(report) return pack_rtcp_packet(RTCP_SR, len(self.reports), payload) @classmethod def parse(cls, data: bytes, count: int): if len(data) != 24 + 24 * count: raise ValueError("RTCP sender report length is invalid") ssrc = unpack_from("!L", data)[0] sender_info = RtcpSenderInfo.parse(data[4:24]) pos = 24 reports = [] for r in range(count): reports.append(RtcpReceiverInfo.parse(data[pos : pos + 24])) pos += 24 return RtcpSrPacket(ssrc=ssrc, sender_info=sender_info, reports=reports) AnyRtcpPacket = Union[ RtcpByePacket, RtcpPsfbPacket, RtcpRrPacket, RtcpRtpfbPacket, RtcpSdesPacket, RtcpSrPacket, ] class RtcpPacket: @classmethod def parse(cls, data: bytes) -> List[AnyRtcpPacket]: pos = 0 packets = [] while pos < len(data): if len(data) < pos + RTCP_HEADER_LENGTH: raise ValueError( f"RTCP packet length is less than {RTCP_HEADER_LENGTH} bytes" ) v_p_count, packet_type, length = unpack("!BBH", data[pos : pos + 4]) version = v_p_count >> 6 padding = (v_p_count >> 5) & 1 count = v_p_count & 0x1F if version != 2: raise ValueError("RTCP packet has invalid version") pos += 4 end = pos + length * 4 if len(data) < end: raise ValueError("RTCP packet is truncated") payload = data[pos:end] pos = end if padding: if not payload or not payload[-1] or payload[-1] > len(payload): raise ValueError("RTCP packet padding length is invalid") payload = payload[0 : -payload[-1]] if packet_type == RTCP_BYE: packets.append(RtcpByePacket.parse(payload, count)) elif packet_type == RTCP_SDES: packets.append(RtcpSdesPacket.parse(payload, count)) elif packet_type == RTCP_SR: packets.append(RtcpSrPacket.parse(payload, count)) elif packet_type == RTCP_RR: packets.append(RtcpRrPacket.parse(payload, count)) elif packet_type == RTCP_RTPFB: packets.append(RtcpRtpfbPacket.parse(payload, count)) elif packet_type == RTCP_PSFB: packets.append(RtcpPsfbPacket.parse(payload, count)) return packets class RtpPacket: def __init__( self, payload_type: int = 0, marker: int = 0, sequence_number: int = 0, timestamp: int = 0, ssrc: int = 0, payload: bytes = b"", ) -> None: self.version = 2 self.marker = marker self.payload_type = payload_type self.sequence_number = sequence_number self.timestamp = timestamp self.ssrc = ssrc self.csrc: List[int] = [] self.extensions = HeaderExtensions() self.payload = payload self.padding_size = 0 def __repr__(self) -> str: return ( f"RtpPacket(seq={self.sequence_number}, ts={self.timestamp}, " f"marker={self.marker}, payload={self.payload_type}, {len(self.payload)} bytes)" ) @classmethod def parse(cls, data: bytes, extensions_map=HeaderExtensionsMap()): if len(data) < RTP_HEADER_LENGTH: raise ValueError( f"RTP packet length is less than {RTP_HEADER_LENGTH} bytes" ) v_p_x_cc, m_pt, sequence_number, timestamp, ssrc = unpack("!BBHLL", data[0:12]) version = v_p_x_cc >> 6 padding = (v_p_x_cc >> 5) & 1 extension = (v_p_x_cc >> 4) & 1 cc = v_p_x_cc & 0x0F if version != 2: raise ValueError("RTP packet has invalid version") if len(data) < RTP_HEADER_LENGTH + 4 * cc: raise ValueError("RTP packet has truncated CSRC") packet = cls( marker=(m_pt >> 7), payload_type=(m_pt & 0x7F), sequence_number=sequence_number, timestamp=timestamp, ssrc=ssrc, ) pos = RTP_HEADER_LENGTH for i in range(0, cc): packet.csrc.append(unpack_from("!L", data, pos)[0]) pos += 4 if extension: if len(data) < pos + 4: raise ValueError("RTP packet has truncated extension profile / length") extension_profile, extension_length = unpack_from("!HH", data, pos) extension_length *= 4 pos += 4 if len(data) < pos + extension_length: raise ValueError("RTP packet has truncated extension value") extension_value = data[pos : pos + extension_length] pos += extension_length packet.extensions = extensions_map.get(extension_profile, extension_value) if padding: padding_len = data[-1] if not padding_len or padding_len > len(data) - pos: raise ValueError("RTP packet padding length is invalid") packet.padding_size = padding_len packet.payload = data[pos:-padding_len] else: packet.payload = data[pos:] return packet def serialize(self, extensions_map=HeaderExtensionsMap()) -> bytes: extension_profile, extension_value = extensions_map.set(self.extensions) has_extension = bool(extension_value) padding = self.padding_size > 0 data = pack( "!BBHLL", (self.version << 6) | (padding << 5) | (has_extension << 4) | len(self.csrc), (self.marker << 7) | self.payload_type, self.sequence_number, self.timestamp, self.ssrc, ) for csrc in self.csrc: data += pack("!L", csrc) if has_extension: data += pack("!HH", extension_profile, len(extension_value) >> 2) data += extension_value data += self.payload if padding: data += os.urandom(self.padding_size - 1) data += bytes([self.padding_size]) return data def unwrap_rtx(rtx: RtpPacket, payload_type: int, ssrc: int) -> RtpPacket: """ Recover initial packet from a retransmission packet. """ packet = RtpPacket( payload_type=payload_type, marker=rtx.marker, sequence_number=unpack("!H", rtx.payload[0:2])[0], timestamp=rtx.timestamp, ssrc=ssrc, payload=rtx.payload[2:], ) packet.csrc = rtx.csrc packet.extensions = rtx.extensions return packet def wrap_rtx( packet: RtpPacket, payload_type: int, sequence_number: int, ssrc: int ) -> RtpPacket: """ Create a retransmission packet from a lost packet. """ rtx = RtpPacket( payload_type=payload_type, marker=packet.marker, sequence_number=sequence_number, timestamp=packet.timestamp, ssrc=ssrc, payload=pack("!H", packet.sequence_number) + packet.payload, ) rtx.csrc = packet.csrc rtx.extensions = packet.extensions return rtx aiortc-1.3.0/src/aiortc/sdp.py000066400000000000000000000436161417604566400162330ustar00rootroot00000000000000import ipaddress import re from collections import OrderedDict from dataclasses import dataclass from typing import Any, Dict, List, Optional, Tuple, Union from . import rtp from .rtcdtlstransport import RTCDtlsFingerprint, RTCDtlsParameters from .rtcicetransport import RTCIceCandidate, RTCIceParameters from .rtcrtpparameters import ( RTCRtcpFeedback, RTCRtpCodecParameters, RTCRtpHeaderExtensionParameters, RTCRtpParameters, ) from .rtcsctptransport import RTCSctpCapabilities DIRECTIONS = ["inactive", "sendonly", "recvonly", "sendrecv"] DTLS_ROLE_SETUP = {"auto": "actpass", "client": "active", "server": "passive"} DTLS_SETUP_ROLE = dict([(v, k) for (k, v) in DTLS_ROLE_SETUP.items()]) FMTP_INT_PARAMETERS = [ "apt", "max-fr", "max-fs", "maxplaybackrate", "minptime", "stereo", "useinbandfec", ] def candidate_from_sdp(sdp: str) -> RTCIceCandidate: bits = sdp.split() assert len(bits) >= 8 candidate = RTCIceCandidate( component=int(bits[1]), foundation=bits[0], ip=bits[4], port=int(bits[5]), priority=int(bits[3]), protocol=bits[2], type=bits[7], ) for i in range(8, len(bits) - 1, 2): if bits[i] == "raddr": candidate.relatedAddress = bits[i + 1] elif bits[i] == "rport": candidate.relatedPort = int(bits[i + 1]) elif bits[i] == "tcptype": candidate.tcpType = bits[i + 1] return candidate def candidate_to_sdp(candidate: RTCIceCandidate) -> str: sdp = ( f"{candidate.foundation} {candidate.component} {candidate.protocol} " f"{candidate.priority} {candidate.ip} {candidate.port} typ {candidate.type}" ) if candidate.relatedAddress is not None: sdp += f" raddr {candidate.relatedAddress}" if candidate.relatedPort is not None: sdp += f" rport {candidate.relatedPort}" if candidate.tcpType is not None: sdp += f" tcptype {candidate.tcpType}" return sdp def grouplines(sdp: str) -> Tuple[List[str], List[List[str]]]: session = [] media = [] for line in sdp.splitlines(): if line.startswith("m="): media.append([line]) elif len(media): media[-1].append(line) else: session.append(line) return session, media def ipaddress_from_sdp(sdp: str) -> str: m = re.match("^IN (IP4|IP6) ([^ ]+)$", sdp) assert m return m.group(2) def ipaddress_to_sdp(addr: str) -> str: version = ipaddress.ip_address(addr).version return f"IN IP{version} {addr}" def parameters_from_sdp(sdp: str) -> OrderedDict: parameters: OrderedDict = OrderedDict() for param in sdp.split(";"): if "=" in param: k, v = param.split("=", 1) if k in FMTP_INT_PARAMETERS: parameters[k] = int(v) else: parameters[k] = v else: parameters[param] = None return parameters def parameters_to_sdp(parameters: OrderedDict) -> str: params = [] for param_k, param_v in parameters.items(): if param_v is not None: params.append(f"{param_k}={param_v}") else: params.append(param_k) return ";".join(params) def parse_attr(line: str) -> Tuple[str, Optional[str]]: if ":" in line: bits = line[2:].split(":", 1) return bits[0], bits[1] else: return line[2:], None @dataclass class GroupDescription: semantic: str items: List[Union[int, str]] def __str__(self) -> str: return f"{self.semantic} {' '.join(map(str, self.items))}" def parse_group(dest: List[GroupDescription], value: str, type=str) -> None: bits = value.split() if bits: dest.append(GroupDescription(semantic=bits[0], items=list(map(type, bits[1:])))) @dataclass class SsrcDescription: ssrc: int cname: Optional[str] = None msid: Optional[str] = None mslabel: Optional[str] = None label: Optional[str] = None SSRC_INFO_ATTRS = ["cname", "msid", "mslabel", "label"] class MediaDescription: def __init__(self, kind: str, port: int, profile: str, fmt: List[Any]) -> None: # rtp self.kind = kind self.port = port self.host: Optional[str] = None self.profile = profile self.direction: Optional[str] = None self.msid: Optional[str] = None # rtcp self.rtcp_port: Optional[int] = None self.rtcp_host: Optional[str] = None self.rtcp_mux = False # ssrc self.ssrc: List[SsrcDescription] = [] self.ssrc_group: List[GroupDescription] = [] # formats self.fmt = fmt self.rtp = RTCRtpParameters() # SCTP self.sctpCapabilities: Optional[RTCSctpCapabilities] = None self.sctpmap: Dict[int, str] = {} self.sctp_port: Optional[int] = None # DTLS self.dtls: Optional[RTCDtlsParameters] = None # ICE self.ice: Optional[RTCIceParameters] = None self.ice_candidates: List[RTCIceCandidate] = [] self.ice_candidates_complete = False self.ice_options: Optional[str] = None def __str__(self) -> str: lines = [] lines.append( f"m={self.kind} {self.port} {self.profile} {' '.join(map(str, self.fmt))}" ) if self.host is not None: lines.append(f"c={ipaddress_to_sdp(self.host)}") if self.direction is not None: lines.append(f"a={self.direction}") for header in self.rtp.headerExtensions: lines.append(f"a=extmap:{header.id} {header.uri}") if self.rtp.muxId: lines.append(f"a=mid:{self.rtp.muxId}") if self.msid: lines.append(f"a=msid:{self.msid}") if self.rtcp_port is not None and self.rtcp_host is not None: lines.append(f"a=rtcp:{self.rtcp_port} {ipaddress_to_sdp(self.rtcp_host)}") if self.rtcp_mux: lines.append("a=rtcp-mux") for group in self.ssrc_group: lines.append(f"a=ssrc-group:{group}") for ssrc_info in self.ssrc: for ssrc_attr in SSRC_INFO_ATTRS: ssrc_value = getattr(ssrc_info, ssrc_attr) if ssrc_value is not None: lines.append(f"a=ssrc:{ssrc_info.ssrc} {ssrc_attr}:{ssrc_value}") for codec in self.rtp.codecs: lines.append(f"a=rtpmap:{codec.payloadType} {codec}") # RTCP feedback for feedback in codec.rtcpFeedback: value = feedback.type if feedback.parameter: value += f" {feedback.parameter}" lines.append(f"a=rtcp-fb:{codec.payloadType} {value}") # parameters params = parameters_to_sdp(codec.parameters) if params: lines.append(f"a=fmtp:{codec.payloadType} {params}") for k, v in self.sctpmap.items(): lines.append(f"a=sctpmap:{k} {v}") if self.sctp_port is not None: lines.append(f"a=sctp-port:{self.sctp_port}") if self.sctpCapabilities is not None: lines.append(f"a=max-message-size:{self.sctpCapabilities.maxMessageSize}") # ice for candidate in self.ice_candidates: lines.append("a=candidate:" + candidate_to_sdp(candidate)) if self.ice_candidates_complete: lines.append("a=end-of-candidates") if self.ice.usernameFragment is not None: lines.append(f"a=ice-ufrag:{self.ice.usernameFragment}") if self.ice.password is not None: lines.append(f"a=ice-pwd:{self.ice.password}") if self.ice_options is not None: lines.append(f"a=ice-options:{self.ice_options}") # dtls if self.dtls: for fingerprint in self.dtls.fingerprints: lines.append( f"a=fingerprint:{fingerprint.algorithm} {fingerprint.value}" ) lines.append(f"a=setup:{DTLS_ROLE_SETUP[self.dtls.role]}") return "\r\n".join(lines) + "\r\n" class SessionDescription: def __init__(self) -> None: self.version = 0 self.origin: Optional[str] = None self.name = "-" self.time = "0 0" self.host: Optional[str] = None self.group: List[GroupDescription] = [] self.msid_semantic: List[GroupDescription] = [] self.media: List[MediaDescription] = [] self.type: Optional[str] = None @classmethod def parse(cls, sdp: str): current_media: Optional[MediaDescription] = None dtls_fingerprints = [] dtls_role = None ice_lite = False ice_options = None ice_password = None ice_usernameFragment = None def find_codec(pt: int) -> RTCRtpCodecParameters: return next(filter(lambda x: x.payloadType == pt, current_media.rtp.codecs)) session_lines, media_groups = grouplines(sdp) # parse session session = cls() for line in session_lines: if line.startswith("v="): session.version = int(line.strip()[2:]) elif line.startswith("o="): session.origin = line.strip()[2:] elif line.startswith("s="): session.name = line.strip()[2:] elif line.startswith("c="): session.host = ipaddress_from_sdp(line[2:]) elif line.startswith("t="): session.time = line.strip()[2:] elif line.startswith("a="): attr, value = parse_attr(line) if attr == "fingerprint": algorithm, fingerprint = value.split() dtls_fingerprints.append( RTCDtlsFingerprint(algorithm=algorithm, value=fingerprint) ) elif attr == "ice-lite": ice_lite = True elif attr == "ice-options": ice_options = value elif attr == "ice-pwd": ice_password = value elif attr == "ice-ufrag": ice_usernameFragment = value elif attr == "group": parse_group(session.group, value) elif attr == "msid-semantic": parse_group(session.msid_semantic, value) elif attr == "setup": dtls_role = DTLS_SETUP_ROLE[value] # parse media for media_lines in media_groups: m = re.match("^m=([^ ]+) ([0-9]+) ([A-Z/]+) (.+)$", media_lines[0]) assert m # check payload types are valid kind = m.group(1) fmt = m.group(4).split() fmt_int: Optional[List[int]] = None if kind in ["audio", "video"]: fmt_int = [int(x) for x in fmt] for pt in fmt_int: assert pt >= 0 and pt < 256 assert pt not in rtp.FORBIDDEN_PAYLOAD_TYPES current_media = MediaDescription( kind=kind, port=int(m.group(2)), profile=m.group(3), fmt=fmt_int or fmt ) current_media.dtls = RTCDtlsParameters( fingerprints=dtls_fingerprints[:], role=dtls_role ) current_media.ice = RTCIceParameters( iceLite=ice_lite, usernameFragment=ice_usernameFragment, password=ice_password, ) current_media.ice_options = ice_options session.media.append(current_media) for line in media_lines[1:]: if line.startswith("c="): current_media.host = ipaddress_from_sdp(line[2:]) elif line.startswith("a="): attr, value = parse_attr(line) if attr == "candidate": current_media.ice_candidates.append(candidate_from_sdp(value)) elif attr == "end-of-candidates": current_media.ice_candidates_complete = True elif attr == "extmap": ext_id, ext_uri = value.split() if "/" in ext_id: ext_id, ext_direction = ext_id.split("/") extension = RTCRtpHeaderExtensionParameters( id=int(ext_id), uri=ext_uri ) current_media.rtp.headerExtensions.append(extension) elif attr == "fingerprint": algorithm, fingerprint = value.split() current_media.dtls.fingerprints.append( RTCDtlsFingerprint(algorithm=algorithm, value=fingerprint) ) elif attr == "ice-options": current_media.ice_options = value elif attr == "ice-pwd": current_media.ice.password = value elif attr == "ice-ufrag": current_media.ice.usernameFragment = value elif attr == "max-message-size": current_media.sctpCapabilities = RTCSctpCapabilities( maxMessageSize=int(value) ) elif attr == "mid": current_media.rtp.muxId = value elif attr == "msid": current_media.msid = value elif attr == "rtcp": port, rest = value.split(" ", 1) current_media.rtcp_port = int(port) current_media.rtcp_host = ipaddress_from_sdp(rest) elif attr == "rtcp-mux": current_media.rtcp_mux = True elif attr == "setup": current_media.dtls.role = DTLS_SETUP_ROLE[value] elif attr in DIRECTIONS: current_media.direction = attr elif attr == "rtpmap": format_id, format_desc = value.split(" ", 1) bits = format_desc.split("/") if current_media.kind == "audio": if len(bits) > 2: channels = int(bits[2]) else: channels = 1 else: channels = None codec = RTCRtpCodecParameters( mimeType=current_media.kind + "/" + bits[0], channels=channels, clockRate=int(bits[1]), payloadType=int(format_id), ) current_media.rtp.codecs.append(codec) elif attr == "sctpmap": format_id, format_desc = value.split(" ", 1) getattr(current_media, attr)[int(format_id)] = format_desc elif attr == "sctp-port": current_media.sctp_port = int(value) elif attr == "ssrc-group": parse_group(current_media.ssrc_group, value, type=int) elif attr == "ssrc": ssrc_str, ssrc_desc = value.split(" ", 1) ssrc = int(ssrc_str) ssrc_attr, ssrc_value = ssrc_desc.split(":", 1) try: ssrc_info = next( (x for x in current_media.ssrc if x.ssrc == ssrc) ) except StopIteration: ssrc_info = SsrcDescription(ssrc=ssrc) current_media.ssrc.append(ssrc_info) if ssrc_attr in SSRC_INFO_ATTRS: setattr(ssrc_info, ssrc_attr, ssrc_value) if current_media.dtls.role is None: current_media.dtls = None # requires codecs to have been parsed for line in media_lines[1:]: if line.startswith("a="): attr, value = parse_attr(line) if attr == "fmtp": format_id, format_desc = value.split(" ", 1) codec = find_codec(int(format_id)) codec.parameters = parameters_from_sdp(format_desc) elif attr == "rtcp-fb": bits = value.split(" ", 2) for codec in current_media.rtp.codecs: if bits[0] in ["*", str(codec.payloadType)]: codec.rtcpFeedback.append( RTCRtcpFeedback( type=bits[1], parameter=bits[2] if len(bits) > 2 else None, ) ) return session def webrtc_track_id(self, media: MediaDescription) -> Optional[str]: assert media in self.media if media.msid is not None and " " in media.msid: bits = media.msid.split() for group in self.msid_semantic: if group.semantic == "WMS" and ( bits[0] in group.items or "*" in group.items ): return bits[1] return None def __str__(self) -> str: lines = [f"v={self.version}", f"o={self.origin}", f"s={self.name}"] if self.host is not None: lines += [f"c={ipaddress_to_sdp(self.host)}"] lines += [f"t={self.time}"] if any(m.ice.iceLite for m in self.media): lines += ["a=ice-lite"] for group in self.group: lines += [f"a=group:{group}"] for group in self.msid_semantic: lines += [f"a=msid-semantic:{group}"] return "\r\n".join(lines) + "\r\n" + "".join([str(m) for m in self.media]) aiortc-1.3.0/src/aiortc/stats.py000066400000000000000000000054771417604566400166060ustar00rootroot00000000000000import datetime from dataclasses import dataclass from typing import Optional @dataclass class RTCStats: """ Base class for statistics. """ timestamp: datetime.datetime "The timestamp associated with this object." type: str id: str @dataclass class RTCRtpStreamStats(RTCStats): ssrc: int kind: str transportId: str @dataclass class RTCReceivedRtpStreamStats(RTCRtpStreamStats): packetsReceived: int packetsLost: int jitter: int @dataclass class RTCSentRtpStreamStats(RTCRtpStreamStats): packetsSent: int "Total number of RTP packets sent for this SSRC." bytesSent: int "Total number of bytes sent for this SSRC." @dataclass class RTCInboundRtpStreamStats(RTCReceivedRtpStreamStats): """ The :class:`RTCInboundRtpStreamStats` dictionary represents the measurement metrics for the incoming RTP media stream. """ pass @dataclass class RTCRemoteInboundRtpStreamStats(RTCReceivedRtpStreamStats): """ The :class:`RTCRemoteInboundRtpStreamStats` dictionary represents the remote endpoint's measurement metrics for a particular incoming RTP stream. """ roundTripTime: float fractionLost: float @dataclass class RTCOutboundRtpStreamStats(RTCSentRtpStreamStats): """ The :class:`RTCOutboundRtpStreamStats` dictionary represents the measurement metrics for the outgoing RTP stream. """ trackId: str @dataclass class RTCRemoteOutboundRtpStreamStats(RTCSentRtpStreamStats): """ The :class:`RTCRemoteOutboundRtpStreamStats` dictionary represents the remote endpoint's measurement metrics for its outgoing RTP stream. """ remoteTimestamp: Optional[datetime.datetime] = None @dataclass class RTCTransportStats(RTCStats): packetsSent: int "Total number of packets sent over this transport." packetsReceived: int "Total number of packets received over this transport." bytesSent: int "Total number of bytes sent over this transport." bytesReceived: int "Total number of bytes received over this transport." iceRole: str "The current value of :attr:`RTCIceTransport.role`." dtlsState: str "The current value of :attr:`RTCDtlsTransport.state`." class RTCStatsReport(dict): """ Provides statistics data about WebRTC connections as returned by the :meth:`RTCPeerConnection.getStats()`, :meth:`RTCRtpReceiver.getStats()` and :meth:`RTCRtpSender.getStats()` coroutines. This object consists of a mapping of string identifiers to objects which are instances of: - :class:`RTCInboundRtpStreamStats` - :class:`RTCOutboundRtpStreamStats` - :class:`RTCRemoteInboundRtpStreamStats` - :class:`RTCRemoteOutboundRtpStreamStats` - :class:`RTCTransportStats` """ def add(self, stats: RTCStats) -> None: self[stats.id] = stats aiortc-1.3.0/src/aiortc/utils.py000066400000000000000000000017221417604566400165750ustar00rootroot00000000000000import os from struct import unpack def random16() -> int: return unpack("!H", os.urandom(2))[0] def random32() -> int: return unpack("!L", os.urandom(4))[0] def uint16_add(a: int, b: int) -> int: """ Return a + b. """ return (a + b) & 0xFFFF def uint16_gt(a: int, b: int) -> bool: """ Return a > b. """ half_mod = 0x8000 return ((a < b) and ((b - a) > half_mod)) or ((a > b) and ((a - b) < half_mod)) def uint16_gte(a: int, b: int) -> bool: """ Return a >= b. """ return (a == b) or uint16_gt(a, b) def uint32_add(a: int, b: int) -> int: """ Return a + b. """ return (a + b) & 0xFFFFFFFF def uint32_gt(a: int, b: int) -> bool: """ Return a > b. """ half_mod = 0x80000000 return ((a < b) and ((b - a) > half_mod)) or ((a > b) and ((a - b) < half_mod)) def uint32_gte(a: int, b: int) -> bool: """ Return a >= b. """ return (a == b) or uint32_gt(a, b) aiortc-1.3.0/stubs/000077500000000000000000000000001417604566400141515ustar00rootroot00000000000000aiortc-1.3.0/stubs/av/000077500000000000000000000000001417604566400145575ustar00rootroot00000000000000aiortc-1.3.0/stubs/av/__init__.pyi000066400000000000000000000041671417604566400170510ustar00rootroot00000000000000from fractions import Fraction from typing import Any, Dict, List, Optional, Sequence, cast from .frame import Frame class AVError(Exception): ... class CodecContext: bit_rate: int framerate: Fraction height: int options: Dict[str, str] pix_fmt: str time_base: Fraction width: int @staticmethod def create(codec: str, mode: Optional[str] = ...) -> CodecContext: ... def decode(self, packet: Packet) -> List[Frame]: ... def encode(self, frame: Frame) -> List[Packet]: ... def open(self) -> None: ... class Packet: pts: int time_base: Fraction def __init__(self, data: bytes) -> None: ... def to_bytes(self) -> bytes: ... class AudioFormat: @property def name(self) -> str: ... class AudioFrame(Frame): sample_rate: int def __init__(self, format: str = ..., layout: str = ..., samples: int = ...): ... @property def format(self) -> AudioFormat: ... @property def layout(self) -> AudioLayout: ... @property def planes(self) -> Sequence[AudioPlane]: ... class AudioLayout: @property def channels(self) -> Sequence[object]: ... @property def name(self) -> str: ... class AudioPlane: def __bytes__(self) -> bytes: ... @property def buffer_ptr(self) -> Any: ... @property def buffer_size(self) -> int: ... def update(self, data: bytes) -> None: ... class VideoFormat: @property def name(self) -> str: ... class VideoFrame(Frame): def __init__(self, width: int = ..., height: int = ..., format: str = ...): ... @property def format(self) -> VideoFormat: ... @property def height(self) -> int: ... @property def planes(self) -> Sequence[VideoPlane]: ... @property def width(self) -> int: ... def reformat( self, width: Optional[int] = ..., height: Optional[int] = ..., format: Optional[Any] = ..., ) -> VideoFrame: ... class VideoPlane: @property def buffer_ptr(self) -> Any: ... @property def buffer_size(self) -> int: ... @property def line_size(self) -> int: ... def update(self, data: bytes) -> None: ... aiortc-1.3.0/stubs/av/frame.pyi000066400000000000000000000001221417604566400163670ustar00rootroot00000000000000from fractions import Fraction class Frame: pts: int time_base: Fraction aiortc-1.3.0/stubs/crc32c.pyi000066400000000000000000000000441417604566400157510ustar00rootroot00000000000000def crc32c(data: bytes) -> int: ... aiortc-1.3.0/stubs/google_crc32c.pyi000066400000000000000000000000431417604566400173040ustar00rootroot00000000000000def value(data: bytes) -> int: ... aiortc-1.3.0/stubs/pyee.pyi000066400000000000000000000003611417604566400156360ustar00rootroot00000000000000from typing import Callable class AsyncIOEventEmitter: def emit(self, name: str, *args) -> None: ... def on(self, name: str, cb: Callable[..., None] = ...) -> Callable[..., None]: ... def remove_all_listeners(self) -> None: ... aiortc-1.3.0/tests/000077500000000000000000000000001417604566400141535ustar00rootroot00000000000000aiortc-1.3.0/tests/__init__.py000066400000000000000000000000001417604566400162520ustar00rootroot00000000000000aiortc-1.3.0/tests/codecs.py000066400000000000000000000106261417604566400157720ustar00rootroot00000000000000import fractions from unittest import TestCase from av import AudioFrame, VideoFrame from aiortc.codecs import depayload, get_decoder, get_encoder from aiortc.jitterbuffer import JitterFrame from aiortc.mediastreams import AUDIO_PTIME, VIDEO_TIME_BASE class CodecTestCase(TestCase): def create_audio_frame(self, samples, pts, layout="mono", sample_rate=48000): frame = AudioFrame(format="s16", layout=layout, samples=samples) for p in frame.planes: p.update(bytes(p.buffer_size)) frame.pts = pts frame.sample_rate = sample_rate frame.time_base = fractions.Fraction(1, sample_rate) return frame def create_audio_frames(self, layout, sample_rate, count): frames = [] timestamp = 0 samples_per_frame = int(AUDIO_PTIME * sample_rate) for i in range(count): frames.append( self.create_audio_frame( samples=samples_per_frame, pts=timestamp, layout=layout, sample_rate=sample_rate, ) ) timestamp += samples_per_frame return frames def create_video_frame( self, width, height, pts, format="yuv420p", time_base=VIDEO_TIME_BASE ): """ Create a single blank video frame. """ frame = VideoFrame(width=width, height=height, format=format) for p in frame.planes: p.update(bytes(p.buffer_size)) frame.pts = pts frame.time_base = time_base return frame def create_video_frames(self, width, height, count, time_base=VIDEO_TIME_BASE): """ Create consecutive blank video frames. """ frames = [] for i in range(count): frames.append( self.create_video_frame( width=width, height=height, pts=int(i / time_base / 30), time_base=time_base, ) ) return frames def roundtrip_audio(self, codec, output_layout, output_sample_rate, drop=[]): """ Round-trip an AudioFrame through encoder then decoder. """ encoder = get_encoder(codec) decoder = get_decoder(codec) input_frames = self.create_audio_frames( layout="mono", sample_rate=8000, count=10 ) output_sample_count = int(output_sample_rate * AUDIO_PTIME) for i, frame in enumerate(input_frames): # encode packages, timestamp = encoder.encode(frame) if i not in drop: # depacketize data = b"" for package in packages: data += depayload(codec, package) # decode frames = decoder.decode(JitterFrame(data=data, timestamp=timestamp)) self.assertEqual(len(frames), 1) self.assertEqual(frames[0].format.name, "s16") self.assertEqual(frames[0].layout.name, output_layout) self.assertEqual(frames[0].samples, output_sample_rate * AUDIO_PTIME) self.assertEqual(frames[0].sample_rate, output_sample_rate) self.assertEqual(frames[0].pts, i * output_sample_count) self.assertEqual( frames[0].time_base, fractions.Fraction(1, output_sample_rate) ) def roundtrip_video(self, codec, width, height, time_base=VIDEO_TIME_BASE): """ Round-trip a VideoFrame through encoder then decoder. """ encoder = get_encoder(codec) decoder = get_decoder(codec) input_frames = self.create_video_frames( width=width, height=height, count=30, time_base=time_base ) for i, frame in enumerate(input_frames): # encode packages, timestamp = encoder.encode(frame) # depacketize data = b"" for package in packages: data += depayload(codec, package) # decode frames = decoder.decode(JitterFrame(data=data, timestamp=timestamp)) self.assertEqual(len(frames), 1) self.assertEqual(frames[0].width, frame.width) self.assertEqual(frames[0].height, frame.height) self.assertEqual(frames[0].pts, i * 3000) self.assertEqual(frames[0].time_base, VIDEO_TIME_BASE) aiortc-1.3.0/tests/h264_0000.bin000066400000000000000000000000271417604566400157660ustar00rootroot00000000000000xgB@Pjh<aiortc-1.3.0/tests/h264_0001.bin000066400000000000000000000016211417604566400157700ustar00rootroot00000000000000| LP94)fOɰuOC98C@#Α1R+ΑN#39RnNF wP3OFhZ3x3Y)=N7k a x[tVwHA79}M! uqVUͭW]mn}SζkclB_P˟~͠â])DiURc˶@zh2p Ҫ\r}so=cԹˌN L?7e!؈Wu-tD#W<{es͢hEijV <'o c?Ff:ӝzo=jK[O7zYzs=뮷]j53򾞺뮞L]sz]s{4]=uz nwjL륮n?뮞뷮]u{b;Nm؈:n~6'k:ؾ~̾%7zu֤ZGZY맾%륎}-t}茶Vo{zInd]-t-]v"\k]2f뮷Ni%ᄏ[OK'7뮺[oWհTkOGF}S W?Ɍx0Y7g@_0\*yM3Ň?od\3/AQ>#w^i:<(oWM |6ß=3JeZG wV 5/B|8=/ş_3!s/ur~1*BY3s?gz?G_yιO~? zx?GCB<?{?~??G}0'|?shIk;t/W9O X\/X ꎹgG[_XL_L) 8?}[X? B_?s{?wph ??W// t+xO@aiortc-1.3.0/tests/rtcp_bye.bin000066400000000000000000000000101417604566400164430ustar00rootroot00000000000000RCaiortc-1.3.0/tests/rtcp_bye_invalid.bin000066400000000000000000000000101417604566400201510ustar00rootroot00000000000000RCaiortc-1.3.0/tests/rtcp_bye_no_sources.bin000066400000000000000000000000041417604566400207050ustar00rootroot00000000000000aiortc-1.3.0/tests/rtcp_bye_padding.bin000066400000000000000000000000101417604566400201310ustar00rootroot00000000000000aiortc-1.3.0/tests/rtcp_psfb_invalid.bin000066400000000000000000000000101417604566400203240ustar00rootroot00000000000000RCaiortc-1.3.0/tests/rtcp_psfb_pli.bin000066400000000000000000000000141417604566400174660ustar00rootroot00000000000000TPbe#?aiortc-1.3.0/tests/rtcp_rr.bin000066400000000000000000000000401417604566400163120ustar00rootroot000000000000000G7vraiortc-1.3.0/tests/rtcp_rr_invalid.bin000066400000000000000000000000101417604566400200150ustar00rootroot00000000000000RCaiortc-1.3.0/tests/rtcp_rtpfb.bin000066400000000000000000000000641417604566400170120ustar00rootroot00000000000000 Dw @6Ln$aiortc-1.3.0/tests/rtcp_rtpfb_invalid.bin000066400000000000000000000000101417604566400205070ustar00rootroot00000000000000RCaiortc-1.3.0/tests/rtcp_sdes.bin000066400000000000000000000000641417604566400166330ustar00rootroot00000000000000 m$S&{63f459ea-41fe-4474-9d33-9707c9ee79d1}aiortc-1.3.0/tests/rtcp_sdes_item_truncated.bin000066400000000000000000000000401417604566400217140ustar00rootroot000000000000000G7vraiortc-1.3.0/tests/rtcp_sdes_source_truncated.bin000066400000000000000000000000161417604566400222610ustar00rootroot00000000000000aiortc-1.3.0/tests/rtcp_sr.bin000066400000000000000000000000641417604566400163210ustar00rootroot00000000000000 m$SFG[\f> 4aiortc-1.3.0/tests/rtcp_sr_invalid.bin000066400000000000000000000000101417604566400200160ustar00rootroot00000000000000RCaiortc-1.3.0/tests/rtp.bin000066400000000000000000000002541417604566400154530ustar00rootroot00000000000000=c@aiortc-1.3.0/tests/rtp_dtmf.bin000066400000000000000000000000201417604566400164540ustar00rootroot00000000000000^XDaiortc-1.3.0/tests/rtp_only_padding.bin000066400000000000000000000003541417604566400202030ustar00rootroot00000000000000xlo C4FU+hguϰpݖpSolS+[8>1=fE ʢ3vTΒ~4 `p :NPɘ609{#8g߄jUI,8YWJ1"<_E@eA~wv{8ghXR-ޏcR=S.bHM KwP9cAO-2yaiortc-1.3.0/tests/rtp_only_padding_with_header_extensions.bin000066400000000000000000000003641417604566400250260ustar00rootroot00000000000000bVzY~m"̌aiortc-1.3.0/tests/rtp_with_csrc.bin000066400000000000000000000002641417604566400175210ustar00rootroot00000000000000>_ޭaiortc-1.3.0/tests/rtp_with_sdes_mid.bin000066400000000000000000000001121417604566400203460ustar00rootroot000000000000007LOu?p0x m<n; 7Expected B-bit for TSN=4ce1f17f, SID=0001, SSN=0000aiortc-1.3.0/tests/sctp_cookie_echo.bin000066400000000000000000000000301417604566400201360ustar00rootroot00000000000000=BuG 12345678aiortc-1.3.0/tests/sctp_data.bin000066400000000000000000000000401417604566400166010ustar00rootroot00000000000000?03pingaiortc-1.3.0/tests/sctp_data_padding.bin000066400000000000000000000000401417604566400202670ustar00rootroot00000000000000!}3Maiortc-1.3.0/tests/sctp_error.bin000066400000000000000000000000301417604566400170200ustar00rootroot00000000000000YW~e 09aiortc-1.3.0/tests/sctp_forward_tsn.bin000066400000000000000000000000301417604566400202170ustar00rootroot00000000000000Dɣ  "aiortc-1.3.0/tests/sctp_heartbeat.bin000066400000000000000000000000701417604566400176320ustar00rootroot00000000000000eK j,(oZvZ{4Faiortc-1.3.0/tests/sctp_init.bin000066400000000000000000000001441417604566400166400ustar00rootroot00000000000000_MV$R0aK $'8liȝt8P3waiortc-1.3.0/tests/sctp_init_bad_verification.bin000066400000000000000000000001441417604566400222100ustar00rootroot0000000000000009'V$R0aK $'8liȝt8P3waiortc-1.3.0/tests/sctp_reconfig_add_out.bin000066400000000000000000000000341417604566400211660ustar00rootroot00000000000000 ӂ 0aiortc-1.3.0/tests/sctp_reconfig_reset_out.bin000066400000000000000000000000441417604566400215610ustar00rootroot00000000000000nk  [s ^aiortc-1.3.0/tests/sctp_reconfig_response.bin000066400000000000000000000000341417604566400214050ustar00rootroot00000000000000~D7R STaiortc-1.3.0/tests/sctp_sack.bin000066400000000000000000000000501417604566400166120ustar00rootroot00000000000000KO![[aiortc-1.3.0/tests/sctp_shutdown.bin000066400000000000000000000000241417604566400175450ustar00rootroot00000000000000rQ4aiortc-1.3.0/tests/test_clock.py000066400000000000000000000020061417604566400166550ustar00rootroot00000000000000import datetime from unittest import TestCase from unittest.mock import patch from aiortc import clock class ClockTest(TestCase): @patch("aiortc.clock.current_datetime") def test_current_ms(self, mock_now): mock_now.return_value = datetime.datetime( 2018, 9, 11, tzinfo=datetime.timezone.utc ) self.assertEqual(clock.current_ms(), 3745612800000) mock_now.return_value = datetime.datetime( 2018, 9, 11, 0, 0, 1, tzinfo=datetime.timezone.utc ) self.assertEqual(clock.current_ms(), 3745612801000) def test_datetime_from_ntp(self): dt = datetime.datetime( 2018, 6, 28, 9, 3, 5, 423998, tzinfo=datetime.timezone.utc ) self.assertEqual(clock.datetime_from_ntp(16059593044731306503), dt) def test_datetime_to_ntp(self): dt = datetime.datetime( 2018, 6, 28, 9, 3, 5, 423998, tzinfo=datetime.timezone.utc ) self.assertEqual(clock.datetime_to_ntp(dt), 16059593044731306503) aiortc-1.3.0/tests/test_codecs.py000066400000000000000000000007731417604566400170330ustar00rootroot00000000000000from unittest import TestCase from aiortc.codecs import get_decoder, get_encoder from aiortc.rtcrtpparameters import RTCRtpCodecParameters BOGUS_CODEC = RTCRtpCodecParameters( mimeType="audio/bogus", clockRate=8000, channels=1, payloadType=0 ) class CodecsTest(TestCase): def test_get_decoder(self): with self.assertRaises(ValueError): get_decoder(BOGUS_CODEC) def test_get_encoder(self): with self.assertRaises(ValueError): get_encoder(BOGUS_CODEC) aiortc-1.3.0/tests/test_contrib_media.py000066400000000000000000000520471417604566400203730ustar00rootroot00000000000000import asyncio import errno import os import tempfile import time import wave from unittest import TestCase from unittest.mock import patch import av from aiortc.contrib.media import MediaBlackhole, MediaPlayer, MediaRecorder, MediaRelay from aiortc.mediastreams import AudioStreamTrack, MediaStreamError, VideoStreamTrack from .codecs import CodecTestCase from .utils import asynctest class MediaTestCase(CodecTestCase): def setUp(self): self.directory = tempfile.TemporaryDirectory() def tearDown(self): self.directory.cleanup() def create_audio_file(self, name, channels=1, sample_rate=8000, sample_width=2): path = self.temporary_path(name) writer = wave.open(path, "wb") writer.setnchannels(channels) writer.setframerate(sample_rate) writer.setsampwidth(sample_width) writer.writeframes(b"\x00" * sample_rate * sample_width * channels) writer.close() return path def create_audio_and_video_file( self, name, width=640, height=480, video_rate=30, duration=1 ): path = self.temporary_path(name) audio_pts = 0 audio_rate = 48000 audio_samples = audio_rate // video_rate container = av.open(path, "w") audio_stream = container.add_stream("aac", rate=audio_rate) video_stream = container.add_stream("mpeg4", rate=video_rate) for video_frame in self.create_video_frames( width=width, height=height, count=duration * video_rate ): audio_frame = self.create_audio_frame( samples=audio_samples, pts=audio_pts, sample_rate=audio_rate ) audio_pts += audio_samples for packet in audio_stream.encode(audio_frame): container.mux(packet) for packet in video_stream.encode(video_frame): container.mux(packet) for packet in audio_stream.encode(None): container.mux(packet) for packet in video_stream.encode(None): container.mux(packet) container.close() return path def create_video_file(self, name, width=640, height=480, rate=30, duration=1): path = self.temporary_path(name) container = av.open(path, "w") if name.endswith(".png"): stream = container.add_stream("png", rate=rate) stream.pix_fmt = "rgb24" else: stream = container.add_stream("mpeg4", rate=rate) for frame in self.create_video_frames( width=width, height=height, count=duration * rate ): for packet in stream.encode(frame): container.mux(packet) for packet in stream.encode(None): container.mux(packet) container.close() return path def temporary_path(self, name): return os.path.join(self.directory.name, name) class MediaBlackholeTest(TestCase): @asynctest async def test_audio(self): recorder = MediaBlackhole() recorder.addTrack(AudioStreamTrack()) await recorder.start() await asyncio.sleep(1) await recorder.stop() @asynctest async def test_audio_ended(self): track = AudioStreamTrack() recorder = MediaBlackhole() recorder.addTrack(track) await recorder.start() await asyncio.sleep(1) track.stop() await asyncio.sleep(1) await recorder.stop() @asynctest async def test_audio_and_video(self): recorder = MediaBlackhole() recorder.addTrack(AudioStreamTrack()) recorder.addTrack(VideoStreamTrack()) await recorder.start() await asyncio.sleep(2) await recorder.stop() @asynctest async def test_video(self): recorder = MediaBlackhole() recorder.addTrack(VideoStreamTrack()) await recorder.start() await asyncio.sleep(2) await recorder.stop() @asynctest async def test_video_ended(self): track = VideoStreamTrack() recorder = MediaBlackhole() recorder.addTrack(track) await recorder.start() await asyncio.sleep(1) track.stop() await asyncio.sleep(1) await recorder.stop() class MediaRelayTest(MediaTestCase): @asynctest async def test_audio_stop_consumer(self): source = AudioStreamTrack() relay = MediaRelay() proxy1 = relay.subscribe(source) proxy2 = relay.subscribe(source) # read some frames samples_per_frame = 160 for pts in range(0, 2 * samples_per_frame, samples_per_frame): frame1, frame2 = await asyncio.gather(proxy1.recv(), proxy2.recv()) self.assertEqual(frame1.format.name, "s16") self.assertEqual(frame1.layout.name, "mono") self.assertEqual(frame1.pts, pts) self.assertEqual(frame1.samples, samples_per_frame) self.assertEqual(frame2.format.name, "s16") self.assertEqual(frame2.layout.name, "mono") self.assertEqual(frame2.pts, pts) self.assertEqual(frame2.samples, samples_per_frame) # stop a consumer proxy1.stop() # continue reading for i in range(2): exc1, frame2 = await asyncio.gather( proxy1.recv(), proxy2.recv(), return_exceptions=True ) self.assertTrue(isinstance(exc1, MediaStreamError)) self.assertTrue(isinstance(frame2, av.AudioFrame)) # stop source track source.stop() @asynctest async def test_audio_stop_consumer_unbuffered(self): source = AudioStreamTrack() relay = MediaRelay() proxy1 = relay.subscribe(source, buffered=False) proxy2 = relay.subscribe(source, buffered=False) # read some frames samples_per_frame = 160 for pts in range(0, 2 * samples_per_frame, samples_per_frame): frame1, frame2 = await asyncio.gather(proxy1.recv(), proxy2.recv()) self.assertEqual(frame1.format.name, "s16") self.assertEqual(frame1.layout.name, "mono") self.assertEqual(frame1.pts, pts) self.assertEqual(frame1.samples, samples_per_frame) self.assertEqual(frame2.format.name, "s16") self.assertEqual(frame2.layout.name, "mono") self.assertEqual(frame2.pts, pts) self.assertEqual(frame2.samples, samples_per_frame) # stop a consumer proxy1.stop() # continue reading for i in range(2): exc1, frame2 = await asyncio.gather( proxy1.recv(), proxy2.recv(), return_exceptions=True ) self.assertTrue(isinstance(exc1, MediaStreamError)) self.assertTrue(isinstance(frame2, av.AudioFrame)) # stop source track source.stop() @asynctest async def test_audio_stop_source(self): source = AudioStreamTrack() relay = MediaRelay() proxy1 = relay.subscribe(source) proxy2 = relay.subscribe(source) # read some frames samples_per_frame = 160 for pts in range(0, 2 * samples_per_frame, samples_per_frame): frame1, frame2 = await asyncio.gather(proxy1.recv(), proxy2.recv()) self.assertEqual(frame1.format.name, "s16") self.assertEqual(frame1.layout.name, "mono") self.assertEqual(frame1.pts, pts) self.assertEqual(frame1.samples, samples_per_frame) self.assertEqual(frame2.format.name, "s16") self.assertEqual(frame2.layout.name, "mono") self.assertEqual(frame2.pts, pts) self.assertEqual(frame2.samples, samples_per_frame) # stop source track source.stop() # continue reading await asyncio.gather(proxy1.recv(), proxy2.recv()) for i in range(2): exc1, exc2 = await asyncio.gather( proxy1.recv(), proxy2.recv(), return_exceptions=True ) self.assertTrue(isinstance(exc1, MediaStreamError)) self.assertTrue(isinstance(exc2, MediaStreamError)) @asynctest async def test_audio_stop_source_unbuffered(self): source = AudioStreamTrack() relay = MediaRelay() proxy1 = relay.subscribe(source, buffered=False) proxy2 = relay.subscribe(source, buffered=False) # read some frames samples_per_frame = 160 for pts in range(0, 2 * samples_per_frame, samples_per_frame): frame1, frame2 = await asyncio.gather(proxy1.recv(), proxy2.recv()) self.assertEqual(frame1.format.name, "s16") self.assertEqual(frame1.layout.name, "mono") self.assertEqual(frame1.pts, pts) self.assertEqual(frame1.samples, samples_per_frame) self.assertEqual(frame2.format.name, "s16") self.assertEqual(frame2.layout.name, "mono") self.assertEqual(frame2.pts, pts) self.assertEqual(frame2.samples, samples_per_frame) # stop source track source.stop() # continue reading for i in range(2): exc1, exc2 = await asyncio.gather( proxy1.recv(), proxy2.recv(), return_exceptions=True ) self.assertTrue(isinstance(exc1, MediaStreamError)) self.assertTrue(isinstance(exc2, MediaStreamError)) @asynctest async def test_audio_slow_consumer(self): source = AudioStreamTrack() relay = MediaRelay() proxy1 = relay.subscribe(source, buffered=False) proxy2 = relay.subscribe(source, buffered=False) # read some frames samples_per_frame = 160 for pts in range(0, 2 * samples_per_frame, samples_per_frame): frame1, frame2 = await asyncio.gather(proxy1.recv(), proxy2.recv()) self.assertEqual(frame1.format.name, "s16") self.assertEqual(frame1.layout.name, "mono") self.assertEqual(frame1.pts, pts) self.assertEqual(frame1.samples, samples_per_frame) self.assertEqual(frame2.format.name, "s16") self.assertEqual(frame2.layout.name, "mono") self.assertEqual(frame2.pts, pts) self.assertEqual(frame2.samples, samples_per_frame) # skip some frames timestamp = 5 * samples_per_frame await asyncio.sleep(source._start + (timestamp / 8000) - time.time()) frame1, frame2 = await asyncio.gather(proxy1.recv(), proxy2.recv()) self.assertEqual(frame1.format.name, "s16") self.assertEqual(frame1.layout.name, "mono") self.assertEqual(frame1.pts, 5 * samples_per_frame) self.assertEqual(frame1.samples, samples_per_frame) self.assertEqual(frame2.format.name, "s16") self.assertEqual(frame2.layout.name, "mono") self.assertEqual(frame2.pts, 5 * samples_per_frame) self.assertEqual(frame2.samples, samples_per_frame) # stop a consumer proxy1.stop() # continue reading for i in range(2): exc1, frame2 = await asyncio.gather( proxy1.recv(), proxy2.recv(), return_exceptions=True ) self.assertTrue(isinstance(exc1, MediaStreamError)) self.assertTrue(isinstance(frame2, av.AudioFrame)) # stop source track source.stop() class BufferingInputContainer: def __init__(self, real): self.__failed = False self.__real = real def decode(self, *args, **kwargs): # fail with EAGAIN once if not self.__failed: self.__failed = True raise av.AVError(errno.EAGAIN, "EAGAIN") return self.__real.decode(*args, **kwargs) def __getattr__(self, name): return getattr(self.__real, name) class MediaPlayerTest(MediaTestCase): @asynctest async def test_audio_file_8kHz(self): path = self.create_audio_file("test.wav") player = MediaPlayer(path) # check tracks self.assertIsNotNone(player.audio) self.assertIsNone(player.video) # read all frames self.assertEqual(player.audio.readyState, "live") for i in range(49): frame = await player.audio.recv() self.assertEqual(frame.format.name, "s16") self.assertEqual(frame.layout.name, "stereo") self.assertEqual(frame.samples, 960) self.assertEqual(frame.sample_rate, 48000) with self.assertRaises(MediaStreamError): await player.audio.recv() self.assertEqual(player.audio.readyState, "ended") # try reading again with self.assertRaises(MediaStreamError): await player.audio.recv() @asynctest async def test_audio_file_48kHz(self): path = self.create_audio_file("test.wav", sample_rate=48000) player = MediaPlayer(path) # check tracks self.assertIsNotNone(player.audio) self.assertIsNone(player.video) # read all frames self.assertEqual(player.audio.readyState, "live") for i in range(50): frame = await player.audio.recv() self.assertEqual(frame.format.name, "s16") self.assertEqual(frame.layout.name, "stereo") self.assertEqual(frame.samples, 960) self.assertEqual(frame.sample_rate, 48000) with self.assertRaises(MediaStreamError): await player.audio.recv() self.assertEqual(player.audio.readyState, "ended") @asynctest async def test_audio_file_looping(self): path = self.create_audio_file("test.wav", sample_rate=48000) player = MediaPlayer(path, loop=True) # read all frames, then loop and re-read all frames self.assertEqual(player.audio.readyState, "live") for i in range(100): frame = await player.audio.recv() self.assertEqual(frame.format.name, "s16") self.assertEqual(frame.layout.name, "stereo") self.assertEqual(frame.samples, 960) self.assertEqual(frame.sample_rate, 48000) # read one more time, forcing a second loop await player.audio.recv() self.assertEqual(player.audio.readyState, "live") # stop the player player.audio.stop() @asynctest async def test_audio_and_video_file(self): path = self.create_audio_and_video_file(name="test.mp4", duration=5) player = MediaPlayer(path) # check tracks self.assertIsNotNone(player.audio) self.assertIsNotNone(player.video) # read some frames self.assertEqual(player.audio.readyState, "live") self.assertEqual(player.video.readyState, "live") for i in range(10): await asyncio.gather(player.audio.recv(), player.video.recv()) # stop audio track player.audio.stop() # continue reading for i in range(10): with self.assertRaises(MediaStreamError): await player.audio.recv() await player.video.recv() # stop video track player.video.stop() # continue reading with self.assertRaises(MediaStreamError): await player.audio.recv() with self.assertRaises(MediaStreamError): await player.video.recv() @asynctest async def test_video_file_png(self): path = self.create_video_file("test-%3d.png", duration=3) player = MediaPlayer(path) # check tracks self.assertIsNone(player.audio) self.assertIsNotNone(player.video) # read all frames self.assertEqual(player.video.readyState, "live") for i in range(90): frame = await player.video.recv() self.assertEqual(frame.width, 640) self.assertEqual(frame.height, 480) with self.assertRaises(MediaStreamError): await player.video.recv() self.assertEqual(player.video.readyState, "ended") @asynctest async def test_video_file_mp4(self): path = self.create_video_file("test.mp4", duration=3) player = MediaPlayer(path) # check tracks self.assertIsNone(player.audio) self.assertIsNotNone(player.video) # read all frames self.assertEqual(player.video.readyState, "live") for i in range(90): frame = await player.video.recv() self.assertEqual(frame.width, 640) self.assertEqual(frame.height, 480) with self.assertRaises(MediaStreamError): await player.video.recv() self.assertEqual(player.video.readyState, "ended") @asynctest async def test_video_file_mp4_eagain(self): path = self.create_video_file("test.mp4", duration=3) container = BufferingInputContainer(av.open(path, "r")) with patch("av.open") as mock_open: mock_open.return_value = container player = MediaPlayer(path) # check tracks self.assertIsNone(player.audio) self.assertIsNotNone(player.video) # read all frames self.assertEqual(player.video.readyState, "live") for i in range(90): frame = await player.video.recv() self.assertEqual(frame.width, 640) self.assertEqual(frame.height, 480) with self.assertRaises(MediaStreamError): await player.video.recv() self.assertEqual(player.video.readyState, "ended") class MediaRecorderTest(MediaTestCase): @asynctest async def test_audio_mp3(self): path = self.temporary_path("test.mp3") recorder = MediaRecorder(path) recorder.addTrack(AudioStreamTrack()) await recorder.start() await asyncio.sleep(2) await recorder.stop() # check output media container = av.open(path, "r") self.assertEqual(len(container.streams), 1) self.assertIn(container.streams[0].codec.name, ("mp3", "mp3float")) self.assertGreater( float(container.streams[0].duration * container.streams[0].time_base), 0 ) @asynctest async def test_audio_wav(self): path = self.temporary_path("test.wav") recorder = MediaRecorder(path) recorder.addTrack(AudioStreamTrack()) await recorder.start() await asyncio.sleep(2) await recorder.stop() # check output media container = av.open(path, "r") self.assertEqual(len(container.streams), 1) self.assertEqual(container.streams[0].codec.name, "pcm_s16le") self.assertGreater( float(container.streams[0].duration * container.streams[0].time_base), 0 ) @asynctest async def test_audio_wav_ended(self): track = AudioStreamTrack() recorder = MediaRecorder(self.temporary_path("test.wav")) recorder.addTrack(track) await recorder.start() await asyncio.sleep(1) track.stop() await asyncio.sleep(1) await recorder.stop() @asynctest async def test_audio_and_video(self): path = self.temporary_path("test.mp4") recorder = MediaRecorder(path) recorder.addTrack(AudioStreamTrack()) recorder.addTrack(VideoStreamTrack()) await recorder.start() await asyncio.sleep(2) await recorder.stop() # check output media container = av.open(path, "r") self.assertEqual(len(container.streams), 2) self.assertEqual(container.streams[0].codec.name, "aac") self.assertGreater( float(container.streams[0].duration * container.streams[0].time_base), 0 ) self.assertEqual(container.streams[1].codec.name, "h264") self.assertEqual(container.streams[1].width, 640) self.assertEqual(container.streams[1].height, 480) self.assertGreater( float(container.streams[1].duration * container.streams[1].time_base), 0 ) @asynctest async def test_video_png(self): path = self.temporary_path("test-%3d.png") recorder = MediaRecorder(path) recorder.addTrack(VideoStreamTrack()) await recorder.start() await asyncio.sleep(2) await recorder.stop() # check output media container = av.open(path, "r") self.assertEqual(len(container.streams), 1) self.assertEqual(container.streams[0].codec.name, "png") self.assertGreater( float(container.streams[0].duration * container.streams[0].time_base), 0 ) self.assertEqual(container.streams[0].width, 640) self.assertEqual(container.streams[0].height, 480) @asynctest async def test_video_mp4(self): path = self.temporary_path("test.mp4") recorder = MediaRecorder(path) recorder.addTrack(VideoStreamTrack()) await recorder.start() await asyncio.sleep(2) await recorder.stop() # check output media container = av.open(path, "r") self.assertEqual(len(container.streams), 1) self.assertEqual(container.streams[0].codec.name, "h264") self.assertGreater( float(container.streams[0].duration * container.streams[0].time_base), 0 ) self.assertEqual(container.streams[0].width, 640) self.assertEqual(container.streams[0].height, 480) aiortc-1.3.0/tests/test_contrib_signaling.py000066400000000000000000000145471417604566400212720ustar00rootroot00000000000000import argparse import asyncio import os from unittest import TestCase from aiortc import RTCIceCandidate, RTCSessionDescription from aiortc.contrib.signaling import ( BYE, add_signaling_arguments, create_signaling, object_from_string, object_to_string, ) from .utils import asynctest async def delay(coro): await asyncio.sleep(0.1) return await coro() offer = RTCSessionDescription(sdp="some-offer", type="offer") answer = RTCSessionDescription(sdp="some-answer", type="answer") class SignalingTest(TestCase): def setUp(self): def mock_print(*args, **kwargs): pass # hijack print() self.original_print = __builtins__["print"] __builtins__["print"] = mock_print def tearDown(self): # restore print() __builtins__["print"] = self.original_print @asynctest async def test_copy_and_paste(self): parser = argparse.ArgumentParser() add_signaling_arguments(parser) args = parser.parse_args(["-s", "copy-and-paste"]) sig_server = create_signaling(args) sig_client = create_signaling(args) def make_pipes(): r, w = os.pipe() return os.fdopen(r, "r"), os.fdopen(w, "w") # mock out read / write pipes sig_server._read_pipe, sig_client._write_pipe = make_pipes() sig_client._read_pipe, sig_server._write_pipe = make_pipes() # connect await sig_server.connect() await sig_client.connect() res = await asyncio.gather(sig_server.send(offer), delay(sig_client.receive)) self.assertEqual(res[1], offer) res = await asyncio.gather(sig_client.send(answer), delay(sig_server.receive)) self.assertEqual(res[1], answer) await asyncio.gather(sig_server.close(), sig_client.close()) # cleanup mocks sig_client._write_pipe.close() sig_server._write_pipe.close() @asynctest async def test_tcp_socket(self): parser = argparse.ArgumentParser() add_signaling_arguments(parser) args = parser.parse_args(["-s", "tcp-socket"]) sig_server = create_signaling(args) sig_client = create_signaling(args) # connect await sig_server.connect() await sig_client.connect() res = await asyncio.gather(sig_server.send(offer), delay(sig_client.receive)) self.assertEqual(res[1], offer) res = await asyncio.gather(sig_client.send(answer), delay(sig_server.receive)) self.assertEqual(res[1], answer) await asyncio.gather(sig_server.close(), sig_client.close()) @asynctest async def test_tcp_socket_abrupt_disconnect(self): parser = argparse.ArgumentParser() add_signaling_arguments(parser) args = parser.parse_args(["-s", "tcp-socket"]) sig_server = create_signaling(args) sig_client = create_signaling(args) # connect await sig_server.connect() await sig_client.connect() res = await asyncio.gather(sig_server.send(offer), delay(sig_client.receive)) self.assertEqual(res[1], offer) # break connection sig_client._writer.close() sig_server._writer.close() res = await sig_server.receive() self.assertIsNone(res) res = await sig_client.receive() self.assertIsNone(res) await asyncio.gather(sig_server.close(), sig_client.close()) @asynctest async def test_unix_socket(self): parser = argparse.ArgumentParser() add_signaling_arguments(parser) args = parser.parse_args(["-s", "unix-socket"]) sig_server = create_signaling(args) sig_client = create_signaling(args) # connect await sig_server.connect() await sig_client.connect() res = await asyncio.gather(sig_server.send(offer), delay(sig_client.receive)) self.assertEqual(res[1], offer) res = await asyncio.gather(sig_client.send(answer), delay(sig_server.receive)) self.assertEqual(res[1], answer) await asyncio.gather(sig_server.close(), sig_client.close()) @asynctest async def test_unix_socket_abrupt_disconnect(self): parser = argparse.ArgumentParser() add_signaling_arguments(parser) args = parser.parse_args(["-s", "unix-socket"]) sig_server = create_signaling(args) sig_client = create_signaling(args) # connect await sig_server.connect() await sig_client.connect() res = await asyncio.gather(sig_server.send(offer), delay(sig_client.receive)) self.assertEqual(res[1], offer) # break connection sig_client._writer.close() sig_server._writer.close() res = await sig_server.receive() self.assertIsNone(res) res = await sig_client.receive() self.assertIsNone(res) await asyncio.gather(sig_server.close(), sig_client.close()) class SignalingUtilsTest(TestCase): def test_bye_from_string(self): self.assertEqual(object_from_string('{"type": "bye"}'), BYE) def test_bye_to_string(self): self.assertEqual(object_to_string(BYE), '{"type": "bye"}') def test_candidate_from_string(self): candidate = object_from_string( '{"candidate": "candidate:0 1 UDP 2122252543 192.168.99.7 33543 typ host", "id": "audio", "label": 0, "type": "candidate"}' ) self.assertEqual(candidate.component, 1) self.assertEqual(candidate.foundation, "0") self.assertEqual(candidate.ip, "192.168.99.7") self.assertEqual(candidate.port, 33543) self.assertEqual(candidate.priority, 2122252543) self.assertEqual(candidate.protocol, "UDP") self.assertEqual(candidate.sdpMid, "audio") self.assertEqual(candidate.sdpMLineIndex, 0) self.assertEqual(candidate.type, "host") def test_candidate_to_string(self): candidate = RTCIceCandidate( component=1, foundation="0", ip="192.168.99.7", port=33543, priority=2122252543, protocol="UDP", type="host", ) candidate.sdpMid = "audio" candidate.sdpMLineIndex = 0 self.assertEqual( object_to_string(candidate), '{"candidate": "candidate:0 1 UDP 2122252543 192.168.99.7 33543 typ host", "id": "audio", "label": 0, "type": "candidate"}', ) aiortc-1.3.0/tests/test_g711.py000066400000000000000000000111321417604566400162410ustar00rootroot00000000000000import fractions from aiortc.codecs import PCMA_CODEC, PCMU_CODEC, get_decoder, get_encoder from aiortc.codecs.g711 import PcmaDecoder, PcmaEncoder, PcmuDecoder, PcmuEncoder from aiortc.jitterbuffer import JitterFrame from .codecs import CodecTestCase class PcmaTest(CodecTestCase): def test_decoder(self): decoder = get_decoder(PCMA_CODEC) self.assertTrue(isinstance(decoder, PcmaDecoder)) frames = decoder.decode(JitterFrame(data=b"\xd5" * 160, timestamp=0)) self.assertEqual(len(frames), 1) frame = frames[0] self.assertEqual(frame.format.name, "s16") self.assertEqual(frame.layout.name, "mono") self.assertEqual(bytes(frame.planes[0]), b"\x08\x00" * 160) self.assertEqual(frame.pts, 0) self.assertEqual(frame.samples, 160) self.assertEqual(frame.sample_rate, 8000) self.assertEqual(frame.time_base, fractions.Fraction(1, 8000)) def test_encoder_mono_8hz(self): encoder = get_encoder(PCMA_CODEC) self.assertTrue(isinstance(encoder, PcmaEncoder)) for frame in self.create_audio_frames( layout="mono", sample_rate=8000, count=10 ): payloads, timestamp = encoder.encode(frame) self.assertEqual(payloads, [b"\xd5" * 160]) self.assertEqual(timestamp, frame.pts) def test_encoder_stereo_8khz(self): encoder = get_encoder(PCMA_CODEC) self.assertTrue(isinstance(encoder, PcmaEncoder)) for frame in self.create_audio_frames( layout="stereo", sample_rate=8000, count=10 ): payloads, timestamp = encoder.encode(frame) self.assertEqual(payloads, [b"\xd5" * 160]) self.assertEqual(timestamp, frame.pts) def test_encoder_stereo_48khz(self): encoder = get_encoder(PCMA_CODEC) self.assertTrue(isinstance(encoder, PcmaEncoder)) for frame in self.create_audio_frames( layout="stereo", sample_rate=48000, count=10 ): payloads, timestamp = encoder.encode(frame) self.assertEqual(payloads, [b"\xd5" * 160]) self.assertEqual(timestamp, frame.pts // 6) def test_roundtrip(self): self.roundtrip_audio(PCMA_CODEC, output_layout="mono", output_sample_rate=8000) def test_roundtrip_with_loss(self): self.roundtrip_audio( PCMA_CODEC, output_layout="mono", output_sample_rate=8000, drop=[1] ) class PcmuTest(CodecTestCase): def test_decoder(self): decoder = get_decoder(PCMU_CODEC) self.assertTrue(isinstance(decoder, PcmuDecoder)) frames = decoder.decode(JitterFrame(data=b"\xff" * 160, timestamp=0)) self.assertEqual(len(frames), 1) frame = frames[0] self.assertEqual(frame.format.name, "s16") self.assertEqual(frame.layout.name, "mono") self.assertEqual(bytes(frame.planes[0]), b"\x00\x00" * 160) self.assertEqual(frame.pts, 0) self.assertEqual(frame.samples, 160) self.assertEqual(frame.sample_rate, 8000) self.assertEqual(frame.time_base, fractions.Fraction(1, 8000)) def test_encoder_mono_8hz(self): encoder = get_encoder(PCMU_CODEC) self.assertTrue(isinstance(encoder, PcmuEncoder)) for frame in self.create_audio_frames( layout="mono", sample_rate=8000, count=10 ): payloads, timestamp = encoder.encode(frame) self.assertEqual(payloads, [b"\xff" * 160]) self.assertEqual(timestamp, frame.pts) def test_encoder_stereo_8khz(self): encoder = get_encoder(PCMU_CODEC) self.assertTrue(isinstance(encoder, PcmuEncoder)) for frame in self.create_audio_frames( layout="stereo", sample_rate=8000, count=10 ): payloads, timestamp = encoder.encode(frame) self.assertEqual(payloads, [b"\xff" * 160]) self.assertEqual(timestamp, frame.pts) def test_encoder_stereo_48khz(self): encoder = get_encoder(PCMU_CODEC) self.assertTrue(isinstance(encoder, PcmuEncoder)) for frame in self.create_audio_frames( layout="stereo", sample_rate=48000, count=10 ): payloads, timestamp = encoder.encode(frame) self.assertEqual(payloads, [b"\xff" * 160]) self.assertEqual(timestamp, frame.pts // 6) def test_roundtrip(self): self.roundtrip_audio(PCMU_CODEC, output_layout="mono", output_sample_rate=8000) def test_roundtrip_with_loss(self): self.roundtrip_audio( PCMU_CODEC, output_layout="mono", output_sample_rate=8000, drop=[1] ) aiortc-1.3.0/tests/test_h264.py000066400000000000000000000242041417604566400162510ustar00rootroot00000000000000import fractions import io from contextlib import redirect_stderr from unittest import TestCase from aiortc.codecs import get_decoder, get_encoder, h264 from aiortc.codecs.h264 import H264Decoder, H264Encoder, H264PayloadDescriptor from aiortc.jitterbuffer import JitterFrame from aiortc.rtcrtpparameters import RTCRtpCodecParameters from .codecs import CodecTestCase from .utils import load H264_CODEC = RTCRtpCodecParameters( mimeType="video/H264", clockRate=90000, payloadType=100 ) class DummyPacket: def __init__(self, dts, pts): self.dts = dts self.pts = pts def to_bytes(self): return b"" class FragmentedCodecContext: def __init__(self, orig): self.__orig = orig def encode(self, frame): packages = self.__orig.encode(frame) packages.append(DummyPacket(packages[0].dts, packages[0].pts)) return packages def __getattr__(self, name): return getattr(self.__orig, name) class H264PayloadDescriptorTest(TestCase): def test_parse_empty(self): with self.assertRaises(ValueError) as cm: H264PayloadDescriptor.parse(b"") self.assertEqual(str(cm.exception), "NAL unit is too short") def test_parse_stap_a(self): payload = load("h264_0000.bin") descr, rest = H264PayloadDescriptor.parse(payload) self.assertEqual(descr.first_fragment, True) self.assertEqual(repr(descr), "H264PayloadDescriptor(FF=True)") self.assertEqual(rest[:4], b"\00\00\00\01") self.assertEqual(len(rest), 26) def test_parse_stap_a_truncated(self): payload = load("h264_0000.bin") with self.assertRaises(ValueError) as cm: H264PayloadDescriptor.parse(payload[0:1]) self.assertEqual(str(cm.exception), "NAL unit is too short") with self.assertRaises(ValueError) as cm: H264PayloadDescriptor.parse(payload[0:2]) self.assertEqual(str(cm.exception), "STAP-A length field is truncated") with self.assertRaises(ValueError) as cm: H264PayloadDescriptor.parse(payload[0:3]) self.assertEqual(str(cm.exception), "STAP-A data is truncated") def test_parse_stap_b(self): with self.assertRaises(ValueError) as cm: H264PayloadDescriptor.parse(b"\x19\x00") self.assertEqual(str(cm.exception), "NAL unit type 25 is not supported") def test_parse_fu_a_1(self): payload = load("h264_0001.bin") descr, rest = H264PayloadDescriptor.parse(payload) self.assertEqual(descr.first_fragment, True) self.assertEqual(repr(descr), "H264PayloadDescriptor(FF=True)") self.assertEqual(rest[:4], b"\00\00\00\01") self.assertEqual(len(rest), 916) def test_parse_fu_a_2(self): payload = load("h264_0002.bin") descr, rest = H264PayloadDescriptor.parse(payload) self.assertEqual(descr.first_fragment, False) self.assertEqual(repr(descr), "H264PayloadDescriptor(FF=False)") self.assertNotEqual(rest[:4], b"\00\00\00\01") self.assertEqual(len(rest), 912) def test_parse_fu_a_truncated(self): with self.assertRaises(ValueError) as cm: H264PayloadDescriptor.parse(b"\x7c") self.assertEqual(str(cm.exception), "NAL unit is too short") def test_parse_nalu(self): payload = load("h264_0003.bin") descr, rest = H264PayloadDescriptor.parse(payload) self.assertEqual(descr.first_fragment, True) self.assertEqual(repr(descr), "H264PayloadDescriptor(FF=True)") self.assertEqual(rest[:4], b"\00\00\00\01") self.assertEqual(rest[4:], payload) self.assertEqual(len(rest), 564) class H264Test(CodecTestCase): def test_decoder(self): decoder = get_decoder(H264_CODEC) self.assertTrue(isinstance(decoder, H264Decoder)) # decode junk with redirect_stderr(io.StringIO()): frames = decoder.decode(JitterFrame(data=b"123", timestamp=0)) self.assertEqual(frames, []) def test_encoder(self): encoder = get_encoder(H264_CODEC) self.assertTrue(isinstance(encoder, H264Encoder)) frame = self.create_video_frame(width=640, height=480, pts=0) packages, timestamp = encoder.encode(frame) self.assertGreaterEqual(len(packages), 1) def test_encoder_buffering(self): create_encoder_context = h264.create_encoder_context def mock_create_encoder_context(*args, **kwargs): codec, _ = create_encoder_context(*args, **kwargs) return FragmentedCodecContext(codec), True h264.create_encoder_context = mock_create_encoder_context try: encoder = get_encoder(H264_CODEC) self.assertTrue(isinstance(encoder, H264Encoder)) frame = self.create_video_frame(width=640, height=480, pts=0) packages, timestamp = encoder.encode(frame) self.assertEqual(len(packages), 0) frame = self.create_video_frame(width=640, height=480, pts=3000) packages, timestamp = encoder.encode(frame) self.assertGreaterEqual(len(packages), 1) finally: h264.create_encoder_context = create_encoder_context def test_encoder_target_bitrate(self): encoder = get_encoder(H264_CODEC) self.assertTrue(isinstance(encoder, H264Encoder)) self.assertEqual(encoder.target_bitrate, 1000000) frame = self.create_video_frame(width=640, height=480, pts=0) packages, timestamp = encoder.encode(frame) self.assertGreaterEqual(len(packages), 1) self.assertTrue(len(packages[0]) < 1300) self.assertEqual(timestamp, 0) # change target bitrate encoder.target_bitrate = 1200000 self.assertEqual(encoder.target_bitrate, 1200000) frame = self.create_video_frame(width=640, height=480, pts=3000) packages, timestamp = encoder.encode(frame) self.assertGreaterEqual(len(packages), 1) self.assertTrue(len(packages[0]) < 1300) self.assertEqual(timestamp, 3000) def test_roundtrip_1280_720(self): self.roundtrip_video(H264_CODEC, 1280, 720) def test_roundtrip_960_540(self): self.roundtrip_video(H264_CODEC, 960, 540) def test_roundtrip_640_480(self): self.roundtrip_video(H264_CODEC, 640, 480) def test_roundtrip_640_480_time_base(self): self.roundtrip_video( H264_CODEC, 640, 480, time_base=fractions.Fraction(1, 9000) ) def test_roundtrip_320_240(self): self.roundtrip_video(H264_CODEC, 320, 240) def test_split_bitstream(self): # No start code packages = list(H264Encoder._split_bitstream(b"\x00\x00\x00\x00")) self.assertEqual(packages, []) # 3-byte start code packages = list( H264Encoder._split_bitstream(b"\x00\x00\x01\xFF\x00\x00\x01\xFB") ) self.assertEqual(packages, [b"\xFF", b"\xFB"]) # 4-byte start code packages = list( H264Encoder._split_bitstream(b"\x00\x00\x00\x01\xFF\x00\x00\x00\x01\xFB") ) self.assertEqual(packages, [b"\xFF", b"\xFB"]) # Multiple bytes in a packet packages = list( H264Encoder._split_bitstream( b"\x00\x00\x00\x01\xFF\xAB\xCD\x00\x00\x00\x01\xFB" ) ) self.assertEqual(packages, [b"\xFF\xAB\xCD", b"\xFB"]) # Skip leading 0s packages = list(H264Encoder._split_bitstream(b"\x00\x00\x00\x01\xFF")) self.assertEqual(packages, [b"\xFF"]) # Both leading and trailing 0s packages = list( H264Encoder._split_bitstream( b"\x00\x00\x00\x00\x00\x00\x01\xFF\x00\x00\x00\x00\x00" ) ) self.assertEqual(packages, [b"\xFF\x00\x00\x00\x00\x00"]) def test_packetize_one_small(self): packages = [bytes([0xFF, 0xFF])] packetize_packages = H264Encoder._packetize(packages) self.assertListEqual(packages, packetize_packages) packages = [bytes([0xFF]) * 1300] packetize_packages = H264Encoder._packetize(packages) self.assertListEqual(packages, packetize_packages) def test_packetize_one_big(self): packages = [bytes([0xFF, 0xFF] * 1000)] packetize_packages = H264Encoder._packetize(packages) self.assertEqual(len(packetize_packages), 2) self.assertEqual(packetize_packages[0][0] & 0x1F, 28) self.assertEqual(packetize_packages[1][0] & 0x1F, 28) def test_packetize_two_small(self): packages = [bytes([0x01, 0xFF]), bytes([0xFF, 0xFF])] packetize_packages = H264Encoder._packetize(packages) self.assertEqual(len(packetize_packages), 1) self.assertEqual(packetize_packages[0][0] & 0x1F, 24) def test_packetize_multiple_small(self): packages = [bytes([0x01, 0xFF])] * 9 packetize_packages = H264Encoder._packetize(packages) self.assertEqual(len(packetize_packages), 1) self.assertEqual(packetize_packages[0][0] & 0x1F, 24) packages = [bytes([0x01, 0xFF])] * 10 packetize_packages = H264Encoder._packetize(packages) self.assertEqual(len(packetize_packages), 2) self.assertEqual(packetize_packages[0][0] & 0x1F, 24) self.assertEqual(packetize_packages[1], packages[-1]) def test_frame_encoder(self): encoder = get_encoder(H264_CODEC) frame = self.create_video_frame(width=640, height=480, pts=0) packages = list(encoder._encode_frame(frame, False)) self.assertGreaterEqual(len(packages), 3) # first frame must have at least set(p[0] & 0x1F for p in packages).issuperset( { 8, # PPS (picture parameter set) 7, # SPS (session parameter set) 5, # IDR (aka key frame) } ) frame = self.create_video_frame(width=640, height=480, pts=3000) packages = list(encoder._encode_frame(frame, False)) self.assertGreaterEqual(len(packages), 1) # change resolution frame = self.create_video_frame(width=320, height=240, pts=6000) packages = list(encoder._encode_frame(frame, False)) self.assertGreaterEqual(len(packages), 1) aiortc-1.3.0/tests/test_jitterbuffer.py000066400000000000000000000270421417604566400202640ustar00rootroot00000000000000from unittest import TestCase from aiortc.jitterbuffer import JitterBuffer from aiortc.rtp import RtpPacket class JitterBufferTest(TestCase): def assertPackets(self, jbuffer, expected): found = [x.sequence_number if x else None for x in jbuffer._packets] self.assertEqual(found, expected) def test_create(self): jbuffer = JitterBuffer(capacity=2) self.assertEqual(jbuffer._packets, [None, None]) self.assertEqual(jbuffer._origin, None) jbuffer = JitterBuffer(capacity=4) self.assertEqual(jbuffer._packets, [None, None, None, None]) self.assertEqual(jbuffer._origin, None) def test_add_ordered(self): jbuffer = JitterBuffer(capacity=4) pli_flag, frame = jbuffer.add(RtpPacket(sequence_number=0, timestamp=1234)) self.assertIsNone(frame) self.assertPackets(jbuffer, [0, None, None, None]) self.assertEqual(jbuffer._origin, 0) self.assertFalse(pli_flag) pli_flag, frame = jbuffer.add(RtpPacket(sequence_number=1, timestamp=1234)) self.assertIsNone(frame) self.assertPackets(jbuffer, [0, 1, None, None]) self.assertEqual(jbuffer._origin, 0) self.assertFalse(pli_flag) pli_flag, frame = jbuffer.add(RtpPacket(sequence_number=2, timestamp=1234)) self.assertIsNone(frame) self.assertPackets(jbuffer, [0, 1, 2, None]) self.assertEqual(jbuffer._origin, 0) self.assertFalse(pli_flag) pli_flag, frame = jbuffer.add(RtpPacket(sequence_number=3, timestamp=1234)) self.assertIsNone(frame) self.assertPackets(jbuffer, [0, 1, 2, 3]) self.assertEqual(jbuffer._origin, 0) self.assertFalse(pli_flag) def test_add_unordered(self): jbuffer = JitterBuffer(capacity=4) pli_flag, frame = jbuffer.add(RtpPacket(sequence_number=1, timestamp=1234)) self.assertIsNone(frame) self.assertPackets(jbuffer, [None, 1, None, None]) self.assertEqual(jbuffer._origin, 1) self.assertFalse(pli_flag) pli_flag, frame = jbuffer.add(RtpPacket(sequence_number=3, timestamp=1234)) self.assertIsNone(frame) self.assertPackets(jbuffer, [None, 1, None, 3]) self.assertEqual(jbuffer._origin, 1) self.assertFalse(pli_flag) pli_flag, frame = jbuffer.add(RtpPacket(sequence_number=2, timestamp=1234)) self.assertIsNone(frame) self.assertPackets(jbuffer, [None, 1, 2, 3]) self.assertEqual(jbuffer._origin, 1) self.assertFalse(pli_flag) def test_add_seq_too_low_drop(self): jbuffer = JitterBuffer(capacity=4) pli_flag, frame = jbuffer.add(RtpPacket(sequence_number=2, timestamp=1234)) self.assertIsNone(frame) self.assertPackets(jbuffer, [None, None, 2, None]) self.assertEqual(jbuffer._origin, 2) self.assertFalse(pli_flag) pli_flag, frame = jbuffer.add(RtpPacket(sequence_number=1, timestamp=1234)) self.assertIsNone(frame) self.assertPackets(jbuffer, [None, None, 2, None]) self.assertEqual(jbuffer._origin, 2) self.assertFalse(pli_flag) def test_add_seq_too_low_reset(self): jbuffer = JitterBuffer(capacity=4) pli_flag, frame = jbuffer.add(RtpPacket(sequence_number=2000, timestamp=1234)) self.assertIsNone(frame) self.assertPackets(jbuffer, [2000, None, None, None]) self.assertEqual(jbuffer._origin, 2000) self.assertFalse(pli_flag) pli_flag, frame = jbuffer.add(RtpPacket(sequence_number=1, timestamp=1234)) self.assertIsNone(frame) self.assertPackets(jbuffer, [None, 1, None, None]) self.assertEqual(jbuffer._origin, 1) self.assertFalse(pli_flag) def test_add_seq_too_high_discard_one(self): jbuffer = JitterBuffer(capacity=4) jbuffer.add(RtpPacket(sequence_number=0, timestamp=1234)) self.assertEqual(jbuffer._origin, 0) jbuffer.add(RtpPacket(sequence_number=1, timestamp=1234)) self.assertEqual(jbuffer._origin, 0) jbuffer.add(RtpPacket(sequence_number=2, timestamp=1234)) self.assertEqual(jbuffer._origin, 0) jbuffer.add(RtpPacket(sequence_number=3, timestamp=1234)) self.assertEqual(jbuffer._origin, 0) jbuffer.add(RtpPacket(sequence_number=4, timestamp=1234)) self.assertEqual(jbuffer._origin, 4) self.assertPackets(jbuffer, [4, None, None, None]) def test_add_seq_too_high_discard_one_v2(self): jbuffer = JitterBuffer(capacity=4) jbuffer.add(RtpPacket(sequence_number=0, timestamp=1234)) self.assertEqual(jbuffer._origin, 0) jbuffer.add(RtpPacket(sequence_number=2, timestamp=1234)) self.assertEqual(jbuffer._origin, 0) jbuffer.add(RtpPacket(sequence_number=3, timestamp=1235)) self.assertEqual(jbuffer._origin, 0) jbuffer.add(RtpPacket(sequence_number=4, timestamp=1235)) self.assertEqual(jbuffer._origin, 3) self.assertPackets(jbuffer, [4, None, None, 3]) def test_add_seq_too_high_discard_four(self): jbuffer = JitterBuffer(capacity=4) jbuffer.add(RtpPacket(sequence_number=0, timestamp=1234)) self.assertEqual(jbuffer._origin, 0) jbuffer.add(RtpPacket(sequence_number=1, timestamp=1234)) self.assertEqual(jbuffer._origin, 0) jbuffer.add(RtpPacket(sequence_number=3, timestamp=1234)) self.assertEqual(jbuffer._origin, 0) jbuffer.add(RtpPacket(sequence_number=7, timestamp=1235)) self.assertEqual(jbuffer._origin, 7) self.assertPackets(jbuffer, [None, None, None, 7]) def test_add_seq_too_high_discard_more(self): jbuffer = JitterBuffer(capacity=4) jbuffer.add(RtpPacket(sequence_number=0, timestamp=1234)) self.assertEqual(jbuffer._origin, 0) jbuffer.add(RtpPacket(sequence_number=1, timestamp=1234)) self.assertEqual(jbuffer._origin, 0) jbuffer.add(RtpPacket(sequence_number=2, timestamp=1234)) self.assertEqual(jbuffer._origin, 0) jbuffer.add(RtpPacket(sequence_number=3, timestamp=1234)) self.assertEqual(jbuffer._origin, 0) jbuffer.add(RtpPacket(sequence_number=8, timestamp=1234)) self.assertEqual(jbuffer._origin, 8) self.assertPackets(jbuffer, [8, None, None, None]) def test_add_seq_too_high_reset(self): jbuffer = JitterBuffer(capacity=4) jbuffer.add(RtpPacket(sequence_number=0, timestamp=1234)) self.assertEqual(jbuffer._origin, 0) self.assertPackets(jbuffer, [0, None, None, None]) jbuffer.add(RtpPacket(sequence_number=3000, timestamp=1234)) self.assertEqual(jbuffer._origin, 3000) self.assertPackets(jbuffer, [3000, None, None, None]) def test_remove(self): jbuffer = JitterBuffer(capacity=4) jbuffer.add(RtpPacket(sequence_number=0, timestamp=1234)) jbuffer.add(RtpPacket(sequence_number=1, timestamp=1234)) jbuffer.add(RtpPacket(sequence_number=2, timestamp=1234)) jbuffer.add(RtpPacket(sequence_number=3, timestamp=1234)) self.assertEqual(jbuffer._origin, 0) self.assertPackets(jbuffer, [0, 1, 2, 3]) # remove 1 packet jbuffer.remove(1) self.assertEqual(jbuffer._origin, 1) self.assertPackets(jbuffer, [None, 1, 2, 3]) # remove 2 packets jbuffer.remove(2) self.assertEqual(jbuffer._origin, 3) self.assertPackets(jbuffer, [None, None, None, 3]) def test_smart_remove(self): jbuffer = JitterBuffer(capacity=4) jbuffer.add(RtpPacket(sequence_number=0, timestamp=1234)) jbuffer.add(RtpPacket(sequence_number=1, timestamp=1234)) jbuffer.add(RtpPacket(sequence_number=3, timestamp=1235)) self.assertEqual(jbuffer._origin, 0) self.assertPackets(jbuffer, [0, 1, None, 3]) # remove 1 packet jbuffer.smart_remove(1) self.assertEqual(jbuffer._origin, 3) self.assertPackets(jbuffer, [None, None, None, 3]) def test_remove_audio_frame(self): """ Audio jitter buffer. """ jbuffer = JitterBuffer(capacity=16, prefetch=4) packet = RtpPacket(sequence_number=0, timestamp=1234) packet._data = b"0000" pli_flag, frame = jbuffer.add(packet) self.assertIsNone(frame) packet = RtpPacket(sequence_number=1, timestamp=1235) packet._data = b"0001" pli_flag, frame = jbuffer.add(packet) self.assertIsNone(frame) packet = RtpPacket(sequence_number=2, timestamp=1236) packet._data = b"0002" pli_flag, frame = jbuffer.add(packet) self.assertIsNone(frame) packet = RtpPacket(sequence_number=3, timestamp=1237) packet._data = b"0003" pli_flag, frame = jbuffer.add(packet) self.assertIsNone(frame) packet = RtpPacket(sequence_number=4, timestamp=1238) packet._data = b"0003" pli_flag, frame = jbuffer.add(packet) self.assertIsNotNone(frame) self.assertEqual(frame.data, b"0000") self.assertEqual(frame.timestamp, 1234) packet = RtpPacket(sequence_number=5, timestamp=1239) packet._data = b"0004" pli_flag, frame = jbuffer.add(packet) self.assertIsNotNone(frame) self.assertEqual(frame.data, b"0001") self.assertEqual(frame.timestamp, 1235) def test_remove_video_frame(self): """ Video jitter buffer. """ jbuffer = JitterBuffer(capacity=128, is_video=True) packet = RtpPacket(sequence_number=0, timestamp=1234) packet._data = b"0000" pli_flag, frame = jbuffer.add(packet) self.assertIsNone(frame) packet = RtpPacket(sequence_number=1, timestamp=1234) packet._data = b"0001" pli_flag, frame = jbuffer.add(packet) self.assertIsNone(frame) packet = RtpPacket(sequence_number=2, timestamp=1234) packet._data = b"0002" pli_flag, frame = jbuffer.add(packet) self.assertIsNone(frame) packet = RtpPacket(sequence_number=3, timestamp=1235) packet._data = b"0003" pli_flag, frame = jbuffer.add(packet) self.assertIsNotNone(frame) self.assertEqual(frame.data, b"000000010002") self.assertEqual(frame.timestamp, 1234) def test_pli_flag(self): """ Video jitter buffer. """ jbuffer = JitterBuffer(capacity=128, is_video=True) pli_flag, frame = jbuffer.add(RtpPacket(sequence_number=2000, timestamp=1234)) self.assertIsNone(frame) self.assertEqual(jbuffer._origin, 2000) self.assertFalse(pli_flag) # test_add_seq_too_low_reset for video (capacity >= 128) pli_flag, frame = jbuffer.add(RtpPacket(sequence_number=1, timestamp=1234)) self.assertIsNone(frame) self.assertEqual(jbuffer._origin, 1) self.assertTrue(pli_flag) pli_flag, frame = jbuffer.add(RtpPacket(sequence_number=128, timestamp=1235)) self.assertIsNone(frame) self.assertEqual(jbuffer._origin, 1) self.assertFalse(pli_flag) # test_add_seq_too_high_discard_one for video (capacity >= 128) pli_flag, frame = jbuffer.add(RtpPacket(sequence_number=129, timestamp=1235)) self.assertIsNone(frame) self.assertEqual(jbuffer._origin, 128) self.assertTrue(pli_flag) # test_add_seq_too_high_reset for video (capacity >= 128) pli_flag, frame = jbuffer.add(RtpPacket(sequence_number=2000, timestamp=2345)) self.assertIsNone(frame) self.assertEqual(jbuffer._origin, 2000) self.assertTrue(pli_flag) aiortc-1.3.0/tests/test_mediastreams.py000066400000000000000000000006711417604566400202460ustar00rootroot00000000000000from unittest import TestCase from aiortc.mediastreams import AudioStreamTrack, VideoStreamTrack class MediaStreamTrackTest(TestCase): def test_audio(self): track = AudioStreamTrack() self.assertEqual(track.kind, "audio") self.assertEqual(len(track.id), 36) def test_video(self): track = VideoStreamTrack() self.assertEqual(track.kind, "video") self.assertEqual(len(track.id), 36) aiortc-1.3.0/tests/test_opus.py000066400000000000000000000055061417604566400165600ustar00rootroot00000000000000import fractions from aiortc.codecs import get_decoder, get_encoder from aiortc.codecs.opus import OpusDecoder, OpusEncoder from aiortc.jitterbuffer import JitterFrame from aiortc.rtcrtpparameters import RTCRtpCodecParameters from .codecs import CodecTestCase OPUS_CODEC = RTCRtpCodecParameters( mimeType="audio/opus", clockRate=48000, channels=2, payloadType=100 ) class OpusTest(CodecTestCase): def test_decoder(self): decoder = get_decoder(OPUS_CODEC) self.assertTrue(isinstance(decoder, OpusDecoder)) frames = decoder.decode(JitterFrame(data=b"\xfc\xff\xfe", timestamp=0)) self.assertEqual(len(frames), 1) frame = frames[0] self.assertEqual(frame.format.name, "s16") self.assertEqual(frame.layout.name, "stereo") self.assertEqual(bytes(frame.planes[0]), b"\x00" * 4 * 960) self.assertEqual(frame.sample_rate, 48000) self.assertEqual(frame.pts, 0) self.assertEqual(frame.time_base, fractions.Fraction(1, 48000)) def test_encoder_mono_8khz(self): encoder = get_encoder(OPUS_CODEC) self.assertTrue(isinstance(encoder, OpusEncoder)) frames = self.create_audio_frames(layout="mono", sample_rate=8000, count=2) # first frame payloads, timestamp = encoder.encode(frames[0]) self.assertEqual(payloads, [b"\xfc\xff\xfe"]) self.assertEqual(timestamp, 0) # second frame payloads, timestamp = encoder.encode(frames[1]) self.assertEqual(timestamp, 960) def test_encoder_stereo_8khz(self): encoder = get_encoder(OPUS_CODEC) self.assertTrue(isinstance(encoder, OpusEncoder)) frames = self.create_audio_frames(layout="stereo", sample_rate=8000, count=2) # first frame payloads, timestamp = encoder.encode(frames[0]) self.assertEqual(payloads, [b"\xfc\xff\xfe"]) self.assertEqual(timestamp, 0) # second frame payloads, timestamp = encoder.encode(frames[1]) self.assertEqual(timestamp, 960) def test_encoder_stereo_48khz(self): encoder = get_encoder(OPUS_CODEC) self.assertTrue(isinstance(encoder, OpusEncoder)) frames = self.create_audio_frames(layout="stereo", sample_rate=48000, count=2) # first frame payloads, timestamp = encoder.encode(frames[0]) self.assertEqual(payloads, [b"\xfc\xff\xfe"]) self.assertEqual(timestamp, 0) # second frame payloads, timestamp = encoder.encode(frames[1]) self.assertEqual(timestamp, 960) def test_roundtrip(self): self.roundtrip_audio( OPUS_CODEC, output_layout="stereo", output_sample_rate=48000 ) def test_roundtrip_with_loss(self): self.roundtrip_audio( OPUS_CODEC, output_layout="stereo", output_sample_rate=48000, drop=[1] ) aiortc-1.3.0/tests/test_ortc.py000066400000000000000000000040651417604566400165400ustar00rootroot00000000000000import asyncio from unittest import TestCase from aiortc import ( RTCCertificate, RTCDtlsTransport, RTCIceGatherer, RTCIceTransport, RTCSctpTransport, ) from .utils import asynctest async def start_dtls_pair(ice_a, ice_b): dtls_a = RTCDtlsTransport(ice_a, [RTCCertificate.generateCertificate()]) dtls_b = RTCDtlsTransport(ice_b, [RTCCertificate.generateCertificate()]) await asyncio.gather( dtls_a.start(dtls_b.getLocalParameters()), dtls_b.start(dtls_a.getLocalParameters()), ) return dtls_a, dtls_b async def start_ice_pair(): ice_a = RTCIceTransport(gatherer=RTCIceGatherer()) ice_b = RTCIceTransport(gatherer=RTCIceGatherer()) await asyncio.gather(ice_a.iceGatherer.gather(), ice_b.iceGatherer.gather()) for candidate in ice_b.iceGatherer.getLocalCandidates(): await ice_a.addRemoteCandidate(candidate) for candidate in ice_a.iceGatherer.getLocalCandidates(): await ice_b.addRemoteCandidate(candidate) await asyncio.gather( ice_a.start(ice_b.iceGatherer.getLocalParameters()), ice_b.start(ice_a.iceGatherer.getLocalParameters()), ) return ice_a, ice_b async def start_sctp_pair(dtls_a, dtls_b): sctp_a = RTCSctpTransport(dtls_a) sctp_b = RTCSctpTransport(dtls_b) await asyncio.gather( sctp_a.start(sctp_b.getCapabilities(), sctp_b.port), sctp_b.start(sctp_a.getCapabilities(), sctp_a.port), ) return sctp_a, sctp_b class OrtcTest(TestCase): @asynctest async def test_sctp(self): # start ICE transports ice_a, ice_b = await start_ice_pair() # start DTLS transports dtls_a, dtls_b = await start_dtls_pair(ice_a, ice_b) # start SCTP transports sctp_a, sctp_b = await start_sctp_pair(dtls_a, dtls_b) # stop SCTP transports await asyncio.gather(sctp_a.stop(), sctp_b.stop()) # stop DTLS transports await asyncio.gather(dtls_a.stop(), dtls_b.stop()) # stop ICE transports await asyncio.gather(ice_a.stop(), ice_b.stop()) aiortc-1.3.0/tests/test_rate.py000066400000000000000000000757671417604566400165450ustar00rootroot00000000000000from unittest import TestCase from numpy import random from aiortc.rate import ( AimdRateControl, BandwidthUsage, InterArrival, OveruseDetector, OveruseEstimator, RateBucket, RateControlState, RateCounter, RemoteBitrateEstimator, ) TIMESTAMP_GROUP_LENGTH_US = 5000 MIN_STEP_US = 20 TRIGGER_NEW_GROUP_US = TIMESTAMP_GROUP_LENGTH_US + MIN_STEP_US BURST_THRESHOLD_MS = 5 START_RTP_TIMESTAMP_WRAP_US = 47721858827 START_ABS_SEND_TIME_WRAP_US = 63999995 def abs_send_time(us): absolute_send_time = (((us << 18) + 500000) // 1000000) & 0xFFFFFF return absolute_send_time << 8 def rtp_timestamp(us): return ((us * 90 + 500) // 1000) & 0xFFFFFFFF class AimdRateControlTest(TestCase): def setUp(self): self.rate_control = AimdRateControl() def test_update_normal(self): bitrate = 300000 now_ms = 0 self.rate_control.set_estimate(bitrate, now_ms) estimate = self.rate_control.update(BandwidthUsage.NORMAL, bitrate, now_ms) self.assertEqual(estimate, 301000) self.assertEqual(self.rate_control.state, RateControlState.INCREASE) self.assertEqual(self.rate_control.avg_max_bitrate_kbps, None) self.assertEqual(self.rate_control.var_max_bitrate_kbps, 0.4) def test_update_normal_no_estimated_throughput(self): bitrate = 300000 now_ms = 0 self.rate_control.set_estimate(bitrate, now_ms) estimate = self.rate_control.update(BandwidthUsage.NORMAL, None, now_ms) self.assertEqual(estimate, 301000) def test_update_overuse(self): bitrate = 300000 now_ms = 0 self.rate_control.set_estimate(bitrate, now_ms) estimate = self.rate_control.update(BandwidthUsage.OVERUSING, bitrate, now_ms) self.assertEqual(estimate, 255000) self.assertEqual(self.rate_control.state, RateControlState.HOLD) self.assertEqual(self.rate_control.avg_max_bitrate_kbps, 300.0) self.assertEqual(self.rate_control.var_max_bitrate_kbps, 0.4) def test_update_underuse(self): bitrate = 300000 now_ms = 0 self.rate_control.set_estimate(bitrate, now_ms) estimate = self.rate_control.update(BandwidthUsage.UNDERUSING, bitrate, now_ms) self.assertEqual(estimate, 300000) self.assertEqual(self.rate_control.state, RateControlState.HOLD) self.assertEqual(self.rate_control.avg_max_bitrate_kbps, None) self.assertEqual(self.rate_control.var_max_bitrate_kbps, 0.4) def test_additive_rate_increase(self): acked_bitrate = 100000 self.rate_control.set_estimate(acked_bitrate, 0) for now_ms in range(0, 20000, 100): estimate = self.rate_control.update( BandwidthUsage.NORMAL, acked_bitrate, now_ms ) self.assertEqual(estimate, 160000) self.assertEqual(self.rate_control.near_max, False) # overuse -> hold estimate = self.rate_control.update( BandwidthUsage.OVERUSING, acked_bitrate, now_ms ) self.assertEqual(estimate, 85000) self.assertEqual(self.rate_control.near_max, True) now_ms += 1000 # back to normal -> hold estimate = self.rate_control.update( BandwidthUsage.NORMAL, acked_bitrate, now_ms ) self.assertEqual(estimate, 85000) self.assertEqual(self.rate_control.near_max, True) now_ms += 1000 # still normal -> additive increase estimate = self.rate_control.update( BandwidthUsage.NORMAL, acked_bitrate, now_ms ) self.assertEqual(estimate, 94444) self.assertEqual(self.rate_control.near_max, True) now_ms += 1000 # overuse -> hold estimate = self.rate_control.update( BandwidthUsage.OVERUSING, acked_bitrate, now_ms ) self.assertEqual(estimate, 85000) self.assertEqual(self.rate_control.near_max, True) now_ms += 1000 def test_clear_max_throughput(self): normal_bitrate = 100000 high_bitrate = 150000 now_ms = 0 self.rate_control.set_estimate(normal_bitrate, now_ms) self.rate_control.update(BandwidthUsage.NORMAL, normal_bitrate, now_ms) now_ms += 1000 # overuse self.rate_control.update(BandwidthUsage.OVERUSING, normal_bitrate, now_ms) self.assertEqual(self.rate_control.avg_max_bitrate_kbps, 100.0) now_ms += 1000 # stable self.rate_control.update(BandwidthUsage.NORMAL, normal_bitrate, now_ms) self.assertEqual(self.rate_control.avg_max_bitrate_kbps, 100.0) now_ms += 1000 # large increase in throughput self.rate_control.update(BandwidthUsage.NORMAL, high_bitrate, now_ms) self.assertEqual(self.rate_control.avg_max_bitrate_kbps, None) now_ms += 1000 # overuse self.rate_control.update(BandwidthUsage.OVERUSING, high_bitrate, now_ms) self.assertEqual(self.rate_control.avg_max_bitrate_kbps, 150.0) now_ms += 1000 # overuse and large decrease in throughput self.rate_control.update(BandwidthUsage.OVERUSING, normal_bitrate, now_ms) self.assertEqual(self.rate_control.avg_max_bitrate_kbps, 100.0) now_ms += 1000 def test_bwe_limited_by_acked_bitrate(self): acked_bitrate = 10000 self.rate_control.set_estimate(acked_bitrate, 0) for now_ms in range(0, 20000, 100): estimate = self.rate_control.update( BandwidthUsage.NORMAL, acked_bitrate, now_ms ) self.assertEqual(estimate, 25000) def test_bwe_not_limited_by_decreasing_acked_bitrate(self): acked_bitrate = 100000 self.rate_control.set_estimate(acked_bitrate, 0) for now_ms in range(0, 20000, 100): estimate = self.rate_control.update( BandwidthUsage.NORMAL, acked_bitrate, now_ms ) self.assertEqual(estimate, 160000) # estimate doesn't change estimate = self.rate_control.update( BandwidthUsage.NORMAL, acked_bitrate // 2, now_ms ) self.assertEqual(estimate, 160000) class InterArrivalTest(TestCase): def setUp(self): self.inter_arrival_ast = InterArrival( abs_send_time(TIMESTAMP_GROUP_LENGTH_US), 1000 / (1 << 26) ) self.inter_arrival_rtp = InterArrival( rtp_timestamp(TIMESTAMP_GROUP_LENGTH_US), 1 / 9 ) def assertComputed( self, timestamp_us, arrival_time_ms, packet_size, timestamp_delta_us, arrival_time_delta_ms, packet_size_delta, timestamp_near=0, ): # AbsSendTime deltas = self.inter_arrival_ast.compute_deltas( abs_send_time(timestamp_us), arrival_time_ms, packet_size ) self.assertIsNotNone(deltas) self.assertAlmostEqual( deltas.timestamp, abs_send_time(timestamp_delta_us), delta=timestamp_near << 8, ) self.assertEqual(deltas.arrival_time, arrival_time_delta_ms) self.assertEqual(deltas.size, packet_size_delta) # RtpTimestamp deltas = self.inter_arrival_rtp.compute_deltas( rtp_timestamp(timestamp_us), arrival_time_ms, packet_size ) self.assertIsNotNone(deltas) self.assertAlmostEqual( deltas.timestamp, rtp_timestamp(timestamp_delta_us), delta=timestamp_near ) self.assertEqual(deltas.arrival_time, arrival_time_delta_ms) self.assertEqual(deltas.size, packet_size_delta) def assertNotComputed(self, timestamp_us, arrival_time_ms, packet_size): self.assertIsNone( self.inter_arrival_ast.compute_deltas( abs_send_time(timestamp_us), arrival_time_ms, packet_size ) ) self.assertIsNone( self.inter_arrival_rtp.compute_deltas( rtp_timestamp(timestamp_us), arrival_time_ms, packet_size ) ) def wrapTest(self, wrap_start_us, unorderly_within_group): timestamp_near = 1 # G1 arrival_time = 17 self.assertNotComputed(0, arrival_time, 1) # G2 arrival_time += BURST_THRESHOLD_MS + 1 self.assertNotComputed(wrap_start_us // 4, arrival_time, 1) # G3 arrival_time += BURST_THRESHOLD_MS + 1 self.assertComputed( wrap_start_us // 2, arrival_time, 1, wrap_start_us // 4, 6, 0 ) # G4 arrival_time += BURST_THRESHOLD_MS + 1 self.assertComputed( wrap_start_us // 2 + wrap_start_us // 4, arrival_time, 1, wrap_start_us // 4, 6, 0, timestamp_near, ) g4_arrival_time = arrival_time # G5 arrival_time += BURST_THRESHOLD_MS + 1 self.assertComputed( wrap_start_us, arrival_time, 2, wrap_start_us // 4, 6, 0, timestamp_near ) for i in range(10): arrival_time += BURST_THRESHOLD_MS + 1 if unorderly_within_group: self.assertNotComputed( wrap_start_us + (9 - i) * MIN_STEP_US, arrival_time, 1 ) else: self.assertNotComputed(wrap_start_us + i * MIN_STEP_US, arrival_time, 1) g5_arrival_time = arrival_time # out of order arrival_time += BURST_THRESHOLD_MS + 1 self.assertNotComputed(wrap_start_us - 100, arrival_time, 100) # G6 arrival_time += BURST_THRESHOLD_MS + 1 self.assertComputed( wrap_start_us + TRIGGER_NEW_GROUP_US, arrival_time, 10, wrap_start_us // 4 + 9 * MIN_STEP_US, g5_arrival_time - g4_arrival_time, 11, timestamp_near, ) g6_arrival_time = arrival_time # out of order arrival_time += BURST_THRESHOLD_MS + 1 self.assertNotComputed( wrap_start_us + TIMESTAMP_GROUP_LENGTH_US, arrival_time, 100 ) # G7 arrival_time += BURST_THRESHOLD_MS + 1 self.assertComputed( wrap_start_us + 2 * TRIGGER_NEW_GROUP_US, arrival_time, 10, TRIGGER_NEW_GROUP_US - 9 * MIN_STEP_US, g6_arrival_time - g5_arrival_time, -2, timestamp_near, ) def test_first_packet(self): self.assertNotComputed(0, 17, 1) def test_first_group(self): # G1 timestamp = 0 arrival_time = 17 self.assertNotComputed(timestamp, arrival_time, 1) g1_arrival_time = arrival_time # G2 timestamp += TRIGGER_NEW_GROUP_US arrival_time += BURST_THRESHOLD_MS + 1 self.assertNotComputed(timestamp, arrival_time, 2) g2_arrival_time = arrival_time # G3 timestamp += TRIGGER_NEW_GROUP_US arrival_time += BURST_THRESHOLD_MS + 1 self.assertComputed( timestamp, arrival_time, 1, TRIGGER_NEW_GROUP_US, g2_arrival_time - g1_arrival_time, 1, ) def test_second_group(self): # G1 timestamp = 0 arrival_time = 17 self.assertNotComputed(timestamp, arrival_time, 1) g1_arrival_time = arrival_time # G2 timestamp += TRIGGER_NEW_GROUP_US arrival_time += BURST_THRESHOLD_MS + 1 self.assertNotComputed(timestamp, arrival_time, 2) g2_arrival_time = arrival_time # G3 timestamp += TRIGGER_NEW_GROUP_US arrival_time += BURST_THRESHOLD_MS + 1 self.assertComputed( timestamp, arrival_time, 1, TRIGGER_NEW_GROUP_US, g2_arrival_time - g1_arrival_time, 1, ) g3_arrival_time = arrival_time # G4 timestamp += TRIGGER_NEW_GROUP_US arrival_time += BURST_THRESHOLD_MS + 1 self.assertComputed( timestamp, arrival_time, 2, TRIGGER_NEW_GROUP_US, g3_arrival_time - g2_arrival_time, -1, ) def test_accumulated_group(self): # G1 timestamp = 0 arrival_time = 17 self.assertNotComputed(timestamp, arrival_time, 1) g1_timestamp = timestamp g1_arrival_time = arrival_time # G2 timestamp += TRIGGER_NEW_GROUP_US arrival_time += BURST_THRESHOLD_MS + 1 self.assertNotComputed(timestamp, 28, 2) for i in range(10): timestamp += MIN_STEP_US arrival_time += BURST_THRESHOLD_MS + 1 self.assertNotComputed(timestamp, arrival_time, 1) g2_timestamp = timestamp g2_arrival_time = arrival_time # G3 timestamp = 2 * TRIGGER_NEW_GROUP_US arrival_time = 500 self.assertComputed( timestamp, arrival_time, 100, g2_timestamp - g1_timestamp, g2_arrival_time - g1_arrival_time, 11, ) def test_out_of_order_packet(self): # G1 timestamp = 0 arrival_time = 17 self.assertNotComputed(timestamp, arrival_time, 1) g1_timestamp = timestamp g1_arrival_time = arrival_time # G2 timestamp += TRIGGER_NEW_GROUP_US arrival_time += 11 self.assertNotComputed(timestamp, 28, 2) for i in range(10): timestamp += MIN_STEP_US arrival_time += BURST_THRESHOLD_MS + 1 self.assertNotComputed(timestamp, arrival_time, 1) g2_timestamp = timestamp g2_arrival_time = arrival_time # out of order packet arrival_time = 281 self.assertNotComputed(g1_timestamp, arrival_time, 1) # G3 timestamp = 2 * TRIGGER_NEW_GROUP_US arrival_time = 500 self.assertComputed( timestamp, arrival_time, 100, g2_timestamp - g1_timestamp, g2_arrival_time - g1_arrival_time, 11, ) def test_out_of_order_within_group(self): # G1 timestamp = 0 arrival_time = 17 self.assertNotComputed(timestamp, arrival_time, 1) g1_timestamp = timestamp g1_arrival_time = arrival_time # G2 timestamp += TRIGGER_NEW_GROUP_US arrival_time += 11 self.assertNotComputed(timestamp, 28, 2) timestamp += 10 * MIN_STEP_US g2_timestamp = timestamp for i in range(10): arrival_time += BURST_THRESHOLD_MS + 1 self.assertNotComputed(timestamp, arrival_time, 1) timestamp -= MIN_STEP_US g2_arrival_time = arrival_time # out of order packet arrival_time = 281 self.assertNotComputed(g1_timestamp, arrival_time, 1) # G3 timestamp = 2 * TRIGGER_NEW_GROUP_US arrival_time = 500 self.assertComputed( timestamp, arrival_time, 100, g2_timestamp - g1_timestamp, g2_arrival_time - g1_arrival_time, 11, ) def test_two_bursts(self): # G1 timestamp = 0 arrival_time = 17 self.assertNotComputed(timestamp, arrival_time, 1) g1_timestamp = timestamp g1_arrival_time = arrival_time # G2 timestamp += TRIGGER_NEW_GROUP_US arrival_time = 100 for i in range(10): timestamp += 30000 arrival_time += BURST_THRESHOLD_MS self.assertNotComputed(timestamp, arrival_time, 1) g2_timestamp = timestamp g2_arrival_time = arrival_time # G3 timestamp += 30000 arrival_time += BURST_THRESHOLD_MS + 1 self.assertComputed( timestamp, arrival_time, 100, g2_timestamp - g1_timestamp, g2_arrival_time - g1_arrival_time, 9, ) def test_no_bursts(self): # G1 timestamp = 0 arrival_time = 17 self.assertNotComputed(timestamp, arrival_time, 1) g1_timestamp = timestamp g1_arrival_time = arrival_time # G2 timestamp += TRIGGER_NEW_GROUP_US arrival_time = 28 self.assertNotComputed(timestamp, arrival_time, 2) g2_timestamp = timestamp g2_arrival_time = arrival_time # G3 timestamp += 30000 arrival_time += BURST_THRESHOLD_MS + 1 self.assertComputed( timestamp, arrival_time, 100, g2_timestamp - g1_timestamp, g2_arrival_time - g1_arrival_time, 1, ) def test_wrap_abs_send_time(self): self.wrapTest(START_ABS_SEND_TIME_WRAP_US, False) def test_wrap_abs_send_time_out_of_order_within_group(self): self.wrapTest(START_ABS_SEND_TIME_WRAP_US, True) def test_wrap_rtp_timestamp(self): self.wrapTest(START_RTP_TIMESTAMP_WRAP_US, False) def test_wrap_rtp_timestamp_out_of_order_within_group(self): self.wrapTest(START_RTP_TIMESTAMP_WRAP_US, True) class OveruseDetectorTest(TestCase): def setUp(self): self.timestamp_to_ms = 1 / 90 self.detector = OveruseDetector() self.estimator = OveruseEstimator() self.inter_arrival = InterArrival(5 * 90, 1 / 9) self.packet_size = 1200 self.now_ms = 0 self.receive_time_ms = 0 self.rtp_timestamp = 900 random.seed(21) def test_simple_non_overuse_30fps(self): frame_duration_ms = 33 for i in range(1000): self.update_detector(self.rtp_timestamp, self.now_ms) self.now_ms += frame_duration_ms self.rtp_timestamp += frame_duration_ms * 90 self.assertEqual(self.detector.state(), BandwidthUsage.NORMAL) def test_simple_non_overuse_with_receive_variance(self): frame_duration_ms = 10 for i in range(1000): self.update_detector(self.rtp_timestamp, self.now_ms) self.rtp_timestamp += frame_duration_ms * 90 if i % 2: self.now_ms += frame_duration_ms - 5 else: self.now_ms += frame_duration_ms + 5 self.assertEqual(self.detector.state(), BandwidthUsage.NORMAL) def test_simple_non_overuse_with_rtp_timestamp_variance(self): frame_duration_ms = 10 for i in range(1000): self.update_detector(self.rtp_timestamp, self.now_ms) self.now_ms += frame_duration_ms if i % 2: self.rtp_timestamp += (frame_duration_ms - 5) * 90 else: self.rtp_timestamp += (frame_duration_ms + 5) * 90 self.assertEqual(self.detector.state(), BandwidthUsage.NORMAL) def test_simple_overuse_2000Kbit_30fps(self): packets_per_frame = 6 frame_duration_ms = 33 drift_per_frame_ms = 1 sigma_ms = 0 unique_overuse = self.run_100000_samples( packets_per_frame, frame_duration_ms, sigma_ms ) self.assertEqual(unique_overuse, 0) frames_until_overuse = self.run_until_overuse( packets_per_frame, frame_duration_ms, sigma_ms, drift_per_frame_ms ) self.assertEqual(frames_until_overuse, 7) def test_simple_overuse_100Kbit_10fps(self): packets_per_frame = 1 frame_duration_ms = 100 drift_per_frame_ms = 1 sigma_ms = 0 unique_overuse = self.run_100000_samples( packets_per_frame, frame_duration_ms, sigma_ms ) self.assertEqual(unique_overuse, 0) frames_until_overuse = self.run_until_overuse( packets_per_frame, frame_duration_ms, sigma_ms, drift_per_frame_ms ) self.assertEqual(frames_until_overuse, 7) def test_overuse_with_low_variance_2000Kbit_30fps(self): frame_duration_ms = 33 drift_per_frame_ms = 1 self.rtp_timestamp = frame_duration_ms * 90 offset = 0 # run 1000 samples to reach steady state for i in range(1000): for j in range(6): self.update_detector(self.rtp_timestamp, self.now_ms) self.rtp_timestamp += frame_duration_ms * 90 if i % 2: offset = random.randint(0, 1) self.now_ms += frame_duration_ms - offset else: self.now_ms += frame_duration_ms + offset self.assertEqual(self.detector.state(), BandwidthUsage.NORMAL) # simulate a higher send pace, that is too high. for i in range(3): for j in range(6): self.update_detector(self.rtp_timestamp, self.now_ms) self.now_ms += frame_duration_ms + drift_per_frame_ms * 6 self.rtp_timestamp += frame_duration_ms * 90 self.assertEqual(self.detector.state(), BandwidthUsage.NORMAL) self.update_detector(self.rtp_timestamp, self.now_ms) self.assertEqual(self.detector.state(), BandwidthUsage.OVERUSING) def test_low_gaussian_variance_fast_drift_30Kbit_3fps(self): packets_per_frame = 1 frame_duration_ms = 333 drift_per_frame_ms = 100 sigma_ms = 3 unique_overuse = self.run_100000_samples( packets_per_frame, frame_duration_ms, sigma_ms ) self.assertEqual(unique_overuse, 0) frames_until_overuse = self.run_until_overuse( packets_per_frame, frame_duration_ms, sigma_ms, drift_per_frame_ms ) self.assertEqual(frames_until_overuse, 4) def test_high_haussian_variance_30Kbit_3fps(self): packets_per_frame = 1 frame_duration_ms = 333 drift_per_frame_ms = 1 sigma_ms = 10 unique_overuse = self.run_100000_samples( packets_per_frame, frame_duration_ms, sigma_ms ) self.assertEqual(unique_overuse, 0) frames_until_overuse = self.run_until_overuse( packets_per_frame, frame_duration_ms, sigma_ms, drift_per_frame_ms ) self.assertEqual(frames_until_overuse, 44) def run_100000_samples(self, packets_per_frame, mean_ms, standard_deviation_ms): unique_overuse = 0 last_overuse = -1 for i in range(100000): for j in range(packets_per_frame): self.update_detector(self.rtp_timestamp, self.receive_time_ms) self.rtp_timestamp += mean_ms * 90 self.now_ms += mean_ms self.receive_time_ms = max( self.receive_time_ms, int(self.now_ms + random.normal(0, standard_deviation_ms) + 0.5), ) if self.detector.state() == BandwidthUsage.OVERUSING: if last_overuse + 1 != i: unique_overuse += 1 last_overuse = i return unique_overuse def run_until_overuse( self, packets_per_frame, mean_ms, standard_deviation_ms, drift_per_frame_ms ): for i in range(100000): for j in range(packets_per_frame): self.update_detector(self.rtp_timestamp, self.receive_time_ms) self.rtp_timestamp += mean_ms * 90 self.now_ms += mean_ms + drift_per_frame_ms self.receive_time_ms = max( self.receive_time_ms, int(self.now_ms + random.normal(0, standard_deviation_ms) + 0.5), ) if self.detector.state() == BandwidthUsage.OVERUSING: return i + 1 return -1 def update_detector(self, timestamp, receive_time_ms): deltas = self.inter_arrival.compute_deltas( timestamp, receive_time_ms, self.packet_size ) if deltas is not None: timestamp_delta_ms = deltas.timestamp / 90 self.estimator.update( deltas.arrival_time, timestamp_delta_ms, deltas.size, self.detector.state(), receive_time_ms, ) self.detector.detect( self.estimator.offset(), timestamp_delta_ms, self.estimator.num_of_deltas(), receive_time_ms, ) class RateCounterTest(TestCase): def test_constructor(self): counter = RateCounter(10) self.assertEqual( counter._buckets, [ RateBucket(), RateBucket(), RateBucket(), RateBucket(), RateBucket(), RateBucket(), RateBucket(), RateBucket(), RateBucket(), RateBucket(), ], ) self.assertIsNone(counter._origin_ms) self.assertEqual(counter._origin_index, 0) self.assertEqual(counter._total, RateBucket()) self.assertIsNone(counter.rate(0)) def test_add(self): counter = RateCounter(10) counter.add(500, 123) self.assertEqual( counter._buckets, [ RateBucket(1, 500), RateBucket(), RateBucket(), RateBucket(), RateBucket(), RateBucket(), RateBucket(), RateBucket(), RateBucket(), RateBucket(), ], ) self.assertEqual(counter._origin_index, 0) self.assertEqual(counter._origin_ms, 123) self.assertEqual(counter._total, RateBucket(1, 500)) self.assertIsNone(counter.rate(123)) counter.add(501, 123) self.assertEqual( counter._buckets, [ RateBucket(2, 1001), RateBucket(), RateBucket(), RateBucket(), RateBucket(), RateBucket(), RateBucket(), RateBucket(), RateBucket(), RateBucket(), ], ) self.assertEqual(counter._origin_index, 0) self.assertEqual(counter._origin_ms, 123) self.assertEqual(counter._total, RateBucket(2, 1001)) self.assertIsNone(counter.rate(123)) counter.add(502, 125) self.assertEqual( counter._buckets, [ RateBucket(2, 1001), RateBucket(), RateBucket(1, 502), RateBucket(), RateBucket(), RateBucket(), RateBucket(), RateBucket(), RateBucket(), RateBucket(), ], ) self.assertEqual(counter._origin_index, 0) self.assertEqual(counter._origin_ms, 123) self.assertEqual(counter._total, RateBucket(3, 1503)) self.assertEqual(counter.rate(125), 4008000) counter.add(503, 128) self.assertEqual( counter._buckets, [ RateBucket(2, 1001), RateBucket(), RateBucket(1, 502), RateBucket(), RateBucket(), RateBucket(1, 503), RateBucket(), RateBucket(), RateBucket(), RateBucket(), ], ) self.assertEqual(counter._origin_index, 0) self.assertEqual(counter._origin_ms, 123) self.assertEqual(counter._total, RateBucket(4, 2006)) self.assertEqual(counter.rate(128), 2674667) counter.add(504, 132) self.assertEqual( counter._buckets, [ RateBucket(2, 1001), RateBucket(), RateBucket(1, 502), RateBucket(), RateBucket(), RateBucket(1, 503), RateBucket(), RateBucket(), RateBucket(), RateBucket(1, 504), ], ) self.assertEqual(counter._origin_index, 0) self.assertEqual(counter._origin_ms, 123) self.assertEqual(counter._total, RateBucket(5, 2510)) self.assertEqual(counter.rate(132), 2008000) counter.add(505, 134) self.assertEqual( counter._buckets, [ RateBucket(), RateBucket(1, 505), RateBucket(1, 502), RateBucket(), RateBucket(), RateBucket(1, 503), RateBucket(), RateBucket(), RateBucket(), RateBucket(1, 504), ], ) self.assertEqual(counter._origin_index, 2) self.assertEqual(counter._origin_ms, 125) self.assertEqual(counter._total, RateBucket(4, 2014)) self.assertEqual(counter.rate(134), 1611200) counter.add(506, 135) self.assertEqual( counter._buckets, [ RateBucket(), RateBucket(1, 505), RateBucket(1, 506), RateBucket(), RateBucket(), RateBucket(1, 503), RateBucket(), RateBucket(), RateBucket(), RateBucket(1, 504), ], ) self.assertEqual(counter._origin_index, 3) self.assertEqual(counter._origin_ms, 126) self.assertEqual(counter._total, RateBucket(4, 2018)) self.assertEqual(counter.rate(135), 1614400) class Stream: def __init__(self, capacity): self.capacity = capacity self.framerate = 30 self.payload_size = 1500 self.send_time_us = 0 self.arrival_time_us = 0 def generate_frames(self, count): for i in range(count): abs_send_time = self.send_time_us * (1 << 18) // 1000000 self.arrival_time_us = max(self.arrival_time_us, self.send_time_us) + round( (self.payload_size * 8000000) / self.capacity ) self.send_time_us += 1000000 // self.framerate yield abs_send_time, self.arrival_time_us // 1000, self.payload_size class RemoteBitrateEstimatorTest(TestCase): def test_capacity_drop(self): estimator = RemoteBitrateEstimator() stream = Stream(capacity=500000) target_bitrate = None for abs_send_time, arrival_time_ms, payload_size in stream.generate_frames( 1000 ): res = estimator.add( abs_send_time=abs_send_time, arrival_time_ms=arrival_time_ms, payload_size=payload_size, ssrc=1234, ) if res is not None: target_bitrate = res[0] self.assertEqual(target_bitrate, 550000) # reduce capacity stream.capacity = 250000 for abs_send_time, arrival_time_ms, payload_size in stream.generate_frames( 1000 ): res = estimator.add( abs_send_time=abs_send_time, arrival_time_ms=arrival_time_ms, payload_size=payload_size, ssrc=1234, ) if res is not None: target_bitrate = res[0] self.assertEqual(target_bitrate, 214200) aiortc-1.3.0/tests/test_rtcdtlstransport.py000066400000000000000000000452721417604566400212320ustar00rootroot00000000000000import asyncio import datetime from unittest import TestCase from unittest.mock import patch from aiortc.rtcdtlstransport import ( DtlsError, RTCCertificate, RTCDtlsFingerprint, RTCDtlsParameters, RTCDtlsTransport, RtpRouter, ) from aiortc.rtcrtpparameters import ( RTCRtpCodecParameters, RTCRtpDecodingParameters, RTCRtpReceiveParameters, ) from aiortc.rtp import ( RTCP_PSFB_APP, RTCP_PSFB_PLI, RTCP_RTPFB_NACK, RtcpByePacket, RtcpPsfbPacket, RtcpReceiverInfo, RtcpRrPacket, RtcpRtpfbPacket, RtcpSenderInfo, RtcpSrPacket, RtpPacket, pack_remb_fci, ) from .utils import asynctest, dummy_ice_transport_pair, load RTP = load("rtp.bin") RTCP = load("rtcp_sr.bin") class BrokenDataReceiver: def __init__(self): self.data = [] async def _handle_data(self, data): raise Exception("some error") class DummyDataReceiver: def __init__(self): self.data = [] async def _handle_data(self, data): self.data.append(data) class DummyRtpReceiver: def __init__(self): self.rtp_packets = [] self.rtcp_packets = [] def _handle_disconnect(self): pass async def _handle_rtp_packet(self, packet, arrival_time_ms): self.rtp_packets.append(packet) async def _handle_rtcp_packet(self, packet): self.rtcp_packets.append(packet) class RTCCertificateTest(TestCase): def test_generate(self): certificate = RTCCertificate.generateCertificate() self.assertIsNotNone(certificate) expires = certificate.expires self.assertIsNotNone(expires) self.assertTrue(isinstance(expires, datetime.datetime)) fingerprints = certificate.getFingerprints() self.assertEqual(len(fingerprints), 1) self.assertEqual(fingerprints[0].algorithm, "sha-256") self.assertEqual(len(fingerprints[0].value), 95) class RTCDtlsTransportTest(TestCase): def assertCounters(self, transport_a, transport_b, packets_sent_a, packets_sent_b): stats_a = transport_a._get_stats()[transport_a._stats_id] stats_b = transport_b._get_stats()[transport_b._stats_id] self.assertEqual(stats_a.packetsSent, packets_sent_a) self.assertEqual(stats_a.packetsReceived, packets_sent_b) self.assertGreater(stats_a.bytesSent, 0) self.assertGreater(stats_a.bytesReceived, 0) self.assertEqual(stats_b.packetsSent, packets_sent_b) self.assertEqual(stats_b.packetsReceived, packets_sent_a) self.assertGreater(stats_b.bytesSent, 0) self.assertGreater(stats_b.bytesReceived, 0) self.assertEqual(stats_a.bytesSent, stats_b.bytesReceived) self.assertEqual(stats_b.bytesSent, stats_a.bytesReceived) @patch("aiortc.rtcdtlstransport.lib.SSL_CTX_use_certificate") @asynctest async def test_broken_ssl(self, mock_use_certificate): mock_use_certificate.return_value = 0 transport1, transport2 = dummy_ice_transport_pair() certificate = RTCCertificate.generateCertificate() with self.assertRaises(DtlsError): RTCDtlsTransport(transport1, [certificate]) @asynctest async def test_data(self): transport1, transport2 = dummy_ice_transport_pair() certificate1 = RTCCertificate.generateCertificate() session1 = RTCDtlsTransport(transport1, [certificate1]) receiver1 = DummyDataReceiver() session1._register_data_receiver(receiver1) certificate2 = RTCCertificate.generateCertificate() session2 = RTCDtlsTransport(transport2, [certificate2]) receiver2 = DummyDataReceiver() session2._register_data_receiver(receiver2) await asyncio.gather( session1.start(session2.getLocalParameters()), session2.start(session1.getLocalParameters()), ) # send encypted data await session1._send_data(b"ping") await asyncio.sleep(0.1) self.assertEqual(receiver2.data, [b"ping"]) await session2._send_data(b"pong") await asyncio.sleep(0.1) self.assertEqual(receiver1.data, [b"pong"]) # shutdown await session1.stop() await asyncio.sleep(0.1) self.assertEqual(session1.state, "closed") self.assertEqual(session2.state, "closed") # try closing again await session1.stop() await session2.stop() # try sending after close with self.assertRaises(ConnectionError): await session1._send_data(b"foo") @asynctest async def test_data_handler_error(self): transport1, transport2 = dummy_ice_transport_pair() certificate1 = RTCCertificate.generateCertificate() session1 = RTCDtlsTransport(transport1, [certificate1]) receiver1 = DummyDataReceiver() session1._register_data_receiver(receiver1) certificate2 = RTCCertificate.generateCertificate() session2 = RTCDtlsTransport(transport2, [certificate2]) session2._register_data_receiver(BrokenDataReceiver()) await asyncio.gather( session1.start(session2.getLocalParameters()), session2.start(session1.getLocalParameters()), ) # send encypted data await session1._send_data(b"ping") await asyncio.sleep(0.1) # shutdown await session1.stop() await session2.stop() @asynctest async def test_rtp(self): transport1, transport2 = dummy_ice_transport_pair() certificate1 = RTCCertificate.generateCertificate() session1 = RTCDtlsTransport(transport1, [certificate1]) receiver1 = DummyRtpReceiver() session1._register_rtp_receiver( receiver1, RTCRtpReceiveParameters( codecs=[ RTCRtpCodecParameters( mimeType="audio/PCMU", clockRate=8000, payloadType=0 ) ], encodings=[RTCRtpDecodingParameters(ssrc=1831097322, payloadType=0)], ), ) certificate2 = RTCCertificate.generateCertificate() session2 = RTCDtlsTransport(transport2, [certificate2]) receiver2 = DummyRtpReceiver() session2._register_rtp_receiver( receiver2, RTCRtpReceiveParameters( codecs=[ RTCRtpCodecParameters( mimeType="audio/PCMU", clockRate=8000, payloadType=0 ) ], encodings=[RTCRtpDecodingParameters(ssrc=4028317929, payloadType=0)], ), ) await asyncio.gather( session1.start(session2.getLocalParameters()), session2.start(session1.getLocalParameters()), ) self.assertCounters(session1, session2, 2, 2) # send RTP await session1._send_rtp(RTP) await asyncio.sleep(0.1) self.assertCounters(session1, session2, 3, 2) self.assertEqual(len(receiver2.rtcp_packets), 0) self.assertEqual(len(receiver2.rtp_packets), 1) # send RTCP await session2._send_rtp(RTCP) await asyncio.sleep(0.1) self.assertCounters(session1, session2, 3, 3) self.assertEqual(len(receiver1.rtcp_packets), 1) self.assertEqual(len(receiver1.rtp_packets), 0) # shutdown await session1.stop() await asyncio.sleep(0.1) self.assertCounters(session1, session2, 4, 3) self.assertEqual(session1.state, "closed") self.assertEqual(session2.state, "closed") # try closing again await session1.stop() await session2.stop() # try sending after close with self.assertRaises(ConnectionError): await session1._send_rtp(RTP) @asynctest async def test_rtp_malformed(self): transport1, transport2 = dummy_ice_transport_pair() certificate1 = RTCCertificate.generateCertificate() session1 = RTCDtlsTransport(transport1, [certificate1]) # receive truncated RTP await session1._handle_rtp_data(RTP[0:8], 0) # receive truncated RTCP await session1._handle_rtcp_data(RTCP[0:8]) @asynctest async def test_srtp_unprotect_error(self): transport1, transport2 = dummy_ice_transport_pair() certificate1 = RTCCertificate.generateCertificate() session1 = RTCDtlsTransport(transport1, [certificate1]) receiver1 = DummyRtpReceiver() session1._register_rtp_receiver( receiver1, RTCRtpReceiveParameters( codecs=[ RTCRtpCodecParameters( mimeType="audio/PCMU", clockRate=8000, payloadType=0 ) ], encodings=[RTCRtpDecodingParameters(ssrc=1831097322, payloadType=0)], ), ) certificate2 = RTCCertificate.generateCertificate() session2 = RTCDtlsTransport(transport2, [certificate2]) receiver2 = DummyRtpReceiver() session2._register_rtp_receiver( receiver2, RTCRtpReceiveParameters( codecs=[ RTCRtpCodecParameters( mimeType="audio/PCMU", clockRate=8000, payloadType=0 ) ], encodings=[RTCRtpDecodingParameters(ssrc=4028317929, payloadType=0)], ), ) await asyncio.gather( session1.start(session2.getLocalParameters()), session2.start(session1.getLocalParameters()), ) # send same RTP twice, to trigger error on the receiver side: # "replay check failed (bad index)" await session1._send_rtp(RTP) await session1._send_rtp(RTP) await asyncio.sleep(0.1) self.assertEqual(len(receiver2.rtcp_packets), 0) self.assertEqual(len(receiver2.rtp_packets), 1) # shutdown await session1.stop() await session2.stop() @asynctest async def test_abrupt_disconnect(self): transport1, transport2 = dummy_ice_transport_pair() certificate1 = RTCCertificate.generateCertificate() session1 = RTCDtlsTransport(transport1, [certificate1]) certificate2 = RTCCertificate.generateCertificate() session2 = RTCDtlsTransport(transport2, [certificate2]) await asyncio.gather( session1.start(session2.getLocalParameters()), session2.start(session1.getLocalParameters()), ) # break connections -> tasks exits await transport1.stop() await transport2.stop() await asyncio.sleep(0.1) # close DTLS await session1.stop() await session2.stop() # check outcome self.assertEqual(session1.state, "closed") self.assertEqual(session2.state, "closed") @asynctest async def test_abrupt_disconnect_2(self): transport1, transport2 = dummy_ice_transport_pair() certificate1 = RTCCertificate.generateCertificate() session1 = RTCDtlsTransport(transport1, [certificate1]) certificate2 = RTCCertificate.generateCertificate() session2 = RTCDtlsTransport(transport2, [certificate2]) await asyncio.gather( session1.start(session2.getLocalParameters()), session2.start(session1.getLocalParameters()), ) def fake_write_ssl(): raise ConnectionError session1._write_ssl = fake_write_ssl # close DTLS -> ConnectionError await session1.stop() await session2.stop() await asyncio.sleep(0.1) # check outcome self.assertEqual(session1.state, "closed") self.assertEqual(session2.state, "closed") @asynctest async def test_bad_client_fingerprint(self): transport1, transport2 = dummy_ice_transport_pair() certificate1 = RTCCertificate.generateCertificate() session1 = RTCDtlsTransport(transport1, [certificate1]) certificate2 = RTCCertificate.generateCertificate() session2 = RTCDtlsTransport(transport2, [certificate2]) bogus_parameters = RTCDtlsParameters( fingerprints=[ RTCDtlsFingerprint(algorithm="sha-256", value="bogus_fingerprint") ] ) await asyncio.gather( session1.start(bogus_parameters), session2.start(session1.getLocalParameters()), ) self.assertEqual(session1.state, "failed") self.assertEqual(session2.state, "connected") await session1.stop() await session2.stop() @patch("aiortc.rtcdtlstransport.lib.SSL_do_handshake") @patch("aiortc.rtcdtlstransport.lib.SSL_get_error") @patch("aiortc.rtcdtlstransport.lib.ERR_get_error") @asynctest async def test_handshake_error( self, mock_err_get_error, mock_ssl_get_error, mock_do_handshake ): mock_err_get_error.side_effect = [0x2006D080, 0, 0] mock_ssl_get_error.return_value = 1 mock_do_handshake.return_value = -1 transport1, transport2 = dummy_ice_transport_pair() certificate1 = RTCCertificate.generateCertificate() session1 = RTCDtlsTransport(transport1, [certificate1]) certificate2 = RTCCertificate.generateCertificate() session2 = RTCDtlsTransport(transport2, [certificate2]) await asyncio.gather( session1.start(session2.getLocalParameters()), session2.start(session1.getLocalParameters()), ) self.assertEqual(session1.state, "failed") self.assertEqual(session2.state, "failed") await session1.stop() await session2.stop() @asynctest async def test_lossy_channel(self): """ Transport with 25% loss eventually connects. """ transport1, transport2 = dummy_ice_transport_pair() loss_pattern = [True, False, False, False] transport1._connection.loss_pattern = loss_pattern transport2._connection.loss_pattern = loss_pattern certificate1 = RTCCertificate.generateCertificate() session1 = RTCDtlsTransport(transport1, [certificate1]) certificate2 = RTCCertificate.generateCertificate() session2 = RTCDtlsTransport(transport2, [certificate2]) await asyncio.gather( session1.start(session2.getLocalParameters()), session2.start(session1.getLocalParameters()), ) await session1.stop() await session2.stop() class RtpRouterTest(TestCase): def test_route_rtcp(self): receiver = object() sender = object() router = RtpRouter() router.register_receiver(receiver, ssrcs=[1234, 2345], payload_types=[96, 97]) router.register_sender(sender, ssrc=3456) # BYE packet = RtcpByePacket(sources=[1234, 2345]) self.assertEqual(router.route_rtcp(packet), set([receiver])) # RR packet = RtcpRrPacket( ssrc=1234, reports=[ RtcpReceiverInfo( ssrc=3456, fraction_lost=0, packets_lost=0, highest_sequence=630, jitter=1906, lsr=0, dlsr=0, ) ], ) self.assertEqual(router.route_rtcp(packet), set([sender])) # SR packet = RtcpSrPacket( ssrc=1234, sender_info=RtcpSenderInfo( ntp_timestamp=0, rtp_timestamp=0, packet_count=0, octet_count=0 ), reports=[ RtcpReceiverInfo( ssrc=3456, fraction_lost=0, packets_lost=0, highest_sequence=630, jitter=1906, lsr=0, dlsr=0, ) ], ) self.assertEqual(router.route_rtcp(packet), set([receiver, sender])) # PSFB - PLI packet = RtcpPsfbPacket(fmt=RTCP_PSFB_PLI, ssrc=1234, media_ssrc=3456) self.assertEqual(router.route_rtcp(packet), set([sender])) # PSFB - REMB packet = RtcpPsfbPacket( fmt=RTCP_PSFB_APP, ssrc=1234, media_ssrc=0, fci=pack_remb_fci(4160000, [3456]), ) self.assertEqual(router.route_rtcp(packet), set([sender])) # PSFB - JUNK packet = RtcpPsfbPacket(fmt=RTCP_PSFB_APP, ssrc=1234, media_ssrc=0, fci=b"JUNK") self.assertEqual(router.route_rtcp(packet), set()) # RTPFB packet = RtcpRtpfbPacket(fmt=RTCP_RTPFB_NACK, ssrc=1234, media_ssrc=3456) self.assertEqual(router.route_rtcp(packet), set([sender])) def test_route_rtp(self): receiver1 = object() receiver2 = object() router = RtpRouter() router.register_receiver(receiver1, ssrcs=[1234, 2345], payload_types=[96, 97]) router.register_receiver(receiver2, ssrcs=[3456, 4567], payload_types=[98, 99]) # known SSRC and payload type self.assertEqual( router.route_rtp(RtpPacket(ssrc=1234, payload_type=96)), receiver1 ) self.assertEqual( router.route_rtp(RtpPacket(ssrc=2345, payload_type=97)), receiver1 ) self.assertEqual( router.route_rtp(RtpPacket(ssrc=3456, payload_type=98)), receiver2 ) self.assertEqual( router.route_rtp(RtpPacket(ssrc=4567, payload_type=99)), receiver2 ) # unknown SSRC, known payload type self.assertEqual( router.route_rtp(RtpPacket(ssrc=5678, payload_type=96)), receiver1 ) self.assertEqual(router.ssrc_table[5678], receiver1) # unknown SSRC and payload type self.assertEqual(router.route_rtp(RtpPacket(ssrc=6789, payload_type=100)), None) def test_route_rtp_ambiguous_payload_type(self): receiver1 = object() receiver2 = object() router = RtpRouter() router.register_receiver(receiver1, ssrcs=[1234, 2345], payload_types=[96, 97]) router.register_receiver(receiver2, ssrcs=[3456, 4567], payload_types=[96, 97]) # known SSRC and payload type self.assertEqual( router.route_rtp(RtpPacket(ssrc=1234, payload_type=96)), receiver1 ) self.assertEqual( router.route_rtp(RtpPacket(ssrc=2345, payload_type=97)), receiver1 ) self.assertEqual( router.route_rtp(RtpPacket(ssrc=3456, payload_type=96)), receiver2 ) self.assertEqual( router.route_rtp(RtpPacket(ssrc=4567, payload_type=97)), receiver2 ) # unknown SSRC, ambiguous payload type self.assertEqual(router.route_rtp(RtpPacket(ssrc=5678, payload_type=96)), None) self.assertEqual(router.route_rtp(RtpPacket(ssrc=5678, payload_type=97)), None) aiortc-1.3.0/tests/test_rtcicetransport.py000066400000000000000000000301051417604566400210110ustar00rootroot00000000000000import asyncio from unittest import TestCase import aioice.stun from aioice import ConnectionClosed from aiortc.exceptions import InvalidStateError from aiortc.rtcconfiguration import RTCIceServer from aiortc.rtcicetransport import ( RTCIceCandidate, RTCIceGatherer, RTCIceParameters, RTCIceTransport, connection_kwargs, parse_stun_turn_uri, ) from .utils import asynctest async def mock_connect(): pass async def mock_get_event(): await asyncio.sleep(0.5) return ConnectionClosed() class ConnectionKwargsTest(TestCase): def test_empty(self): self.assertEqual(connection_kwargs([]), {}) def test_stun(self): self.assertEqual( connection_kwargs([RTCIceServer("stun:stun.l.google.com:19302")]), {"stun_server": ("stun.l.google.com", 19302)}, ) def test_stun_multiple_servers(self): self.assertEqual( connection_kwargs( [ RTCIceServer("stun:stun.l.google.com:19302"), RTCIceServer("stun:stun.example.com"), ] ), {"stun_server": ("stun.l.google.com", 19302)}, ) def test_stun_multiple_urls(self): self.assertEqual( connection_kwargs( [ RTCIceServer( [ "stun:stun1.l.google.com:19302", "stun:stun2.l.google.com:19302", ] ) ] ), {"stun_server": ("stun1.l.google.com", 19302)}, ) def test_turn(self): self.assertEqual( connection_kwargs([RTCIceServer("turn:turn.example.com")]), { "turn_password": None, "turn_server": ("turn.example.com", 3478), "turn_ssl": False, "turn_transport": "udp", "turn_username": None, }, ) def test_turn_multiple_servers(self): self.assertEqual( connection_kwargs( [ RTCIceServer("turn:turn.example.com"), RTCIceServer("turn:turn.example.net"), ] ), { "turn_password": None, "turn_server": ("turn.example.com", 3478), "turn_ssl": False, "turn_transport": "udp", "turn_username": None, }, ) def test_turn_multiple_urls(self): self.assertEqual( connection_kwargs( [RTCIceServer(["turn:turn1.example.com", "turn:turn2.example.com"])] ), { "turn_password": None, "turn_server": ("turn1.example.com", 3478), "turn_ssl": False, "turn_transport": "udp", "turn_username": None, }, ) def test_turn_over_bogus(self): self.assertEqual( connection_kwargs([RTCIceServer("turn:turn.example.com?transport=bogus")]), {}, ) def test_turn_over_tcp(self): self.assertEqual( connection_kwargs([RTCIceServer("turn:turn.example.com?transport=tcp")]), { "turn_password": None, "turn_server": ("turn.example.com", 3478), "turn_ssl": False, "turn_transport": "tcp", "turn_username": None, }, ) def test_turn_with_password(self): self.assertEqual( connection_kwargs( [ RTCIceServer( urls="turn:turn.example.com", username="foo", credential="bar" ) ] ), { "turn_password": "bar", "turn_server": ("turn.example.com", 3478), "turn_ssl": False, "turn_transport": "udp", "turn_username": "foo", }, ) def test_turn_with_token(self): self.assertEqual( connection_kwargs( [ RTCIceServer( urls="turn:turn.example.com", username="foo", credential="bar", credentialType="token", ) ] ), {}, ) def test_turns(self): self.assertEqual( connection_kwargs([RTCIceServer("turns:turn.example.com")]), { "turn_password": None, "turn_server": ("turn.example.com", 5349), "turn_ssl": True, "turn_transport": "tcp", "turn_username": None, }, ) def test_turns_over_udp(self): self.assertEqual( connection_kwargs([RTCIceServer("turns:turn.example.com?transport=udp")]), {}, ) class ParseStunTurnUriTest(TestCase): def test_invalid_scheme(self): with self.assertRaises(ValueError) as cm: parse_stun_turn_uri("foo") self.assertEqual(str(cm.exception), "malformed uri: invalid scheme") def test_invalid_uri(self): with self.assertRaises(ValueError) as cm: parse_stun_turn_uri("stun") self.assertEqual(str(cm.exception), "malformed uri") def test_stun(self): uri = parse_stun_turn_uri("stun:stun.services.mozilla.com") self.assertEqual( uri, {"host": "stun.services.mozilla.com", "port": 3478, "scheme": "stun"} ) def test_stuns(self): uri = parse_stun_turn_uri("stuns:stun.services.mozilla.com") self.assertEqual( uri, {"host": "stun.services.mozilla.com", "port": 5349, "scheme": "stuns"} ) def test_stun_with_port(self): uri = parse_stun_turn_uri("stun:stun.l.google.com:19302") self.assertEqual( uri, {"host": "stun.l.google.com", "port": 19302, "scheme": "stun"} ) def test_turn(self): uri = parse_stun_turn_uri("turn:1.2.3.4") self.assertEqual( uri, {"host": "1.2.3.4", "port": 3478, "scheme": "turn", "transport": "udp"} ) def test_turn_with_port_and_transport(self): uri = parse_stun_turn_uri("turn:1.2.3.4:3478?transport=tcp") self.assertEqual( uri, {"host": "1.2.3.4", "port": 3478, "scheme": "turn", "transport": "tcp"} ) def test_turns(self): uri = parse_stun_turn_uri("turns:1.2.3.4") self.assertEqual( uri, {"host": "1.2.3.4", "port": 5349, "scheme": "turns", "transport": "tcp"}, ) def test_turns_with_port_and_transport(self): uri = parse_stun_turn_uri("turns:1.2.3.4:1234?transport=tcp") self.assertEqual( uri, {"host": "1.2.3.4", "port": 1234, "scheme": "turns", "transport": "tcp"}, ) class RTCIceGathererTest(TestCase): @asynctest async def test_gather(self): gatherer = RTCIceGatherer() self.assertEqual(gatherer.state, "new") self.assertEqual(gatherer.getLocalCandidates(), []) await gatherer.gather() self.assertEqual(gatherer.state, "completed") self.assertTrue(len(gatherer.getLocalCandidates()) > 0) # close await gatherer._connection.close() def test_default_ice_servers(self): self.assertEqual( RTCIceGatherer.getDefaultIceServers(), [RTCIceServer(urls="stun:stun.l.google.com:19302")], ) class RTCIceTransportTest(TestCase): def setUp(self): # save timers self.retry_max = aioice.stun.RETRY_MAX self.retry_rto = aioice.stun.RETRY_RTO # shorten timers to run tests faster aioice.stun.RETRY_MAX = 1 aioice.stun.RETRY_RTO = 0.1 def tearDown(self): # restore timers aioice.stun.RETRY_MAX = self.retry_max aioice.stun.RETRY_RTO = self.retry_rto @asynctest async def test_construct(self): gatherer = RTCIceGatherer() connection = RTCIceTransport(gatherer) self.assertEqual(connection.state, "new") self.assertEqual(connection.getRemoteCandidates(), []) candidate = RTCIceCandidate( component=1, foundation="0", ip="192.168.99.7", port=33543, priority=2122252543, protocol="UDP", type="host", ) # add candidate await connection.addRemoteCandidate(candidate) self.assertEqual(connection.getRemoteCandidates(), [candidate]) # end-of-candidates await connection.addRemoteCandidate(None) self.assertEqual(connection.getRemoteCandidates(), [candidate]) @asynctest async def test_connect(self): gatherer_1 = RTCIceGatherer() transport_1 = RTCIceTransport(gatherer_1) gatherer_2 = RTCIceGatherer() transport_2 = RTCIceTransport(gatherer_2) # gather candidates await asyncio.gather(gatherer_1.gather(), gatherer_2.gather()) for candidate in gatherer_2.getLocalCandidates(): await transport_1.addRemoteCandidate(candidate) for candidate in gatherer_1.getLocalCandidates(): await transport_2.addRemoteCandidate(candidate) self.assertEqual(transport_1.state, "new") self.assertEqual(transport_2.state, "new") # connect await asyncio.gather( transport_1.start(gatherer_2.getLocalParameters()), transport_2.start(gatherer_1.getLocalParameters()), ) self.assertEqual(transport_1.state, "completed") self.assertEqual(transport_2.state, "completed") # cleanup await asyncio.gather(transport_1.stop(), transport_2.stop()) self.assertEqual(transport_1.state, "closed") self.assertEqual(transport_2.state, "closed") @asynctest async def test_connect_fail(self): gatherer_1 = RTCIceGatherer() transport_1 = RTCIceTransport(gatherer_1) gatherer_2 = RTCIceGatherer() transport_2 = RTCIceTransport(gatherer_2) # gather candidates await asyncio.gather(gatherer_1.gather(), gatherer_2.gather()) for candidate in gatherer_2.getLocalCandidates(): await transport_1.addRemoteCandidate(candidate) for candidate in gatherer_1.getLocalCandidates(): await transport_2.addRemoteCandidate(candidate) self.assertEqual(transport_1.state, "new") self.assertEqual(transport_2.state, "new") # connect await transport_2.stop() await transport_1.start(gatherer_2.getLocalParameters()) self.assertEqual(transport_1.state, "failed") self.assertEqual(transport_2.state, "closed") # cleanup await asyncio.gather(transport_1.stop(), transport_2.stop()) self.assertEqual(transport_1.state, "closed") self.assertEqual(transport_2.state, "closed") @asynctest async def test_connect_when_closed(self): gatherer = RTCIceGatherer() transport = RTCIceTransport(gatherer) # stop transport await transport.stop() self.assertEqual(transport.state, "closed") # try to start it with self.assertRaises(InvalidStateError) as cm: await transport.start( RTCIceParameters(usernameFragment="foo", password="bar") ) self.assertEqual(str(cm.exception), "RTCIceTransport is closed") @asynctest async def test_connection_closed(self): gatherer = RTCIceGatherer() # mock out methods gatherer._connection.connect = mock_connect gatherer._connection.get_event = mock_get_event transport = RTCIceTransport(gatherer) self.assertEqual(transport.state, "new") await transport.start(RTCIceParameters(usernameFragment="foo", password="bar")) self.assertEqual(transport.state, "completed") await asyncio.sleep(1) self.assertEqual(transport.state, "failed") await transport.stop() self.assertEqual(transport.state, "closed") aiortc-1.3.0/tests/test_rtcpeerconnection.py000066400000000000000000005413261417604566400213230ustar00rootroot00000000000000import asyncio import re from unittest import TestCase import aioice.ice import aioice.stun from aiortc import ( RTCConfiguration, RTCIceCandidate, RTCPeerConnection, RTCSessionDescription, ) from aiortc.contrib.media import MediaPlayer from aiortc.exceptions import ( InternalError, InvalidAccessError, InvalidStateError, OperationError, ) from aiortc.mediastreams import AudioStreamTrack, VideoStreamTrack from aiortc.rtcpeerconnection import filter_preferred_codecs, find_common_codecs from aiortc.rtcrtpparameters import ( RTCRtcpFeedback, RTCRtpCodecCapability, RTCRtpCodecParameters, ) from aiortc.rtcrtpsender import RTCRtpSender from aiortc.sdp import SessionDescription from aiortc.stats import RTCStatsReport from .test_contrib_media import MediaTestCase from .utils import asynctest, lf2crlf LONG_DATA = b"\xff" * 2000 STRIP_CANDIDATES_RE = re.compile("^a=(candidate:.*|end-of-candidates)\r\n", re.M) class BogusStreamTrack(AudioStreamTrack): kind = "bogus" def mids(pc): mids = [x.mid for x in pc.getTransceivers()] if pc.sctp: mids.append(pc.sctp.mid) return sorted(mids) def strip_ice_candidates(description): return RTCSessionDescription( sdp=STRIP_CANDIDATES_RE.sub("", description.sdp), type=description.type ) def track_states(pc): states = { "connectionState": [pc.connectionState], "iceConnectionState": [pc.iceConnectionState], "iceGatheringState": [pc.iceGatheringState], "signalingState": [pc.signalingState], } @pc.on("connectionstatechange") def connectionstatechange(): states["connectionState"].append(pc.connectionState) @pc.on("iceconnectionstatechange") def iceconnectionstatechange(): states["iceConnectionState"].append(pc.iceConnectionState) @pc.on("icegatheringstatechange") def icegatheringstatechange(): states["iceGatheringState"].append(pc.iceGatheringState) @pc.on("signalingstatechange") def signalingstatechange(): states["signalingState"].append(pc.signalingState) return states def track_remote_tracks(pc): tracks = [] @pc.on("track") def track(track): tracks.append(track) return tracks class RTCRtpCodecParametersTest(TestCase): def test_common_static(self): local_codecs = [ RTCRtpCodecParameters( mimeType="audio/opus", clockRate=48000, channels=2, payloadType=96 ), RTCRtpCodecParameters( mimeType="audio/PCMU", clockRate=8000, channels=1, payloadType=0 ), RTCRtpCodecParameters( mimeType="audio/PCMA", clockRate=8000, channels=1, payloadType=8 ), ] remote_codecs = [ RTCRtpCodecParameters( mimeType="audio/PCMA", clockRate=8000, channels=1, payloadType=8 ), RTCRtpCodecParameters( mimeType="audio/PCMU", clockRate=8000, channels=1, payloadType=0 ), ] common = find_common_codecs(local_codecs, remote_codecs) self.assertEqual( common, [ RTCRtpCodecParameters( mimeType="audio/PCMA", clockRate=8000, channels=1, payloadType=8 ), RTCRtpCodecParameters( mimeType="audio/PCMU", clockRate=8000, channels=1, payloadType=0 ), ], ) def test_common_dynamic(self): local_codecs = [ RTCRtpCodecParameters( mimeType="audio/opus", clockRate=48000, channels=2, payloadType=96 ), RTCRtpCodecParameters( mimeType="audio/PCMU", clockRate=8000, channels=1, payloadType=0 ), RTCRtpCodecParameters( mimeType="audio/PCMA", clockRate=8000, channels=1, payloadType=8 ), ] remote_codecs = [ RTCRtpCodecParameters( mimeType="audio/opus", clockRate=48000, channels=2, payloadType=100 ), RTCRtpCodecParameters( mimeType="audio/PCMA", clockRate=8000, channels=1, payloadType=8 ), ] common = find_common_codecs(local_codecs, remote_codecs) self.assertEqual( common, [ RTCRtpCodecParameters( mimeType="audio/opus", clockRate=48000, channels=2, payloadType=100 ), RTCRtpCodecParameters( mimeType="audio/PCMA", clockRate=8000, channels=1, payloadType=8 ), ], ) def test_common_feedback(self): local_codecs = [ RTCRtpCodecParameters( mimeType="video/VP8", clockRate=90000, payloadType=100, rtcpFeedback=[ RTCRtcpFeedback(type="nack"), RTCRtcpFeedback(type="nack", parameter="pli"), ], ) ] remote_codecs = [ RTCRtpCodecParameters( mimeType="video/VP8", clockRate=90000, payloadType=120, rtcpFeedback=[ RTCRtcpFeedback(type="nack"), RTCRtcpFeedback(type="nack", parameter="sli"), ], ) ] common = find_common_codecs(local_codecs, remote_codecs) self.assertEqual(len(common), 1) self.assertEqual(common[0].clockRate, 90000) self.assertEqual(common[0].name, "VP8") self.assertEqual(common[0].payloadType, 120) self.assertEqual(common[0].rtcpFeedback, [RTCRtcpFeedback(type="nack")]) def test_common_rtx(self): local_codecs = [ RTCRtpCodecParameters( mimeType="video/VP8", clockRate=90000, payloadType=100 ), RTCRtpCodecParameters( mimeType="video/rtx", clockRate=90000, payloadType=101, parameters={"apt": 100}, ), ] remote_codecs = [ RTCRtpCodecParameters( mimeType="video/VP8", clockRate=90000, payloadType=96 ), RTCRtpCodecParameters( mimeType="video/rtx", clockRate=90000, payloadType=97, parameters={"apt": 96}, ), RTCRtpCodecParameters( mimeType="video/VP9", clockRate=90000, payloadType=98 ), RTCRtpCodecParameters( mimeType="video/rtx", clockRate=90000, payloadType=99, parameters={"apt": 98}, ), ] common = find_common_codecs(local_codecs, remote_codecs) self.assertEqual( common, [ RTCRtpCodecParameters( mimeType="video/VP8", clockRate=90000, payloadType=96 ), RTCRtpCodecParameters( mimeType="video/rtx", clockRate=90000, payloadType=97, parameters={"apt": 96}, ), ], ) def test_filter_preferred(self): codecs = [ RTCRtpCodecParameters( mimeType="video/VP8", clockRate=90000, payloadType=100 ), RTCRtpCodecParameters( mimeType="video/rtx", clockRate=90000, payloadType=101, parameters={"apt": 100}, ), RTCRtpCodecParameters( mimeType="video/H264", clockRate=90000, payloadType=102 ), RTCRtpCodecParameters( mimeType="video/rtx", clockRate=90000, payloadType=103, parameters={"apt": 102}, ), ] # no preferences self.assertEqual(filter_preferred_codecs(codecs, []), codecs) # with RTX, prefer VP8 self.assertEqual( filter_preferred_codecs( codecs, [ RTCRtpCodecCapability(mimeType="video/VP8", clockRate=90000), RTCRtpCodecCapability(mimeType="video/rtx", clockRate=90000), RTCRtpCodecCapability(mimeType="video/H264", clockRate=90000), ], ), [ RTCRtpCodecParameters( mimeType="video/VP8", clockRate=90000, payloadType=100 ), RTCRtpCodecParameters( mimeType="video/rtx", clockRate=90000, payloadType=101, parameters={"apt": 100}, ), RTCRtpCodecParameters( mimeType="video/H264", clockRate=90000, payloadType=102 ), RTCRtpCodecParameters( mimeType="video/rtx", clockRate=90000, payloadType=103, parameters={"apt": 102}, ), ], ) # with RTX, prefer H264 self.assertEqual( filter_preferred_codecs( codecs, [ RTCRtpCodecCapability(mimeType="video/H264", clockRate=90000), RTCRtpCodecCapability(mimeType="video/rtx", clockRate=90000), RTCRtpCodecCapability(mimeType="video/VP8", clockRate=90000), ], ), [ RTCRtpCodecParameters( mimeType="video/H264", clockRate=90000, payloadType=102 ), RTCRtpCodecParameters( mimeType="video/rtx", clockRate=90000, payloadType=103, parameters={"apt": 102}, ), RTCRtpCodecParameters( mimeType="video/VP8", clockRate=90000, payloadType=100 ), RTCRtpCodecParameters( mimeType="video/rtx", clockRate=90000, payloadType=101, parameters={"apt": 100}, ), ], ) # no RTX, same order self.assertEqual( filter_preferred_codecs( codecs, [ RTCRtpCodecCapability(mimeType="video/VP8", clockRate=90000), RTCRtpCodecCapability(mimeType="video/H264", clockRate=90000), ], ), [ RTCRtpCodecParameters( mimeType="video/VP8", clockRate=90000, payloadType=100 ), RTCRtpCodecParameters( mimeType="video/H264", clockRate=90000, payloadType=102 ), ], ) class RTCPeerConnectionTest(TestCase): def assertBundled(self, pc): transceivers = pc.getTransceivers() self.assertEqual( transceivers[0].receiver.transport, transceivers[0].sender.transport ) transport = transceivers[0].receiver.transport for i in range(1, len(transceivers)): self.assertEqual(transceivers[i].receiver.transport, transport) self.assertEqual(transceivers[i].sender.transport, transport) if pc.sctp: self.assertEqual(pc.sctp.transport, transport) async def assertDataChannelOpen(self, dc): await self.sleepWhile(lambda: dc.readyState == "connecting") self.assertEqual(dc.readyState, "open") async def assertIceChecking(self, pc): await self.sleepWhile(lambda: pc.iceConnectionState == "new") self.assertEqual(pc.iceConnectionState, "checking") self.assertEqual(pc.iceGatheringState, "complete") async def assertIceCompleted(self, pc1, pc2): await self.sleepWhile( lambda: pc1.iceConnectionState == "checking" or pc2.iceConnectionState == "checking" ) self.assertEqual(pc1.iceConnectionState, "completed") self.assertEqual(pc2.iceConnectionState, "completed") def assertHasIceCandidates(self, description): self.assertTrue("a=candidate:" in description.sdp) self.assertTrue("a=end-of-candidates" in description.sdp) def assertHasDtls(self, description, setup): self.assertTrue("a=fingerprint:sha-256" in description.sdp) self.assertEqual( set(re.findall("a=setup:(.*)\r$", description.sdp)), set([setup]) ) async def closeDataChannel(self, dc): dc.close() await self.sleepWhile(lambda: dc.readyState == "closing") self.assertEqual(dc.readyState, "closed") async def sleepWhile(self, f, max_sleep=1.0): sleep = 0.1 total = 0.0 while f() and total < max_sleep: await asyncio.sleep(sleep) total += sleep def setUp(self): # save timers self.consent_failures = aioice.ice.CONSENT_FAILURES self.consent_interval = aioice.ice.CONSENT_INTERVAL self.retry_max = aioice.stun.RETRY_MAX self.retry_rto = aioice.stun.RETRY_RTO # shorten timers to run tests faster aioice.ice.CONSENT_FAILURES = 1 aioice.ice.CONSENT_INTERVAL = 1 aioice.stun.RETRY_MAX = 1 aioice.stun.RETRY_RTO = 0.1 def tearDown(self): # restore timers aioice.ice.CONSENT_FAILURES = self.consent_failures aioice.ice.CONSENT_INTERVAL = self.consent_interval aioice.stun.RETRY_MAX = self.retry_max aioice.stun.RETRY_RTO = self.retry_rto @asynctest async def test_addIceCandidate_no_sdpMid_or_sdpMLineIndex(self): pc = RTCPeerConnection() with self.assertRaises(ValueError) as cm: await pc.addIceCandidate( RTCIceCandidate( component=1, foundation="0", ip="192.168.99.7", port=33543, priority=2122252543, protocol="UDP", type="host", ) ) self.assertEqual( str(cm.exception), "Candidate must have either sdpMid or sdpMLineIndex" ) @asynctest async def test_addTrack_audio(self): pc = RTCPeerConnection() # add audio track track1 = AudioStreamTrack() sender1 = pc.addTrack(track1) self.assertIsNotNone(sender1) self.assertEqual(sender1.track, track1) self.assertEqual(pc.getSenders(), [sender1]) self.assertEqual(len(pc.getTransceivers()), 1) # try to add same track again with self.assertRaises(InvalidAccessError) as cm: pc.addTrack(track1) self.assertEqual(str(cm.exception), "Track already has a sender") # add another audio track track2 = AudioStreamTrack() sender2 = pc.addTrack(track2) self.assertIsNotNone(sender2) self.assertEqual(sender2.track, track2) self.assertEqual(pc.getSenders(), [sender1, sender2]) self.assertEqual(len(pc.getTransceivers()), 2) @asynctest async def test_addTrack_bogus(self): pc = RTCPeerConnection() # try adding a bogus track with self.assertRaises(InternalError) as cm: pc.addTrack(BogusStreamTrack()) self.assertEqual(str(cm.exception), 'Invalid track kind "bogus"') @asynctest async def test_addTrack_video(self): pc = RTCPeerConnection() # add video track video_track1 = VideoStreamTrack() video_sender1 = pc.addTrack(video_track1) self.assertIsNotNone(video_sender1) self.assertEqual(video_sender1.track, video_track1) self.assertEqual(pc.getSenders(), [video_sender1]) self.assertEqual(len(pc.getTransceivers()), 1) # try to add same track again with self.assertRaises(InvalidAccessError) as cm: pc.addTrack(video_track1) self.assertEqual(str(cm.exception), "Track already has a sender") # add another video track video_track2 = VideoStreamTrack() video_sender2 = pc.addTrack(video_track2) self.assertIsNotNone(video_sender2) self.assertEqual(video_sender2.track, video_track2) self.assertEqual(pc.getSenders(), [video_sender1, video_sender2]) self.assertEqual(len(pc.getTransceivers()), 2) # add audio track audio_track = AudioStreamTrack() audio_sender = pc.addTrack(audio_track) self.assertIsNotNone(audio_sender) self.assertEqual(audio_sender.track, audio_track) self.assertEqual(pc.getSenders(), [video_sender1, video_sender2, audio_sender]) self.assertEqual(len(pc.getTransceivers()), 3) @asynctest async def test_addTrack_closed(self): pc = RTCPeerConnection() await pc.close() with self.assertRaises(InvalidStateError) as cm: pc.addTrack(AudioStreamTrack()) self.assertEqual(str(cm.exception), "RTCPeerConnection is closed") @asynctest async def test_addTransceiver_audio_inactive(self): pc = RTCPeerConnection() # add transceiver transceiver = pc.addTransceiver("audio", direction="inactive") self.assertIsNotNone(transceiver) self.assertEqual(transceiver.currentDirection, None) self.assertEqual(transceiver.direction, "inactive") self.assertEqual(transceiver.sender.track, None) self.assertEqual(transceiver.stopped, False) self.assertEqual(pc.getSenders(), [transceiver.sender]) self.assertEqual(len(pc.getTransceivers()), 1) # add track track = AudioStreamTrack() pc.addTrack(track) self.assertEqual(transceiver.currentDirection, None) self.assertEqual(transceiver.direction, "sendonly") self.assertEqual(transceiver.sender.track, track) self.assertEqual(transceiver.stopped, False) self.assertEqual(len(pc.getTransceivers()), 1) # stop transceiver await transceiver.stop() self.assertEqual(transceiver.currentDirection, None) self.assertEqual(transceiver.direction, "sendonly") self.assertEqual(transceiver.sender.track, track) self.assertEqual(transceiver.stopped, True) @asynctest async def test_addTransceiver_audio_sendrecv(self): pc = RTCPeerConnection() # add transceiver transceiver = pc.addTransceiver("audio") self.assertIsNotNone(transceiver) self.assertEqual(transceiver.currentDirection, None) self.assertEqual(transceiver.direction, "sendrecv") self.assertEqual(transceiver.sender.track, None) self.assertEqual(transceiver.stopped, False) self.assertEqual(pc.getSenders(), [transceiver.sender]) self.assertEqual(len(pc.getTransceivers()), 1) # add track track = AudioStreamTrack() pc.addTrack(track) self.assertEqual(transceiver.currentDirection, None) self.assertEqual(transceiver.direction, "sendrecv") self.assertEqual(transceiver.sender.track, track) self.assertEqual(transceiver.stopped, False) self.assertEqual(len(pc.getTransceivers()), 1) @asynctest async def test_addTransceiver_audio_track(self): pc = RTCPeerConnection() # add audio track track1 = AudioStreamTrack() transceiver1 = pc.addTransceiver(track1) self.assertIsNotNone(transceiver1) self.assertEqual(transceiver1.currentDirection, None) self.assertEqual(transceiver1.direction, "sendrecv") self.assertEqual(transceiver1.sender.track, track1) self.assertEqual(transceiver1.stopped, False) self.assertEqual(pc.getSenders(), [transceiver1.sender]) self.assertEqual(len(pc.getTransceivers()), 1) # try to add same track again with self.assertRaises(InvalidAccessError) as cm: pc.addTransceiver(track1) self.assertEqual(str(cm.exception), "Track already has a sender") # add another audio track track2 = AudioStreamTrack() transceiver2 = pc.addTransceiver(track2) self.assertIsNotNone(transceiver2) self.assertEqual(transceiver2.currentDirection, None) self.assertEqual(transceiver2.direction, "sendrecv") self.assertEqual(transceiver2.sender.track, track2) self.assertEqual(transceiver2.stopped, False) self.assertEqual(pc.getSenders(), [transceiver1.sender, transceiver2.sender]) self.assertEqual(len(pc.getTransceivers()), 2) def test_addTransceiver_bogus_direction(self): pc = RTCPeerConnection() # try adding a bogus kind with self.assertRaises(InternalError) as cm: pc.addTransceiver("audio", direction="bogus") self.assertEqual(str(cm.exception), 'Invalid direction "bogus"') def test_addTransceiver_bogus_kind(self): pc = RTCPeerConnection() # try adding a bogus kind with self.assertRaises(InternalError) as cm: pc.addTransceiver("bogus") self.assertEqual(str(cm.exception), 'Invalid track kind "bogus"') def test_addTransceiver_bogus_track(self): pc = RTCPeerConnection() # try adding a bogus track with self.assertRaises(InternalError) as cm: pc.addTransceiver(BogusStreamTrack()) self.assertEqual(str(cm.exception), 'Invalid track kind "bogus"') @asynctest async def test_close(self): pc = RTCPeerConnection() pc_states = track_states(pc) # close once await pc.close() # close twice await pc.close() self.assertEqual(pc_states["signalingState"], ["stable", "closed"]) async def _test_connect_audio_bidirectional(self, pc1, pc2): pc1_states = track_states(pc1) pc1_tracks = track_remote_tracks(pc1) pc2_states = track_states(pc2) pc2_tracks = track_remote_tracks(pc2) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "new") self.assertIsNone(pc1.localDescription) self.assertIsNone(pc1.remoteDescription) self.assertEqual(pc2.iceConnectionState, "new") self.assertEqual(pc2.iceGatheringState, "new") self.assertIsNone(pc2.localDescription) self.assertIsNone(pc2.remoteDescription) # create offer track1 = AudioStreamTrack() pc1.addTrack(track1) offer = await pc1.createOffer() self.assertEqual(offer.type, "offer") self.assertTrue("m=audio " in offer.sdp) self.assertFalse("a=candidate:" in offer.sdp) self.assertFalse("a=end-of-candidates" in offer.sdp) await pc1.setLocalDescription(offer) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "complete") self.assertEqual(mids(pc1), ["0"]) self.assertTrue("m=audio " in pc1.localDescription.sdp) self.assertTrue( lf2crlf( """a=rtpmap:96 opus/48000/2 a=rtpmap:0 PCMU/8000 a=rtpmap:8 PCMA/8000 """ ) in pc1.localDescription.sdp ) self.assertTrue("a=sendrecv" in pc1.localDescription.sdp) self.assertHasIceCandidates(pc1.localDescription) self.assertHasDtls(pc1.localDescription, "actpass") # handle offer await pc2.setRemoteDescription(pc1.localDescription) self.assertEqual(pc2.remoteDescription, pc1.localDescription) self.assertEqual(len(pc2.getReceivers()), 1) self.assertEqual(len(pc2.getSenders()), 1) self.assertEqual(len(pc2.getTransceivers()), 1) self.assertEqual(mids(pc2), ["0"]) # the RemoteStreamTrack should have the same ID as the source track self.assertEqual(len(pc2_tracks), 1) self.assertEqual(pc2_tracks[0].id, track1.id) # create answer track2 = AudioStreamTrack() pc2.addTrack(track2) answer = await pc2.createAnswer() self.assertEqual(answer.type, "answer") self.assertTrue("m=audio " in answer.sdp) self.assertFalse("a=candidate:" in answer.sdp) self.assertFalse("a=end-of-candidates" in answer.sdp) await pc2.setLocalDescription(answer) await self.assertIceChecking(pc2) self.assertEqual(mids(pc2), ["0"]) self.assertTrue("m=audio " in pc2.localDescription.sdp) self.assertTrue( lf2crlf( """a=rtpmap:96 opus/48000/2 a=rtpmap:0 PCMU/8000 a=rtpmap:8 PCMA/8000 """ ) in pc2.localDescription.sdp ) self.assertTrue("a=sendrecv" in pc2.localDescription.sdp) self.assertHasIceCandidates(pc2.localDescription) self.assertHasDtls(pc2.localDescription, "active") self.assertEqual(pc2.getTransceivers()[0].currentDirection, "sendrecv") self.assertEqual(pc2.getTransceivers()[0].direction, "sendrecv") # handle answer await pc1.setRemoteDescription(pc2.localDescription) self.assertEqual(pc1.remoteDescription, pc2.localDescription) self.assertEqual(pc1.getTransceivers()[0].currentDirection, "sendrecv") self.assertEqual(pc1.getTransceivers()[0].direction, "sendrecv") # the RemoteStreamTrack should have the same ID as the source track self.assertEqual(len(pc1_tracks), 1) self.assertEqual(pc1_tracks[0].id, track2.id) # check outcome await self.assertIceCompleted(pc1, pc2) # allow media to flow long enough to collect stats await asyncio.sleep(2) # check stats report = await pc1.getStats() self.assertTrue(isinstance(report, RTCStatsReport)) self.assertEqual( sorted([s.type for s in report.values()]), [ "inbound-rtp", "outbound-rtp", "remote-inbound-rtp", "remote-outbound-rtp", "transport", ], ) # close await pc1.close() await pc2.close() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "closed") # check state changes self.assertEqual( pc1_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc1_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc1_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc1_states["signalingState"], ["stable", "have-local-offer", "stable", "closed"], ) self.assertEqual( pc2_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc2_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc2_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc2_states["signalingState"], ["stable", "have-remote-offer", "stable", "closed"], ) @asynctest async def test_connect_audio_bidirectional(self): pc1 = RTCPeerConnection() pc2 = RTCPeerConnection() await self._test_connect_audio_bidirectional(pc1, pc2) @asynctest async def test_connect_audio_bidirectional_with_empty_iceservers(self): pc1 = RTCPeerConnection(RTCConfiguration(iceServers=[])) pc2 = RTCPeerConnection() await self._test_connect_audio_bidirectional(pc1, pc2) @asynctest async def test_connect_audio_bidirectional_with_trickle(self): pc1 = RTCPeerConnection() pc1_states = track_states(pc1) pc2 = RTCPeerConnection() pc2_states = track_states(pc2) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "new") self.assertIsNone(pc1.localDescription) self.assertIsNone(pc1.remoteDescription) self.assertEqual(pc2.iceConnectionState, "new") self.assertEqual(pc2.iceGatheringState, "new") self.assertIsNone(pc2.localDescription) self.assertIsNone(pc2.remoteDescription) # create offer pc1.addTrack(AudioStreamTrack()) offer = await pc1.createOffer() self.assertEqual(offer.type, "offer") self.assertTrue("m=audio " in offer.sdp) self.assertFalse("a=candidate:" in offer.sdp) self.assertFalse("a=end-of-candidates" in offer.sdp) await pc1.setLocalDescription(offer) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "complete") self.assertEqual(mids(pc1), ["0"]) self.assertTrue("m=audio " in pc1.localDescription.sdp) self.assertTrue("a=sendrecv" in pc1.localDescription.sdp) self.assertHasIceCandidates(pc1.localDescription) self.assertHasDtls(pc1.localDescription, "actpass") # strip out candidates desc1 = strip_ice_candidates(pc1.localDescription) # handle offer await pc2.setRemoteDescription(desc1) self.assertEqual(pc2.remoteDescription, desc1) self.assertEqual(len(pc2.getReceivers()), 1) self.assertEqual(len(pc2.getSenders()), 1) self.assertEqual(len(pc2.getTransceivers()), 1) self.assertEqual(mids(pc2), ["0"]) # create answer pc2.addTrack(AudioStreamTrack()) answer = await pc2.createAnswer() self.assertEqual(answer.type, "answer") self.assertTrue("m=audio " in answer.sdp) self.assertFalse("a=candidate:" in answer.sdp) self.assertFalse("a=end-of-candidates" in answer.sdp) await pc2.setLocalDescription(answer) await self.assertIceChecking(pc2) self.assertEqual(mids(pc2), ["0"]) self.assertTrue("m=audio " in pc2.localDescription.sdp) self.assertTrue("a=sendrecv" in pc2.localDescription.sdp) self.assertHasIceCandidates(pc2.localDescription) self.assertHasDtls(pc2.localDescription, "active") # strip out candidates desc2 = strip_ice_candidates(pc2.localDescription) # handle answer await pc1.setRemoteDescription(desc2) self.assertEqual(pc1.remoteDescription, desc2) # trickle candidates for transceiver in pc2.getTransceivers(): iceGatherer = transceiver.sender.transport.transport.iceGatherer for candidate in iceGatherer.getLocalCandidates(): candidate.sdpMid = transceiver.mid await pc1.addIceCandidate(candidate) for transceiver in pc1.getTransceivers(): iceGatherer = transceiver.sender.transport.transport.iceGatherer for candidate in iceGatherer.getLocalCandidates(): candidate.sdpMid = transceiver.mid await pc2.addIceCandidate(candidate) # check outcome await self.assertIceCompleted(pc1, pc2) # close await pc1.close() await pc2.close() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "closed") # check state changes self.assertEqual( pc1_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc1_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc1_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc1_states["signalingState"], ["stable", "have-local-offer", "stable", "closed"], ) self.assertEqual( pc2_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc2_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc2_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc2_states["signalingState"], ["stable", "have-remote-offer", "stable", "closed"], ) @asynctest async def test_connect_audio_bidirectional_and_close(self): pc1 = RTCPeerConnection() pc1_states = track_states(pc1) pc2 = RTCPeerConnection() pc2_states = track_states(pc2) # create offer track1 = AudioStreamTrack() pc1.addTrack(track1) offer = await pc1.createOffer() await pc1.setLocalDescription(offer) # handle offer await pc2.setRemoteDescription(pc1.localDescription) # create answer track2 = AudioStreamTrack() pc2.addTrack(track2) answer = await pc2.createAnswer() await pc2.setLocalDescription(answer) # handle answer await pc1.setRemoteDescription(pc2.localDescription) # check outcome await self.assertIceCompleted(pc1, pc2) # close one side await pc1.close() self.assertEqual(pc1.iceConnectionState, "closed") # wait for consent to expire await asyncio.sleep(2) # close other side await pc2.close() self.assertEqual(pc2.iceConnectionState, "closed") # check state changes self.assertEqual( pc1_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc1_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc1_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc1_states["signalingState"], ["stable", "have-local-offer", "stable", "closed"], ) self.assertEqual( pc2_states["connectionState"], ["new", "connecting", "connected", "failed", "closed"], ) self.assertEqual( pc2_states["iceConnectionState"], ["new", "checking", "completed", "failed", "closed"], ) self.assertEqual( pc2_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc2_states["signalingState"], ["stable", "have-remote-offer", "stable", "closed"], ) @asynctest async def test_connect_audio_codec_preferences_offerer(self): pc1 = RTCPeerConnection() pc1_states = track_states(pc1) pc2 = RTCPeerConnection() pc2_states = track_states(pc2) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "new") self.assertIsNone(pc1.localDescription) self.assertIsNone(pc1.remoteDescription) self.assertEqual(pc2.iceConnectionState, "new") self.assertEqual(pc2.iceGatheringState, "new") self.assertIsNone(pc2.localDescription) self.assertIsNone(pc2.remoteDescription) # add track and set codec preferences to prefer PCMA / PCMU pc1.addTrack(AudioStreamTrack()) capabilities = RTCRtpSender.getCapabilities("audio") preferences = list(filter(lambda x: x.name == "PCMA", capabilities.codecs)) preferences += list(filter(lambda x: x.name == "PCMU", capabilities.codecs)) transceiver = pc1.getTransceivers()[0] transceiver.setCodecPreferences(preferences) # create offer offer = await pc1.createOffer() self.assertEqual(offer.type, "offer") self.assertTrue("m=audio " in offer.sdp) self.assertFalse("a=candidate:" in offer.sdp) self.assertFalse("a=end-of-candidates" in offer.sdp) await pc1.setLocalDescription(offer) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "complete") self.assertEqual(mids(pc1), ["0"]) self.assertTrue("m=audio " in pc1.localDescription.sdp) self.assertTrue( lf2crlf( """a=rtpmap:8 PCMA/8000 a=rtpmap:0 PCMU/8000 """ ) in pc1.localDescription.sdp ) self.assertTrue("a=sendrecv" in pc1.localDescription.sdp) self.assertHasIceCandidates(pc1.localDescription) self.assertHasDtls(pc1.localDescription, "actpass") # handle offer await pc2.setRemoteDescription(pc1.localDescription) self.assertEqual(pc2.remoteDescription, pc1.localDescription) self.assertEqual(len(pc2.getReceivers()), 1) self.assertEqual(len(pc2.getSenders()), 1) self.assertEqual(len(pc2.getTransceivers()), 1) self.assertEqual(mids(pc2), ["0"]) # create answer pc2.addTrack(AudioStreamTrack()) answer = await pc2.createAnswer() self.assertEqual(answer.type, "answer") self.assertTrue("m=audio " in answer.sdp) self.assertFalse("a=candidate:" in answer.sdp) self.assertFalse("a=end-of-candidates" in answer.sdp) await pc2.setLocalDescription(answer) await self.assertIceChecking(pc2) self.assertEqual(mids(pc2), ["0"]) self.assertTrue("m=audio " in pc2.localDescription.sdp) self.assertTrue( lf2crlf( """a=rtpmap:8 PCMA/8000 a=rtpmap:0 PCMU/8000 """ ) in pc2.localDescription.sdp ) self.assertTrue("a=sendrecv" in pc2.localDescription.sdp) self.assertHasIceCandidates(pc2.localDescription) self.assertHasDtls(pc2.localDescription, "active") self.assertEqual(pc2.getTransceivers()[0].currentDirection, "sendrecv") self.assertEqual(pc2.getTransceivers()[0].direction, "sendrecv") # handle answer await pc1.setRemoteDescription(pc2.localDescription) self.assertEqual(pc1.remoteDescription, pc2.localDescription) self.assertEqual(pc1.getTransceivers()[0].currentDirection, "sendrecv") self.assertEqual(pc1.getTransceivers()[0].direction, "sendrecv") # check outcome await self.assertIceCompleted(pc1, pc2) # allow media to flow long enough to collect stats await asyncio.sleep(2) # check stats report = await pc1.getStats() self.assertTrue(isinstance(report, RTCStatsReport)) self.assertEqual( sorted([s.type for s in report.values()]), [ "inbound-rtp", "outbound-rtp", "remote-inbound-rtp", "remote-outbound-rtp", "transport", ], ) # close await pc1.close() await pc2.close() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "closed") # check state changes self.assertEqual( pc1_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc1_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc1_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc1_states["signalingState"], ["stable", "have-local-offer", "stable", "closed"], ) self.assertEqual( pc2_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc2_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc2_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc2_states["signalingState"], ["stable", "have-remote-offer", "stable", "closed"], ) @asynctest async def test_connect_audio_mid_changes(self): pc1 = RTCPeerConnection() pc1_states = track_states(pc1) pc2 = RTCPeerConnection() pc2_states = track_states(pc2) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "new") self.assertIsNone(pc1.localDescription) self.assertIsNone(pc1.remoteDescription) self.assertEqual(pc2.iceConnectionState, "new") self.assertEqual(pc2.iceGatheringState, "new") self.assertIsNone(pc2.localDescription) self.assertIsNone(pc2.remoteDescription) # add audio tracks immediately pc1.addTrack(AudioStreamTrack()) pc2.addTrack(AudioStreamTrack()) # create offer offer = await pc1.createOffer() self.assertEqual(offer.type, "offer") self.assertTrue("m=audio " in offer.sdp) self.assertFalse("a=candidate:" in offer.sdp) self.assertFalse("a=end-of-candidates" in offer.sdp) # pretend we're Firefox! offer.sdp = offer.sdp.replace("a=mid:0", "a=mid:sdparta_0") await pc1.setLocalDescription(offer) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "complete") self.assertEqual(mids(pc1), ["sdparta_0"]) self.assertTrue("m=audio " in pc1.localDescription.sdp) self.assertTrue("a=sendrecv" in pc1.localDescription.sdp) self.assertHasIceCandidates(pc1.localDescription) self.assertHasDtls(pc1.localDescription, "actpass") self.assertTrue("a=mid:sdparta_0" in pc1.localDescription.sdp) # handle offer await pc2.setRemoteDescription(pc1.localDescription) self.assertEqual(pc2.remoteDescription, pc1.localDescription) self.assertEqual(len(pc2.getReceivers()), 1) self.assertEqual(len(pc2.getSenders()), 1) self.assertEqual(len(pc2.getTransceivers()), 1) self.assertEqual(mids(pc2), ["sdparta_0"]) # create answer answer = await pc2.createAnswer() self.assertEqual(answer.type, "answer") self.assertTrue("m=audio " in answer.sdp) self.assertFalse("a=candidate:" in answer.sdp) self.assertFalse("a=end-of-candidates" in answer.sdp) await pc2.setLocalDescription(answer) await self.assertIceChecking(pc2) self.assertTrue("m=audio " in pc2.localDescription.sdp) self.assertTrue("a=sendrecv" in pc2.localDescription.sdp) self.assertHasIceCandidates(pc2.localDescription) self.assertHasDtls(pc2.localDescription, "active") self.assertTrue("a=mid:sdparta_0" in pc2.localDescription.sdp) # handle answer await pc1.setRemoteDescription(pc2.localDescription) self.assertEqual(pc1.remoteDescription, pc2.localDescription) # check outcome await self.assertIceCompleted(pc1, pc2) # close await pc1.close() await pc2.close() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "closed") # check state changes self.assertEqual( pc1_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc1_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc1_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc1_states["signalingState"], ["stable", "have-local-offer", "stable", "closed"], ) self.assertEqual( pc2_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc2_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc2_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc2_states["signalingState"], ["stable", "have-remote-offer", "stable", "closed"], ) @asynctest async def test_connect_audio_offer_recvonly_answer_recvonly(self): pc1 = RTCPeerConnection() pc1_states = track_states(pc1) pc2 = RTCPeerConnection() pc2_states = track_states(pc2) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "new") self.assertIsNone(pc1.localDescription) self.assertIsNone(pc1.remoteDescription) self.assertEqual(pc2.iceConnectionState, "new") self.assertEqual(pc2.iceGatheringState, "new") self.assertIsNone(pc2.localDescription) self.assertIsNone(pc2.remoteDescription) # create offer pc1.addTransceiver("audio", direction="recvonly") offer = await pc1.createOffer() self.assertEqual(offer.type, "offer") self.assertTrue("m=audio " in offer.sdp) self.assertFalse("a=candidate:" in offer.sdp) self.assertFalse("a=end-of-candidates" in offer.sdp) await pc1.setLocalDescription(offer) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "complete") self.assertEqual(mids(pc1), ["0"]) self.assertTrue("m=audio " in pc1.localDescription.sdp) self.assertTrue("a=recvonly" in pc1.localDescription.sdp) self.assertHasIceCandidates(pc1.localDescription) self.assertHasDtls(pc1.localDescription, "actpass") # handle offer await pc2.setRemoteDescription(pc1.localDescription) self.assertEqual(pc2.remoteDescription, pc1.localDescription) self.assertEqual(len(pc2.getReceivers()), 1) self.assertEqual(len(pc2.getSenders()), 1) self.assertEqual(len(pc2.getTransceivers()), 1) self.assertEqual(mids(pc2), ["0"]) # create answer answer = await pc2.createAnswer() self.assertEqual(answer.type, "answer") self.assertTrue("m=audio " in answer.sdp) self.assertFalse("a=candidate:" in answer.sdp) self.assertFalse("a=end-of-candidates" in answer.sdp) await pc2.setLocalDescription(answer) await self.assertIceChecking(pc2) self.assertEqual(mids(pc2), ["0"]) self.assertTrue("m=audio " in pc2.localDescription.sdp) self.assertTrue("a=inactive" in pc2.localDescription.sdp) self.assertHasIceCandidates(pc2.localDescription) self.assertHasDtls(pc2.localDescription, "active") self.assertEqual(pc2.getTransceivers()[0].currentDirection, "inactive") self.assertEqual(pc2.getTransceivers()[0].direction, "recvonly") # handle answer await pc1.setRemoteDescription(pc2.localDescription) self.assertEqual(pc1.remoteDescription, pc2.localDescription) self.assertEqual(pc1.getTransceivers()[0].currentDirection, "inactive") self.assertEqual(pc1.getTransceivers()[0].direction, "recvonly") # check outcome await self.assertIceCompleted(pc1, pc2) # close await pc1.close() await pc2.close() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "closed") # check state changes self.assertEqual( pc1_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc1_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc1_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc1_states["signalingState"], ["stable", "have-local-offer", "stable", "closed"], ) self.assertEqual( pc2_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc2_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc2_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc2_states["signalingState"], ["stable", "have-remote-offer", "stable", "closed"], ) @asynctest async def test_connect_audio_offer_recvonly(self): pc1 = RTCPeerConnection() pc1_states = track_states(pc1) pc2 = RTCPeerConnection() pc2_states = track_states(pc2) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "new") self.assertIsNone(pc1.localDescription) self.assertIsNone(pc1.remoteDescription) self.assertEqual(pc2.iceConnectionState, "new") self.assertEqual(pc2.iceGatheringState, "new") self.assertIsNone(pc2.localDescription) self.assertIsNone(pc2.remoteDescription) # create offer pc1.addTransceiver("audio", direction="recvonly") offer = await pc1.createOffer() self.assertEqual(offer.type, "offer") self.assertTrue("m=audio " in offer.sdp) self.assertFalse("a=candidate:" in offer.sdp) self.assertFalse("a=end-of-candidates" in offer.sdp) await pc1.setLocalDescription(offer) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "complete") self.assertEqual(mids(pc1), ["0"]) self.assertTrue("m=audio " in pc1.localDescription.sdp) self.assertTrue("a=recvonly" in pc1.localDescription.sdp) self.assertHasIceCandidates(pc1.localDescription) self.assertHasDtls(pc1.localDescription, "actpass") # handle offer await pc2.setRemoteDescription(pc1.localDescription) self.assertEqual(pc2.remoteDescription, pc1.localDescription) self.assertEqual(len(pc2.getReceivers()), 1) self.assertEqual(len(pc2.getSenders()), 1) self.assertEqual(len(pc2.getTransceivers()), 1) self.assertEqual(mids(pc2), ["0"]) # create answer pc2.addTrack(AudioStreamTrack()) answer = await pc2.createAnswer() self.assertEqual(answer.type, "answer") self.assertTrue("m=audio " in answer.sdp) self.assertFalse("a=candidate:" in answer.sdp) self.assertFalse("a=end-of-candidates" in answer.sdp) await pc2.setLocalDescription(answer) await self.assertIceChecking(pc2) self.assertEqual(mids(pc2), ["0"]) self.assertTrue("m=audio " in pc2.localDescription.sdp) self.assertTrue("a=sendonly" in pc2.localDescription.sdp) self.assertHasIceCandidates(pc2.localDescription) self.assertHasDtls(pc2.localDescription, "active") self.assertEqual(pc2.getTransceivers()[0].currentDirection, "sendonly") self.assertEqual(pc2.getTransceivers()[0].direction, "sendrecv") # handle answer await pc1.setRemoteDescription(pc2.localDescription) self.assertEqual(pc1.remoteDescription, pc2.localDescription) self.assertEqual(pc1.getTransceivers()[0].currentDirection, "recvonly") self.assertEqual(pc1.getTransceivers()[0].direction, "recvonly") # check outcome await self.assertIceCompleted(pc1, pc2) # close await pc1.close() await pc2.close() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "closed") # check state changes self.assertEqual( pc1_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc1_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc1_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc1_states["signalingState"], ["stable", "have-local-offer", "stable", "closed"], ) self.assertEqual( pc2_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc2_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc2_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc2_states["signalingState"], ["stable", "have-remote-offer", "stable", "closed"], ) @asynctest async def test_connect_audio_offer_sendonly(self): pc1 = RTCPeerConnection() pc1_states = track_states(pc1) pc2 = RTCPeerConnection() pc2_states = track_states(pc2) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "new") self.assertIsNone(pc1.localDescription) self.assertIsNone(pc1.remoteDescription) self.assertEqual(pc2.iceConnectionState, "new") self.assertEqual(pc2.iceGatheringState, "new") self.assertIsNone(pc2.localDescription) self.assertIsNone(pc2.remoteDescription) # create offer pc1.addTransceiver(AudioStreamTrack(), direction="sendonly") offer = await pc1.createOffer() self.assertEqual(offer.type, "offer") self.assertTrue("m=audio " in offer.sdp) self.assertFalse("a=candidate:" in offer.sdp) self.assertFalse("a=end-of-candidates" in offer.sdp) await pc1.setLocalDescription(offer) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "complete") self.assertEqual(mids(pc1), ["0"]) self.assertTrue("m=audio " in pc1.localDescription.sdp) self.assertTrue("a=sendonly" in pc1.localDescription.sdp) self.assertHasIceCandidates(pc1.localDescription) self.assertHasDtls(pc1.localDescription, "actpass") # handle offer await pc2.setRemoteDescription(pc1.localDescription) self.assertEqual(pc2.remoteDescription, pc1.localDescription) self.assertEqual(len(pc2.getReceivers()), 1) self.assertEqual(len(pc2.getSenders()), 1) self.assertEqual(len(pc2.getTransceivers()), 1) self.assertEqual(mids(pc2), ["0"]) # create answer answer = await pc2.createAnswer() self.assertEqual(answer.type, "answer") self.assertTrue("m=audio " in answer.sdp) self.assertFalse("a=candidate:" in answer.sdp) self.assertFalse("a=end-of-candidates" in answer.sdp) await pc2.setLocalDescription(answer) await self.assertIceChecking(pc2) self.assertEqual(mids(pc2), ["0"]) self.assertTrue("m=audio " in pc2.localDescription.sdp) self.assertTrue("a=recvonly" in pc2.localDescription.sdp) self.assertHasIceCandidates(pc2.localDescription) self.assertHasDtls(pc2.localDescription, "active") self.assertEqual(pc2.getTransceivers()[0].currentDirection, "recvonly") self.assertEqual(pc2.getTransceivers()[0].direction, "recvonly") # handle answer await pc1.setRemoteDescription(pc2.localDescription) self.assertEqual(pc1.remoteDescription, pc2.localDescription) self.assertEqual(pc1.getTransceivers()[0].currentDirection, "sendonly") self.assertEqual(pc1.getTransceivers()[0].direction, "sendonly") # check outcome await self.assertIceCompleted(pc1, pc2) # close await pc1.close() await pc2.close() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "closed") # check state changes self.assertEqual( pc1_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc1_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc1_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc1_states["signalingState"], ["stable", "have-local-offer", "stable", "closed"], ) self.assertEqual( pc1_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc2_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc2_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc2_states["signalingState"], ["stable", "have-remote-offer", "stable", "closed"], ) @asynctest async def test_connect_audio_offer_sendrecv_answer_recvonly(self): pc1 = RTCPeerConnection() pc1_states = track_states(pc1) pc2 = RTCPeerConnection() pc2_states = track_states(pc2) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "new") self.assertIsNone(pc1.localDescription) self.assertIsNone(pc1.remoteDescription) self.assertEqual(pc2.iceConnectionState, "new") self.assertEqual(pc2.iceGatheringState, "new") self.assertIsNone(pc2.localDescription) self.assertIsNone(pc2.remoteDescription) # create offer pc1.addTrack(AudioStreamTrack()) offer = await pc1.createOffer() self.assertEqual(offer.type, "offer") self.assertTrue("m=audio " in offer.sdp) self.assertFalse("a=candidate:" in offer.sdp) self.assertFalse("a=end-of-candidates" in offer.sdp) await pc1.setLocalDescription(offer) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "complete") self.assertTrue("m=audio " in pc1.localDescription.sdp) self.assertTrue("a=sendrecv" in pc1.localDescription.sdp) self.assertHasIceCandidates(pc1.localDescription) self.assertHasDtls(pc1.localDescription, "actpass") # handle offer await pc2.setRemoteDescription(pc1.localDescription) self.assertEqual(pc2.remoteDescription, pc1.localDescription) self.assertEqual(len(pc2.getReceivers()), 1) self.assertEqual(len(pc2.getSenders()), 1) self.assertEqual(len(pc2.getTransceivers()), 1) self.assertEqual(mids(pc2), ["0"]) # create answer answer = await pc2.createAnswer() self.assertEqual(answer.type, "answer") self.assertTrue("m=audio " in answer.sdp) self.assertFalse("a=candidate:" in answer.sdp) self.assertFalse("a=end-of-candidates" in answer.sdp) await pc2.setLocalDescription(answer) await self.assertIceChecking(pc2) self.assertTrue("m=audio " in pc2.localDescription.sdp) self.assertTrue("a=recvonly" in pc2.localDescription.sdp) self.assertHasIceCandidates(pc2.localDescription) self.assertHasDtls(pc2.localDescription, "active") self.assertEqual(pc2.getTransceivers()[0].currentDirection, "recvonly") self.assertEqual(pc2.getTransceivers()[0].direction, "recvonly") # handle answer await pc1.setRemoteDescription(pc2.localDescription) self.assertEqual(pc1.remoteDescription, pc2.localDescription) self.assertEqual(pc1.getTransceivers()[0].currentDirection, "sendonly") self.assertEqual(pc1.getTransceivers()[0].direction, "sendrecv") # check outcome await self.assertIceCompleted(pc1, pc2) # close await pc1.close() await pc2.close() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "closed") # check state changes self.assertEqual( pc1_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc1_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc1_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc1_states["signalingState"], ["stable", "have-local-offer", "stable", "closed"], ) self.assertEqual( pc2_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc2_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc2_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc2_states["signalingState"], ["stable", "have-remote-offer", "stable", "closed"], ) @asynctest async def test_connect_audio_offer_sendrecv_answer_sendonly(self): pc1 = RTCPeerConnection() pc1_states = track_states(pc1) pc2 = RTCPeerConnection() pc2_states = track_states(pc2) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "new") self.assertIsNone(pc1.localDescription) self.assertIsNone(pc1.remoteDescription) self.assertEqual(pc2.iceConnectionState, "new") self.assertEqual(pc2.iceGatheringState, "new") self.assertIsNone(pc2.localDescription) self.assertIsNone(pc2.remoteDescription) # create offer pc1.addTrack(AudioStreamTrack()) offer = await pc1.createOffer() self.assertEqual(offer.type, "offer") self.assertTrue("m=audio " in offer.sdp) self.assertFalse("a=candidate:" in offer.sdp) self.assertFalse("a=end-of-candidates" in offer.sdp) await pc1.setLocalDescription(offer) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "complete") self.assertTrue("m=audio " in pc1.localDescription.sdp) self.assertTrue("a=sendrecv" in pc1.localDescription.sdp) self.assertHasIceCandidates(pc1.localDescription) self.assertHasDtls(pc1.localDescription, "actpass") # handle offer await pc2.setRemoteDescription(pc1.localDescription) pc2.getTransceivers()[0].direction = "sendonly" self.assertEqual(pc2.remoteDescription, pc1.localDescription) self.assertEqual(len(pc2.getReceivers()), 1) self.assertEqual(len(pc2.getSenders()), 1) self.assertEqual(len(pc2.getTransceivers()), 1) self.assertEqual(mids(pc2), ["0"]) # create answer answer = await pc2.createAnswer() self.assertEqual(answer.type, "answer") self.assertTrue("m=audio " in answer.sdp) self.assertFalse("a=candidate:" in answer.sdp) self.assertFalse("a=end-of-candidates" in answer.sdp) await pc2.setLocalDescription(answer) await self.assertIceChecking(pc2) self.assertTrue("m=audio " in pc2.localDescription.sdp) self.assertTrue("a=sendonly" in pc2.localDescription.sdp) self.assertHasIceCandidates(pc2.localDescription) self.assertHasDtls(pc2.localDescription, "active") self.assertEqual(pc2.getTransceivers()[0].currentDirection, "sendonly") self.assertEqual(pc2.getTransceivers()[0].direction, "sendonly") # handle answer await pc1.setRemoteDescription(pc2.localDescription) self.assertEqual(pc1.remoteDescription, pc2.localDescription) self.assertEqual(pc1.getTransceivers()[0].currentDirection, "recvonly") self.assertEqual(pc1.getTransceivers()[0].direction, "sendrecv") # check outcome await self.assertIceCompleted(pc1, pc2) # close await pc1.close() await pc2.close() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "closed") # check state changes self.assertEqual( pc1_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc1_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc1_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc1_states["signalingState"], ["stable", "have-local-offer", "stable", "closed"], ) self.assertEqual( pc2_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc2_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc2_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc2_states["signalingState"], ["stable", "have-remote-offer", "stable", "closed"], ) @asynctest async def test_connect_audio_two_tracks(self): pc1 = RTCPeerConnection() pc1_states = track_states(pc1) pc2 = RTCPeerConnection() pc2_states = track_states(pc2) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "new") self.assertIsNone(pc1.localDescription) self.assertIsNone(pc1.remoteDescription) self.assertEqual(pc2.iceConnectionState, "new") self.assertEqual(pc2.iceGatheringState, "new") self.assertIsNone(pc2.localDescription) self.assertIsNone(pc2.remoteDescription) # create offer pc1.addTrack(AudioStreamTrack()) pc1.addTrack(AudioStreamTrack()) offer = await pc1.createOffer() self.assertEqual(offer.type, "offer") self.assertTrue("m=audio " in offer.sdp) self.assertFalse("a=candidate:" in offer.sdp) self.assertFalse("a=end-of-candidates" in offer.sdp) await pc1.setLocalDescription(offer) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "complete") self.assertEqual(mids(pc1), ["0", "1"]) self.assertTrue("m=audio " in pc1.localDescription.sdp) self.assertTrue("a=sendrecv" in pc1.localDescription.sdp) self.assertHasIceCandidates(pc1.localDescription) self.assertHasDtls(pc1.localDescription, "actpass") # handle offer await pc2.setRemoteDescription(pc1.localDescription) self.assertEqual(pc2.remoteDescription, pc1.localDescription) self.assertEqual(len(pc2.getReceivers()), 2) self.assertEqual(len(pc2.getSenders()), 2) self.assertEqual(len(pc2.getTransceivers()), 2) self.assertEqual(mids(pc2), ["0", "1"]) # create answer pc2.addTrack(AudioStreamTrack()) answer = await pc2.createAnswer() self.assertEqual(answer.type, "answer") self.assertTrue("m=audio " in answer.sdp) self.assertFalse("a=candidate:" in answer.sdp) self.assertFalse("a=end-of-candidates" in answer.sdp) await pc2.setLocalDescription(answer) await self.assertIceChecking(pc2) self.assertEqual(mids(pc2), ["0", "1"]) self.assertTrue("m=audio " in pc2.localDescription.sdp) self.assertTrue("a=sendrecv" in pc2.localDescription.sdp) self.assertHasIceCandidates(pc2.localDescription) self.assertHasDtls(pc2.localDescription, "active") # handle answer await pc1.setRemoteDescription(pc2.localDescription) self.assertEqual(pc1.remoteDescription, pc2.localDescription) # check outcome await self.assertIceCompleted(pc1, pc2) # close await pc1.close() await pc2.close() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "closed") # check state changes self.assertEqual( pc1_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc1_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc1_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc1_states["signalingState"], ["stable", "have-local-offer", "stable", "closed"], ) self.assertEqual( pc2_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc2_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc2_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc2_states["signalingState"], ["stable", "have-remote-offer", "stable", "closed"], ) @asynctest async def test_connect_audio_and_video(self): pc1 = RTCPeerConnection() pc1_states = track_states(pc1) pc2 = RTCPeerConnection() pc2_states = track_states(pc2) self.assertEqual(pc1.connectionState, "new") self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "new") self.assertIsNone(pc1.localDescription) self.assertIsNone(pc1.remoteDescription) self.assertEqual(pc2.connectionState, "new") self.assertEqual(pc2.iceConnectionState, "new") self.assertEqual(pc2.iceGatheringState, "new") self.assertIsNone(pc2.localDescription) self.assertIsNone(pc2.remoteDescription) # create offer pc1.addTrack(AudioStreamTrack()) pc1.addTrack(VideoStreamTrack()) offer = await pc1.createOffer() self.assertEqual(offer.type, "offer") self.assertTrue("m=audio " in offer.sdp) self.assertTrue("m=video " in offer.sdp) await pc1.setLocalDescription(offer) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "complete") self.assertEqual(mids(pc1), ["0", "1"]) # handle offer await pc2.setRemoteDescription(pc1.localDescription) self.assertEqual(pc2.remoteDescription, pc1.localDescription) self.assertEqual(len(pc2.getReceivers()), 2) self.assertEqual(len(pc2.getSenders()), 2) self.assertEqual(len(pc2.getTransceivers()), 2) self.assertEqual(mids(pc2), ["0", "1"]) # create answer pc2.addTrack(AudioStreamTrack()) pc2.addTrack(VideoStreamTrack()) answer = await pc2.createAnswer() self.assertEqual(answer.type, "answer") self.assertTrue("m=audio " in answer.sdp) self.assertTrue("m=video " in answer.sdp) await pc2.setLocalDescription(answer) await self.assertIceChecking(pc2) self.assertTrue("m=audio " in pc2.localDescription.sdp) self.assertTrue("m=video " in pc2.localDescription.sdp) # handle answer await pc1.setRemoteDescription(pc2.localDescription) self.assertEqual(pc1.remoteDescription, pc2.localDescription) # check outcome await self.assertIceCompleted(pc1, pc2) # check a single transport is used self.assertBundled(pc1) self.assertBundled(pc2) # close await pc1.close() await pc2.close() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "closed") # check state changes self.assertEqual( pc1_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc1_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc1_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc1_states["signalingState"], ["stable", "have-local-offer", "stable", "closed"], ) self.assertEqual( pc2_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc2_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc2_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc2_states["signalingState"], ["stable", "have-remote-offer", "stable", "closed"], ) async def _test_connect_audio_and_video_mediaplayer(self, stop_tracks: bool): """ Negotiate bidirectional audio + video, with one party reading media from a file. We can optionally stop the media tracks before closing the peer connections. """ media_test = MediaTestCase() media_test.setUp() media_path = media_test.create_audio_and_video_file(name="test.mp4", duration=5) player = MediaPlayer(media_path) pc1 = RTCPeerConnection() pc1_states = track_states(pc1) pc2 = RTCPeerConnection() pc2_states = track_states(pc2) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "new") self.assertIsNone(pc1.localDescription) self.assertIsNone(pc1.remoteDescription) self.assertEqual(pc2.iceConnectionState, "new") self.assertEqual(pc2.iceGatheringState, "new") self.assertIsNone(pc2.localDescription) self.assertIsNone(pc2.remoteDescription) # create offer pc1.addTrack(player.audio) pc1.addTrack(player.video) offer = await pc1.createOffer() self.assertEqual(offer.type, "offer") self.assertTrue("m=audio " in offer.sdp) self.assertTrue("m=video " in offer.sdp) await pc1.setLocalDescription(offer) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "complete") self.assertEqual(mids(pc1), ["0", "1"]) # handle offer await pc2.setRemoteDescription(pc1.localDescription) self.assertEqual(pc2.remoteDescription, pc1.localDescription) self.assertEqual(len(pc2.getReceivers()), 2) self.assertEqual(len(pc2.getSenders()), 2) self.assertEqual(len(pc2.getTransceivers()), 2) self.assertEqual(mids(pc2), ["0", "1"]) # create answer pc2.addTrack(AudioStreamTrack()) pc2.addTrack(VideoStreamTrack()) answer = await pc2.createAnswer() self.assertEqual(answer.type, "answer") self.assertTrue("m=audio " in answer.sdp) self.assertTrue("m=video " in answer.sdp) await pc2.setLocalDescription(answer) await self.assertIceChecking(pc2) self.assertTrue("m=audio " in pc2.localDescription.sdp) self.assertTrue("m=video " in pc2.localDescription.sdp) # handle answer await pc1.setRemoteDescription(pc2.localDescription) self.assertEqual(pc1.remoteDescription, pc2.localDescription) # check outcome await self.assertIceCompleted(pc1, pc2) # check a single transport is used self.assertBundled(pc1) self.assertBundled(pc2) # let media flow await asyncio.sleep(1) # stop tracks if stop_tracks: player.audio.stop() player.video.stop() # close await pc1.close() await pc2.close() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "closed") # check state changes self.assertEqual( pc1_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc1_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc1_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc1_states["signalingState"], ["stable", "have-local-offer", "stable", "closed"], ) self.assertEqual( pc2_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc2_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc2_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc2_states["signalingState"], ["stable", "have-remote-offer", "stable", "closed"], ) media_test.tearDown() @asynctest async def test_connect_audio_and_video_mediaplayer(self): await self._test_connect_audio_and_video_mediaplayer(stop_tracks=False) @asynctest async def test_connect_audio_and_video_mediaplayer_stop_tracks(self): await self._test_connect_audio_and_video_mediaplayer(stop_tracks=True) @asynctest async def test_connect_audio_and_video_and_data_channel(self): pc1 = RTCPeerConnection() pc1_states = track_states(pc1) pc2 = RTCPeerConnection() pc2_states = track_states(pc2) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "new") self.assertIsNone(pc1.localDescription) self.assertIsNone(pc1.remoteDescription) self.assertEqual(pc2.iceConnectionState, "new") self.assertEqual(pc2.iceGatheringState, "new") self.assertIsNone(pc2.localDescription) self.assertIsNone(pc2.remoteDescription) # create offer pc1.addTrack(AudioStreamTrack()) pc1.addTrack(VideoStreamTrack()) pc1.createDataChannel("chat", protocol="bob") offer = await pc1.createOffer() self.assertEqual(offer.type, "offer") self.assertTrue("m=audio " in offer.sdp) self.assertTrue("m=video " in offer.sdp) self.assertTrue("m=application " in offer.sdp) await pc1.setLocalDescription(offer) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "complete") self.assertEqual(mids(pc1), ["0", "1", "2"]) # handle offer await pc2.setRemoteDescription(pc1.localDescription) self.assertEqual(pc2.remoteDescription, pc1.localDescription) self.assertEqual(len(pc2.getReceivers()), 2) self.assertEqual(len(pc2.getSenders()), 2) self.assertEqual(len(pc2.getTransceivers()), 2) self.assertEqual(mids(pc2), ["0", "1", "2"]) # create answer pc2.addTrack(AudioStreamTrack()) pc2.addTrack(VideoStreamTrack()) answer = await pc2.createAnswer() self.assertEqual(answer.type, "answer") self.assertTrue("m=audio " in answer.sdp) self.assertTrue("m=video " in answer.sdp) self.assertTrue("m=application " in answer.sdp) await pc2.setLocalDescription(answer) await self.assertIceChecking(pc2) self.assertTrue("m=audio " in pc2.localDescription.sdp) self.assertTrue("m=video " in pc2.localDescription.sdp) self.assertTrue("m=application " in pc2.localDescription.sdp) # handle answer await pc1.setRemoteDescription(pc2.localDescription) self.assertEqual(pc1.remoteDescription, pc2.localDescription) # check outcome await self.assertIceCompleted(pc1, pc2) # check a single transport is used self.assertBundled(pc1) self.assertBundled(pc2) # close await pc1.close() await pc2.close() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "closed") # check state changes self.assertEqual( pc1_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc1_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc1_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc1_states["signalingState"], ["stable", "have-local-offer", "stable", "closed"], ) self.assertEqual( pc2_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc2_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc2_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc2_states["signalingState"], ["stable", "have-remote-offer", "stable", "closed"], ) @asynctest async def test_connect_audio_and_video_and_data_channel_ice_fail(self): pc1 = RTCPeerConnection() pc1_states = track_states(pc1) pc2 = RTCPeerConnection() pc2_states = track_states(pc2) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "new") self.assertIsNone(pc1.localDescription) self.assertIsNone(pc1.remoteDescription) self.assertEqual(pc2.iceConnectionState, "new") self.assertEqual(pc2.iceGatheringState, "new") self.assertIsNone(pc2.localDescription) self.assertIsNone(pc2.remoteDescription) # create offer pc1.addTrack(AudioStreamTrack()) pc1.addTrack(VideoStreamTrack()) pc1.createDataChannel("chat", protocol="bob") offer = await pc1.createOffer() self.assertEqual(offer.type, "offer") self.assertTrue("m=audio " in offer.sdp) self.assertTrue("m=video " in offer.sdp) self.assertTrue("m=application " in offer.sdp) await pc1.setLocalDescription(offer) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "complete") self.assertEqual(mids(pc1), ["0", "1", "2"]) # close one side pc1_description = pc1.localDescription await pc1.close() # handle offer await pc2.setRemoteDescription(pc1_description) self.assertEqual(pc2.remoteDescription, pc1_description) self.assertEqual(len(pc2.getReceivers()), 2) self.assertEqual(len(pc2.getSenders()), 2) self.assertEqual(len(pc2.getTransceivers()), 2) self.assertEqual(mids(pc2), ["0", "1", "2"]) # create answer pc2.addTrack(AudioStreamTrack()) pc2.addTrack(VideoStreamTrack()) answer = await pc2.createAnswer() self.assertEqual(answer.type, "answer") self.assertTrue("m=audio " in answer.sdp) self.assertTrue("m=video " in answer.sdp) self.assertTrue("m=application " in answer.sdp) await pc2.setLocalDescription(answer) await self.assertIceChecking(pc2) self.assertTrue("m=audio " in pc2.localDescription.sdp) self.assertTrue("m=video " in pc2.localDescription.sdp) self.assertTrue("m=application " in pc2.localDescription.sdp) # check outcome done = asyncio.Event() @pc2.on("iceconnectionstatechange") def iceconnectionstatechange(): done.set() await done.wait() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "failed") # close await pc1.close() await pc2.close() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "closed") # check state changes self.assertEqual(pc1_states["connectionState"], ["new", "closed"]) self.assertEqual(pc1_states["iceConnectionState"], ["new", "closed"]) self.assertEqual( pc1_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc1_states["signalingState"], ["stable", "have-local-offer", "closed"] ) self.assertEqual( pc2_states["connectionState"], ["new", "connecting", "failed", "closed"] ) self.assertEqual( pc2_states["iceConnectionState"], ["new", "checking", "failed", "closed"] ) self.assertEqual( pc2_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc2_states["signalingState"], ["stable", "have-remote-offer", "stable", "closed"], ) @asynctest async def test_connect_audio_then_video(self): pc1 = RTCPeerConnection() pc1_states = track_states(pc1) pc2 = RTCPeerConnection() pc2_states = track_states(pc2) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "new") self.assertIsNone(pc1.localDescription) self.assertIsNone(pc1.remoteDescription) self.assertEqual(pc2.iceConnectionState, "new") self.assertEqual(pc2.iceGatheringState, "new") self.assertIsNone(pc2.localDescription) self.assertIsNone(pc2.remoteDescription) # 1. AUDIO ONLY # create offer pc1.addTrack(AudioStreamTrack()) offer = await pc1.createOffer() self.assertEqual(offer.type, "offer") self.assertTrue("m=audio " in offer.sdp) self.assertFalse("m=video " in offer.sdp) await pc1.setLocalDescription(offer) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "complete") self.assertEqual(mids(pc1), ["0"]) # handle offer await pc2.setRemoteDescription(pc1.localDescription) self.assertEqual(pc2.remoteDescription, pc1.localDescription) self.assertEqual(len(pc2.getReceivers()), 1) self.assertEqual(len(pc2.getSenders()), 1) self.assertEqual(len(pc2.getTransceivers()), 1) self.assertEqual(mids(pc2), ["0"]) # create answer pc2.addTrack(AudioStreamTrack()) answer = await pc2.createAnswer() self.assertEqual(answer.type, "answer") self.assertTrue("m=audio " in answer.sdp) self.assertFalse("m=video " in answer.sdp) await pc2.setLocalDescription(answer) await self.assertIceChecking(pc2) self.assertTrue("m=audio " in pc2.localDescription.sdp) self.assertFalse("m=video " in pc2.localDescription.sdp) # handle answer await pc1.setRemoteDescription(pc2.localDescription) self.assertEqual(pc1.remoteDescription, pc2.localDescription) # check outcome await self.assertIceCompleted(pc1, pc2) # check a single transport is used self.assertBundled(pc1) self.assertBundled(pc2) # 2. ADD VIDEO # create offer pc1.addTrack(VideoStreamTrack()) offer = await pc1.createOffer() self.assertEqual(offer.type, "offer") self.assertTrue("m=audio " in offer.sdp) self.assertTrue("m=video " in offer.sdp) await pc1.setLocalDescription(offer) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "complete") self.assertEqual(mids(pc1), ["0", "1"]) # handle offer await pc2.setRemoteDescription(pc1.localDescription) self.assertEqual(pc2.remoteDescription, pc1.localDescription) self.assertEqual(len(pc2.getReceivers()), 2) self.assertEqual(len(pc2.getSenders()), 2) self.assertEqual(len(pc2.getTransceivers()), 2) self.assertEqual(mids(pc2), ["0", "1"]) # create answer pc2.addTrack(VideoStreamTrack()) answer = await pc2.createAnswer() self.assertEqual(answer.type, "answer") self.assertTrue("m=audio " in answer.sdp) self.assertTrue("m=video " in answer.sdp) await pc2.setLocalDescription(answer) self.assertEqual(pc2.iceConnectionState, "completed") self.assertEqual(pc2.iceGatheringState, "complete") self.assertTrue("m=audio " in pc2.localDescription.sdp) self.assertTrue("m=video " in pc2.localDescription.sdp) # handle answer await pc1.setRemoteDescription(pc2.localDescription) self.assertEqual(pc1.remoteDescription, pc2.localDescription) self.assertEqual(pc1.iceConnectionState, "completed") # check outcome await self.assertIceCompleted(pc1, pc2) # check a single transport is used self.assertBundled(pc1) self.assertBundled(pc2) # close await pc1.close() await pc2.close() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "closed") # check state changes self.assertEqual( pc1_states["connectionState"], ["new", "connecting", "connected", "connecting", "connected", "closed"], ) self.assertEqual( pc1_states["iceConnectionState"], ["new", "checking", "completed", "new", "completed", "closed"], ) self.assertEqual( pc1_states["iceGatheringState"], ["new", "gathering", "complete", "new", "gathering", "complete"], ) self.assertEqual( pc1_states["signalingState"], [ "stable", "have-local-offer", "stable", "have-local-offer", "stable", "closed", ], ) self.assertEqual( pc2_states["connectionState"], ["new", "connecting", "connected", "connecting", "connected", "closed"], ) self.assertEqual( pc2_states["iceConnectionState"], ["new", "checking", "completed", "new", "completed", "closed"], ) self.assertEqual( pc2_states["iceGatheringState"], ["new", "gathering", "complete", "new", "complete"], ) self.assertEqual( pc2_states["signalingState"], [ "stable", "have-remote-offer", "stable", "have-remote-offer", "stable", "closed", ], ) @asynctest async def test_connect_video_bidirectional(self): pc1 = RTCPeerConnection() pc1_states = track_states(pc1) pc2 = RTCPeerConnection() pc2_states = track_states(pc2) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "new") self.assertIsNone(pc1.localDescription) self.assertIsNone(pc1.remoteDescription) self.assertEqual(pc2.iceConnectionState, "new") self.assertEqual(pc2.iceGatheringState, "new") self.assertIsNone(pc2.localDescription) self.assertIsNone(pc2.remoteDescription) # create offer pc1.addTrack(VideoStreamTrack()) offer = await pc1.createOffer() self.assertEqual(offer.type, "offer") self.assertTrue("m=video " in offer.sdp) self.assertFalse("a=candidate:" in offer.sdp) self.assertFalse("a=end-of-candidates" in offer.sdp) await pc1.setLocalDescription(offer) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "complete") self.assertEqual(mids(pc1), ["0"]) self.assertTrue("m=video " in pc1.localDescription.sdp) self.assertTrue( lf2crlf( """a=rtpmap:97 VP8/90000 a=rtcp-fb:97 nack a=rtcp-fb:97 nack pli a=rtcp-fb:97 goog-remb a=rtpmap:98 rtx/90000 a=fmtp:98 apt=97 a=rtpmap:99 H264/90000 a=rtcp-fb:99 nack a=rtcp-fb:99 nack pli a=rtcp-fb:99 goog-remb a=fmtp:99 packetization-mode=1;level-asymmetry-allowed=1;profile-level-id=42001f a=rtpmap:100 rtx/90000 a=fmtp:100 apt=99 a=rtpmap:101 H264/90000 a=rtcp-fb:101 nack a=rtcp-fb:101 nack pli a=rtcp-fb:101 goog-remb a=fmtp:101 packetization-mode=1;level-asymmetry-allowed=1;profile-level-id=42e01f a=rtpmap:102 rtx/90000 a=fmtp:102 apt=101 """ ) in pc1.localDescription.sdp ) self.assertTrue("a=sendrecv" in pc1.localDescription.sdp) self.assertHasIceCandidates(pc1.localDescription) self.assertHasDtls(pc1.localDescription, "actpass") # handle offer await pc2.setRemoteDescription(pc1.localDescription) self.assertEqual(pc2.remoteDescription, pc1.localDescription) self.assertEqual(len(pc2.getReceivers()), 1) self.assertEqual(len(pc2.getSenders()), 1) self.assertEqual(len(pc2.getTransceivers()), 1) self.assertEqual(mids(pc2), ["0"]) # create answer pc2.addTrack(VideoStreamTrack()) answer = await pc2.createAnswer() self.assertEqual(answer.type, "answer") self.assertTrue("m=video " in answer.sdp) self.assertFalse("a=candidate:" in answer.sdp) self.assertFalse("a=end-of-candidates" in answer.sdp) await pc2.setLocalDescription(answer) await self.assertIceChecking(pc2) self.assertTrue("m=video " in pc2.localDescription.sdp) self.assertTrue( lf2crlf( """a=rtpmap:97 VP8/90000 a=rtcp-fb:97 nack a=rtcp-fb:97 nack pli a=rtcp-fb:97 goog-remb a=rtpmap:98 rtx/90000 a=fmtp:98 apt=97 a=rtpmap:99 H264/90000 a=rtcp-fb:99 nack a=rtcp-fb:99 nack pli a=rtcp-fb:99 goog-remb a=fmtp:99 packetization-mode=1;level-asymmetry-allowed=1;profile-level-id=42001f a=rtpmap:100 rtx/90000 a=fmtp:100 apt=99 a=rtpmap:101 H264/90000 a=rtcp-fb:101 nack a=rtcp-fb:101 nack pli a=rtcp-fb:101 goog-remb a=fmtp:101 packetization-mode=1;level-asymmetry-allowed=1;profile-level-id=42e01f a=rtpmap:102 rtx/90000 a=fmtp:102 apt=101 """ ) in pc2.localDescription.sdp ) self.assertTrue("a=sendrecv" in pc2.localDescription.sdp) self.assertHasIceCandidates(pc2.localDescription) self.assertHasDtls(pc2.localDescription, "active") # handle answer await pc1.setRemoteDescription(pc2.localDescription) self.assertEqual(pc1.remoteDescription, pc2.localDescription) # check outcome await self.assertIceCompleted(pc1, pc2) # let media flow to trigger RTCP feedback, including REMB await asyncio.sleep(5) # check stats report = await pc1.getStats() self.assertTrue(isinstance(report, RTCStatsReport)) self.assertEqual( sorted([s.type for s in report.values()]), [ "inbound-rtp", "outbound-rtp", "remote-inbound-rtp", "remote-outbound-rtp", "transport", ], ) # close await pc1.close() await pc2.close() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "closed") # check state changes self.assertEqual( pc1_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc1_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc1_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc1_states["signalingState"], ["stable", "have-local-offer", "stable", "closed"], ) self.assertEqual( pc2_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc2_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc2_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc2_states["signalingState"], ["stable", "have-remote-offer", "stable", "closed"], ) @asynctest async def test_connect_video_h264(self): pc1 = RTCPeerConnection() pc1_states = track_states(pc1) pc2 = RTCPeerConnection() pc2_states = track_states(pc2) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "new") self.assertIsNone(pc1.localDescription) self.assertIsNone(pc1.remoteDescription) self.assertEqual(pc2.iceConnectionState, "new") self.assertEqual(pc2.iceGatheringState, "new") self.assertIsNone(pc2.localDescription) self.assertIsNone(pc2.remoteDescription) # create offer pc1.addTrack(VideoStreamTrack()) offer = await pc1.createOffer() self.assertEqual(offer.type, "offer") self.assertTrue("m=video " in offer.sdp) self.assertFalse("a=candidate:" in offer.sdp) self.assertFalse("a=end-of-candidates" in offer.sdp) await pc1.setLocalDescription(offer) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "complete") self.assertEqual(mids(pc1), ["0"]) self.assertTrue("m=video " in pc1.localDescription.sdp) self.assertTrue("a=sendrecv" in pc1.localDescription.sdp) self.assertHasIceCandidates(pc1.localDescription) self.assertHasDtls(pc1.localDescription, "actpass") # strip out vp8 parsed = SessionDescription.parse(pc1.localDescription.sdp) parsed.media[0].rtp.codecs.pop(0) parsed.media[0].fmt.pop(0) desc1 = RTCSessionDescription(sdp=str(parsed), type=pc1.localDescription.type) self.assertFalse("VP8" in desc1.sdp) self.assertTrue("H264" in desc1.sdp) # handle offer await pc2.setRemoteDescription(desc1) self.assertEqual(pc2.remoteDescription, desc1) self.assertEqual(len(pc2.getReceivers()), 1) self.assertEqual(len(pc2.getSenders()), 1) self.assertEqual(len(pc2.getTransceivers()), 1) self.assertEqual(mids(pc2), ["0"]) # create answer pc2.addTrack(VideoStreamTrack()) answer = await pc2.createAnswer() self.assertEqual(answer.type, "answer") self.assertTrue("m=video " in answer.sdp) self.assertFalse("a=candidate:" in answer.sdp) self.assertFalse("a=end-of-candidates" in answer.sdp) await pc2.setLocalDescription(answer) await self.assertIceChecking(pc2) self.assertTrue("m=video " in pc2.localDescription.sdp) self.assertTrue("a=sendrecv" in pc2.localDescription.sdp) self.assertHasIceCandidates(pc2.localDescription) self.assertHasDtls(pc2.localDescription, "active") # handle answer await pc1.setRemoteDescription(pc2.localDescription) self.assertEqual(pc1.remoteDescription, pc2.localDescription) # check outcome await self.assertIceCompleted(pc1, pc2) # close await pc1.close() await pc2.close() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "closed") # check state changes self.assertEqual( pc1_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc1_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc1_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc1_states["signalingState"], ["stable", "have-local-offer", "stable", "closed"], ) self.assertEqual( pc2_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc2_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc2_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc2_states["signalingState"], ["stable", "have-remote-offer", "stable", "closed"], ) @asynctest async def test_connect_video_no_ssrc(self): pc1 = RTCPeerConnection() pc1_states = track_states(pc1) pc2 = RTCPeerConnection() pc2_states = track_states(pc2) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "new") self.assertIsNone(pc1.localDescription) self.assertIsNone(pc1.remoteDescription) self.assertEqual(pc2.iceConnectionState, "new") self.assertEqual(pc2.iceGatheringState, "new") self.assertIsNone(pc2.localDescription) self.assertIsNone(pc2.remoteDescription) # create offer pc1.addTrack(VideoStreamTrack()) offer = await pc1.createOffer() self.assertEqual(offer.type, "offer") self.assertTrue("m=video " in offer.sdp) self.assertFalse("a=candidate:" in offer.sdp) self.assertFalse("a=end-of-candidates" in offer.sdp) await pc1.setLocalDescription(offer) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "complete") self.assertEqual(mids(pc1), ["0"]) self.assertTrue("m=video " in pc1.localDescription.sdp) self.assertTrue("a=sendrecv" in pc1.localDescription.sdp) self.assertHasIceCandidates(pc1.localDescription) self.assertHasDtls(pc1.localDescription, "actpass") # strip out SSRC mangled = RTCSessionDescription( sdp=re.sub("^a=ssrc:.*\r\n", "", pc1.localDescription.sdp, flags=re.M), type=pc1.localDescription.type, ) # handle offer await pc2.setRemoteDescription(mangled) self.assertEqual(pc2.remoteDescription, mangled) self.assertEqual(len(pc2.getReceivers()), 1) self.assertEqual(len(pc2.getSenders()), 1) self.assertEqual(len(pc2.getTransceivers()), 1) self.assertEqual(mids(pc2), ["0"]) # create answer pc2.addTrack(VideoStreamTrack()) answer = await pc2.createAnswer() self.assertEqual(answer.type, "answer") self.assertTrue("m=video " in answer.sdp) self.assertFalse("a=candidate:" in answer.sdp) self.assertFalse("a=end-of-candidates" in answer.sdp) await pc2.setLocalDescription(answer) await self.assertIceChecking(pc2) self.assertTrue("m=video " in pc2.localDescription.sdp) self.assertTrue("a=sendrecv" in pc2.localDescription.sdp) self.assertHasIceCandidates(pc2.localDescription) self.assertHasDtls(pc2.localDescription, "active") # handle answer await pc1.setRemoteDescription(pc2.localDescription) self.assertEqual(pc1.remoteDescription, pc2.localDescription) # check outcome await self.assertIceCompleted(pc1, pc2) # close await pc1.close() await pc2.close() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "closed") # check state changes self.assertEqual( pc1_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc1_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc1_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc1_states["signalingState"], ["stable", "have-local-offer", "stable", "closed"], ) self.assertEqual( pc2_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc2_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc2_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc2_states["signalingState"], ["stable", "have-remote-offer", "stable", "closed"], ) @asynctest async def test_connect_video_codec_preferences_offerer(self): pc1 = RTCPeerConnection() pc1_states = track_states(pc1) pc2 = RTCPeerConnection() pc2_states = track_states(pc2) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "new") self.assertIsNone(pc1.localDescription) self.assertIsNone(pc1.remoteDescription) self.assertEqual(pc2.iceConnectionState, "new") self.assertEqual(pc2.iceGatheringState, "new") self.assertIsNone(pc2.localDescription) self.assertIsNone(pc2.remoteDescription) # add track and set codec preferences to prefer H264 pc1.addTrack(VideoStreamTrack()) capabilities = RTCRtpSender.getCapabilities("video") preferences = list(filter(lambda x: x.name == "H264", capabilities.codecs)) preferences += list(filter(lambda x: x.name == "VP8", capabilities.codecs)) preferences += list(filter(lambda x: x.name == "rtx", capabilities.codecs)) transceiver = pc1.getTransceivers()[0] transceiver.setCodecPreferences(preferences) # create offer offer = await pc1.createOffer() self.assertEqual(offer.type, "offer") self.assertTrue("m=video " in offer.sdp) self.assertFalse("a=candidate:" in offer.sdp) self.assertFalse("a=end-of-candidates" in offer.sdp) await pc1.setLocalDescription(offer) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "complete") self.assertEqual(mids(pc1), ["0"]) self.assertTrue("m=video " in pc1.localDescription.sdp) self.assertTrue("a=sendrecv" in pc1.localDescription.sdp) self.assertHasIceCandidates(pc1.localDescription) self.assertHasDtls(pc1.localDescription, "actpass") self.assertTrue( lf2crlf( """a=rtpmap:99 H264/90000 a=rtcp-fb:99 nack a=rtcp-fb:99 nack pli a=rtcp-fb:99 goog-remb a=fmtp:99 packetization-mode=1;level-asymmetry-allowed=1;profile-level-id=42001f a=rtpmap:100 rtx/90000 a=fmtp:100 apt=99 a=rtpmap:101 H264/90000 a=rtcp-fb:101 nack a=rtcp-fb:101 nack pli a=rtcp-fb:101 goog-remb a=fmtp:101 packetization-mode=1;level-asymmetry-allowed=1;profile-level-id=42e01f a=rtpmap:102 rtx/90000 a=fmtp:102 apt=101 a=rtpmap:97 VP8/90000 a=rtcp-fb:97 nack a=rtcp-fb:97 nack pli a=rtcp-fb:97 goog-remb a=rtpmap:98 rtx/90000 a=fmtp:98 apt=97 """ ) in pc1.localDescription.sdp ) # handle offer await pc2.setRemoteDescription(pc1.localDescription) self.assertEqual(pc2.remoteDescription, pc1.localDescription) self.assertEqual(len(pc2.getReceivers()), 1) self.assertEqual(len(pc2.getSenders()), 1) self.assertEqual(len(pc2.getTransceivers()), 1) self.assertEqual(mids(pc2), ["0"]) # create answer pc2.addTrack(VideoStreamTrack()) answer = await pc2.createAnswer() self.assertEqual(answer.type, "answer") self.assertTrue("m=video " in answer.sdp) self.assertFalse("a=candidate:" in answer.sdp) self.assertFalse("a=end-of-candidates" in answer.sdp) await pc2.setLocalDescription(answer) await self.assertIceChecking(pc2) self.assertTrue("m=video " in pc2.localDescription.sdp) self.assertTrue("a=sendrecv" in pc2.localDescription.sdp) self.assertHasIceCandidates(pc2.localDescription) self.assertHasDtls(pc2.localDescription, "active") self.assertTrue( lf2crlf( """a=rtpmap:99 H264/90000 a=rtcp-fb:99 nack a=rtcp-fb:99 nack pli a=rtcp-fb:99 goog-remb a=fmtp:99 packetization-mode=1;level-asymmetry-allowed=1;profile-level-id=42001f a=rtpmap:100 rtx/90000 a=fmtp:100 apt=99 a=rtpmap:101 H264/90000 a=rtcp-fb:101 nack a=rtcp-fb:101 nack pli a=rtcp-fb:101 goog-remb a=fmtp:101 packetization-mode=1;level-asymmetry-allowed=1;profile-level-id=42e01f a=rtpmap:102 rtx/90000 a=fmtp:102 apt=101 a=rtpmap:97 VP8/90000 a=rtcp-fb:97 nack a=rtcp-fb:97 nack pli a=rtcp-fb:97 goog-remb a=rtpmap:98 rtx/90000 a=fmtp:98 apt=97 """ ) in pc2.localDescription.sdp ) # handle answer await pc1.setRemoteDescription(pc2.localDescription) self.assertEqual(pc1.remoteDescription, pc2.localDescription) # check outcome await self.assertIceCompleted(pc1, pc2) # close await pc1.close() await pc2.close() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "closed") # check state changes self.assertEqual( pc1_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc1_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc1_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc1_states["signalingState"], ["stable", "have-local-offer", "stable", "closed"], ) self.assertEqual( pc2_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc2_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc2_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc2_states["signalingState"], ["stable", "have-remote-offer", "stable", "closed"], ) @asynctest async def test_connect_video_codec_preferences_offerer_only_h264(self): pc1 = RTCPeerConnection() pc1_states = track_states(pc1) pc2 = RTCPeerConnection() pc2_states = track_states(pc2) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "new") self.assertIsNone(pc1.localDescription) self.assertIsNone(pc1.remoteDescription) self.assertEqual(pc2.iceConnectionState, "new") self.assertEqual(pc2.iceGatheringState, "new") self.assertIsNone(pc2.localDescription) self.assertIsNone(pc2.remoteDescription) # add track and set codec preferences to only allow H264 pc1.addTrack(VideoStreamTrack()) capabilities = RTCRtpSender.getCapabilities("video") preferences = list(filter(lambda x: x.name == "H264", capabilities.codecs)) preferences += list(filter(lambda x: x.name == "rtx", capabilities.codecs)) transceiver = pc1.getTransceivers()[0] transceiver.setCodecPreferences(preferences) # create offer offer = await pc1.createOffer() self.assertEqual(offer.type, "offer") self.assertTrue("m=video " in offer.sdp) self.assertFalse("a=candidate:" in offer.sdp) self.assertFalse("a=end-of-candidates" in offer.sdp) await pc1.setLocalDescription(offer) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "complete") self.assertEqual(mids(pc1), ["0"]) self.assertTrue("m=video " in pc1.localDescription.sdp) self.assertTrue("a=sendrecv" in pc1.localDescription.sdp) self.assertHasIceCandidates(pc1.localDescription) self.assertHasDtls(pc1.localDescription, "actpass") self.assertFalse("VP8" in pc1.localDescription.sdp) # handle offer await pc2.setRemoteDescription(pc1.localDescription) self.assertEqual(pc2.remoteDescription, pc1.localDescription) self.assertEqual(len(pc2.getReceivers()), 1) self.assertEqual(len(pc2.getSenders()), 1) self.assertEqual(len(pc2.getTransceivers()), 1) self.assertEqual(mids(pc2), ["0"]) # create answer pc2.addTrack(VideoStreamTrack()) answer = await pc2.createAnswer() self.assertEqual(answer.type, "answer") self.assertTrue("m=video " in answer.sdp) self.assertFalse("a=candidate:" in answer.sdp) self.assertFalse("a=end-of-candidates" in answer.sdp) await pc2.setLocalDescription(answer) await self.assertIceChecking(pc2) self.assertTrue("m=video " in pc2.localDescription.sdp) self.assertTrue("a=sendrecv" in pc2.localDescription.sdp) self.assertHasIceCandidates(pc2.localDescription) self.assertHasDtls(pc2.localDescription, "active") self.assertFalse("VP8" in pc2.localDescription.sdp) # handle answer await pc1.setRemoteDescription(pc2.localDescription) self.assertEqual(pc1.remoteDescription, pc2.localDescription) # check outcome await self.assertIceCompleted(pc1, pc2) # close await pc1.close() await pc2.close() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "closed") # check state changes self.assertEqual( pc1_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc1_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc1_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc1_states["signalingState"], ["stable", "have-local-offer", "stable", "closed"], ) self.assertEqual( pc2_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc2_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc2_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc2_states["signalingState"], ["stable", "have-remote-offer", "stable", "closed"], ) @asynctest async def test_connect_datachannel_and_close_immediately(self): pc1 = RTCPeerConnection() pc2 = RTCPeerConnection() # create two data channels dc1 = pc1.createDataChannel("chat1") self.assertEqual(dc1.readyState, "connecting") dc2 = pc1.createDataChannel("chat2") self.assertEqual(dc2.readyState, "connecting") # close one data channel dc1.close() self.assertEqual(dc1.readyState, "closed") self.assertEqual(dc2.readyState, "connecting") # perform SDP exchange await pc1.setLocalDescription(await pc1.createOffer()) await pc2.setRemoteDescription(pc1.localDescription) await pc2.setLocalDescription(await pc2.createAnswer()) await pc1.setRemoteDescription(pc2.localDescription) # check outcome await self.assertIceCompleted(pc1, pc2) self.assertEqual(dc1.readyState, "closed") await self.assertDataChannelOpen(dc2) # close await pc1.close() await pc2.close() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "closed") @asynctest async def test_connect_datachannel_negotiated_and_close_immediately(self): pc1 = RTCPeerConnection() pc2 = RTCPeerConnection() # create two negotiated data channels dc1 = pc1.createDataChannel("chat1", negotiated=True, id=100) self.assertEqual(dc1.readyState, "connecting") dc2 = pc1.createDataChannel("chat2", negotiated=True, id=102) self.assertEqual(dc2.readyState, "connecting") # close one data channel dc1.close() self.assertEqual(dc1.readyState, "closed") self.assertEqual(dc2.readyState, "connecting") # perform SDP exchange await pc1.setLocalDescription(await pc1.createOffer()) await pc2.setRemoteDescription(pc1.localDescription) await pc2.setLocalDescription(await pc2.createAnswer()) await pc1.setRemoteDescription(pc2.localDescription) # check outcome await self.assertIceCompleted(pc1, pc2) self.assertEqual(dc1.readyState, "closed") await self.assertDataChannelOpen(dc2) # close await pc1.close() await pc2.close() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "closed") @asynctest async def test_connect_datachannel_legacy_sdp(self): pc1 = RTCPeerConnection() pc1._sctpLegacySdp = True pc1_data_messages = [] pc1_states = track_states(pc1) pc2 = RTCPeerConnection() pc2_data_channels = [] pc2_data_messages = [] pc2_states = track_states(pc2) @pc2.on("datachannel") def on_datachannel(channel): self.assertEqual(channel.readyState, "open") pc2_data_channels.append(channel) @channel.on("message") def on_message(message): pc2_data_messages.append(message) if isinstance(message, str): channel.send("string-echo: " + message) else: channel.send(b"binary-echo: " + message) # create data channel dc = pc1.createDataChannel("chat", protocol="bob") self.assertEqual(dc.label, "chat") self.assertEqual(dc.maxPacketLifeTime, None) self.assertEqual(dc.maxRetransmits, None) self.assertEqual(dc.ordered, True) self.assertEqual(dc.protocol, "bob") self.assertEqual(dc.readyState, "connecting") # send messages @dc.on("open") def on_open(): dc.send("hello") dc.send("") dc.send(b"\x00\x01\x02\x03") dc.send(b"") dc.send(LONG_DATA) with self.assertRaises(ValueError) as cm: dc.send(1234) self.assertEqual( str(cm.exception), "Cannot send unsupported data type: " ) self.assertEqual(dc.bufferedAmount, 2011) @dc.on("message") def on_message(message): pc1_data_messages.append(message) # create offer offer = await pc1.createOffer() self.assertEqual(offer.type, "offer") self.assertTrue("m=application " in offer.sdp) self.assertFalse("a=candidate:" in offer.sdp) self.assertFalse("a=end-of-candidates" in offer.sdp) await pc1.setLocalDescription(offer) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "complete") self.assertEqual(mids(pc1), ["0"]) self.assertTrue("m=application " in pc1.localDescription.sdp) self.assertTrue( "a=sctpmap:5000 webrtc-datachannel 65535" in pc1.localDescription.sdp ) self.assertHasIceCandidates(pc1.localDescription) self.assertHasDtls(pc1.localDescription, "actpass") # handle offer await pc2.setRemoteDescription(pc1.localDescription) self.assertEqual(pc2.remoteDescription, pc1.localDescription) self.assertEqual(len(pc2.getReceivers()), 0) self.assertEqual(len(pc2.getSenders()), 0) self.assertEqual(len(pc2.getTransceivers()), 0) self.assertEqual(mids(pc2), ["0"]) # create answer answer = await pc2.createAnswer() self.assertEqual(answer.type, "answer") self.assertTrue("m=application " in answer.sdp) self.assertFalse("a=candidate:" in answer.sdp) self.assertFalse("a=end-of-candidates" in answer.sdp) await pc2.setLocalDescription(answer) await self.assertIceChecking(pc2) self.assertTrue("m=application " in pc2.localDescription.sdp) self.assertTrue( "a=sctpmap:5000 webrtc-datachannel 65535" in pc2.localDescription.sdp ) self.assertHasIceCandidates(pc2.localDescription) self.assertHasDtls(pc2.localDescription, "active") # handle answer await pc1.setRemoteDescription(pc2.localDescription) self.assertEqual(pc1.remoteDescription, pc2.localDescription) # check outcome await self.assertIceCompleted(pc1, pc2) await self.assertDataChannelOpen(dc) self.assertEqual(dc.bufferedAmount, 0) # check pc2 got a datachannel self.assertEqual(len(pc2_data_channels), 1) self.assertEqual(pc2_data_channels[0].label, "chat") self.assertEqual(pc2_data_channels[0].maxPacketLifeTime, None) self.assertEqual(pc2_data_channels[0].maxRetransmits, None) self.assertEqual(pc2_data_channels[0].ordered, True) self.assertEqual(pc2_data_channels[0].protocol, "bob") # check pc2 got messages await asyncio.sleep(0.1) self.assertEqual( pc2_data_messages, ["hello", "", b"\x00\x01\x02\x03", b"", LONG_DATA] ) # check pc1 got replies self.assertEqual( pc1_data_messages, [ "string-echo: hello", "string-echo: ", b"binary-echo: \x00\x01\x02\x03", b"binary-echo: ", b"binary-echo: " + LONG_DATA, ], ) # close data channel await self.closeDataChannel(dc) # close await pc1.close() await pc2.close() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "closed") # check state changes self.assertEqual( pc1_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc1_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc1_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc1_states["signalingState"], ["stable", "have-local-offer", "stable", "closed"], ) self.assertEqual( pc2_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc2_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc2_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc2_states["signalingState"], ["stable", "have-remote-offer", "stable", "closed"], ) @asynctest async def test_connect_datachannel_modern_sdp(self): pc1 = RTCPeerConnection() pc1._sctpLegacySdp = False pc1_data_messages = [] pc1_states = track_states(pc1) pc2 = RTCPeerConnection() pc2_data_channels = [] pc2_data_messages = [] pc2_states = track_states(pc2) @pc2.on("datachannel") def on_datachannel(channel): self.assertEqual(channel.readyState, "open") pc2_data_channels.append(channel) @channel.on("message") def on_message(message): pc2_data_messages.append(message) if isinstance(message, str): channel.send("string-echo: " + message) else: channel.send(b"binary-echo: " + message) # create data channel dc = pc1.createDataChannel("chat", protocol="bob") self.assertEqual(dc.label, "chat") self.assertEqual(dc.maxPacketLifeTime, None) self.assertEqual(dc.maxRetransmits, None) self.assertEqual(dc.ordered, True) self.assertEqual(dc.protocol, "bob") self.assertEqual(dc.readyState, "connecting") # send messages @dc.on("open") def on_open(): dc.send("hello") dc.send("") dc.send(b"\x00\x01\x02\x03") dc.send(b"") dc.send(LONG_DATA) with self.assertRaises(ValueError) as cm: dc.send(1234) self.assertEqual( str(cm.exception), "Cannot send unsupported data type: " ) @dc.on("message") def on_message(message): pc1_data_messages.append(message) # create offer offer = await pc1.createOffer() self.assertEqual(offer.type, "offer") self.assertTrue("m=application " in offer.sdp) self.assertFalse("a=candidate:" in offer.sdp) self.assertFalse("a=end-of-candidates" in offer.sdp) await pc1.setLocalDescription(offer) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "complete") self.assertEqual(mids(pc1), ["0"]) self.assertTrue("m=application " in pc1.localDescription.sdp) self.assertTrue("a=sctp-port:5000" in pc1.localDescription.sdp) self.assertHasIceCandidates(pc1.localDescription) self.assertHasDtls(pc1.localDescription, "actpass") # handle offer await pc2.setRemoteDescription(pc1.localDescription) self.assertEqual(pc2.remoteDescription, pc1.localDescription) self.assertEqual(len(pc2.getReceivers()), 0) self.assertEqual(len(pc2.getSenders()), 0) self.assertEqual(len(pc2.getTransceivers()), 0) self.assertEqual(mids(pc2), ["0"]) # create answer answer = await pc2.createAnswer() self.assertEqual(answer.type, "answer") self.assertTrue("m=application " in answer.sdp) self.assertFalse("a=candidate:" in answer.sdp) self.assertFalse("a=end-of-candidates" in answer.sdp) await pc2.setLocalDescription(answer) await self.assertIceChecking(pc2) self.assertTrue("m=application " in pc2.localDescription.sdp) self.assertTrue("a=sctp-port:5000" in pc2.localDescription.sdp) self.assertHasIceCandidates(pc2.localDescription) self.assertHasDtls(pc2.localDescription, "active") # handle answer await pc1.setRemoteDescription(pc2.localDescription) self.assertEqual(pc1.remoteDescription, pc2.localDescription) # check outcome await self.assertIceCompleted(pc1, pc2) await self.assertDataChannelOpen(dc) # check pc2 got a datachannel self.assertEqual(len(pc2_data_channels), 1) self.assertEqual(pc2_data_channels[0].label, "chat") self.assertEqual(pc2_data_channels[0].maxPacketLifeTime, None) self.assertEqual(pc2_data_channels[0].maxRetransmits, None) self.assertEqual(pc2_data_channels[0].ordered, True) self.assertEqual(pc2_data_channels[0].protocol, "bob") # check pc2 got messages await asyncio.sleep(0.1) self.assertEqual( pc2_data_messages, ["hello", "", b"\x00\x01\x02\x03", b"", LONG_DATA] ) # check pc1 got replies self.assertEqual( pc1_data_messages, [ "string-echo: hello", "string-echo: ", b"binary-echo: \x00\x01\x02\x03", b"binary-echo: ", b"binary-echo: " + LONG_DATA, ], ) # close data channel await self.closeDataChannel(dc) # close await pc1.close() await pc2.close() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "closed") # check state changes self.assertEqual( pc1_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc1_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc1_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc1_states["signalingState"], ["stable", "have-local-offer", "stable", "closed"], ) self.assertEqual( pc2_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc2_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc2_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc2_states["signalingState"], ["stable", "have-remote-offer", "stable", "closed"], ) @asynctest async def test_connect_datachannel_modern_sdp_negotiated(self): pc1 = RTCPeerConnection() pc1._sctpLegacySdp = False pc1_data_messages = [] pc1_states = track_states(pc1) pc2 = RTCPeerConnection() pc2_data_messages = [] pc2_states = track_states(pc2) # create data channels dc1 = pc1.createDataChannel("chat", protocol="bob", negotiated=True, id=100) self.assertEqual(dc1.id, 100) self.assertEqual(dc1.label, "chat") self.assertEqual(dc1.maxPacketLifeTime, None) self.assertEqual(dc1.maxRetransmits, None) self.assertEqual(dc1.ordered, True) self.assertEqual(dc1.protocol, "bob") self.assertEqual(dc1.readyState, "connecting") dc2 = pc2.createDataChannel("chat", protocol="bob", negotiated=True, id=100) self.assertEqual(dc2.id, 100) self.assertEqual(dc2.label, "chat") self.assertEqual(dc2.maxPacketLifeTime, None) self.assertEqual(dc2.maxRetransmits, None) self.assertEqual(dc2.ordered, True) self.assertEqual(dc2.protocol, "bob") self.assertEqual(dc2.readyState, "connecting") @dc1.on("message") def on_message1(message): pc1_data_messages.append(message) @dc2.on("message") def on_message2(message): pc2_data_messages.append(message) if isinstance(message, str): dc2.send("string-echo: " + message) else: dc2.send(b"binary-echo: " + message) # create offer offer = await pc1.createOffer() self.assertEqual(offer.type, "offer") self.assertTrue("m=application " in offer.sdp) self.assertFalse("a=candidate:" in offer.sdp) self.assertFalse("a=end-of-candidates" in offer.sdp) await pc1.setLocalDescription(offer) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "complete") self.assertEqual(mids(pc1), ["0"]) self.assertTrue("m=application " in pc1.localDescription.sdp) self.assertTrue("a=sctp-port:5000" in pc1.localDescription.sdp) self.assertHasIceCandidates(pc1.localDescription) self.assertHasDtls(pc1.localDescription, "actpass") # handle offer await pc2.setRemoteDescription(pc1.localDescription) self.assertEqual(pc2.remoteDescription, pc1.localDescription) self.assertEqual(len(pc2.getReceivers()), 0) self.assertEqual(len(pc2.getSenders()), 0) self.assertEqual(len(pc2.getTransceivers()), 0) self.assertEqual(mids(pc2), ["0"]) # create answer answer = await pc2.createAnswer() self.assertEqual(answer.type, "answer") self.assertTrue("m=application " in answer.sdp) self.assertFalse("a=candidate:" in answer.sdp) self.assertFalse("a=end-of-candidates" in answer.sdp) await pc2.setLocalDescription(answer) await self.assertIceChecking(pc2) self.assertTrue("m=application " in pc2.localDescription.sdp) self.assertTrue("a=sctp-port:5000" in pc2.localDescription.sdp) self.assertHasIceCandidates(pc2.localDescription) self.assertHasDtls(pc2.localDescription, "active") # handle answer await pc1.setRemoteDescription(pc2.localDescription) self.assertEqual(pc1.remoteDescription, pc2.localDescription) # check outcome await self.assertIceCompleted(pc1, pc2) await self.assertDataChannelOpen(dc1) await self.assertDataChannelOpen(dc2) # send message dc1.send("hello") dc1.send("") dc1.send(b"\x00\x01\x02\x03") dc1.send(b"") dc1.send(LONG_DATA) with self.assertRaises(ValueError) as cm: dc1.send(1234) self.assertEqual( str(cm.exception), "Cannot send unsupported data type: " ) # check pc2 got messages await asyncio.sleep(0.1) self.assertEqual( pc2_data_messages, ["hello", "", b"\x00\x01\x02\x03", b"", LONG_DATA] ) # check pc1 got replies self.assertEqual( pc1_data_messages, [ "string-echo: hello", "string-echo: ", b"binary-echo: \x00\x01\x02\x03", b"binary-echo: ", b"binary-echo: " + LONG_DATA, ], ) # close data channels await self.closeDataChannel(dc1) # close await pc1.close() await pc2.close() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "closed") # check state changes self.assertEqual( pc1_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc1_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc1_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc1_states["signalingState"], ["stable", "have-local-offer", "stable", "closed"], ) self.assertEqual( pc2_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc2_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc2_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc2_states["signalingState"], ["stable", "have-remote-offer", "stable", "closed"], ) @asynctest async def test_connect_datachannel_recycle_stream_id(self): pc1 = RTCPeerConnection() pc2 = RTCPeerConnection() # create three data channels dc1 = pc1.createDataChannel("chat1") self.assertEqual(dc1.readyState, "connecting") dc2 = pc1.createDataChannel("chat2") self.assertEqual(dc2.readyState, "connecting") dc3 = pc1.createDataChannel("chat3") self.assertEqual(dc3.readyState, "connecting") # perform SDP exchange await pc1.setLocalDescription(await pc1.createOffer()) await pc2.setRemoteDescription(pc1.localDescription) await pc2.setLocalDescription(await pc2.createAnswer()) await pc1.setRemoteDescription(pc2.localDescription) # check outcome await self.assertIceCompleted(pc1, pc2) await self.assertDataChannelOpen(dc1) self.assertEqual(dc1.id, 1) await self.assertDataChannelOpen(dc2) self.assertEqual(dc2.id, 3) await self.assertDataChannelOpen(dc3) self.assertEqual(dc3.id, 5) # close one data channel await self.closeDataChannel(dc2) # create a new data channel dc4 = pc1.createDataChannel("chat4") await self.assertDataChannelOpen(dc4) self.assertEqual(dc4.id, 3) # close await pc1.close() await pc2.close() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "closed") def test_create_datachannel_with_maxpacketlifetime_and_maxretransmits(self): pc = RTCPeerConnection() with self.assertRaises(ValueError) as cm: pc.createDataChannel("chat", maxPacketLifeTime=500, maxRetransmits=0) self.assertEqual( str(cm.exception), "Cannot specify both maxPacketLifeTime and maxRetransmits", ) @asynctest async def test_datachannel_bufferedamountlowthreshold(self): pc = RTCPeerConnection() dc = pc.createDataChannel("chat") self.assertEqual(dc.bufferedAmountLowThreshold, 0) dc.bufferedAmountLowThreshold = 4294967295 self.assertEqual(dc.bufferedAmountLowThreshold, 4294967295) dc.bufferedAmountLowThreshold = 16384 self.assertEqual(dc.bufferedAmountLowThreshold, 16384) dc.bufferedAmountLowThreshold = 0 self.assertEqual(dc.bufferedAmountLowThreshold, 0) with self.assertRaises(ValueError): dc.bufferedAmountLowThreshold = -1 self.assertEqual(dc.bufferedAmountLowThreshold, 0) with self.assertRaises(ValueError): dc.bufferedAmountLowThreshold = 4294967296 self.assertEqual(dc.bufferedAmountLowThreshold, 0) @asynctest async def test_datachannel_send_invalid_state(self): pc = RTCPeerConnection() dc = pc.createDataChannel("chat") with self.assertRaises(InvalidStateError): dc.send("hello") @asynctest async def test_connect_datachannel_then_audio(self): pc1 = RTCPeerConnection() pc1_data_messages = [] pc1_states = track_states(pc1) pc2 = RTCPeerConnection() pc2_data_channels = [] pc2_data_messages = [] pc2_states = track_states(pc2) @pc2.on("datachannel") def on_datachannel(channel): self.assertEqual(channel.readyState, "open") pc2_data_channels.append(channel) @channel.on("message") def on_message(message): pc2_data_messages.append(message) if isinstance(message, str): channel.send("string-echo: " + message) else: channel.send(b"binary-echo: " + message) # create data channel dc = pc1.createDataChannel("chat", protocol="bob") self.assertEqual(dc.label, "chat") self.assertEqual(dc.maxPacketLifeTime, None) self.assertEqual(dc.maxRetransmits, None) self.assertEqual(dc.ordered, True) self.assertEqual(dc.protocol, "bob") self.assertEqual(dc.readyState, "connecting") # send messages @dc.on("open") def on_open(): dc.send("hello") dc.send("") dc.send(b"\x00\x01\x02\x03") dc.send(b"") dc.send(LONG_DATA) with self.assertRaises(ValueError) as cm: dc.send(1234) self.assertEqual( str(cm.exception), "Cannot send unsupported data type: " ) @dc.on("message") def on_message(message): pc1_data_messages.append(message) # 1. DATA CHANNEL ONLY # create offer offer = await pc1.createOffer() self.assertEqual(offer.type, "offer") self.assertTrue("m=application " in offer.sdp) self.assertFalse("a=candidate:" in offer.sdp) self.assertFalse("a=end-of-candidates" in offer.sdp) await pc1.setLocalDescription(offer) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "complete") self.assertEqual(mids(pc1), ["0"]) self.assertTrue("m=application " in pc1.localDescription.sdp) self.assertHasIceCandidates(pc1.localDescription) self.assertHasDtls(pc1.localDescription, "actpass") # handle offer await pc2.setRemoteDescription(pc1.localDescription) self.assertEqual(pc2.remoteDescription, pc1.localDescription) self.assertEqual(len(pc2.getReceivers()), 0) self.assertEqual(len(pc2.getSenders()), 0) self.assertEqual(len(pc2.getTransceivers()), 0) self.assertEqual(mids(pc2), ["0"]) # create answer answer = await pc2.createAnswer() self.assertEqual(answer.type, "answer") self.assertTrue("m=application " in answer.sdp) self.assertFalse("a=candidate:" in answer.sdp) self.assertFalse("a=end-of-candidates" in answer.sdp) await pc2.setLocalDescription(answer) await self.assertIceChecking(pc2) self.assertTrue("m=application " in pc2.localDescription.sdp) self.assertHasIceCandidates(pc2.localDescription) self.assertHasDtls(pc2.localDescription, "active") # handle answer await pc1.setRemoteDescription(pc2.localDescription) self.assertEqual(pc1.remoteDescription, pc2.localDescription) # check outcome await self.assertIceCompleted(pc1, pc2) await self.assertDataChannelOpen(dc) # check pc2 got a datachannel self.assertEqual(len(pc2_data_channels), 1) self.assertEqual(pc2_data_channels[0].label, "chat") self.assertEqual(pc2_data_channels[0].maxPacketLifeTime, None) self.assertEqual(pc2_data_channels[0].maxRetransmits, None) self.assertEqual(pc2_data_channels[0].ordered, True) self.assertEqual(pc2_data_channels[0].protocol, "bob") # check pc2 got messages await asyncio.sleep(0.1) self.assertEqual( pc2_data_messages, ["hello", "", b"\x00\x01\x02\x03", b"", LONG_DATA] ) # check pc1 got replies self.assertEqual( pc1_data_messages, [ "string-echo: hello", "string-echo: ", b"binary-echo: \x00\x01\x02\x03", b"binary-echo: ", b"binary-echo: " + LONG_DATA, ], ) # 2. ADD AUDIO # create offer pc1.addTrack(AudioStreamTrack()) offer = await pc1.createOffer() self.assertEqual(offer.type, "offer") self.assertTrue("m=application " in offer.sdp) self.assertTrue("m=audio " in offer.sdp) await pc1.setLocalDescription(offer) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "complete") self.assertEqual(mids(pc1), ["0", "1"]) # handle offer await pc2.setRemoteDescription(pc1.localDescription) self.assertEqual(pc2.remoteDescription, pc1.localDescription) self.assertEqual(len(pc2.getReceivers()), 1) self.assertEqual(len(pc2.getSenders()), 1) self.assertEqual(len(pc2.getTransceivers()), 1) self.assertEqual(mids(pc2), ["0", "1"]) # create answer pc2.addTrack(AudioStreamTrack()) answer = await pc2.createAnswer() self.assertEqual(answer.type, "answer") self.assertTrue("m=application " in answer.sdp) self.assertTrue("m=audio " in answer.sdp) await pc2.setLocalDescription(answer) self.assertEqual(pc2.iceConnectionState, "completed") self.assertEqual(pc2.iceGatheringState, "complete") self.assertTrue("m=application " in pc2.localDescription.sdp) self.assertTrue("m=audio " in pc2.localDescription.sdp) # handle answer await pc1.setRemoteDescription(pc2.localDescription) self.assertEqual(pc1.remoteDescription, pc2.localDescription) self.assertEqual(pc1.iceConnectionState, "completed") # check outcome await self.assertIceCompleted(pc1, pc2) # check a single transport is used self.assertBundled(pc1) self.assertBundled(pc2) # 3. CLEANUP # close data channel await self.closeDataChannel(dc) # close await pc1.close() await pc2.close() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "closed") # check state changes self.assertEqual( pc1_states["connectionState"], ["new", "connecting", "connected", "connecting", "connected", "closed"], ) self.assertEqual( pc1_states["iceConnectionState"], ["new", "checking", "completed", "new", "completed", "closed"], ) self.assertEqual( pc1_states["iceGatheringState"], ["new", "gathering", "complete", "new", "gathering", "complete"], ) self.assertEqual( pc1_states["signalingState"], [ "stable", "have-local-offer", "stable", "have-local-offer", "stable", "closed", ], ) self.assertEqual( pc2_states["connectionState"], ["new", "connecting", "connected", "connecting", "connected", "closed"], ) self.assertEqual( pc2_states["iceConnectionState"], ["new", "checking", "completed", "new", "completed", "closed"], ) self.assertEqual( pc2_states["iceGatheringState"], ["new", "gathering", "complete", "new", "complete"], ) self.assertEqual( pc2_states["signalingState"], [ "stable", "have-remote-offer", "stable", "have-remote-offer", "stable", "closed", ], ) @asynctest async def test_connect_datachannel_trickle(self): pc1 = RTCPeerConnection() pc1_data_messages = [] pc1_states = track_states(pc1) pc2 = RTCPeerConnection() pc2_data_channels = [] pc2_data_messages = [] pc2_states = track_states(pc2) @pc2.on("datachannel") def on_datachannel(channel): self.assertEqual(channel.readyState, "open") pc2_data_channels.append(channel) @channel.on("message") def on_message(message): pc2_data_messages.append(message) if isinstance(message, str): channel.send("string-echo: " + message) else: channel.send(b"binary-echo: " + message) # create data channel dc = pc1.createDataChannel("chat", protocol="bob") self.assertEqual(dc.label, "chat") self.assertEqual(dc.maxPacketLifeTime, None) self.assertEqual(dc.maxRetransmits, None) self.assertEqual(dc.ordered, True) self.assertEqual(dc.protocol, "bob") self.assertEqual(dc.readyState, "connecting") # send messages @dc.on("open") def on_open(): dc.send("hello") dc.send("") dc.send(b"\x00\x01\x02\x03") dc.send(b"") dc.send(LONG_DATA) with self.assertRaises(ValueError) as cm: dc.send(1234) self.assertEqual( str(cm.exception), "Cannot send unsupported data type: " ) @dc.on("message") def on_message(message): pc1_data_messages.append(message) # create offer offer = await pc1.createOffer() self.assertEqual(offer.type, "offer") self.assertTrue("m=application " in offer.sdp) self.assertFalse("a=candidate:" in offer.sdp) self.assertFalse("a=end-of-candidates" in offer.sdp) await pc1.setLocalDescription(offer) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "complete") self.assertEqual(mids(pc1), ["0"]) self.assertTrue("m=application " in pc1.localDescription.sdp) self.assertHasIceCandidates(pc1.localDescription) self.assertHasDtls(pc1.localDescription, "actpass") # strip out candidates desc1 = strip_ice_candidates(pc1.localDescription) # handle offer await pc2.setRemoteDescription(desc1) self.assertEqual(pc2.remoteDescription, desc1) self.assertEqual(len(pc2.getReceivers()), 0) self.assertEqual(len(pc2.getSenders()), 0) self.assertEqual(len(pc2.getTransceivers()), 0) self.assertEqual(mids(pc2), ["0"]) # create answer answer = await pc2.createAnswer() self.assertEqual(answer.type, "answer") self.assertTrue("m=application " in answer.sdp) self.assertFalse("a=candidate:" in answer.sdp) self.assertFalse("a=end-of-candidates" in answer.sdp) await pc2.setLocalDescription(answer) await self.assertIceChecking(pc2) self.assertTrue("m=application " in pc2.localDescription.sdp) self.assertHasIceCandidates(pc2.localDescription) self.assertHasDtls(pc2.localDescription, "active") # strip out candidates desc2 = strip_ice_candidates(pc2.localDescription) # handle answer await pc1.setRemoteDescription(desc2) self.assertEqual(pc1.remoteDescription, desc2) # trickle candidates for candidate in pc2.sctp.transport.transport.iceGatherer.getLocalCandidates(): candidate.sdpMid = pc2.sctp.mid await pc1.addIceCandidate(candidate) for candidate in pc1.sctp.transport.transport.iceGatherer.getLocalCandidates(): candidate.sdpMid = pc1.sctp.mid await pc2.addIceCandidate(candidate) # check outcome await self.assertIceCompleted(pc1, pc2) await self.assertDataChannelOpen(dc) # check pc2 got a datachannel self.assertEqual(len(pc2_data_channels), 1) self.assertEqual(pc2_data_channels[0].label, "chat") self.assertEqual(pc2_data_channels[0].maxPacketLifeTime, None) self.assertEqual(pc2_data_channels[0].maxRetransmits, None) self.assertEqual(pc2_data_channels[0].ordered, True) self.assertEqual(pc2_data_channels[0].protocol, "bob") # check pc2 got messages await asyncio.sleep(0.1) self.assertEqual( pc2_data_messages, ["hello", "", b"\x00\x01\x02\x03", b"", LONG_DATA] ) # check pc1 got replies self.assertEqual( pc1_data_messages, [ "string-echo: hello", "string-echo: ", b"binary-echo: \x00\x01\x02\x03", b"binary-echo: ", b"binary-echo: " + LONG_DATA, ], ) # close data channel await self.closeDataChannel(dc) # close await pc1.close() await pc2.close() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "closed") # check state changes self.assertEqual( pc1_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc1_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc1_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc1_states["signalingState"], ["stable", "have-local-offer", "stable", "closed"], ) self.assertEqual( pc2_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc2_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc2_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc2_states["signalingState"], ["stable", "have-remote-offer", "stable", "closed"], ) @asynctest async def test_connect_datachannel_max_packet_lifetime(self): pc1 = RTCPeerConnection() pc1_data_messages = [] pc1_states = track_states(pc1) pc2 = RTCPeerConnection() pc2_data_channels = [] pc2_data_messages = [] pc2_states = track_states(pc2) @pc2.on("datachannel") def on_datachannel(channel): self.assertEqual(channel.readyState, "open") pc2_data_channels.append(channel) @channel.on("message") def on_message(message): pc2_data_messages.append(message) channel.send("string-echo: " + message) # create data channel dc = pc1.createDataChannel("chat", maxPacketLifeTime=500, protocol="bob") self.assertEqual(dc.label, "chat") self.assertEqual(dc.maxPacketLifeTime, 500) self.assertEqual(dc.maxRetransmits, None) self.assertEqual(dc.ordered, True) self.assertEqual(dc.protocol, "bob") self.assertEqual(dc.readyState, "connecting") # send message @dc.on("open") def on_open(): dc.send("hello") @dc.on("message") def on_message(message): pc1_data_messages.append(message) # create offer offer = await pc1.createOffer() await pc1.setLocalDescription(offer) await pc2.setRemoteDescription(pc1.localDescription) # create answer answer = await pc2.createAnswer() await pc2.setLocalDescription(answer) await pc1.setRemoteDescription(pc2.localDescription) # check outcome await self.assertIceCompleted(pc1, pc2) await self.assertDataChannelOpen(dc) # check pc2 got a datachannel self.assertEqual(len(pc2_data_channels), 1) self.assertEqual(pc2_data_channels[0].label, "chat") self.assertEqual(pc2_data_channels[0].maxPacketLifeTime, 500) self.assertEqual(pc2_data_channels[0].maxRetransmits, None) self.assertEqual(pc2_data_channels[0].ordered, True) self.assertEqual(pc2_data_channels[0].protocol, "bob") # check pc2 got message await asyncio.sleep(0.1) self.assertEqual(pc2_data_messages, ["hello"]) # check pc1 got replies self.assertEqual(pc1_data_messages, ["string-echo: hello"]) # close data channel await self.closeDataChannel(dc) # close await pc1.close() await pc2.close() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "closed") # check state changes self.assertEqual( pc1_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc1_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc1_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc1_states["signalingState"], ["stable", "have-local-offer", "stable", "closed"], ) self.assertEqual( pc2_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc2_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc2_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc2_states["signalingState"], ["stable", "have-remote-offer", "stable", "closed"], ) @asynctest async def test_connect_datachannel_max_retransmits(self): pc1 = RTCPeerConnection() pc1_data_messages = [] pc1_states = track_states(pc1) pc2 = RTCPeerConnection() pc2_data_channels = [] pc2_data_messages = [] pc2_states = track_states(pc2) @pc2.on("datachannel") def on_datachannel(channel): self.assertEqual(channel.readyState, "open") pc2_data_channels.append(channel) @channel.on("message") def on_message(message): pc2_data_messages.append(message) channel.send("string-echo: " + message) # create data channel dc = pc1.createDataChannel("chat", maxRetransmits=0, protocol="bob") self.assertEqual(dc.label, "chat") self.assertEqual(dc.maxPacketLifeTime, None) self.assertEqual(dc.maxRetransmits, 0) self.assertEqual(dc.ordered, True) self.assertEqual(dc.protocol, "bob") self.assertEqual(dc.readyState, "connecting") # send message @dc.on("open") def on_open(): dc.send("hello") @dc.on("message") def on_message(message): pc1_data_messages.append(message) # create offer offer = await pc1.createOffer() await pc1.setLocalDescription(offer) await pc2.setRemoteDescription(pc1.localDescription) # create answer answer = await pc2.createAnswer() await pc2.setLocalDescription(answer) await pc1.setRemoteDescription(pc2.localDescription) # check outcome await self.assertIceCompleted(pc1, pc2) await self.assertDataChannelOpen(dc) # check pc2 got a datachannel self.assertEqual(len(pc2_data_channels), 1) self.assertEqual(pc2_data_channels[0].label, "chat") self.assertEqual(pc2_data_channels[0].maxPacketLifeTime, None) self.assertEqual(pc2_data_channels[0].maxRetransmits, 0) self.assertEqual(pc2_data_channels[0].ordered, True) self.assertEqual(pc2_data_channels[0].protocol, "bob") # check pc2 got message await asyncio.sleep(0.1) self.assertEqual(pc2_data_messages, ["hello"]) # check pc1 got replies self.assertEqual(pc1_data_messages, ["string-echo: hello"]) # close data channel await self.closeDataChannel(dc) # close await pc1.close() await pc2.close() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "closed") # check state changes self.assertEqual( pc1_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc1_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc1_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc1_states["signalingState"], ["stable", "have-local-offer", "stable", "closed"], ) self.assertEqual( pc2_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc2_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc2_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc2_states["signalingState"], ["stable", "have-remote-offer", "stable", "closed"], ) @asynctest async def test_connect_datachannel_unordered(self): pc1 = RTCPeerConnection() pc1_data_messages = [] pc1_states = track_states(pc1) pc2 = RTCPeerConnection() pc2_data_channels = [] pc2_data_messages = [] pc2_states = track_states(pc2) @pc2.on("datachannel") def on_datachannel(channel): self.assertEqual(channel.readyState, "open") pc2_data_channels.append(channel) @channel.on("message") def on_message(message): pc2_data_messages.append(message) channel.send("string-echo: " + message) # create data channel dc = pc1.createDataChannel("chat", ordered=False, protocol="bob") self.assertEqual(dc.label, "chat") self.assertEqual(dc.maxPacketLifeTime, None) self.assertEqual(dc.maxRetransmits, None) self.assertEqual(dc.ordered, False) self.assertEqual(dc.protocol, "bob") self.assertEqual(dc.readyState, "connecting") # send message @dc.on("open") def on_open(): dc.send("hello") @dc.on("message") def on_message(message): pc1_data_messages.append(message) # create offer offer = await pc1.createOffer() self.assertEqual(offer.type, "offer") self.assertTrue("m=application " in offer.sdp) self.assertFalse("a=candidate:" in offer.sdp) self.assertFalse("a=end-of-candidates" in offer.sdp) await pc1.setLocalDescription(offer) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "complete") self.assertEqual(mids(pc1), ["0"]) self.assertTrue("m=application " in pc1.localDescription.sdp) self.assertHasIceCandidates(pc1.localDescription) self.assertHasDtls(pc1.localDescription, "actpass") # handle offer await pc2.setRemoteDescription(pc1.localDescription) self.assertEqual(pc2.remoteDescription, pc1.localDescription) self.assertEqual(len(pc2.getReceivers()), 0) self.assertEqual(len(pc2.getSenders()), 0) self.assertEqual(len(pc2.getTransceivers()), 0) self.assertEqual(mids(pc2), ["0"]) # create answer answer = await pc2.createAnswer() self.assertEqual(answer.type, "answer") self.assertTrue("m=application " in answer.sdp) self.assertFalse("a=candidate:" in answer.sdp) self.assertFalse("a=end-of-candidates" in answer.sdp) await pc2.setLocalDescription(answer) await self.assertIceChecking(pc2) self.assertTrue("m=application " in pc2.localDescription.sdp) self.assertHasIceCandidates(pc2.localDescription) self.assertHasDtls(pc2.localDescription, "active") # handle answer await pc1.setRemoteDescription(pc2.localDescription) self.assertEqual(pc1.remoteDescription, pc2.localDescription) # check outcome await self.assertIceCompleted(pc1, pc2) await self.assertDataChannelOpen(dc) # check pc2 got a datachannel self.assertEqual(len(pc2_data_channels), 1) self.assertEqual(pc2_data_channels[0].label, "chat") self.assertEqual(pc2_data_channels[0].maxPacketLifeTime, None) self.assertEqual(pc2_data_channels[0].maxRetransmits, None) self.assertEqual(pc2_data_channels[0].ordered, False) self.assertEqual(pc2_data_channels[0].protocol, "bob") # check pc2 got message await asyncio.sleep(0.1) self.assertEqual(pc2_data_messages, ["hello"]) # check pc1 got replies self.assertEqual(pc1_data_messages, ["string-echo: hello"]) # close data channel await self.closeDataChannel(dc) # close await pc1.close() await pc2.close() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "closed") # check state changes self.assertEqual( pc1_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc1_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc1_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc1_states["signalingState"], ["stable", "have-local-offer", "stable", "closed"], ) self.assertEqual( pc2_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc2_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc2_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc2_states["signalingState"], ["stable", "have-remote-offer", "stable", "closed"], ) @asynctest async def test_createAnswer_closed(self): pc = RTCPeerConnection() await pc.close() with self.assertRaises(InvalidStateError) as cm: await pc.createAnswer() self.assertEqual(str(cm.exception), "RTCPeerConnection is closed") @asynctest async def test_createAnswer_without_offer(self): pc = RTCPeerConnection() with self.assertRaises(InvalidStateError) as cm: await pc.createAnswer() self.assertEqual( str(cm.exception), 'Cannot create answer in signaling state "stable"' ) @asynctest async def test_createOffer_closed(self): pc = RTCPeerConnection() await pc.close() with self.assertRaises(InvalidStateError) as cm: await pc.createOffer() self.assertEqual(str(cm.exception), "RTCPeerConnection is closed") @asynctest async def test_createOffer_without_media(self): pc = RTCPeerConnection() with self.assertRaises(InternalError) as cm: await pc.createOffer() self.assertEqual( str(cm.exception), "Cannot create an offer with no media and no data channels", ) # close await pc.close() @asynctest async def test_setLocalDescription_unexpected_answer(self): pc = RTCPeerConnection() pc.addTrack(AudioStreamTrack()) answer = await pc.createOffer() answer.type = "answer" with self.assertRaises(InvalidStateError) as cm: await pc.setLocalDescription(answer) self.assertEqual( str(cm.exception), 'Cannot handle answer in signaling state "stable"' ) # close await pc.close() @asynctest async def test_setLocalDescription_unexpected_offer(self): pc1 = RTCPeerConnection() pc2 = RTCPeerConnection() # apply offer pc1.addTrack(AudioStreamTrack()) await pc1.setLocalDescription(await pc1.createOffer()) await pc2.setRemoteDescription(pc1.localDescription) # mangle answer into an offer offer = pc2.remoteDescription offer.type = "offer" with self.assertRaises(InvalidStateError) as cm: await pc2.setLocalDescription(offer) self.assertEqual( str(cm.exception), 'Cannot handle offer in signaling state "have-remote-offer"', ) # close await pc1.close() await pc2.close() @asynctest async def test_setRemoteDescription_no_common_audio(self): pc1 = RTCPeerConnection() pc2 = RTCPeerConnection() pc1.addTrack(AudioStreamTrack()) offer = await pc1.createOffer() mangled_sdp = [] for line in offer.sdp.split("\n"): if line.startswith("a=rtpmap:"): continue mangled_sdp.append(line) mangled = RTCSessionDescription(sdp="\n".join(mangled_sdp), type=offer.type) with self.assertRaises(OperationError) as cm: await pc2.setRemoteDescription(mangled) self.assertEqual( str(cm.exception), "Failed to set remote audio description send parameters" ) # close await pc1.close() await pc2.close() @asynctest async def test_setRemoteDescription_no_common_video(self): pc1 = RTCPeerConnection() pc2 = RTCPeerConnection() pc1.addTrack(VideoStreamTrack()) offer = await pc1.createOffer() mangled = RTCSessionDescription( sdp=offer.sdp.replace("90000", "92000"), type=offer.type, ) with self.assertRaises(OperationError) as cm: await pc2.setRemoteDescription(mangled) self.assertEqual( str(cm.exception), "Failed to set remote video description send parameters" ) # close await pc1.close() await pc2.close() @asynctest async def test_setRemoteDescription_media_mismatch(self): pc1 = RTCPeerConnection() pc2 = RTCPeerConnection() # apply offer pc1.addTrack(AudioStreamTrack()) offer = await pc1.createOffer() await pc1.setLocalDescription(offer) await pc2.setRemoteDescription(pc1.localDescription) # apply answer answer = await pc2.createAnswer() await pc2.setLocalDescription(answer) mangled = RTCSessionDescription( sdp=pc2.localDescription.sdp.replace("m=audio", "m=video"), type=pc2.localDescription.type, ) with self.assertRaises(ValueError) as cm: await pc1.setRemoteDescription(mangled) self.assertEqual( str(cm.exception), "Media sections in answer do not match offer" ) # close await pc1.close() await pc2.close() @asynctest async def test_setRemoteDescription_with_invalid_dtls_setup_for_offer(self): pc1 = RTCPeerConnection() pc2 = RTCPeerConnection() # apply offer pc1.addTrack(AudioStreamTrack()) offer = await pc1.createOffer() await pc1.setLocalDescription(offer) mangled = RTCSessionDescription( sdp=pc1.localDescription.sdp.replace("a=setup:actpass", "a=setup:active"), type=pc1.localDescription.type, ) with self.assertRaises(ValueError) as cm: await pc2.setRemoteDescription(mangled) self.assertEqual( str(cm.exception), "DTLS setup attribute must be 'actpass' for an offer", ) # close await pc1.close() await pc2.close() @asynctest async def test_setRemoteDescription_with_invalid_dtls_setup_for_answer(self): pc1 = RTCPeerConnection() pc2 = RTCPeerConnection() # apply offer pc1.addTrack(AudioStreamTrack()) offer = await pc1.createOffer() await pc1.setLocalDescription(offer) await pc2.setRemoteDescription(pc1.localDescription) # apply answer answer = await pc2.createAnswer() await pc2.setLocalDescription(answer) mangled = RTCSessionDescription( sdp=pc2.localDescription.sdp.replace("a=setup:active", "a=setup:actpass"), type=pc2.localDescription.type, ) with self.assertRaises(ValueError) as cm: await pc1.setRemoteDescription(mangled) self.assertEqual( str(cm.exception), "DTLS setup attribute must be 'active' or 'passive' for an answer", ) # close await pc1.close() await pc2.close() @asynctest async def test_setRemoteDescription_without_ice_credentials(self): pc1 = RTCPeerConnection() pc2 = RTCPeerConnection() pc1.addTrack(AudioStreamTrack()) offer = await pc1.createOffer() await pc1.setLocalDescription(offer) mangled = RTCSessionDescription( sdp=re.sub( "^a=(ice-ufrag|ice-pwd):.*\r\n", "", pc1.localDescription.sdp, flags=re.M, ), type=pc1.localDescription.type, ) with self.assertRaises(ValueError) as cm: await pc2.setRemoteDescription(mangled) self.assertEqual( str(cm.exception), "ICE username fragment or password is missing" ) # close await pc1.close() await pc2.close() @asynctest async def test_setRemoteDescription_without_rtcp_mux(self): pc1 = RTCPeerConnection() pc2 = RTCPeerConnection() pc1.addTrack(AudioStreamTrack()) offer = await pc1.createOffer() await pc1.setLocalDescription(offer) mangled = RTCSessionDescription( sdp=re.sub("^a=rtcp-mux\r\n", "", pc1.localDescription.sdp, flags=re.M), type=pc1.localDescription.type, ) with self.assertRaises(ValueError) as cm: await pc2.setRemoteDescription(mangled) self.assertEqual(str(cm.exception), "RTCP mux is not enabled") # close await pc1.close() await pc2.close() @asynctest async def test_setRemoteDescription_unexpected_answer(self): pc = RTCPeerConnection() with self.assertRaises(InvalidStateError) as cm: await pc.setRemoteDescription(RTCSessionDescription(sdp="", type="answer")) self.assertEqual( str(cm.exception), 'Cannot handle answer in signaling state "stable"' ) # close await pc.close() @asynctest async def test_setRemoteDescription_unexpected_offer(self): pc = RTCPeerConnection() pc.addTrack(AudioStreamTrack()) offer = await pc.createOffer() await pc.setLocalDescription(offer) with self.assertRaises(InvalidStateError) as cm: await pc.setRemoteDescription(RTCSessionDescription(sdp="", type="offer")) self.assertEqual( str(cm.exception), 'Cannot handle offer in signaling state "have-local-offer"', ) # close await pc.close() @asynctest async def test_setRemoteDescription_media_datachannel_bundled(self): pc1 = RTCPeerConnection() pc2 = RTCPeerConnection() pc1_states = track_states(pc1) pc2_states = track_states(pc2) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "new") self.assertIsNone(pc1.localDescription) self.assertIsNone(pc1.remoteDescription) self.assertEqual(pc2.iceConnectionState, "new") self.assertEqual(pc2.iceGatheringState, "new") self.assertIsNone(pc2.localDescription) self.assertIsNone(pc2.remoteDescription) """ initial negotiation """ # create offer pc1.addTrack(AudioStreamTrack()) pc1.createDataChannel("chat", protocol="") offer = await pc1.createOffer() self.assertEqual(offer.type, "offer") await pc1.setLocalDescription(offer) self.assertEqual(pc1.iceConnectionState, "new") self.assertEqual(pc1.iceGatheringState, "complete") self.assertEqual(mids(pc1), ["0", "1"]) self.assertTrue("a=group:BUNDLE 0 1" in pc1.localDescription.sdp) self.assertTrue("m=audio " in pc1.localDescription.sdp) # handle offer await pc2.setRemoteDescription(pc1.localDescription) self.assertEqual(pc2.remoteDescription, pc1.localDescription) self.assertEqual(len(pc2.getReceivers()), 1) self.assertEqual(len(pc2.getSenders()), 1) self.assertEqual(len(pc2.getTransceivers()), 1) self.assertEqual(mids(pc2), ["0", "1"]) # create answer answer = await pc2.createAnswer() self.assertEqual(answer.type, "answer") self.assertTrue("a=group:BUNDLE 0 1" in answer.sdp) self.assertTrue("m=audio " in answer.sdp) self.assertTrue("m=application " in answer.sdp) await pc2.setLocalDescription(answer) await self.assertIceChecking(pc2) self.assertEqual(mids(pc2), ["0", "1"]) self.assertTrue("a=group:BUNDLE 0 1" in pc2.localDescription.sdp) self.assertTrue("m=audio " in pc2.localDescription.sdp) self.assertTrue("m=application " in pc2.localDescription.sdp) # handle answer await pc1.setRemoteDescription(pc2.localDescription) self.assertEqual(pc1.remoteDescription, pc2.localDescription) # check outcome await self.assertIceCompleted(pc1, pc2) """ renegotiation """ # create offer offer = await pc1.createOffer() self.assertEqual(offer.type, "offer") await pc1.setLocalDescription(offer) self.assertEqual(pc1.iceConnectionState, "completed") self.assertEqual(pc1.iceGatheringState, "complete") self.assertEqual(mids(pc1), ["0", "1"]) self.assertTrue("a=group:BUNDLE 0 1" in pc1.localDescription.sdp) self.assertTrue("m=audio " in pc1.localDescription.sdp) self.assertTrue("m=application " in pc1.localDescription.sdp) self.assertHasDtls(pc1.localDescription, "actpass") # handle offer await pc2.setRemoteDescription(pc1.localDescription) self.assertEqual(pc2.remoteDescription, pc1.localDescription) self.assertEqual(len(pc2.getReceivers()), 1) self.assertEqual(len(pc2.getSenders()), 1) self.assertEqual(len(pc2.getTransceivers()), 1) self.assertEqual(mids(pc2), ["0", "1"]) # create answer answer = await pc2.createAnswer() self.assertEqual(answer.type, "answer") self.assertTrue("a=group:BUNDLE 0 1" in answer.sdp) self.assertTrue("m=audio " in answer.sdp) self.assertTrue("m=application " in answer.sdp) await pc2.setLocalDescription(answer) self.assertEqual(pc2.iceConnectionState, "completed") self.assertEqual(pc2.iceGatheringState, "complete") self.assertEqual(mids(pc2), ["0", "1"]) self.assertTrue("a=group:BUNDLE 0 1" in pc2.localDescription.sdp) self.assertTrue("m=audio " in pc2.localDescription.sdp) self.assertTrue("m=application " in pc2.localDescription.sdp) self.assertHasDtls(pc2.localDescription, "active") # handle answer await pc1.setRemoteDescription(pc2.localDescription) self.assertEqual(pc1.remoteDescription, pc2.localDescription) self.assertEqual(pc1.iceConnectionState, "completed") # allow media to flow long enough to collect stats await asyncio.sleep(2) # close await pc1.close() await pc2.close() self.assertEqual(pc1.iceConnectionState, "closed") self.assertEqual(pc2.iceConnectionState, "closed") # check state changes self.assertEqual( pc1_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc1_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc1_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc1_states["signalingState"], [ "stable", "have-local-offer", "stable", "have-local-offer", "stable", "closed", ], ) self.assertEqual( pc2_states["connectionState"], ["new", "connecting", "connected", "closed"] ) self.assertEqual( pc2_states["iceConnectionState"], ["new", "checking", "completed", "closed"] ) self.assertEqual( pc2_states["iceGatheringState"], ["new", "gathering", "complete"] ) self.assertEqual( pc2_states["signalingState"], [ "stable", "have-remote-offer", "stable", "have-remote-offer", "stable", "closed", ], ) aiortc-1.3.0/tests/test_rtcrtpreceiver.py000066400000000000000000000441431417604566400206350ustar00rootroot00000000000000import asyncio import contextlib import fractions from collections import OrderedDict from unittest import TestCase from unittest.mock import patch from aiortc.codecs import PCMU_CODEC, get_encoder from aiortc.exceptions import InvalidStateError from aiortc.mediastreams import MediaStreamError from aiortc.rtcrtpparameters import ( RTCRtpCapabilities, RTCRtpCodecCapability, RTCRtpCodecParameters, RTCRtpEncodingParameters, RTCRtpHeaderExtensionCapability, RTCRtpReceiveParameters, RTCRtpRtxParameters, ) from aiortc.rtcrtpreceiver import ( NackGenerator, RemoteStreamTrack, RTCRtpReceiver, RTCRtpSynchronizationSource, StreamStatistics, TimestampMapper, ) from aiortc.rtp import RtcpPacket, RtpPacket from aiortc.stats import RTCStatsReport from aiortc.utils import uint16_add from .codecs import CodecTestCase from .utils import ClosedDtlsTransport, asynctest, dummy_dtls_transport_pair, load VP8_CODEC = RTCRtpCodecParameters( mimeType="video/VP8", clockRate=90000, payloadType=100 ) @contextlib.asynccontextmanager async def create_receiver(kind): async with dummy_dtls_transport_pair() as (local_transport, _): receiver = RTCRtpReceiver(kind, local_transport) assert receiver.transport == local_transport try: yield receiver finally: await receiver.stop() def create_rtp_packets(count, seq=0): packets = [] for i in range(count): packets.append( RtpPacket( payload_type=0, sequence_number=uint16_add(seq, i), ssrc=1234, timestamp=i * 160, ) ) return packets def create_rtp_video_packets(self, codec, frames, seq=0): encoder = get_encoder(codec) packets = [] for frame in self.create_video_frames(width=640, height=480, count=frames): payloads, timestamp = encoder.encode(frame) self.assertEqual(len(payloads), 1) packet = RtpPacket( payload_type=codec.payloadType, sequence_number=seq, ssrc=1234, timestamp=timestamp, ) packet.payload = payloads[0] packet.marker = 1 packets.append(packet) seq = uint16_add(seq, 1) return packets class NackGeneratorTest(TestCase): def test_no_loss(self): generator = NackGenerator() for packet in create_rtp_packets(20, 0): missed = generator.add(packet) self.assertEqual(missed, False) self.assertEqual(generator.missing, set()) def test_with_loss(self): generator = NackGenerator() # receive packets: 0, <1 missing>, 2 packets = create_rtp_packets(3, 0) missing = packets.pop(1) for packet in packets: missed = generator.add(packet) self.assertEqual(missed, packet.sequence_number == 2) self.assertEqual(generator.missing, set([1])) # late arrival missed = generator.add(missing) self.assertEqual(missed, False) self.assertEqual(generator.missing, set()) class StreamStatisticsTest(TestCase): def create_counter(self): return StreamStatistics(clockrate=8000) def test_no_loss(self): counter = self.create_counter() packets = create_rtp_packets(20, 0) # receive 10 packets for packet in packets[0:10]: counter.add(packet) self.assertEqual(counter.max_seq, 9) self.assertEqual(counter.packets_received, 10) self.assertEqual(counter.packets_lost, 0) self.assertEqual(counter.fraction_lost, 0) # receive 10 more packets for packet in packets[10:20]: counter.add(packet) self.assertEqual(counter.max_seq, 19) self.assertEqual(counter.packets_received, 20) self.assertEqual(counter.packets_lost, 0) self.assertEqual(counter.fraction_lost, 0) def test_no_loss_cycle(self): counter = self.create_counter() # receive 10 packets (with sequence cycle) for packet in create_rtp_packets(10, 65530): counter.add(packet) self.assertEqual(counter.max_seq, 3) self.assertEqual(counter.packets_received, 10) self.assertEqual(counter.packets_lost, 0) self.assertEqual(counter.fraction_lost, 0) def test_with_loss(self): counter = self.create_counter() packets = create_rtp_packets(20, 0) packets.pop(1) # receive 9 packets (one missing) for packet in packets[0:9]: counter.add(packet) self.assertEqual(counter.max_seq, 9) self.assertEqual(counter.packets_received, 9) self.assertEqual(counter.packets_lost, 1) self.assertEqual(counter.fraction_lost, 25) # receive 10 more packets for packet in packets[9:19]: counter.add(packet) self.assertEqual(counter.max_seq, 19) self.assertEqual(counter.packets_received, 19) self.assertEqual(counter.packets_lost, 1) self.assertEqual(counter.fraction_lost, 0) @patch("time.time") def test_no_jitter(self, mock_time): counter = self.create_counter() packets = create_rtp_packets(3, 0) mock_time.return_value = 1531562330.00 counter.add(packets[0]) self.assertEqual(counter._jitter_q4, 0) self.assertEqual(counter.jitter, 0) mock_time.return_value = 1531562330.02 counter.add(packets[1]) self.assertEqual(counter._jitter_q4, 0) self.assertEqual(counter.jitter, 0) mock_time.return_value = 1531562330.04 counter.add(packets[2]) self.assertEqual(counter._jitter_q4, 0) self.assertEqual(counter.jitter, 0) @patch("time.time") def test_with_jitter(self, mock_time): counter = self.create_counter() packets = create_rtp_packets(3, 0) mock_time.return_value = 1531562330.00 counter.add(packets[0]) self.assertEqual(counter._jitter_q4, 0) self.assertEqual(counter.jitter, 0) mock_time.return_value = 1531562330.03 counter.add(packets[1]) self.assertEqual(counter._jitter_q4, 80) self.assertEqual(counter.jitter, 5) mock_time.return_value = 1531562330.05 counter.add(packets[2]) self.assertEqual(counter._jitter_q4, 75) self.assertEqual(counter.jitter, 4) class RTCRtpReceiverTest(CodecTestCase): def test_capabilities(self): # audio capabilities = RTCRtpReceiver.getCapabilities("audio") self.assertTrue(isinstance(capabilities, RTCRtpCapabilities)) self.assertEqual( capabilities.codecs, [ RTCRtpCodecCapability( mimeType="audio/opus", clockRate=48000, channels=2 ), RTCRtpCodecCapability( mimeType="audio/PCMU", clockRate=8000, channels=1 ), RTCRtpCodecCapability( mimeType="audio/PCMA", clockRate=8000, channels=1 ), ], ) self.assertEqual( capabilities.headerExtensions, [ RTCRtpHeaderExtensionCapability( uri="urn:ietf:params:rtp-hdrext:sdes:mid" ), RTCRtpHeaderExtensionCapability( uri="urn:ietf:params:rtp-hdrext:ssrc-audio-level" ), ], ) # video capabilities = RTCRtpReceiver.getCapabilities("video") self.assertTrue(isinstance(capabilities, RTCRtpCapabilities)) self.assertEqual( capabilities.codecs, [ RTCRtpCodecCapability(mimeType="video/VP8", clockRate=90000), RTCRtpCodecCapability(mimeType="video/rtx", clockRate=90000), RTCRtpCodecCapability( mimeType="video/H264", clockRate=90000, parameters=OrderedDict( [ ("packetization-mode", "1"), ("level-asymmetry-allowed", "1"), ("profile-level-id", "42001f"), ] ), ), RTCRtpCodecCapability( mimeType="video/H264", clockRate=90000, parameters=OrderedDict( [ ("packetization-mode", "1"), ("level-asymmetry-allowed", "1"), ("profile-level-id", "42e01f"), ] ), ), ], ) self.assertEqual( capabilities.headerExtensions, [ RTCRtpHeaderExtensionCapability( uri="urn:ietf:params:rtp-hdrext:sdes:mid" ), RTCRtpHeaderExtensionCapability( uri="http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time" ), ], ) # bogus with self.assertRaises(ValueError): RTCRtpReceiver.getCapabilities("bogus") @asynctest async def test_connection_error(self): """ Close the underlying transport before the receiver. """ async with create_receiver("audio") as receiver: receiver._track = RemoteStreamTrack(kind="audio") receiver._set_rtcp_ssrc(1234) await receiver.receive(RTCRtpReceiveParameters(codecs=[PCMU_CODEC])) # receive a packet to prime RTCP packet = RtpPacket.parse(load("rtp.bin")) await receiver._handle_rtp_packet(packet, arrival_time_ms=0) # break connection await receiver.transport.stop() # give RTCP time to send a report await asyncio.sleep(2) @asynctest async def test_rtp_and_rtcp(self): async with create_receiver("audio") as receiver: receiver._track = RemoteStreamTrack(kind="audio") self.assertEqual(receiver.track.readyState, "live") await receiver.receive(RTCRtpReceiveParameters(codecs=[PCMU_CODEC])) # receive RTP for i in range(10): packet = RtpPacket.parse(load("rtp.bin")) packet.sequence_number += i packet.timestamp += i * 160 await receiver._handle_rtp_packet(packet, arrival_time_ms=i * 20) # receive RTCP SR for packet in RtcpPacket.parse(load("rtcp_sr.bin")): await receiver._handle_rtcp_packet(packet) # check stats report = await receiver.getStats() self.assertTrue(isinstance(report, RTCStatsReport)) self.assertEqual( sorted([s.type for s in report.values()]), ["inbound-rtp", "remote-outbound-rtp", "transport"], ) # check sources sources = receiver.getSynchronizationSources() self.assertEqual(len(sources), 1) self.assertTrue(isinstance(sources[0], RTCRtpSynchronizationSource)) self.assertEqual(sources[0].source, 4028317929) # check remote track frame = await receiver.track.recv() self.assertEqual(frame.pts, 0) self.assertEqual(frame.sample_rate, 8000) self.assertEqual(frame.time_base, fractions.Fraction(1, 8000)) frame = await receiver.track.recv() self.assertEqual(frame.pts, 160) self.assertEqual(frame.sample_rate, 8000) self.assertEqual(frame.time_base, fractions.Fraction(1, 8000)) # shutdown await receiver.stop() # read until end with self.assertRaises(MediaStreamError): while True: await receiver.track.recv() self.assertEqual(receiver.track.readyState, "ended") # try reading again with self.assertRaises(MediaStreamError): await receiver.track.recv() @asynctest async def test_rtp_missing_video_packet(self): nacks = [] pli = [] async def mock_send_rtcp_nack(*args): nacks.append(args) async def mock_send_rtcp_pli(*args): pli.append(args[0]) async with create_receiver("video") as receiver: receiver._send_rtcp_nack = mock_send_rtcp_nack receiver._send_rtcp_pli = mock_send_rtcp_pli receiver._track = RemoteStreamTrack(kind="video") await receiver.receive(RTCRtpReceiveParameters(codecs=[VP8_CODEC])) # generate some packets packets = create_rtp_video_packets(self, codec=VP8_CODEC, frames=129) # receive RTP with a with a gap await receiver._handle_rtp_packet(packets[0], arrival_time_ms=0) await receiver._handle_rtp_packet(packets[128], arrival_time_ms=0) # check NACK was triggered lost_packets = [] for i in range(127): lost_packets.append(i + 1) self.assertEqual(nacks[0], (1234, lost_packets)) # check PLI was triggered self.assertEqual(pli, [1234]) @asynctest async def test_rtp_empty_video_packet(self): async with create_receiver("video") as receiver: receiver._track = RemoteStreamTrack(kind="video") await receiver.receive(RTCRtpReceiveParameters(codecs=[VP8_CODEC])) # receive RTP with empty payload packet = RtpPacket(payload_type=100) await receiver._handle_rtp_packet(packet, arrival_time_ms=0) @asynctest async def test_rtp_invalid_payload(self): async with create_receiver("video") as receiver: receiver._track = RemoteStreamTrack(kind="video") await receiver.receive(RTCRtpReceiveParameters(codecs=[VP8_CODEC])) # receive RTP with unknown payload type packet = RtpPacket(payload_type=100, payload=b"\x80") await receiver._handle_rtp_packet(packet, arrival_time_ms=0) @asynctest async def test_rtp_unknown_payload_type(self): async with create_receiver("video") as receiver: receiver._track = RemoteStreamTrack(kind="video") await receiver.receive(RTCRtpReceiveParameters(codecs=[VP8_CODEC])) # receive RTP with unknown payload type packet = RtpPacket(payload_type=123) await receiver._handle_rtp_packet(packet, arrival_time_ms=0) @asynctest async def test_rtp_rtx(self): async with create_receiver("video") as receiver: receiver._track = RemoteStreamTrack(kind="video") await receiver.receive( RTCRtpReceiveParameters( codecs=[ VP8_CODEC, RTCRtpCodecParameters( mimeType="video/rtx", clockRate=90000, payloadType=101, parameters={"apt": 100}, ), ], encodings=[ RTCRtpEncodingParameters( ssrc=1234, payloadType=100, rtx=RTCRtpRtxParameters(ssrc=2345), ) ], ) ) # receive RTX with payload packet = RtpPacket(payload_type=101, ssrc=2345, payload=b"\x00\x00") await receiver._handle_rtp_packet(packet, arrival_time_ms=0) # receive RTX without payload packet = RtpPacket(payload_type=101, ssrc=2345) await receiver._handle_rtp_packet(packet, arrival_time_ms=0) @asynctest async def test_rtp_rtx_unknown_ssrc(self): async with create_receiver("video") as receiver: receiver._track = RemoteStreamTrack(kind="video") await receiver.receive( RTCRtpReceiveParameters( codecs=[ VP8_CODEC, RTCRtpCodecParameters( mimeType="video/rtx", clockRate=90000, payloadType=101, parameters={"apt": 100}, ), ] ) ) # receive RTX with unknown SSRC packet = RtpPacket(payload_type=101, ssrc=1234) await receiver._handle_rtp_packet(packet, arrival_time_ms=0) @asynctest async def test_send_rtcp_nack(self): async with create_receiver("video") as receiver: receiver._set_rtcp_ssrc(1234) receiver._track = RemoteStreamTrack(kind="video") await receiver.receive(RTCRtpReceiveParameters(codecs=[VP8_CODEC])) # send RTCP feedback NACK await receiver._send_rtcp_nack(5678, [7654]) @asynctest async def test_send_rtcp_pli(self): async with create_receiver("video") as receiver: receiver._set_rtcp_ssrc(1234) receiver._track = RemoteStreamTrack(kind="video") await receiver.receive(RTCRtpReceiveParameters(codecs=[VP8_CODEC])) # send RTCP feedback PLI await receiver._send_rtcp_pli(5678) def test_invalid_dtls_transport_state(self): dtlsTransport = ClosedDtlsTransport() with self.assertRaises(InvalidStateError): RTCRtpReceiver("audio", dtlsTransport) class TimestampMapperTest(TestCase): def test_simple(self): mapper = TimestampMapper() self.assertEqual(mapper.map(1000), 0) self.assertEqual(mapper.map(1001), 1) self.assertEqual(mapper.map(1003), 3) self.assertEqual(mapper.map(1004), 4) self.assertEqual(mapper.map(1010), 10) def test_wrap(self): mapper = TimestampMapper() self.assertEqual(mapper.map(4294967293), 0) self.assertEqual(mapper.map(4294967294), 1) self.assertEqual(mapper.map(4294967295), 2) self.assertEqual(mapper.map(0), 3) self.assertEqual(mapper.map(1), 4) aiortc-1.3.0/tests/test_rtcrtpsender.py000066400000000000000000000330121417604566400203020ustar00rootroot00000000000000import asyncio from collections import OrderedDict from struct import pack from unittest import TestCase from aiortc import MediaStreamTrack from aiortc.codecs import PCMU_CODEC from aiortc.exceptions import InvalidStateError from aiortc.mediastreams import AudioStreamTrack, VideoStreamTrack from aiortc.rtcrtpparameters import ( RTCRtpCapabilities, RTCRtpCodecCapability, RTCRtpCodecParameters, RTCRtpHeaderExtensionCapability, RTCRtpParameters, ) from aiortc.rtcrtpsender import RTCRtpSender from aiortc.rtp import ( RTCP_PSFB_APP, RTCP_PSFB_PLI, RTCP_RTPFB_NACK, RtcpPsfbPacket, RtcpReceiverInfo, RtcpRrPacket, RtcpRtpfbPacket, RtpPacket, is_rtcp, pack_remb_fci, ) from aiortc.stats import RTCStatsReport from .utils import ClosedDtlsTransport, asynctest, dummy_dtls_transport_pair VP8_CODEC = RTCRtpCodecParameters( mimeType="video/VP8", clockRate=90000, payloadType=100 ) class BuggyStreamTrack(MediaStreamTrack): kind = "audio" async def recv(self): raise Exception("I'm a buggy track!") class RTCRtpSenderTest(TestCase): def test_capabilities(self): # audio capabilities = RTCRtpSender.getCapabilities("audio") self.assertTrue(isinstance(capabilities, RTCRtpCapabilities)) self.assertEqual( capabilities.codecs, [ RTCRtpCodecCapability( mimeType="audio/opus", clockRate=48000, channels=2 ), RTCRtpCodecCapability( mimeType="audio/PCMU", clockRate=8000, channels=1 ), RTCRtpCodecCapability( mimeType="audio/PCMA", clockRate=8000, channels=1 ), ], ) self.assertEqual( capabilities.headerExtensions, [ RTCRtpHeaderExtensionCapability( uri="urn:ietf:params:rtp-hdrext:sdes:mid" ), RTCRtpHeaderExtensionCapability( uri="urn:ietf:params:rtp-hdrext:ssrc-audio-level" ), ], ) # video capabilities = RTCRtpSender.getCapabilities("video") self.assertTrue(isinstance(capabilities, RTCRtpCapabilities)) self.assertEqual( capabilities.codecs, [ RTCRtpCodecCapability(mimeType="video/VP8", clockRate=90000), RTCRtpCodecCapability(mimeType="video/rtx", clockRate=90000), RTCRtpCodecCapability( mimeType="video/H264", clockRate=90000, parameters=OrderedDict( [ ("packetization-mode", "1"), ("level-asymmetry-allowed", "1"), ("profile-level-id", "42001f"), ] ), ), RTCRtpCodecCapability( mimeType="video/H264", clockRate=90000, parameters=OrderedDict( [ ("packetization-mode", "1"), ("level-asymmetry-allowed", "1"), ("profile-level-id", "42e01f"), ] ), ), ], ) self.assertEqual( capabilities.headerExtensions, [ RTCRtpHeaderExtensionCapability( uri="urn:ietf:params:rtp-hdrext:sdes:mid" ), RTCRtpHeaderExtensionCapability( uri="http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time" ), ], ) # bogus with self.assertRaises(ValueError): RTCRtpSender.getCapabilities("bogus") @asynctest async def test_construct(self): async with dummy_dtls_transport_pair() as (local_transport, _): sender = RTCRtpSender("audio", local_transport) self.assertEqual(sender.kind, "audio") self.assertEqual(sender.transport, local_transport) def test_construct_invalid_dtls_transport_state(self): dtlsTransport = ClosedDtlsTransport() with self.assertRaises(InvalidStateError): RTCRtpSender("audio", dtlsTransport) @asynctest async def test_connection_error(self): """ Close the underlying transport before the sender. """ async with dummy_dtls_transport_pair() as (local_transport, _): sender = RTCRtpSender(AudioStreamTrack(), local_transport) self.assertEqual(sender.kind, "audio") await sender.send(RTCRtpParameters(codecs=[PCMU_CODEC])) await local_transport.stop() @asynctest async def test_handle_rtcp_nack(self): async with dummy_dtls_transport_pair() as (local_transport, _): sender = RTCRtpSender(VideoStreamTrack(), local_transport) self.assertEqual(sender.kind, "video") await sender.send(RTCRtpParameters(codecs=[VP8_CODEC])) # receive RTCP feedback NACK packet = RtcpRtpfbPacket( fmt=RTCP_RTPFB_NACK, ssrc=1234, media_ssrc=sender._ssrc ) packet.lost.append(7654) await sender._handle_rtcp_packet(packet) # clean shutdown await sender.stop() @asynctest async def test_handle_rtcp_pli(self): async with dummy_dtls_transport_pair() as (local_transport, _): sender = RTCRtpSender(VideoStreamTrack(), local_transport) self.assertEqual(sender.kind, "video") await sender.send(RTCRtpParameters(codecs=[VP8_CODEC])) # receive RTCP feedback NACK packet = RtcpPsfbPacket( fmt=RTCP_PSFB_PLI, ssrc=1234, media_ssrc=sender._ssrc ) await sender._handle_rtcp_packet(packet) # clean shutdown await sender.stop() @asynctest async def test_handle_rtcp_remb(self): async with dummy_dtls_transport_pair() as (local_transport, _): sender = RTCRtpSender(VideoStreamTrack(), local_transport) self.assertEqual(sender.kind, "video") await sender.send(RTCRtpParameters(codecs=[VP8_CODEC])) # receive RTCP feedback REMB packet = RtcpPsfbPacket( fmt=RTCP_PSFB_APP, ssrc=1234, media_ssrc=0, fci=pack_remb_fci(4160000, [sender._ssrc]), ) await sender._handle_rtcp_packet(packet) # receive RTCP feedback REMB (malformed) packet = RtcpPsfbPacket( fmt=RTCP_PSFB_APP, ssrc=1234, media_ssrc=0, fci=b"JUNK" ) await sender._handle_rtcp_packet(packet) # clean shutdown await sender.stop() @asynctest async def test_handle_rtcp_rr(self): async with dummy_dtls_transport_pair() as (local_transport, _): sender = RTCRtpSender(VideoStreamTrack(), local_transport) self.assertEqual(sender.kind, "video") await sender.send(RTCRtpParameters(codecs=[VP8_CODEC])) # receive RTCP RR packet = RtcpRrPacket( ssrc=1234, reports=[ RtcpReceiverInfo( ssrc=sender._ssrc, fraction_lost=0, packets_lost=0, highest_sequence=630, jitter=1906, lsr=0, dlsr=0, ) ], ) await sender._handle_rtcp_packet(packet) # check stats report = await sender.getStats() self.assertTrue(isinstance(report, RTCStatsReport)) self.assertEqual( sorted([s.type for s in report.values()]), ["outbound-rtp", "remote-inbound-rtp", "transport"], ) # clean shutdown await sender.stop() @asynctest async def test_send_keyframe(self): """ Ask for a keyframe. """ queue = asyncio.Queue() async def mock_send_rtp(data): if not is_rtcp(data): await queue.put(RtpPacket.parse(data)) async with dummy_dtls_transport_pair() as (local_transport, _): local_transport._send_rtp = mock_send_rtp sender = RTCRtpSender(VideoStreamTrack(), local_transport) self.assertEqual(sender.kind, "video") await sender.send(RTCRtpParameters(codecs=[VP8_CODEC])) # wait for one packet to be transmitted, and ask for keyframe await queue.get() sender._send_keyframe() # wait for packet to be transmitted, then shutdown await asyncio.sleep(0.1) await sender.stop() @asynctest async def test_retransmit(self): """ Ask for an RTP packet retransmission. """ queue = asyncio.Queue() async def mock_send_rtp(data): if not is_rtcp(data): await queue.put(RtpPacket.parse(data)) async with dummy_dtls_transport_pair() as (local_transport, _): local_transport._send_rtp = mock_send_rtp sender = RTCRtpSender(VideoStreamTrack(), local_transport) sender._ssrc = 1234 self.assertEqual(sender.kind, "video") await sender.send(RTCRtpParameters(codecs=[VP8_CODEC])) # wait for one packet to be transmitted, and ask to retransmit packet = await queue.get() await sender._retransmit(packet.sequence_number) # wait for packet to be retransmitted, then shutdown await asyncio.sleep(0.1) await sender.stop() # check packet was retransmitted found_rtx = None while not queue.empty(): queue_packet = queue.get_nowait() if queue_packet.sequence_number == packet.sequence_number: found_rtx = queue_packet break self.assertIsNotNone(found_rtx) self.assertEqual(found_rtx.payload_type, 100) self.assertEqual(found_rtx.ssrc, 1234) @asynctest async def test_retransmit_with_rtx(self): """ Ask for an RTP packet retransmission. """ queue = asyncio.Queue() async def mock_send_rtp(data): if not is_rtcp(data): await queue.put(RtpPacket.parse(data)) async with dummy_dtls_transport_pair() as (local_transport, _): local_transport._send_rtp = mock_send_rtp sender = RTCRtpSender(VideoStreamTrack(), local_transport) sender._ssrc = 1234 sender._rtx_ssrc = 2345 self.assertEqual(sender.kind, "video") await sender.send( RTCRtpParameters( codecs=[ VP8_CODEC, RTCRtpCodecParameters( mimeType="video/rtx", clockRate=90000, payloadType=101, parameters={"apt": 100}, ), ] ) ) # wait for one packet to be transmitted, and ask to retransmit packet = await queue.get() await sender._retransmit(packet.sequence_number) # wait for packet to be retransmitted, then shutdown await asyncio.sleep(0.1) await sender.stop() # check packet was retransmitted found_rtx = None while not queue.empty(): queue_packet = queue.get_nowait() if queue_packet.payload_type == 101: found_rtx = queue_packet break self.assertIsNotNone(found_rtx) self.assertEqual(found_rtx.payload_type, 101) self.assertEqual(found_rtx.ssrc, 2345) self.assertEqual(found_rtx.payload[0:2], pack("!H", packet.sequence_number)) @asynctest async def test_stop(self): async with dummy_dtls_transport_pair() as (local_transport, _): sender = RTCRtpSender(AudioStreamTrack(), local_transport) self.assertEqual(sender.kind, "audio") await sender.send(RTCRtpParameters(codecs=[PCMU_CODEC])) # clean shutdown await sender.stop() @asynctest async def test_stop_before_send(self): async with dummy_dtls_transport_pair() as (local_transport, _): sender = RTCRtpSender(AudioStreamTrack(), local_transport) await sender.stop() @asynctest async def test_stop_on_exception(self): async with dummy_dtls_transport_pair() as (local_transport, _): sender = RTCRtpSender(BuggyStreamTrack(), local_transport) self.assertEqual(sender.kind, "audio") await sender.send(RTCRtpParameters(codecs=[PCMU_CODEC])) # clean shutdown await sender.stop() @asynctest async def test_track_ended(self): async with dummy_dtls_transport_pair() as (local_transport, _): track = AudioStreamTrack() sender = RTCRtpSender(track, local_transport) await sender.send(RTCRtpParameters(codecs=[PCMU_CODEC])) # stop track and wait for RTP loop to exit track.stop() await asyncio.sleep(0.1) aiortc-1.3.0/tests/test_rtcrtptransceiver.py000066400000000000000000000033341417604566400213530ustar00rootroot00000000000000from unittest import TestCase from aiortc.rtcrtpparameters import RTCRtpCodecCapability from aiortc.rtcrtptransceiver import RTCRtpTransceiver class RTCRtpTransceiverTest(TestCase): def test_codec_preferences(self): transceiver = RTCRtpTransceiver("audio", None, None) self.assertEqual(transceiver._preferred_codecs, []) # set empty preferences transceiver.setCodecPreferences([]) self.assertEqual(transceiver._preferred_codecs, []) # set single codec transceiver.setCodecPreferences( [RTCRtpCodecCapability(mimeType="audio/PCMU", clockRate=8000, channels=1)] ) self.assertEqual( transceiver._preferred_codecs, [RTCRtpCodecCapability(mimeType="audio/PCMU", clockRate=8000, channels=1)], ) # set single codec (duplicated) transceiver.setCodecPreferences( [ RTCRtpCodecCapability( mimeType="audio/PCMU", clockRate=8000, channels=1 ), RTCRtpCodecCapability( mimeType="audio/PCMU", clockRate=8000, channels=1 ), ] ) self.assertEqual( transceiver._preferred_codecs, [RTCRtpCodecCapability(mimeType="audio/PCMU", clockRate=8000, channels=1)], ) # set single codec (invalid) with self.assertRaises(ValueError) as cm: transceiver.setCodecPreferences( [ RTCRtpCodecCapability( mimeType="audio/bogus", clockRate=8000, channels=1 ) ] ) self.assertEqual(str(cm.exception), "Codec is not in capabilities") aiortc-1.3.0/tests/test_rtcsctptransport.py000066400000000000000000002650541417604566400212370ustar00rootroot00000000000000import asyncio import contextlib from unittest import TestCase from unittest.mock import patch from aiortc.exceptions import InvalidStateError from aiortc.rtcdatachannel import RTCDataChannel, RTCDataChannelParameters from aiortc.rtcsctptransport import ( SCTP_DATA_FIRST_FRAG, SCTP_DATA_LAST_FRAG, SCTP_DATA_UNORDERED, USERDATA_MAX_LENGTH, AbortChunk, CookieEchoChunk, DataChunk, ErrorChunk, ForwardTsnChunk, HeartbeatAckChunk, HeartbeatChunk, InboundStream, InitChunk, ReconfigChunk, RTCSctpCapabilities, RTCSctpTransport, SackChunk, ShutdownAckChunk, ShutdownChunk, ShutdownCompleteChunk, StreamAddOutgoingParam, StreamResetOutgoingParam, StreamResetResponseParam, parse_packet, serialize_packet, tsn_minus_one, tsn_plus_one, ) from .utils import ClosedDtlsTransport, asynctest, dummy_dtls_transport_pair, load @contextlib.asynccontextmanager async def client_and_server(): async with dummy_dtls_transport_pair() as (client_transport, server_transport): client = RTCSctpTransport(client_transport) server = RTCSctpTransport(server_transport) assert client.is_server is False assert server.is_server is True try: yield client, server finally: await client.stop() await server.stop() assert client._association_state == RTCSctpTransport.State.CLOSED assert client.state == "closed" assert server._association_state == RTCSctpTransport.State.CLOSED assert server.state == "closed" @contextlib.asynccontextmanager async def client_standalone(): async with dummy_dtls_transport_pair() as (client_transport, server_transport): client = RTCSctpTransport(client_transport) assert client.is_server is False try: yield client finally: await client.stop() def outstanding_tsns(client): return [chunk.tsn for chunk in client._sent_queue] def queued_tsns(client): return [chunk.tsn for chunk in client._outbound_queue] def track_channels(transport): channels = [] @transport.on("datachannel") def on_datachannel(channel): channels.append(channel) return channels async def wait_for_outcome(client, server): final = [RTCSctpTransport.State.ESTABLISHED, RTCSctpTransport.State.CLOSED] for i in range(100): if client._association_state in final and server._association_state in final: break await asyncio.sleep(0.1) class SctpPacketTest(TestCase): def roundtrip_packet(self, data): source_port, destination_port, verification_tag, chunks = parse_packet(data) self.assertEqual(source_port, 5000) self.assertEqual(destination_port, 5000) self.assertEqual(len(chunks), 1) output = serialize_packet( source_port, destination_port, verification_tag, chunks[0] ) self.assertEqual(output, data) return chunks[0] def test_parse_init(self): data = load("sctp_init.bin") chunk = self.roundtrip_packet(data) self.assertTrue(isinstance(chunk, InitChunk)) self.assertEqual(chunk.type, 1) self.assertEqual(chunk.flags, 0) self.assertEqual(len(chunk.body), 82) self.assertEqual(repr(chunk), "InitChunk(flags=0)") def test_parse_init_invalid_checksum(self): data = load("sctp_init.bin") data = data[0:8] + b"\x01\x02\x03\x04" + data[12:] with self.assertRaises(ValueError) as cm: self.roundtrip_packet(data) self.assertEqual(str(cm.exception), "SCTP packet has invalid checksum") def test_parse_init_truncated_packet_header(self): data = load("sctp_init.bin")[0:10] with self.assertRaises(ValueError) as cm: self.roundtrip_packet(data) self.assertEqual(str(cm.exception), "SCTP packet length is less than 12 bytes") def test_parse_cookie_echo(self): data = load("sctp_cookie_echo.bin") chunk = self.roundtrip_packet(data) self.assertTrue(isinstance(chunk, CookieEchoChunk)) self.assertEqual(chunk.type, 10) self.assertEqual(chunk.flags, 0) self.assertEqual(len(chunk.body), 8) def test_parse_abort(self): data = load("sctp_abort.bin") chunk = self.roundtrip_packet(data) self.assertTrue(isinstance(chunk, AbortChunk)) self.assertEqual(chunk.type, 6) self.assertEqual(chunk.flags, 0) self.assertEqual( chunk.params, [(13, b"Expected B-bit for TSN=4ce1f17f, SID=0001, SSN=0000")] ) def test_parse_data(self): data = load("sctp_data.bin") chunk = self.roundtrip_packet(data) self.assertTrue(isinstance(chunk, DataChunk)) self.assertEqual(chunk.type, 0) self.assertEqual(chunk.flags, 3) self.assertEqual(chunk.tsn, 2584679421) self.assertEqual(chunk.stream_id, 1) self.assertEqual(chunk.stream_seq, 1) self.assertEqual(chunk.protocol, 51) self.assertEqual(chunk.user_data, b"ping") self.assertEqual( repr(chunk), "DataChunk(flags=3, tsn=2584679421, stream_id=1, stream_seq=1)" ) def test_parse_data_padding(self): data = load("sctp_data_padding.bin") chunk = self.roundtrip_packet(data) self.assertTrue(isinstance(chunk, DataChunk)) self.assertEqual(chunk.type, 0) self.assertEqual(chunk.flags, 3) self.assertEqual(chunk.tsn, 2584679421) self.assertEqual(chunk.stream_id, 1) self.assertEqual(chunk.stream_seq, 1) self.assertEqual(chunk.protocol, 51) self.assertEqual(chunk.user_data, b"M") self.assertEqual( repr(chunk), "DataChunk(flags=3, tsn=2584679421, stream_id=1, stream_seq=1)" ) def test_parse_error(self): data = load("sctp_error.bin") chunk = self.roundtrip_packet(data) self.assertTrue(isinstance(chunk, ErrorChunk)) self.assertEqual(chunk.type, 9) self.assertEqual(chunk.flags, 0) self.assertEqual(chunk.params, [(1, b"\x30\x39\x00\x00")]) def test_parse_forward_tsn(self): data = load("sctp_forward_tsn.bin") chunk = self.roundtrip_packet(data) self.assertTrue(isinstance(chunk, ForwardTsnChunk)) self.assertEqual(chunk.type, 192) self.assertEqual(chunk.flags, 0) self.assertEqual(chunk.cumulative_tsn, 1234) self.assertEqual(chunk.streams, [(12, 34)]) self.assertEqual( repr(chunk), "ForwardTsnChunk(cumulative_tsn=1234, streams=[(12, 34)])" ) def test_parse_heartbeat(self): data = load("sctp_heartbeat.bin") chunk = self.roundtrip_packet(data) self.assertTrue(isinstance(chunk, HeartbeatChunk)) self.assertEqual(chunk.type, 4) self.assertEqual(chunk.flags, 0) self.assertEqual( chunk.params, [ ( 1, b"\xb5o\xaaZvZ\x06\x00\x00\x00\x00\x00\x00\x00\x00\x00{\x10\x00\x00" b"\x004\xeb\x07F\x10\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", ) ], ) def test_parse_reconfig_reset_out(self): data = load("sctp_reconfig_reset_out.bin") chunk = self.roundtrip_packet(data) self.assertTrue(isinstance(chunk, ReconfigChunk)) self.assertEqual(chunk.type, 130) self.assertEqual(chunk.flags, 0) self.assertEqual( chunk.params, [(13, b"\x8b\xd8\n[\xe4\x8b\xecs\x8b\xd8\n^\x00\x01")] ) # Outgoing SSN Reset Request Parameter param_data = chunk.params[0][1] param = StreamResetOutgoingParam.parse(param_data) self.assertEqual(param.request_sequence, 2346191451) self.assertEqual(param.response_sequence, 3834375283) self.assertEqual(param.last_tsn, 2346191454) self.assertEqual(param.streams, [1]) self.assertEqual(bytes(param), param_data) def test_parse_reconfig_add_out(self): data = load("sctp_reconfig_add_out.bin") chunk = self.roundtrip_packet(data) self.assertTrue(isinstance(chunk, ReconfigChunk)) self.assertEqual(chunk.type, 130) self.assertEqual(chunk.flags, 0) self.assertEqual(chunk.params, [(17, b"\xca\x02\xf60\x00\x10\x00\x00")]) # Add Outgoing Streams Request Parameter param_data = chunk.params[0][1] param = StreamAddOutgoingParam.parse(param_data) self.assertEqual(param.request_sequence, 3389191728) self.assertEqual(param.new_streams, 16) self.assertEqual(bytes(param), param_data) def test_parse_reconfig_response(self): data = load("sctp_reconfig_response.bin") chunk = self.roundtrip_packet(data) self.assertTrue(isinstance(chunk, ReconfigChunk)) self.assertEqual(chunk.type, 130) self.assertEqual(chunk.flags, 0) self.assertEqual(chunk.params, [(16, b"\x91S\x1fT\x00\x00\x00\x01")]) # Re-configuration Response Parameter param_data = chunk.params[0][1] param = StreamResetResponseParam.parse(param_data) self.assertEqual(param.response_sequence, 2438143828) self.assertEqual(param.result, 1) self.assertEqual(bytes(param), param_data) def test_parse_sack(self): data = load("sctp_sack.bin") chunk = self.roundtrip_packet(data) self.assertTrue(isinstance(chunk, SackChunk)) self.assertEqual(chunk.type, 3) self.assertEqual(chunk.flags, 0) self.assertEqual(chunk.cumulative_tsn, 2222939037) self.assertEqual(chunk.gaps, [(2, 2), (4, 4)]) self.assertEqual(chunk.duplicates, [2222939041]) self.assertEqual( repr(chunk), "SackChunk(flags=0, advertised_rwnd=128160, cumulative_tsn=2222939037, " "gaps=[(2, 2), (4, 4)])", ) def test_parse_shutdown(self): data = load("sctp_shutdown.bin") chunk = self.roundtrip_packet(data) self.assertTrue(isinstance(chunk, ShutdownChunk)) self.assertEqual( repr(chunk), "ShutdownChunk(flags=0, cumulative_tsn=2696426712)" ) self.assertEqual(chunk.type, 7) self.assertEqual(chunk.flags, 0) self.assertEqual(chunk.cumulative_tsn, 2696426712) class ChunkFactory: def __init__(self, tsn=1): self.tsn = tsn self.stream_seq = 0 def create(self, frags, ordered=True): chunks = [] for i, frag in enumerate(frags): flags = 0 if not ordered: flags |= SCTP_DATA_UNORDERED if i == 0: flags |= SCTP_DATA_FIRST_FRAG if i == len(frags) - 1: flags |= SCTP_DATA_LAST_FRAG chunk = DataChunk(flags=flags) chunk.protocol = 123 chunk.stream_id = 456 if ordered: chunk.stream_seq = self.stream_seq chunk.tsn = self.tsn chunk.user_data = frag chunks.append(chunk) self.tsn += 1 if ordered: self.stream_seq += 1 return chunks class SctpStreamTest(TestCase): def setUp(self): self.factory = ChunkFactory() def test_duplicate(self): stream = InboundStream() chunks = self.factory.create([b"foo", b"bar", b"baz"]) # feed first chunk stream.add_chunk(chunks[0]) self.assertEqual(stream.reassembly, [chunks[0]]) self.assertEqual(stream.sequence_number, 0) self.assertEqual(list(stream.pop_messages()), []) self.assertEqual(stream.reassembly, [chunks[0]]) self.assertEqual(stream.sequence_number, 0) # feed first chunk again with self.assertRaises(AssertionError) as cm: stream.add_chunk(chunks[0]) self.assertEqual(str(cm.exception), "duplicate chunk in reassembly") def test_whole_in_order(self): stream = InboundStream() chunks = self.factory.create([b"foo"]) + self.factory.create([b"bar"]) # feed first unfragmented stream.add_chunk(chunks[0]) self.assertEqual(stream.reassembly, [chunks[0]]) self.assertEqual(stream.sequence_number, 0) self.assertEqual(list(stream.pop_messages()), [(456, 123, b"foo")]) self.assertEqual(stream.reassembly, []) self.assertEqual(stream.sequence_number, 1) # feed second unfragmented stream.add_chunk(chunks[1]) self.assertEqual(stream.reassembly, [chunks[1]]) self.assertEqual(stream.sequence_number, 1) self.assertEqual(list(stream.pop_messages()), [(456, 123, b"bar")]) self.assertEqual(stream.reassembly, []) self.assertEqual(stream.sequence_number, 2) def test_whole_out_of_order(self): stream = InboundStream() chunks = ( self.factory.create([b"foo"]) + self.factory.create([b"bar"]) + self.factory.create([b"baz", b"qux"]) ) # feed second unfragmented stream.add_chunk(chunks[1]) self.assertEqual(stream.reassembly, [chunks[1]]) self.assertEqual(stream.sequence_number, 0) self.assertEqual(list(stream.pop_messages()), []) self.assertEqual(stream.reassembly, [chunks[1]]) self.assertEqual(stream.sequence_number, 0) # feed third partial stream.add_chunk(chunks[2]) self.assertEqual(stream.reassembly, [chunks[1], chunks[2]]) self.assertEqual(stream.sequence_number, 0) self.assertEqual(list(stream.pop_messages()), []) self.assertEqual(stream.reassembly, [chunks[1], chunks[2]]) self.assertEqual(stream.sequence_number, 0) # feed first unfragmented stream.add_chunk(chunks[0]) self.assertEqual(stream.reassembly, [chunks[0], chunks[1], chunks[2]]) self.assertEqual(stream.sequence_number, 0) self.assertEqual( list(stream.pop_messages()), [(456, 123, b"foo"), (456, 123, b"bar")] ) self.assertEqual(stream.reassembly, [chunks[2]]) self.assertEqual(stream.sequence_number, 2) def test_fragments_in_order(self): stream = InboundStream() chunks = self.factory.create([b"foo", b"bar", b"baz"]) # feed first chunk stream.add_chunk(chunks[0]) self.assertEqual(stream.reassembly, [chunks[0]]) self.assertEqual(stream.sequence_number, 0) self.assertEqual(list(stream.pop_messages()), []) self.assertEqual(stream.reassembly, [chunks[0]]) self.assertEqual(stream.sequence_number, 0) # feed second chunk stream.add_chunk(chunks[1]) self.assertEqual(stream.reassembly, [chunks[0], chunks[1]]) self.assertEqual(stream.sequence_number, 0) self.assertEqual(list(stream.pop_messages()), []) self.assertEqual(stream.reassembly, [chunks[0], chunks[1]]) self.assertEqual(stream.sequence_number, 0) # feed third chunk stream.add_chunk(chunks[2]) self.assertEqual(stream.reassembly, [chunks[0], chunks[1], chunks[2]]) self.assertEqual(stream.sequence_number, 0) self.assertEqual(list(stream.pop_messages()), [(456, 123, b"foobarbaz")]) self.assertEqual(stream.reassembly, []) self.assertEqual(stream.sequence_number, 1) def test_fragments_out_of_order(self): stream = InboundStream() chunks = self.factory.create([b"foo", b"bar", b"baz"]) # feed third chunk stream.add_chunk(chunks[2]) self.assertEqual(stream.reassembly, [chunks[2]]) self.assertEqual(stream.sequence_number, 0) self.assertEqual(list(stream.pop_messages()), []) self.assertEqual(stream.reassembly, [chunks[2]]) self.assertEqual(stream.sequence_number, 0) # feed first chunk stream.add_chunk(chunks[0]) self.assertEqual(stream.reassembly, [chunks[0], chunks[2]]) self.assertEqual(stream.sequence_number, 0) self.assertEqual(list(stream.pop_messages()), []) self.assertEqual(stream.reassembly, [chunks[0], chunks[2]]) self.assertEqual(stream.sequence_number, 0) # feed second chunk stream.add_chunk(chunks[1]) self.assertEqual(stream.reassembly, [chunks[0], chunks[1], chunks[2]]) self.assertEqual(stream.sequence_number, 0) self.assertEqual(list(stream.pop_messages()), [(456, 123, b"foobarbaz")]) self.assertEqual(stream.reassembly, []) self.assertEqual(stream.sequence_number, 1) def test_unordered_no_fragments(self): stream = InboundStream() chunks = ( self.factory.create([b"foo"], ordered=False) + self.factory.create([b"bar"], ordered=False) + self.factory.create([b"baz"], ordered=False) ) # feed second unfragmented stream.add_chunk(chunks[1]) self.assertEqual(stream.reassembly, [chunks[1]]) self.assertEqual(stream.sequence_number, 0) self.assertEqual(list(stream.pop_messages()), [(456, 123, b"bar")]) self.assertEqual(stream.reassembly, []) self.assertEqual(stream.sequence_number, 0) # feed third unfragmented stream.add_chunk(chunks[2]) self.assertEqual(stream.reassembly, [chunks[2]]) self.assertEqual(stream.sequence_number, 0) self.assertEqual(list(stream.pop_messages()), [(456, 123, b"baz")]) self.assertEqual(stream.reassembly, []) self.assertEqual(stream.sequence_number, 0) # feed first unfragmented stream.add_chunk(chunks[0]) self.assertEqual(stream.reassembly, [chunks[0]]) self.assertEqual(stream.sequence_number, 0) self.assertEqual(list(stream.pop_messages()), [(456, 123, b"foo")]) self.assertEqual(stream.reassembly, []) self.assertEqual(stream.sequence_number, 0) def test_unordered_with_fragments(self): stream = InboundStream() chunks = ( self.factory.create([b"foo", b"bar"], ordered=False) + self.factory.create([b"baz"], ordered=False) + self.factory.create([b"qux", b"quux", b"corge"], ordered=False) ) # feed second fragment of first message stream.add_chunk(chunks[1]) self.assertEqual(stream.reassembly, [chunks[1]]) self.assertEqual(stream.sequence_number, 0) self.assertEqual(list(stream.pop_messages()), []) self.assertEqual(stream.reassembly, [chunks[1]]) self.assertEqual(stream.sequence_number, 0) # feed second message stream.add_chunk(chunks[2]) self.assertEqual(stream.reassembly, [chunks[1], chunks[2]]) self.assertEqual(stream.sequence_number, 0) self.assertEqual(list(stream.pop_messages()), [(456, 123, b"baz")]) self.assertEqual(stream.reassembly, [chunks[1]]) self.assertEqual(stream.sequence_number, 0) # feed first fragment of third message stream.add_chunk(chunks[3]) self.assertEqual(stream.reassembly, [chunks[1], chunks[3]]) self.assertEqual(stream.sequence_number, 0) self.assertEqual(list(stream.pop_messages()), []) self.assertEqual(stream.reassembly, [chunks[1], chunks[3]]) self.assertEqual(stream.sequence_number, 0) # feed third fragment of third message stream.add_chunk(chunks[5]) self.assertEqual(stream.reassembly, [chunks[1], chunks[3], chunks[5]]) self.assertEqual(stream.sequence_number, 0) self.assertEqual(list(stream.pop_messages()), []) self.assertEqual(stream.reassembly, [chunks[1], chunks[3], chunks[5]]) self.assertEqual(stream.sequence_number, 0) # feed second fragment of third message stream.add_chunk(chunks[4]) self.assertEqual( stream.reassembly, [chunks[1], chunks[3], chunks[4], chunks[5]] ) self.assertEqual(stream.sequence_number, 0) self.assertEqual(list(stream.pop_messages()), [(456, 123, b"quxquuxcorge")]) self.assertEqual(stream.reassembly, [chunks[1]]) self.assertEqual(stream.sequence_number, 0) # feed first fragment of first message stream.add_chunk(chunks[0]) self.assertEqual(stream.reassembly, [chunks[0], chunks[1]]) self.assertEqual(stream.sequence_number, 0) self.assertEqual(list(stream.pop_messages()), [(456, 123, b"foobar")]) self.assertEqual(stream.reassembly, []) self.assertEqual(stream.sequence_number, 0) def test_prune_chunks(self): stream = InboundStream() factory = ChunkFactory(tsn=100) chunks = factory.create([b"foo", b"bar"]) + factory.create([b"baz", b"qux"]) for i in [1, 2]: stream.add_chunk(chunks[i]) self.assertEqual(list(stream.pop_messages()), []) self.assertEqual(stream.reassembly, [chunks[1], chunks[2]]) self.assertEqual(stream.sequence_number, 0) stream.sequence_number = 2 self.assertEqual(list(stream.pop_messages()), []) self.assertEqual(stream.reassembly, [chunks[1], chunks[2]]) self.assertEqual(stream.sequence_number, 2) self.assertEqual(stream.prune_chunks(101), 3) self.assertEqual(stream.reassembly, [chunks[2]]) self.assertEqual(stream.sequence_number, 2) class SctpUtilTest(TestCase): def test_tsn_minus_one(self): self.assertEqual(tsn_minus_one(0), 4294967295) self.assertEqual(tsn_minus_one(1), 0) self.assertEqual(tsn_minus_one(4294967294), 4294967293) self.assertEqual(tsn_minus_one(4294967295), 4294967294) def test_tsn_plus_one(self): self.assertEqual(tsn_plus_one(0), 1) self.assertEqual(tsn_plus_one(1), 2) self.assertEqual(tsn_plus_one(4294967294), 4294967295) self.assertEqual(tsn_plus_one(4294967295), 0) class RTCSctpTransportTest(TestCase): def assertTimerPreserved(self, client): test = self class Ctx: def __enter__(self): self.previous_timer = client._t3_handle def __exit__(self, exc_type, exc_value, traceback): test.assertIsNotNone(client._t3_handle) test.assertEqual(client._t3_handle, self.previous_timer) return Ctx() def assertTimerRestarted(self, client): test = self class Ctx: def __enter__(self): self.previous_timer = client._t3_handle def __exit__(self, exc_type, exc_value, traceback): test.assertIsNotNone(client._t3_handle) test.assertNotEqual(client._t3_handle, self.previous_timer) return Ctx() def assertTimerStopped(self, client): test = self class Ctx: def __enter__(self): pass def __exit__(self, exc_type, exc_value, traceback): test.assertIsNone(client._t3_handle) return Ctx() @asynctest async def test_construct(self): async with dummy_dtls_transport_pair() as (client_transport, _): sctpTransport = RTCSctpTransport(client_transport) self.assertEqual(sctpTransport.transport, client_transport) self.assertEqual(sctpTransport.port, 5000) @asynctest async def test_construct_invalid_dtls_transport_state(self): dtlsTransport = ClosedDtlsTransport() with self.assertRaises(InvalidStateError): RTCSctpTransport(dtlsTransport) @asynctest async def test_connect_broken_transport(self): """ Transport with 100% loss never connects. """ loss_pattern = [True] async with client_and_server() as (client, server): client._rto = 0.1 client.transport.transport._connection.loss_pattern = loss_pattern server._rto = 0.1 server.transport.transport._connection.loss_pattern = loss_pattern # connect await server.start(client.getCapabilities(), client.port) await client.start(server.getCapabilities(), server.port) # check outcome await wait_for_outcome(client, server) self.assertEqual(client._association_state, RTCSctpTransport.State.CLOSED) self.assertEqual(client.state, "closed") self.assertEqual(server._association_state, RTCSctpTransport.State.CLOSED) self.assertEqual(server.state, "connecting") @asynctest async def test_connect_lossy_transport(self): """ Transport with 25% loss eventually connects. """ loss_pattern = [True, False, False, False] async with client_and_server() as (client, server): client._rto = 0.1 client.transport.transport._connection.loss_pattern = loss_pattern server._rto = 0.1 server.transport.transport._connection.loss_pattern = loss_pattern # connect await server.start(client.getCapabilities(), client.port) await client.start(server.getCapabilities(), server.port) # check outcome await wait_for_outcome(client, server) self.assertEqual( client._association_state, RTCSctpTransport.State.ESTABLISHED ) self.assertEqual(client.state, "connected") self.assertEqual( server._association_state, RTCSctpTransport.State.ESTABLISHED ) self.assertEqual(server.state, "connected") # transmit data server_queue = asyncio.Queue() async def server_fake_receive(*args): await server_queue.put(args) server._receive = server_fake_receive for i in range(20): message = (123, i, b"ping") await client._send(*message) received = await server_queue.get() self.assertEqual(received, message) @asynctest async def test_connect_client_limits_streams(self): async with client_and_server() as (client, server): client._inbound_streams_max = 2048 client._outbound_streams_count = 256 self.assertEqual(client.maxChannels, None) self.assertEqual(server.maxChannels, None) # connect await server.start(client.getCapabilities(), client.port) await client.start(server.getCapabilities(), server.port) # check outcome await wait_for_outcome(client, server) self.assertEqual(client.maxChannels, 256) self.assertEqual( client._association_state, RTCSctpTransport.State.ESTABLISHED ) self.assertEqual(client._inbound_streams_count, 2048) self.assertEqual(client._outbound_streams_count, 256) self.assertEqual(client._remote_extensions, [192, 130]) self.assertEqual(server.maxChannels, 256) self.assertEqual( server._association_state, RTCSctpTransport.State.ESTABLISHED ) self.assertEqual(server._inbound_streams_count, 256) self.assertEqual(server._outbound_streams_count, 2048) self.assertEqual(server._remote_extensions, [192, 130]) # client requests additional outbound streams param = StreamAddOutgoingParam( request_sequence=client._reconfig_request_seq, new_streams=16 ) await client._send_reconfig_param(param) await asyncio.sleep(0.1) self.assertEqual(server.maxChannels, 272) self.assertEqual(server._inbound_streams_count, 272) self.assertEqual(server._outbound_streams_count, 2048) @asynctest async def test_connect_server_limits_streams(self): async with client_and_server() as (client, server): server._inbound_streams_max = 2048 server._outbound_streams_count = 256 # connect await server.start(client.getCapabilities(), client.port) await client.start(server.getCapabilities(), server.port) # check outcome await wait_for_outcome(client, server) self.assertEqual(client.maxChannels, 256) self.assertEqual( client._association_state, RTCSctpTransport.State.ESTABLISHED ) self.assertEqual(client._inbound_streams_count, 256) self.assertEqual(client._outbound_streams_count, 2048) self.assertEqual(client._remote_extensions, [192, 130]) self.assertEqual(server.maxChannels, 256) self.assertEqual( server._association_state, RTCSctpTransport.State.ESTABLISHED ) self.assertEqual(server._inbound_streams_count, 2048) self.assertEqual(server._outbound_streams_count, 256) self.assertEqual(server._remote_extensions, [192, 130]) await asyncio.sleep(0.1) @asynctest async def test_connect_then_client_creates_data_channel(self): async with client_and_server() as (client, server): self.assertFalse(client.is_server) self.assertEqual(client.maxChannels, None) self.assertTrue(server.is_server) self.assertEqual(server.maxChannels, None) client_channels = track_channels(client) server_channels = track_channels(server) # connect await server.start(client.getCapabilities(), client.port) await client.start(server.getCapabilities(), server.port) # check outcome await wait_for_outcome(client, server) self.assertEqual(client.maxChannels, 65535) self.assertEqual( client._association_state, RTCSctpTransport.State.ESTABLISHED ) self.assertEqual(client._inbound_streams_count, 65535) self.assertEqual(client._outbound_streams_count, 65535) self.assertEqual(client._remote_extensions, [192, 130]) self.assertEqual(server.maxChannels, 65535) self.assertEqual( server._association_state, RTCSctpTransport.State.ESTABLISHED ) self.assertEqual(server._inbound_streams_count, 65535) self.assertEqual(server._outbound_streams_count, 65535) self.assertEqual(server._remote_extensions, [192, 130]) # create data channel channel = RTCDataChannel(client, RTCDataChannelParameters(label="chat")) self.assertEqual(channel.id, None) self.assertEqual(channel.label, "chat") await asyncio.sleep(0.1) self.assertEqual(channel.id, 1) self.assertEqual(channel.label, "chat") self.assertEqual(len(client_channels), 0) self.assertEqual(len(server_channels), 1) self.assertEqual(server_channels[0].id, 1) self.assertEqual(server_channels[0].label, "chat") @asynctest async def test_connect_then_client_creates_data_channel_with_custom_id(self): async with client_and_server() as (client, server): client_channels = track_channels(client) server_channels = track_channels(server) # connect await server.start(client.getCapabilities(), client.port) await client.start(server.getCapabilities(), server.port) # check outcome await wait_for_outcome(client, server) self.assertEqual( client._association_state, RTCSctpTransport.State.ESTABLISHED ) self.assertEqual(client._inbound_streams_count, 65535) self.assertEqual(client._outbound_streams_count, 65535) self.assertEqual(client._remote_extensions, [192, 130]) self.assertEqual( server._association_state, RTCSctpTransport.State.ESTABLISHED ) self.assertEqual(server._inbound_streams_count, 65535) self.assertEqual(server._outbound_streams_count, 65535) self.assertEqual(server._remote_extensions, [192, 130]) # create data channel channel = RTCDataChannel( client, RTCDataChannelParameters(label="chat", id=100) ) self.assertEqual(channel.id, 100) self.assertEqual(channel.label, "chat") # create second data channel channel2 = RTCDataChannel( client, RTCDataChannelParameters(label="chat", id=101) ) self.assertEqual(channel2.id, 101) self.assertEqual(channel2.label, "chat") await asyncio.sleep(0.1) self.assertEqual(channel.id, 100) self.assertEqual(channel.label, "chat") self.assertEqual(channel2.id, 101) self.assertEqual(channel2.label, "chat") self.assertEqual(len(client_channels), 0) self.assertEqual(len(server_channels), 2) self.assertEqual(server_channels[0].id, 100) self.assertEqual(server_channels[0].label, "chat") self.assertEqual(server_channels[1].id, 101) self.assertEqual(server_channels[1].label, "chat") @asynctest async def test_connect_then_client_creates_data_channel_with_custom_id_and_then_normal( self, ): async with client_and_server() as (client, server): client_channels = track_channels(client) server_channels = track_channels(server) # connect await server.start(client.getCapabilities(), client.port) await client.start(server.getCapabilities(), server.port) # check outcome await wait_for_outcome(client, server) self.assertEqual( client._association_state, RTCSctpTransport.State.ESTABLISHED ) self.assertEqual(client._inbound_streams_count, 65535) self.assertEqual(client._outbound_streams_count, 65535) self.assertEqual(client._remote_extensions, [192, 130]) self.assertEqual( server._association_state, RTCSctpTransport.State.ESTABLISHED ) self.assertEqual(server._inbound_streams_count, 65535) self.assertEqual(server._outbound_streams_count, 65535) self.assertEqual(server._remote_extensions, [192, 130]) # create data channel channel = RTCDataChannel( client, RTCDataChannelParameters(label="chat", id=1) ) self.assertEqual(channel.id, 1) self.assertEqual(channel.label, "chat") # create second data channel channel2 = RTCDataChannel(client, RTCDataChannelParameters(label="chat")) self.assertEqual(channel2.id, None) self.assertEqual(channel2.label, "chat") await asyncio.sleep(0.1) self.assertEqual(channel.id, 1) self.assertEqual(channel.label, "chat") self.assertEqual(channel2.id, 3) self.assertEqual(channel2.label, "chat") self.assertEqual(len(client_channels), 0) self.assertEqual(len(server_channels), 2) self.assertEqual(server_channels[0].id, 1) self.assertEqual(server_channels[0].label, "chat") self.assertEqual(server_channels[1].id, 3) self.assertEqual(server_channels[1].label, "chat") @asynctest async def test_connect_then_client_creates_second_data_channel_with_custom_already_used_id( self, ): async with client_and_server() as (client, server): client_channels = track_channels(client) server_channels = track_channels(server) # connect await server.start(client.getCapabilities(), client.port) await client.start(server.getCapabilities(), server.port) # check outcome await wait_for_outcome(client, server) self.assertEqual( client._association_state, RTCSctpTransport.State.ESTABLISHED ) self.assertEqual(client._inbound_streams_count, 65535) self.assertEqual(client._outbound_streams_count, 65535) self.assertEqual(client._remote_extensions, [192, 130]) self.assertEqual( server._association_state, RTCSctpTransport.State.ESTABLISHED ) self.assertEqual(server._inbound_streams_count, 65535) self.assertEqual(server._outbound_streams_count, 65535) self.assertEqual(server._remote_extensions, [192, 130]) # create data channel channel = RTCDataChannel( client, RTCDataChannelParameters(label="chat", id=100) ) self.assertEqual(channel.id, 100) self.assertEqual(channel.label, "chat") # create second data channel with the same id self.assertRaises( ValueError, lambda: RTCDataChannel( client, RTCDataChannelParameters(label="chat", id=100) ), ) await asyncio.sleep(0.1) self.assertEqual(channel.id, 100) self.assertEqual(channel.label, "chat") self.assertEqual(len(client_channels), 0) self.assertEqual(len(server_channels), 1) self.assertEqual(server_channels[0].id, 100) self.assertEqual(server_channels[0].label, "chat") @asynctest async def test_connect_then_client_creates_negotiated_data_channel_without_id(self): async with client_and_server() as (client, server): # connect await server.start(client.getCapabilities(), client.port) await client.start(server.getCapabilities(), server.port) # check outcome await wait_for_outcome(client, server) self.assertEqual( client._association_state, RTCSctpTransport.State.ESTABLISHED ) self.assertEqual(client._inbound_streams_count, 65535) self.assertEqual(client._outbound_streams_count, 65535) self.assertEqual(client._remote_extensions, [192, 130]) self.assertEqual( server._association_state, RTCSctpTransport.State.ESTABLISHED ) self.assertEqual(server._inbound_streams_count, 65535) self.assertEqual(server._outbound_streams_count, 65535) self.assertEqual(server._remote_extensions, [192, 130]) # create data channel self.assertRaises( ValueError, lambda: RTCDataChannel( client, RTCDataChannelParameters(label="chat", negotiated=True) ), ) await asyncio.sleep(0.1) @asynctest async def test_connect_then_client_and_server_creates_negotiated_data_channel(self): async with client_and_server() as (client, server): client_channels = track_channels(client) server_channels = track_channels(server) # connect await server.start(client.getCapabilities(), client.port) await client.start(server.getCapabilities(), server.port) # check outcome await wait_for_outcome(client, server) self.assertEqual( client._association_state, RTCSctpTransport.State.ESTABLISHED ) self.assertEqual(client._inbound_streams_count, 65535) self.assertEqual(client._outbound_streams_count, 65535) self.assertEqual(client._remote_extensions, [192, 130]) self.assertEqual( server._association_state, RTCSctpTransport.State.ESTABLISHED ) self.assertEqual(server._inbound_streams_count, 65535) self.assertEqual(server._outbound_streams_count, 65535) self.assertEqual(server._remote_extensions, [192, 130]) # create data channel for client channel_client = RTCDataChannel( client, RTCDataChannelParameters(label="chat", negotiated=True, id=100) ) self.assertEqual(channel_client.id, 100) self.assertEqual(channel_client.label, "chat") # create data channel for server channel_server = RTCDataChannel( server, RTCDataChannelParameters(label="chat", negotiated=True, id=100) ) self.assertEqual(channel_server.id, 100) self.assertEqual(channel_server.label, "chat") await asyncio.sleep(0.1) self.assertEqual(channel_client.id, 100) self.assertEqual(channel_client.label, "chat") self.assertEqual(channel_server.id, 100) self.assertEqual(channel_server.label, "chat") # both arrays should be 0 as they track data channels created by event # which is not the case in out-of-band self.assertEqual(len(client_channels), 0) self.assertEqual(len(server_channels), 0) @asynctest async def test_connect_then_client_creates_negotiated_data_channel_with_used_id( self, ): async with client_and_server() as (client, server): client_channels = track_channels(client) server_channels = track_channels(server) # connect await server.start(client.getCapabilities(), client.port) await client.start(server.getCapabilities(), server.port) # check outcome await wait_for_outcome(client, server) self.assertEqual( client._association_state, RTCSctpTransport.State.ESTABLISHED ) self.assertEqual(client._inbound_streams_count, 65535) self.assertEqual(client._outbound_streams_count, 65535) self.assertEqual(client._remote_extensions, [192, 130]) self.assertEqual( server._association_state, RTCSctpTransport.State.ESTABLISHED ) self.assertEqual(server._inbound_streams_count, 65535) self.assertEqual(server._outbound_streams_count, 65535) self.assertEqual(server._remote_extensions, [192, 130]) # create data channel for client channel_client = RTCDataChannel( client, RTCDataChannelParameters(label="chat", negotiated=True, id=100) ) self.assertEqual(channel_client.id, 100) self.assertEqual(channel_client.label, "chat") self.assertRaises( ValueError, lambda: RTCDataChannel( client, RTCDataChannelParameters(label="chat", negotiated=True, id=100), ), ) await asyncio.sleep(0.1) self.assertEqual(channel_client.id, 100) self.assertEqual(channel_client.label, "chat") # both arrays should be 0 as they track data channels created by event # which is not the case in out-of-band self.assertEqual(len(client_channels), 0) self.assertEqual(len(server_channels), 0) @asynctest async def test_connect_then_client_and_server_creates_negotiated_data_channel_before_transport( self, ): async with client_and_server() as (client, server): client_channels = track_channels(client) server_channels = track_channels(server) self.assertEqual(client._association_state, RTCSctpTransport.State.CLOSED) self.assertEqual(server._association_state, RTCSctpTransport.State.CLOSED) # create data channel for client channel_client = RTCDataChannel( client, RTCDataChannelParameters(label="chat", negotiated=True, id=100) ) self.assertEqual(channel_client.id, 100) self.assertEqual(channel_client.label, "chat") self.assertEqual(channel_client.readyState, "connecting") # create data channel for server channel_server = RTCDataChannel( server, RTCDataChannelParameters(label="chat", negotiated=True, id=100) ) self.assertEqual(channel_server.id, 100) self.assertEqual(channel_server.label, "chat") self.assertEqual(channel_server.readyState, "connecting") # connect await server.start(client.getCapabilities(), client.port) await client.start(server.getCapabilities(), server.port) # check outcome await wait_for_outcome(client, server) self.assertEqual( client._association_state, RTCSctpTransport.State.ESTABLISHED ) self.assertEqual(client._inbound_streams_count, 65535) self.assertEqual(client._outbound_streams_count, 65535) self.assertEqual(client._remote_extensions, [192, 130]) self.assertEqual( server._association_state, RTCSctpTransport.State.ESTABLISHED ) self.assertEqual(server._inbound_streams_count, 65535) self.assertEqual(server._outbound_streams_count, 65535) self.assertEqual(server._remote_extensions, [192, 130]) self.assertEqual(channel_client.readyState, "open") self.assertEqual(channel_server.readyState, "open") await asyncio.sleep(0.1) self.assertEqual(channel_client.id, 100) self.assertEqual(channel_client.label, "chat") self.assertEqual(channel_server.id, 100) self.assertEqual(channel_server.label, "chat") # both arrays should be 0 as they track data channels created by event # which is not the case in out-of-band self.assertEqual(len(client_channels), 0) self.assertEqual(len(server_channels), 0) @asynctest async def test_connect_then_server_creates_data_channel(self): async with client_and_server() as (client, server): client_channels = track_channels(client) server_channels = track_channels(server) # connect await server.start(client.getCapabilities(), client.port) await client.start(server.getCapabilities(), server.port) # check outcome await wait_for_outcome(client, server) self.assertEqual( client._association_state, RTCSctpTransport.State.ESTABLISHED ) self.assertEqual(client._remote_extensions, [192, 130]) self.assertEqual( server._association_state, RTCSctpTransport.State.ESTABLISHED ) self.assertEqual(server._remote_extensions, [192, 130]) # create data channel channel = RTCDataChannel(server, RTCDataChannelParameters(label="chat")) self.assertEqual(channel.id, None) self.assertEqual(channel.label, "chat") await asyncio.sleep(0.1) self.assertEqual(len(client_channels), 1) self.assertEqual(client_channels[0].id, 0) self.assertEqual(client_channels[0].label, "chat") self.assertEqual(len(server_channels), 0) @patch("aiortc.rtcsctptransport.logger.isEnabledFor") @asynctest async def test_connect_with_logging(self, mock_is_enabled_for): mock_is_enabled_for.return_value = True async with client_and_server() as (client, server): # connect await server.start(client.getCapabilities(), client.port) await client.start(server.getCapabilities(), server.port) # check outcome await wait_for_outcome(client, server) self.assertEqual( client._association_state, RTCSctpTransport.State.ESTABLISHED ) self.assertEqual( server._association_state, RTCSctpTransport.State.ESTABLISHED ) @asynctest async def test_connect_with_partial_reliability(self): async with client_and_server() as (client, server): client._local_partial_reliability = True server._local_partial_reliability = False # connect await server.start(client.getCapabilities(), client.port) await client.start(server.getCapabilities(), server.port) # check outcome await wait_for_outcome(client, server) self.assertEqual( client._association_state, RTCSctpTransport.State.ESTABLISHED ) self.assertEqual(client._remote_extensions, [130]) self.assertEqual(client._remote_partial_reliability, False) self.assertEqual( server._association_state, RTCSctpTransport.State.ESTABLISHED ) self.assertEqual(server._remote_extensions, [192, 130]) self.assertEqual(server._remote_partial_reliability, True) @asynctest async def test_abrupt_disconnect(self): """ Abrupt disconnect causes sending ABORT chunk to fail. """ async with client_and_server() as (client, server): # connect await server.start(client.getCapabilities(), client.port) await client.start(server.getCapabilities(), server.port) # check outcome await wait_for_outcome(client, server) self.assertEqual( client._association_state, RTCSctpTransport.State.ESTABLISHED ) self.assertEqual( server._association_state, RTCSctpTransport.State.ESTABLISHED ) # break connection await client.transport.stop() await server.transport.stop() await asyncio.sleep(0) # FIXME @asynctest async def test_garbage(self): async with client_and_server() as (client, server): await server.start(RTCSctpCapabilities(maxMessageSize=65536), 5000) await server._handle_data(b"garbage") # check outcome await asyncio.sleep(0.1) self.assertEqual(server._association_state, RTCSctpTransport.State.CLOSED) @asynctest async def test_bad_verification_tag(self): # verification tag is 12345 instead of 0 data = load("sctp_init_bad_verification.bin") async with client_and_server() as (client, server): await server.start(RTCSctpCapabilities(maxMessageSize=65536), 5000) await server._handle_data(data) # check outcome await asyncio.sleep(0.1) self.assertEqual(server._association_state, RTCSctpTransport.State.CLOSED) @asynctest async def test_bad_cookie(self): async with client_and_server() as (client, server): # corrupt cookie real_send_chunk = client._send_chunk async def mock_send_chunk(chunk): if isinstance(chunk, CookieEchoChunk): chunk.body = b"garbage" return await real_send_chunk(chunk) client._send_chunk = mock_send_chunk await server.start(client.getCapabilities(), client.port) await client.start(server.getCapabilities(), server.port) # check outcome await asyncio.sleep(0.1) self.assertEqual( client._association_state, RTCSctpTransport.State.COOKIE_ECHOED ) self.assertEqual(server._association_state, RTCSctpTransport.State.CLOSED) @asynctest async def test_maybe_abandon(self): async def mock_send_chunk(chunk): pass async with client_standalone() as client: client._local_tsn = 0 client._send_chunk = mock_send_chunk # send 3 chunks await client._send(123, 456, b"M" * USERDATA_MAX_LENGTH * 3) self.assertEqual(outstanding_tsns(client), [0, 1, 2]) self.assertEqual(queued_tsns(client), []) for chunk in client._outbound_queue: self.assertEqual(chunk._abandoned, False) # try abandon middle chunk client._maybe_abandon(client._sent_queue[1]) for chunk in client._outbound_queue: self.assertEqual(chunk._abandoned, False) @asynctest async def test_maybe_abandon_max_retransmits(self): async def mock_send_chunk(chunk): pass async with client_standalone() as client: client._local_tsn = 1 client._last_sacked_tsn = 0 client._advanced_peer_ack_tsn = 0 client._send_chunk = mock_send_chunk # send 3 chunks await client._send( 123, 456, b"M" * USERDATA_MAX_LENGTH * 3, max_retransmits=0 ) self.assertEqual(outstanding_tsns(client), [1, 2, 3]) self.assertEqual(queued_tsns(client), []) self.assertEqual(client._local_tsn, 4) self.assertEqual(client._advanced_peer_ack_tsn, 0) for chunk in client._outbound_queue: self.assertEqual(chunk._abandoned, False) # try abandon middle chunk client._maybe_abandon(client._sent_queue[1]) for chunk in client._outbound_queue: self.assertEqual(chunk._abandoned, True) # try abandon middle chunk (again) client._maybe_abandon(client._sent_queue[1]) for chunk in client._outbound_queue: self.assertEqual(chunk._abandoned, True) # update advanced peer ack point client._update_advanced_peer_ack_point() self.assertEqual(outstanding_tsns(client), []) self.assertEqual(queued_tsns(client), []) self.assertEqual(client._advanced_peer_ack_tsn, 3) # check forward TSN self.assertIsNotNone(client._forward_tsn_chunk) self.assertEqual(client._forward_tsn_chunk.cumulative_tsn, 3) self.assertEqual(client._forward_tsn_chunk.streams, [(123, 0)]) # transmit client._t3_cancel() await client._transmit() self.assertIsNone(client._forward_tsn_chunk) self.assertIsNotNone(client._t3_handle) @asynctest async def test_stale_cookie(self): def mock_timestamp(): mock_timestamp.calls += 1 if mock_timestamp.calls == 1: return 0 else: return 61 mock_timestamp.calls = 0 async with client_and_server() as (client, server): server._get_timestamp = mock_timestamp await server.start(client.getCapabilities(), client.port) await client.start(server.getCapabilities(), server.port) # check outcome await asyncio.sleep(0.1) self.assertEqual(client._association_state, RTCSctpTransport.State.CLOSED) self.assertEqual(server._association_state, RTCSctpTransport.State.CLOSED) @asynctest async def test_receive_data(self): async with client_standalone() as client: client._last_received_tsn = 0 # receive chunk chunk = DataChunk(flags=(SCTP_DATA_FIRST_FRAG | SCTP_DATA_LAST_FRAG)) chunk.user_data = b"foo" chunk.tsn = 1 await client._receive_chunk(chunk) self.assertEqual(client._sack_needed, True) self.assertEqual(client._sack_duplicates, []) self.assertEqual(client._sack_misordered, set()) self.assertEqual(client._last_received_tsn, 1) client._sack_needed = False # receive chunk again await client._receive_chunk(chunk) self.assertEqual(client._sack_needed, True) self.assertEqual(client._sack_duplicates, [1]) self.assertEqual(client._sack_misordered, set()) self.assertEqual(client._last_received_tsn, 1) @asynctest async def test_receive_data_out_of_order(self): async with client_standalone() as client: client._last_received_tsn = 0 # build chunks chunks = [] chunk = DataChunk(flags=SCTP_DATA_FIRST_FRAG) chunk.user_data = b"foo" chunk.tsn = 1 chunks.append(chunk) chunk = DataChunk() chunk.user_data = b"bar" chunk.tsn = 2 chunks.append(chunk) chunk = DataChunk(flags=SCTP_DATA_LAST_FRAG) chunk.user_data = b"baz" chunk.tsn = 3 chunks.append(chunk) # receive first chunk await client._receive_chunk(chunks[0]) self.assertEqual(client._sack_needed, True) self.assertEqual(client._sack_duplicates, []) self.assertEqual(client._sack_misordered, set()) self.assertEqual(client._last_received_tsn, 1) client._sack_needed = False # receive last chunk await client._receive_chunk(chunks[2]) self.assertEqual(client._sack_needed, True) self.assertEqual(client._sack_duplicates, []) self.assertEqual(client._sack_misordered, set([3])) self.assertEqual(client._last_received_tsn, 1) client._sack_needed = False # receive middle chunk await client._receive_chunk(chunks[1]) self.assertEqual(client._sack_needed, True) self.assertEqual(client._sack_duplicates, []) self.assertEqual(client._sack_misordered, set([])) self.assertEqual(client._last_received_tsn, 3) client._sack_needed = False # receive last chunk again await client._receive_chunk(chunks[2]) self.assertEqual(client._sack_needed, True) self.assertEqual(client._sack_duplicates, [3]) self.assertEqual(client._sack_misordered, set([])) self.assertEqual(client._last_received_tsn, 3) client._sack_needed = False @asynctest async def test_receive_forward_tsn(self): received = [] async def fake_receive(*args): received.append(args) async with client_standalone() as client: client._last_received_tsn = 101 client._receive = fake_receive factory = ChunkFactory(tsn=102) chunks = ( factory.create([b"foo"]) + factory.create([b"baz"]) + factory.create([b"qux"]) + factory.create([b"quux"]) + factory.create([b"corge"]) + factory.create([b"grault"]) ) # receive chunks with gaps for i in [0, 2, 3, 5]: await client._receive_chunk(chunks[i]) self.assertEqual(client._sack_needed, True) self.assertEqual(client._sack_duplicates, []) self.assertEqual(client._sack_misordered, set([104, 105, 107])) self.assertEqual(client._last_received_tsn, 102) self.assertEqual(received, [(456, 123, b"foo")]) received.clear() client._sack_needed = False # receive forward tsn chunk = ForwardTsnChunk() chunk.cumulative_tsn = 103 chunk.streams = [(456, 1)] await client._receive_chunk(chunk) self.assertEqual(client._sack_needed, True) self.assertEqual(client._sack_duplicates, []) self.assertEqual(client._sack_misordered, set([107])) self.assertEqual(client._last_received_tsn, 105) self.assertEqual(received, [(456, 123, b"qux"), (456, 123, b"quux")]) received.clear() client._sack_needed = False # receive forward tsn again await client._receive_chunk(chunk) self.assertEqual(client._sack_needed, True) self.assertEqual(client._sack_duplicates, []) self.assertEqual(client._sack_misordered, set([107])) self.assertEqual(client._last_received_tsn, 105) self.assertEqual(received, []) client._sack_needed = False # receive chunk await client._receive_chunk(chunks[4]) self.assertEqual(client._sack_needed, True) self.assertEqual(client._sack_duplicates, []) self.assertEqual(client._sack_misordered, set()) self.assertEqual(client._last_received_tsn, 107) self.assertEqual(received, [(456, 123, b"corge"), (456, 123, b"grault")]) received.clear() client._sack_needed = False @asynctest async def test_receive_heartbeat(self): ack = None async def mock_send_chunk(chunk): nonlocal ack ack = chunk async with client_standalone() as client: client._send_chunk = mock_send_chunk # receive heartbeat chunk = HeartbeatChunk() chunk.params.append((1, b"\x01\x02\x03\x04")) chunk.tsn = 1 await client._receive_chunk(chunk) # check response self.assertTrue(isinstance(ack, HeartbeatAckChunk)) self.assertEqual(ack.params, [(1, b"\x01\x02\x03\x04")]) @asynctest async def test_receive_sack_discard(self): async with client_standalone() as client: client._last_received_tsn = 0 # receive sack sack_point = client._last_sacked_tsn chunk = SackChunk() chunk.cumulative_tsn = tsn_minus_one(sack_point) await client._receive_chunk(chunk) # sack point must not changed self.assertEqual(client._last_sacked_tsn, sack_point) @asynctest async def test_receive_shutdown(self): async def mock_send_chunk(chunk): pass async with client_standalone() as client: client._last_received_tsn = 0 client._send_chunk = mock_send_chunk client._set_state(RTCSctpTransport.State.ESTABLISHED) # receive shutdown chunk = ShutdownChunk() chunk.cumulative_tsn = tsn_minus_one(client._last_sacked_tsn) await client._receive_chunk(chunk) self.assertEqual( client._association_state, RTCSctpTransport.State.SHUTDOWN_ACK_SENT ) # receive shutdown complete chunk = ShutdownCompleteChunk() await client._receive_chunk(chunk) self.assertEqual(client._association_state, RTCSctpTransport.State.CLOSED) @asynctest async def test_mark_received(self): async with client_standalone() as client: client._last_received_tsn = 0 # receive 1 self.assertFalse(client._mark_received(1)) self.assertEqual(client._last_received_tsn, 1) self.assertEqual(client._sack_misordered, set()) # receive 3 self.assertFalse(client._mark_received(3)) self.assertEqual(client._last_received_tsn, 1) self.assertEqual(client._sack_misordered, set([3])) # receive 4 self.assertFalse(client._mark_received(4)) self.assertEqual(client._last_received_tsn, 1) self.assertEqual(client._sack_misordered, set([3, 4])) # receive 6 self.assertFalse(client._mark_received(6)) self.assertEqual(client._last_received_tsn, 1) self.assertEqual(client._sack_misordered, set([3, 4, 6])) # receive 2 self.assertFalse(client._mark_received(2)) self.assertEqual(client._last_received_tsn, 4) self.assertEqual(client._sack_misordered, set([6])) @asynctest async def test_send_sack(self): sack = None async def mock_send_chunk(c): nonlocal sack sack = c async with client_standalone() as client: client._last_received_tsn = 123 client._send_chunk = mock_send_chunk await client._send_sack() self.assertIsNotNone(sack) self.assertEqual(sack.duplicates, []) self.assertEqual(sack.gaps, []) self.assertEqual(sack.cumulative_tsn, 123) @asynctest async def test_send_sack_with_duplicates(self): sack = None async def mock_send_chunk(c): nonlocal sack sack = c async with client_standalone() as client: client._last_received_tsn = 123 client._sack_duplicates = [125, 127] client._send_chunk = mock_send_chunk await client._send_sack() self.assertIsNotNone(sack) self.assertEqual(sack.duplicates, [125, 127]) self.assertEqual(sack.gaps, []) self.assertEqual(sack.cumulative_tsn, 123) @asynctest async def test_send_sack_with_gaps(self): sack = None async def mock_send_chunk(c): nonlocal sack sack = c async with client_standalone() as client: client._last_received_tsn = 12 client._sack_misordered = [14, 15, 17] client._send_chunk = mock_send_chunk await client._send_sack() self.assertIsNotNone(sack) self.assertEqual(sack.duplicates, []) self.assertEqual(sack.gaps, [(2, 3), (5, 5)]) self.assertEqual(sack.cumulative_tsn, 12) @asynctest async def test_send_data(self): async def mock_send_chunk(chunk): pass async with client_standalone() as client: client._local_tsn = 0 client._send_chunk = mock_send_chunk # no data await client._transmit() self.assertIsNone(client._t3_handle) self.assertEqual(outstanding_tsns(client), []) self.assertEqual(queued_tsns(client), []) self.assertEqual(client._outbound_stream_seq, {}) # 1 chunk await client._send(123, 456, b"M" * USERDATA_MAX_LENGTH) self.assertIsNotNone(client._t3_handle) self.assertEqual(outstanding_tsns(client), [0]) self.assertEqual(queued_tsns(client), []) self.assertEqual(client._outbound_stream_seq, {123: 1}) @asynctest async def test_send_data_unordered(self): async def mock_send_chunk(chunk): pass async with client_standalone() as client: client._local_tsn = 0 client._send_chunk = mock_send_chunk # 1 chunk await client._send(123, 456, b"M" * USERDATA_MAX_LENGTH, ordered=False) self.assertIsNotNone(client._t3_handle) self.assertEqual(outstanding_tsns(client), [0]) self.assertEqual(queued_tsns(client), []) self.assertEqual(client._outbound_stream_seq, {}) @asynctest async def test_send_data_congestion_control(self): sent_tsns = [] async def mock_send_chunk(chunk): sent_tsns.append(chunk.tsn) async with client_standalone() as client: client._cwnd = 4800 client._last_sacked_tsn = 4294967295 client._local_tsn = 0 client._ssthresh = 4800 client._send_chunk = mock_send_chunk # queue 16 chunks, but cwnd only allows 4 await client._send(123, 456, b"M" * USERDATA_MAX_LENGTH * 16) self.assertEqual(client._cwnd, 4800) self.assertEqual(client._fast_recovery_exit, None) self.assertEqual(client._flight_size, 4800) self.assertEqual(sent_tsns, [0, 1, 2, 3]) self.assertEqual(outstanding_tsns(client), [0, 1, 2, 3]) self.assertEqual( queued_tsns(client), [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] ) # SACK comes in acknowledging 2 chunks sack = SackChunk() sack.cumulative_tsn = 1 await client._receive_chunk(sack) self.assertEqual(client._cwnd, 6000) self.assertEqual(client._fast_recovery_exit, None) self.assertEqual(client._flight_size, 6000) self.assertEqual(sent_tsns, [0, 1, 2, 3, 4, 5, 6]) self.assertEqual(outstanding_tsns(client), [2, 3, 4, 5, 6]) self.assertEqual(queued_tsns(client), [7, 8, 9, 10, 11, 12, 13, 14, 15]) # SACK comes in acknowledging 2 more chunks sack = SackChunk() sack.cumulative_tsn = 3 await client._receive_chunk(sack) self.assertEqual(client._cwnd, 6000) self.assertEqual(client._fast_recovery_exit, None) self.assertEqual(client._flight_size, 6000) self.assertEqual(sent_tsns, [0, 1, 2, 3, 4, 5, 6, 7, 8]) self.assertEqual(outstanding_tsns(client), [4, 5, 6, 7, 8]) self.assertEqual(queued_tsns(client), [9, 10, 11, 12, 13, 14, 15]) # SACK comes in acknowledging 2 more chunks sack = SackChunk() sack.cumulative_tsn = 5 await client._receive_chunk(sack) self.assertEqual(client._cwnd, 6000) self.assertEqual(client._fast_recovery_exit, None) self.assertEqual(client._flight_size, 6000) self.assertEqual(sent_tsns, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) self.assertEqual(outstanding_tsns(client), [6, 7, 8, 9, 10]) self.assertEqual(queued_tsns(client), [11, 12, 13, 14, 15]) # SACK comes in acknowledging 2 more chunks sack = SackChunk() sack.cumulative_tsn = 7 await client._receive_chunk(sack) self.assertEqual(client._cwnd, 7200) self.assertEqual(client._fast_recovery_exit, None) self.assertEqual(client._flight_size, 7200) self.assertEqual(sent_tsns, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]) self.assertEqual(outstanding_tsns(client), [8, 9, 10, 11, 12, 13]) self.assertEqual(queued_tsns(client), [14, 15]) # SACK comes in acknowledging 2 more chunks sack = SackChunk() sack.cumulative_tsn = 9 await client._receive_chunk(sack) self.assertEqual(client._cwnd, 7200) self.assertEqual(client._fast_recovery_exit, None) self.assertEqual(client._flight_size, 7200) self.assertEqual( sent_tsns, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] ) self.assertEqual(outstanding_tsns(client), [10, 11, 12, 13, 14, 15]) self.assertEqual(queued_tsns(client), []) @asynctest async def test_send_data_slow_start(self): sent_tsns = [] async def mock_send_chunk(chunk): sent_tsns.append(chunk.tsn) async with client_standalone() as client: client._last_sacked_tsn = 4294967295 client._local_tsn = 0 client._ssthresh = 131072 client._send_chunk = mock_send_chunk # queue 8 chunks, but cwnd only allows 3 with self.assertTimerRestarted(client): await client._send(123, 456, b"M" * USERDATA_MAX_LENGTH * 8) self.assertEqual(client._cwnd, 3600) self.assertEqual(client._fast_recovery_exit, None) self.assertEqual(client._flight_size, 3600) self.assertEqual(sent_tsns, [0, 1, 2]) self.assertEqual(outstanding_tsns(client), [0, 1, 2]) self.assertEqual(queued_tsns(client), [3, 4, 5, 6, 7]) # SACK comes in acknowledging 2 chunks sack = SackChunk() sack.cumulative_tsn = 1 with self.assertTimerRestarted(client): await client._receive_chunk(sack) self.assertEqual(client._cwnd, 4800) self.assertEqual(client._fast_recovery_exit, None) self.assertEqual(client._flight_size, 4800) self.assertEqual(sent_tsns, [0, 1, 2, 3, 4, 5]) self.assertEqual(outstanding_tsns(client), [2, 3, 4, 5]) self.assertEqual(queued_tsns(client), [6, 7]) # SACK sack comes in acknowledging 2 more chunks sack = SackChunk() sack.cumulative_tsn = 3 with self.assertTimerRestarted(client): await client._receive_chunk(sack) self.assertEqual(client._cwnd, 6000) self.assertEqual(client._fast_recovery_exit, None) self.assertEqual(client._flight_size, 4800) self.assertEqual(sent_tsns, [0, 1, 2, 3, 4, 5, 6, 7]) self.assertEqual(outstanding_tsns(client), [4, 5, 6, 7]) self.assertEqual(queued_tsns(client), []) # SACK comes in acknowledging 2 more chunks sack = SackChunk() sack.cumulative_tsn = 5 with self.assertTimerRestarted(client): await client._receive_chunk(sack) self.assertEqual(client._cwnd, 6000) self.assertEqual(client._fast_recovery_exit, None) self.assertEqual(client._flight_size, 2400) self.assertEqual(sent_tsns, [0, 1, 2, 3, 4, 5, 6, 7]) self.assertEqual(outstanding_tsns(client), [6, 7]) self.assertEqual(queued_tsns(client), []) # SACK comes in acknowledging final chunks sack = SackChunk() sack.cumulative_tsn = 7 with self.assertTimerStopped(client): await client._receive_chunk(sack) self.assertEqual(client._cwnd, 6000) self.assertEqual(client._fast_recovery_exit, None) self.assertEqual(client._flight_size, 0) self.assertEqual(sent_tsns, [0, 1, 2, 3, 4, 5, 6, 7]) self.assertEqual(outstanding_tsns(client), []) self.assertEqual(queued_tsns(client), []) @asynctest async def test_send_data_with_gap(self): sent_tsns = [] async def mock_send_chunk(chunk): sent_tsns.append(chunk.tsn) async with client_standalone() as client: client._last_sacked_tsn = 4294967295 client._local_tsn = 0 client._ssthresh = 131072 client._send_chunk = mock_send_chunk # queue 8 chunks, but cwnd only allows 3 with self.assertTimerRestarted(client): await client._send(123, 456, b"M" * USERDATA_MAX_LENGTH * 8) self.assertEqual(client._cwnd, 3600) self.assertEqual(client._fast_recovery_exit, None) self.assertEqual(client._flight_size, 3600) self.assertEqual(sent_tsns, [0, 1, 2]) self.assertEqual(outstanding_tsns(client), [0, 1, 2]) self.assertEqual(queued_tsns(client), [3, 4, 5, 6, 7]) # SACK comes in acknowledging chunks 0 and 2 sack = SackChunk() sack.cumulative_tsn = 0 sack.gaps = [(2, 2)] # TSN 1 is missing with self.assertTimerRestarted(client): await client._receive_chunk(sack) self.assertEqual(client._cwnd, 4800) self.assertEqual(client._fast_recovery_exit, None) self.assertEqual(client._flight_size, 4800) self.assertEqual(sent_tsns, [0, 1, 2, 3, 4, 5]) self.assertEqual(outstanding_tsns(client), [1, 2, 3, 4, 5]) self.assertEqual(queued_tsns(client), [6, 7]) # SACK comes in acknowledging chunks 1 and 3 sack = SackChunk() sack.cumulative_tsn = 3 with self.assertTimerRestarted(client): await client._receive_chunk(sack) self.assertEqual(client._cwnd, 6000) self.assertEqual(client._fast_recovery_exit, None) self.assertEqual(client._flight_size, 4800) self.assertEqual(sent_tsns, [0, 1, 2, 3, 4, 5, 6, 7]) self.assertEqual(outstanding_tsns(client), [4, 5, 6, 7]) self.assertEqual(queued_tsns(client), []) # SACK comes in acknowledging 2 more chunks sack = SackChunk() sack.cumulative_tsn = 5 with self.assertTimerRestarted(client): await client._receive_chunk(sack) self.assertEqual(client._cwnd, 6000) self.assertEqual(client._fast_recovery_exit, None) self.assertEqual(client._flight_size, 2400) self.assertEqual(sent_tsns, [0, 1, 2, 3, 4, 5, 6, 7]) self.assertEqual(outstanding_tsns(client), [6, 7]) self.assertEqual(queued_tsns(client), []) # SACK comes in acknowledging final chunks sack = SackChunk() sack.cumulative_tsn = 7 with self.assertTimerStopped(client): await client._receive_chunk(sack) self.assertEqual(client._cwnd, 6000) self.assertEqual(client._fast_recovery_exit, None) self.assertEqual(client._flight_size, 0) self.assertEqual(sent_tsns, [0, 1, 2, 3, 4, 5, 6, 7]) self.assertEqual(outstanding_tsns(client), []) self.assertEqual(queued_tsns(client), []) @asynctest async def test_send_data_with_gap_1_retransmit(self): sent_tsns = [] async def mock_send_chunk(chunk): sent_tsns.append(chunk.tsn) async with client_standalone() as client: client._last_sacked_tsn = 4294967295 client._local_tsn = 0 client._ssthresh = 131072 client._send_chunk = mock_send_chunk # queue 8 chunks, but cwnd only allows 3 with self.assertTimerRestarted(client): await client._send(123, 456, b"M" * USERDATA_MAX_LENGTH * 8) self.assertEqual(client._cwnd, 3600) self.assertEqual(client._fast_recovery_exit, None) self.assertEqual(client._flight_size, 3600) self.assertEqual(sent_tsns, [0, 1, 2]) self.assertEqual(outstanding_tsns(client), [0, 1, 2]) self.assertEqual(queued_tsns(client), [3, 4, 5, 6, 7]) # SACK comes in acknowledging chunks 0 and 2 sack = SackChunk() sack.cumulative_tsn = 0 sack.gaps = [(2, 2)] # TSN 1 is missing with self.assertTimerRestarted(client): await client._receive_chunk(sack) self.assertEqual(client._cwnd, 4800) self.assertEqual(client._fast_recovery_exit, None) self.assertEqual(client._flight_size, 4800) self.assertEqual(sent_tsns, [0, 1, 2, 3, 4, 5]) self.assertEqual(outstanding_tsns(client), [1, 2, 3, 4, 5]) self.assertEqual(queued_tsns(client), [6, 7]) # SACK comes in acknowledging chunks 3 and 4 sack = SackChunk() sack.cumulative_tsn = 0 sack.gaps = [(2, 4)] # TSN 1 is missing with self.assertTimerPreserved(client): await client._receive_chunk(sack) self.assertEqual(client._cwnd, 4800) self.assertEqual(client._fast_recovery_exit, None) self.assertEqual(client._flight_size, 4800) self.assertEqual(sent_tsns, [0, 1, 2, 3, 4, 5, 6, 7]) self.assertEqual(outstanding_tsns(client), [1, 2, 3, 4, 5, 6, 7]) self.assertEqual(queued_tsns(client), []) # SACK comes in acknowledging 2 more chunks sack = SackChunk() sack.cumulative_tsn = 0 sack.gaps = [(2, 6)] # TSN 1 is missing with self.assertTimerRestarted(client): await client._receive_chunk(sack) self.assertEqual(client._cwnd, 4800) self.assertEqual(client._fast_recovery_exit, 7) self.assertEqual(client._flight_size, 2400) self.assertEqual(sent_tsns, [0, 1, 2, 3, 4, 5, 6, 7, 1]) self.assertEqual(outstanding_tsns(client), [1, 2, 3, 4, 5, 6, 7]) self.assertEqual(queued_tsns(client), []) # SACK comes in acknowledging final chunks sack = SackChunk() sack.cumulative_tsn = 7 with self.assertTimerStopped(client): await client._receive_chunk(sack) self.assertEqual(client._cwnd, 4800) self.assertEqual(client._fast_recovery_exit, None) self.assertEqual(client._flight_size, 0) self.assertEqual(sent_tsns, [0, 1, 2, 3, 4, 5, 6, 7, 1]) self.assertEqual(outstanding_tsns(client), []) self.assertEqual(queued_tsns(client), []) @asynctest async def test_send_data_with_gap_2_retransmit(self): sent_tsns = [] async def mock_send_chunk(chunk): sent_tsns.append(chunk.tsn) async with client_standalone() as client: client._last_sacked_tsn = 4294967295 client._local_tsn = 0 client._ssthresh = 131072 client._send_chunk = mock_send_chunk # queue 8 chunks, but cwnd only allows 3 with self.assertTimerRestarted(client): await client._send(123, 456, b"M" * USERDATA_MAX_LENGTH * 8) self.assertEqual(client._cwnd, 3600) self.assertEqual(client._fast_recovery_exit, None) self.assertEqual(client._flight_size, 3600) self.assertEqual(sent_tsns, [0, 1, 2]) self.assertEqual(outstanding_tsns(client), [0, 1, 2]) self.assertEqual(queued_tsns(client), [3, 4, 5, 6, 7]) # SACK comes in acknowledging chunk 2 sack = SackChunk() sack.cumulative_tsn = 4294967295 sack.gaps = [(3, 3)] # TSN 0 and 1 are missing with self.assertTimerPreserved(client): await client._receive_chunk(sack) self.assertEqual(client._cwnd, 3600) self.assertEqual(client._fast_recovery_exit, None) self.assertEqual(client._flight_size, 3600) self.assertEqual(sent_tsns, [0, 1, 2, 3]) self.assertEqual(outstanding_tsns(client), [0, 1, 2, 3]) self.assertEqual(queued_tsns(client), [4, 5, 6, 7]) # SACK comes in acknowledging chunk 3 sack = SackChunk() sack.cumulative_tsn = 4294967295 sack.gaps = [(3, 4)] # TSN 0 and 1 are missing with self.assertTimerPreserved(client): await client._receive_chunk(sack) self.assertEqual(client._cwnd, 3600) self.assertEqual(client._fast_recovery_exit, None) self.assertEqual(client._flight_size, 3600) self.assertEqual(sent_tsns, [0, 1, 2, 3, 4]) self.assertEqual(outstanding_tsns(client), [0, 1, 2, 3, 4]) self.assertEqual(queued_tsns(client), [5, 6, 7]) # SACK comes in acknowledging chunk 4 sack = SackChunk() sack.cumulative_tsn = 4294967295 sack.gaps = [(3, 5)] # TSN 0 and 1 are missing with self.assertTimerRestarted(client): await client._receive_chunk(sack) self.assertEqual(client._cwnd, 4800) self.assertEqual(client._fast_recovery_exit, 4) self.assertEqual(client._flight_size, 2400) self.assertEqual(sent_tsns, [0, 1, 2, 3, 4, 0, 1]) self.assertEqual(outstanding_tsns(client), [0, 1, 2, 3, 4]) self.assertEqual(queued_tsns(client), [5, 6, 7]) # SACK comes in acknowledging all chunks up to 4 sack = SackChunk() sack.cumulative_tsn = 4 with self.assertTimerRestarted(client): await client._receive_chunk(sack) self.assertEqual(client._cwnd, 4800) self.assertEqual(client._fast_recovery_exit, None) self.assertEqual(client._flight_size, 3600) self.assertEqual(sent_tsns, [0, 1, 2, 3, 4, 0, 1, 5, 6, 7]) self.assertEqual(outstanding_tsns(client), [5, 6, 7]) self.assertEqual(queued_tsns(client), []) # SACK comes in acknowledging final chunks sack = SackChunk() sack.cumulative_tsn = 7 with self.assertTimerStopped(client): await client._receive_chunk(sack) self.assertEqual(client._cwnd, 4800) self.assertEqual(client._fast_recovery_exit, None) self.assertEqual(client._flight_size, 0) self.assertEqual(sent_tsns, [0, 1, 2, 3, 4, 0, 1, 5, 6, 7]) self.assertEqual(outstanding_tsns(client), []) self.assertEqual(queued_tsns(client), []) @asynctest async def test_send_data_with_gap_3_retransmit(self): sent_tsns = [] async def mock_send_chunk(chunk): sent_tsns.append(chunk.tsn) async with client_standalone() as client: client._last_sacked_tsn = 4294967295 client._local_tsn = 0 client._ssthresh = 131072 client._send_chunk = mock_send_chunk # queue 8 chunks, but cwnd only allows 3 with self.assertTimerRestarted(client): await client._send(123, 456, b"M" * USERDATA_MAX_LENGTH * 8) self.assertEqual(client._cwnd, 3600) self.assertEqual(client._fast_recovery_exit, None) self.assertEqual(client._flight_size, 3600) self.assertEqual(sent_tsns, [0, 1, 2]) self.assertEqual(outstanding_tsns(client), [0, 1, 2]) self.assertEqual(queued_tsns(client), [3, 4, 5, 6, 7]) # SACK comes in acknowledging chunks 0 and 1 sack = SackChunk() sack.cumulative_tsn = 1 with self.assertTimerRestarted(client): await client._receive_chunk(sack) self.assertEqual(client._cwnd, 4800) self.assertEqual(client._fast_recovery_exit, None) self.assertEqual(client._flight_size, 4800) self.assertEqual(sent_tsns, [0, 1, 2, 3, 4, 5]) self.assertEqual(outstanding_tsns(client), [2, 3, 4, 5]) self.assertEqual(queued_tsns(client), [6, 7]) # SACK comes in acknowledging chunk 5 sack = SackChunk() sack.cumulative_tsn = 1 sack.gaps = [(4, 4)] # TSN 2, 3 and 4 are missing with self.assertTimerPreserved(client): await client._receive_chunk(sack) self.assertEqual(client._cwnd, 4800) self.assertEqual(client._fast_recovery_exit, None) self.assertEqual(client._flight_size, 4800) self.assertEqual(sent_tsns, [0, 1, 2, 3, 4, 5, 6]) self.assertEqual(outstanding_tsns(client), [2, 3, 4, 5, 6]) self.assertEqual(queued_tsns(client), [7]) # SACK comes in acknowledging chunk 6 sack = SackChunk() sack.cumulative_tsn = 1 sack.gaps = [(4, 5)] # TSN 2, 3 and 4 are missing with self.assertTimerPreserved(client): await client._receive_chunk(sack) self.assertEqual(client._cwnd, 4800) self.assertEqual(client._fast_recovery_exit, None) self.assertEqual(client._flight_size, 4800) self.assertEqual(sent_tsns, [0, 1, 2, 3, 4, 5, 6, 7]) self.assertEqual(outstanding_tsns(client), [2, 3, 4, 5, 6, 7]) self.assertEqual(queued_tsns(client), []) # artificially raise flight size to hit cwnd client._flight_size += 2400 # SACK comes in acknowledging chunk 7 sack = SackChunk() sack.cumulative_tsn = 1 sack.gaps = [(4, 6)] # TSN 2, 3 and 4 are missing with self.assertTimerRestarted(client): await client._receive_chunk(sack) self.assertEqual(client._cwnd, 4800) self.assertEqual(client._fast_recovery_exit, 7) self.assertEqual(client._flight_size, 4800) self.assertEqual(sent_tsns, [0, 1, 2, 3, 4, 5, 6, 7, 2, 3]) self.assertEqual(outstanding_tsns(client), [2, 3, 4, 5, 6, 7]) self.assertEqual(queued_tsns(client), []) # SACK comes in acknowledging all chunks up to 3, and 5, 6, 7 sack = SackChunk() sack.cumulative_tsn = 3 sack.gaps = [(2, 4)] # TSN 4 is missing with self.assertTimerRestarted(client): await client._receive_chunk(sack) self.assertEqual(client._cwnd, 4800) self.assertEqual(client._fast_recovery_exit, 7) self.assertEqual(client._flight_size, 3600) self.assertEqual(sent_tsns, [0, 1, 2, 3, 4, 5, 6, 7, 2, 3, 4]) self.assertEqual(outstanding_tsns(client), [4, 5, 6, 7]) self.assertEqual(queued_tsns(client), []) # SACK comes in ackowledging all chunks sack = SackChunk() sack.cumulative_tsn = 7 with self.assertTimerStopped(client): await client._receive_chunk(sack) self.assertEqual(client._cwnd, 4800) self.assertEqual(client._fast_recovery_exit, None) self.assertEqual(client._flight_size, 2400) self.assertEqual(sent_tsns, [0, 1, 2, 3, 4, 5, 6, 7, 2, 3, 4]) self.assertEqual(outstanding_tsns(client), []) self.assertEqual(queued_tsns(client), []) @asynctest async def test_t2_expired_when_shutdown_ack_sent(self): async def mock_send_chunk(chunk): pass async with client_standalone() as client: client._send_chunk = mock_send_chunk chunk = ShutdownAckChunk() # fails once client._set_state(RTCSctpTransport.State.SHUTDOWN_ACK_SENT) client._t2_start(chunk) client._t2_expired() self.assertEqual(client._t2_failures, 1) self.assertIsNotNone(client._t2_handle) self.assertEqual( client._association_state, RTCSctpTransport.State.SHUTDOWN_ACK_SENT ) # fails 10 times client._t2_failures = 9 client._t2_expired() self.assertEqual(client._t2_failures, 10) self.assertIsNotNone(client._t2_handle) self.assertEqual( client._association_state, RTCSctpTransport.State.SHUTDOWN_ACK_SENT ) # fails 11 times client._t2_expired() self.assertEqual(client._t2_failures, 11) self.assertIsNone(client._t2_handle) self.assertEqual(client._association_state, RTCSctpTransport.State.CLOSED) # let async code complete await asyncio.sleep(0) @asynctest async def test_t3_expired(self): async def mock_send_chunk(chunk): pass async def mock_transmit(): pass async with client_standalone() as client: client._local_tsn = 0 client._send_chunk = mock_send_chunk # 1 chunk await client._send(123, 456, b"M" * USERDATA_MAX_LENGTH) self.assertIsNotNone(client._t3_handle) self.assertEqual(outstanding_tsns(client), [0]) self.assertEqual(queued_tsns(client), []) # t3 expires client._transmit = mock_transmit client._t3_expired() self.assertIsNone(client._t3_handle) self.assertEqual(outstanding_tsns(client), [0]) self.assertEqual(queued_tsns(client), []) for chunk in client._outbound_queue: self.assertEqual(chunk._retransmit, True) # let async code complete await asyncio.sleep(0) aiortc-1.3.0/tests/test_rtcsessiondescription.py000066400000000000000000000011371417604566400222260ustar00rootroot00000000000000from unittest import TestCase from aiortc import RTCSessionDescription class RTCSessionDescriptionTest(TestCase): def test_bad_type(self): with self.assertRaises(ValueError) as cm: RTCSessionDescription(sdp="v=0\r\n", type="bogus") self.assertEqual( str(cm.exception), "'type' must be in ['offer', 'pranswer', 'answer', 'rollback'] (got 'bogus')", ) def test_good_type(self): desc = RTCSessionDescription(sdp="v=0\r\n", type="answer") self.assertEqual(desc.sdp, "v=0\r\n") self.assertEqual(desc.type, "answer") aiortc-1.3.0/tests/test_rtp.py000066400000000000000000000636671417604566400164130ustar00rootroot00000000000000import fractions import math import sys from unittest import TestCase from av import AudioFrame from aiortc import rtp from aiortc.rtcrtpparameters import RTCRtpHeaderExtensionParameters, RTCRtpParameters from aiortc.rtp import ( RtcpByePacket, RtcpPacket, RtcpPsfbPacket, RtcpRrPacket, RtcpRtpfbPacket, RtcpSdesPacket, RtcpSrPacket, RtpPacket, clamp_packets_lost, pack_header_extensions, pack_packets_lost, pack_remb_fci, unpack_header_extensions, unpack_packets_lost, unpack_remb_fci, unwrap_rtx, wrap_rtx, ) from .utils import load def create_audio_frame(sample_func, samples, pts, layout="mono", sample_rate=48000): frame = AudioFrame(format="s16", layout=layout, samples=samples) for p in frame.planes: buf = bytearray() for i in range(samples): sample = int(sample_func(i) * 32767) buf.extend(int.to_bytes(sample, 2, sys.byteorder, signed=True)) p.update(buf) frame.pts = pts frame.sample_rate = sample_rate frame.time_base = fractions.Fraction(1, sample_rate) return frame class RtcpPacketTest(TestCase): def test_bye(self): data = load("rtcp_bye.bin") packets = RtcpPacket.parse(data) self.assertEqual(len(packets), 1) packet = packets[0] self.assertTrue(isinstance(packet, RtcpByePacket)) self.assertEqual(packet.sources, [2924645187]) self.assertEqual(bytes(packet), data) self.assertEqual(repr(packet), "RtcpByePacket(sources=[2924645187])") def test_bye_invalid(self): data = load("rtcp_bye_invalid.bin") with self.assertRaises(ValueError) as cm: RtcpPacket.parse(data) self.assertEqual(str(cm.exception), "RTCP bye length is invalid") def test_bye_no_sources(self): data = load("rtcp_bye_no_sources.bin") packets = RtcpPacket.parse(data) self.assertEqual(len(packets), 1) packet = packets[0] self.assertTrue(isinstance(packet, RtcpByePacket)) self.assertEqual(packet.sources, []) self.assertEqual(bytes(packet), data) self.assertEqual(repr(packet), "RtcpByePacket(sources=[])") def test_bye_only_padding(self): data = load("rtcp_bye_padding.bin") packets = RtcpPacket.parse(data) self.assertEqual(len(packets), 1) packet = packets[0] self.assertTrue(isinstance(packet, RtcpByePacket)) self.assertEqual(packet.sources, []) self.assertEqual(bytes(packet), b"\x80\xcb\x00\x00") self.assertEqual(repr(packet), "RtcpByePacket(sources=[])") def test_bye_only_padding_zero(self): data = load("rtcp_bye_padding.bin")[0:4] + b"\x00\x00\x00\x00" with self.assertRaises(ValueError) as cm: RtcpPacket.parse(data) self.assertEqual(str(cm.exception), "RTCP packet padding length is invalid") def test_psfb_invalid(self): data = load("rtcp_psfb_invalid.bin") with self.assertRaises(ValueError) as cm: RtcpPacket.parse(data) self.assertEqual( str(cm.exception), "RTCP payload-specific feedback length is invalid" ) def test_psfb_pli(self): data = load("rtcp_psfb_pli.bin") packets = RtcpPacket.parse(data) self.assertEqual(len(packets), 1) packet = packets[0] self.assertTrue(isinstance(packet, RtcpPsfbPacket)) self.assertEqual(packet.fmt, 1) self.assertEqual(packet.ssrc, 1414554213) self.assertEqual(packet.media_ssrc, 587284409) self.assertEqual(packet.fci, b"") self.assertEqual(bytes(packet), data) def test_rr(self): data = load("rtcp_rr.bin") packets = RtcpPacket.parse(data) self.assertEqual(len(packets), 1) packet = packets[0] self.assertTrue(isinstance(packet, RtcpRrPacket)) self.assertEqual(packet.ssrc, 817267719) self.assertEqual(packet.reports[0].ssrc, 1200895919) self.assertEqual(packet.reports[0].fraction_lost, 0) self.assertEqual(packet.reports[0].packets_lost, 0) self.assertEqual(packet.reports[0].highest_sequence, 630) self.assertEqual(packet.reports[0].jitter, 1906) self.assertEqual(packet.reports[0].lsr, 0) self.assertEqual(packet.reports[0].dlsr, 0) self.assertEqual(bytes(packet), data) def test_rr_invalid(self): data = load("rtcp_rr_invalid.bin") with self.assertRaises(ValueError) as cm: RtcpPacket.parse(data) self.assertEqual(str(cm.exception), "RTCP receiver report length is invalid") def test_rr_truncated(self): data = load("rtcp_rr.bin") for length in range(1, 4): with self.assertRaises(ValueError) as cm: RtcpPacket.parse(data[0:length]) self.assertEqual( str(cm.exception), "RTCP packet length is less than 4 bytes" ) for length in range(4, 32): with self.assertRaises(ValueError) as cm: RtcpPacket.parse(data[0:length]) self.assertEqual(str(cm.exception), "RTCP packet is truncated") def test_sdes(self): data = load("rtcp_sdes.bin") packets = RtcpPacket.parse(data) self.assertEqual(len(packets), 1) packet = packets[0] self.assertTrue(isinstance(packet, RtcpSdesPacket)) self.assertEqual(packet.chunks[0].ssrc, 1831097322) self.assertEqual( packet.chunks[0].items, [(1, b"{63f459ea-41fe-4474-9d33-9707c9ee79d1}")] ) self.assertEqual(bytes(packet), data) def test_sdes_item_truncated(self): data = load("rtcp_sdes_item_truncated.bin") with self.assertRaises(ValueError) as cm: RtcpPacket.parse(data) self.assertEqual(str(cm.exception), "RTCP SDES item is truncated") def test_sdes_source_truncated(self): data = load("rtcp_sdes_source_truncated.bin") with self.assertRaises(ValueError) as cm: RtcpPacket.parse(data) self.assertEqual(str(cm.exception), "RTCP SDES source is truncated") def test_sr(self): data = load("rtcp_sr.bin") packets = RtcpPacket.parse(data) self.assertEqual(len(packets), 1) packet = packets[0] self.assertTrue(isinstance(packet, RtcpSrPacket)) self.assertEqual(packet.ssrc, 1831097322) self.assertEqual(packet.sender_info.ntp_timestamp, 16016567581311369308) self.assertEqual(packet.sender_info.rtp_timestamp, 1722342718) self.assertEqual(packet.sender_info.packet_count, 269) self.assertEqual(packet.sender_info.octet_count, 13557) self.assertEqual(len(packet.reports), 1) self.assertEqual(packet.reports[0].ssrc, 2398654957) self.assertEqual(packet.reports[0].fraction_lost, 0) self.assertEqual(packet.reports[0].packets_lost, 0) self.assertEqual(packet.reports[0].highest_sequence, 246) self.assertEqual(packet.reports[0].jitter, 127) self.assertEqual(packet.reports[0].lsr, 0) self.assertEqual(packet.reports[0].dlsr, 0) self.assertEqual(bytes(packet), data) def test_sr_invalid(self): data = load("rtcp_sr_invalid.bin") with self.assertRaises(ValueError) as cm: RtcpPacket.parse(data) self.assertEqual(str(cm.exception), "RTCP sender report length is invalid") def test_rtpfb(self): data = load("rtcp_rtpfb.bin") packets = RtcpPacket.parse(data) self.assertEqual(len(packets), 1) packet = packets[0] self.assertTrue(isinstance(packet, RtcpRtpfbPacket)) self.assertEqual(packet.fmt, 1) self.assertEqual(packet.ssrc, 2336520123) self.assertEqual(packet.media_ssrc, 4145934052) self.assertEqual( packet.lost, [12, 32, 39, 54, 76, 110, 123, 142, 183, 187, 223, 236, 271, 292], ) self.assertEqual(bytes(packet), data) def test_rtpfb_invalid(self): data = load("rtcp_rtpfb_invalid.bin") with self.assertRaises(ValueError) as cm: RtcpPacket.parse(data) self.assertEqual(str(cm.exception), "RTCP RTP feedback length is invalid") def test_compound(self): data = load("rtcp_sr.bin") + load("rtcp_sdes.bin") packets = RtcpPacket.parse(data) self.assertEqual(len(packets), 2) self.assertTrue(isinstance(packets[0], RtcpSrPacket)) self.assertTrue(isinstance(packets[1], RtcpSdesPacket)) def test_bad_version(self): data = b"\xc0" + load("rtcp_rr.bin")[1:] with self.assertRaises(ValueError) as cm: RtcpPacket.parse(data) self.assertEqual(str(cm.exception), "RTCP packet has invalid version") class RtpPacketTest(TestCase): def test_dtmf(self): data = load("rtp_dtmf.bin") packet = RtpPacket.parse(data) self.assertEqual(packet.version, 2) self.assertEqual(packet.marker, 1) self.assertEqual(packet.payload_type, 101) self.assertEqual(packet.sequence_number, 24152) self.assertEqual(packet.timestamp, 4021352124) self.assertEqual(packet.csrc, []) self.assertEqual(packet.extensions, rtp.HeaderExtensions()) self.assertEqual(len(packet.payload), 4) self.assertEqual(packet.serialize(), data) def test_no_ssrc(self): data = load("rtp.bin") packet = RtpPacket.parse(data) self.assertEqual(packet.version, 2) self.assertEqual(packet.marker, 0) self.assertEqual(packet.payload_type, 0) self.assertEqual(packet.sequence_number, 15743) self.assertEqual(packet.timestamp, 3937035252) self.assertEqual(packet.csrc, []) self.assertEqual(packet.extensions, rtp.HeaderExtensions()) self.assertEqual(len(packet.payload), 160) self.assertEqual(packet.serialize(), data) self.assertEqual( repr(packet), "RtpPacket(seq=15743, ts=3937035252, marker=0, payload=0, 160 bytes)", ) def test_padding_only(self): data = load("rtp_only_padding.bin") packet = RtpPacket.parse(data) self.assertEqual(packet.version, 2) self.assertEqual(packet.marker, 0) self.assertEqual(packet.payload_type, 120) self.assertEqual(packet.sequence_number, 27759) self.assertEqual(packet.timestamp, 4044047131) self.assertEqual(packet.csrc, []) self.assertEqual(packet.extensions, rtp.HeaderExtensions()) self.assertEqual(len(packet.payload), 0) self.assertEqual(packet.padding_size, 224) serialized = packet.serialize() self.assertEqual(len(serialized), len(data)) self.assertEqual(serialized[0:12], data[0:12]) self.assertEqual(serialized[-1], data[-1]) def test_padding_only_with_header_extensions(self): extensions_map = rtp.HeaderExtensionsMap() extensions_map.configure( RTCRtpParameters( headerExtensions=[ RTCRtpHeaderExtensionParameters( id=2, uri="http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time", ) ] ) ) data = load("rtp_only_padding_with_header_extensions.bin") packet = RtpPacket.parse(data, extensions_map) self.assertEqual(packet.version, 2) self.assertEqual(packet.marker, 0) self.assertEqual(packet.payload_type, 98) self.assertEqual(packet.sequence_number, 22138) self.assertEqual(packet.timestamp, 3171065731) self.assertEqual(packet.csrc, []) self.assertEqual( packet.extensions, rtp.HeaderExtensions(abs_send_time=15846540) ) self.assertEqual(len(packet.payload), 0) self.assertEqual(packet.padding_size, 224) serialized = packet.serialize(extensions_map) self.assertEqual(len(serialized), len(data)) self.assertEqual(serialized[0:20], data[0:20]) self.assertEqual(serialized[-1], data[-1]) def test_padding_too_long(self): data = load("rtp_only_padding.bin")[0:12] + b"\x02" with self.assertRaises(ValueError) as cm: RtpPacket.parse(data) self.assertEqual(str(cm.exception), "RTP packet padding length is invalid") def test_padding_zero(self): data = load("rtp_only_padding.bin")[0:12] + b"\x00" with self.assertRaises(ValueError) as cm: RtpPacket.parse(data) self.assertEqual(str(cm.exception), "RTP packet padding length is invalid") def test_with_csrc(self): data = load("rtp_with_csrc.bin") packet = RtpPacket.parse(data) self.assertEqual(packet.version, 2) self.assertEqual(packet.marker, 0) self.assertEqual(packet.payload_type, 0) self.assertEqual(packet.sequence_number, 16082) self.assertEqual(packet.timestamp, 144) self.assertEqual(packet.csrc, [2882400001, 3735928559]) self.assertEqual(packet.extensions, rtp.HeaderExtensions()) self.assertEqual(len(packet.payload), 160) self.assertEqual(packet.serialize(), data) def test_with_csrc_truncated(self): data = load("rtp_with_csrc.bin") for length in range(12, 20): with self.assertRaises(ValueError) as cm: RtpPacket.parse(data[0:length]) self.assertEqual(str(cm.exception), "RTP packet has truncated CSRC") def test_with_sdes_mid(self): extensions_map = rtp.HeaderExtensionsMap() extensions_map.configure( RTCRtpParameters( headerExtensions=[ RTCRtpHeaderExtensionParameters( id=9, uri="urn:ietf:params:rtp-hdrext:sdes:mid" ) ] ) ) data = load("rtp_with_sdes_mid.bin") packet = RtpPacket.parse(data, extensions_map) self.assertEqual(packet.version, 2) self.assertEqual(packet.marker, 1) self.assertEqual(packet.payload_type, 111) self.assertEqual(packet.sequence_number, 14156) self.assertEqual(packet.timestamp, 1327210925) self.assertEqual(packet.csrc, []) self.assertEqual(packet.extensions, rtp.HeaderExtensions(mid="0")) self.assertEqual(len(packet.payload), 54) self.assertEqual(packet.serialize(extensions_map), data) def test_with_sdes_mid_truncated(self): data = load("rtp_with_sdes_mid.bin") for length in range(12, 16): with self.assertRaises(ValueError) as cm: RtpPacket.parse(data[0:length]) self.assertEqual( str(cm.exception), "RTP packet has truncated extension profile / length" ) for length in range(16, 20): with self.assertRaises(ValueError) as cm: RtpPacket.parse(data[0:length]) self.assertEqual( str(cm.exception), "RTP packet has truncated extension value" ) def test_truncated(self): data = load("rtp.bin")[0:11] with self.assertRaises(ValueError) as cm: RtpPacket.parse(data) self.assertEqual(str(cm.exception), "RTP packet length is less than 12 bytes") def test_bad_version(self): data = b"\xc0" + load("rtp.bin")[1:] with self.assertRaises(ValueError) as cm: RtpPacket.parse(data) self.assertEqual(str(cm.exception), "RTP packet has invalid version") class RtpUtilTest(TestCase): def test_clamp_packets_lost(self): self.assertEqual(clamp_packets_lost(-8388609), -8388608) self.assertEqual(clamp_packets_lost(-8388608), -8388608) self.assertEqual(clamp_packets_lost(0), 0) self.assertEqual(clamp_packets_lost(8388607), 8388607) self.assertEqual(clamp_packets_lost(8388608), 8388607) def test_pack_packets_lost(self): self.assertEqual(pack_packets_lost(-8388608), b"\x80\x00\x00") self.assertEqual(pack_packets_lost(-1), b"\xff\xff\xff") self.assertEqual(pack_packets_lost(0), b"\x00\x00\x00") self.assertEqual(pack_packets_lost(1), b"\x00\x00\x01") self.assertEqual(pack_packets_lost(8388607), b"\x7f\xff\xff") def test_pack_remb_fci(self): # exponent = 0, mantissa = 0 data = pack_remb_fci(0, [2529072847]) self.assertEqual(data, b"REMB\x01\x00\x00\x00\x96\xbe\x96\xcf") # exponent = 0, mantissa = 0x3ffff data = pack_remb_fci(0x3FFFF, [2529072847]) self.assertEqual(data, b"REMB\x01\x03\xff\xff\x96\xbe\x96\xcf") # exponent = 1, mantissa = 0 data = pack_remb_fci(0x40000, [2529072847]) self.assertEqual(data, b"REMB\x01\x06\x00\x00\x96\xbe\x96\xcf") data = pack_remb_fci(4160000, [2529072847]) self.assertEqual(data, b"REMB\x01\x13\xf7\xa0\x96\xbe\x96\xcf") # exponent = 63, mantissa = 0x3ffff data = pack_remb_fci(0x3FFFF << 63, [2529072847]) self.assertEqual(data, b"REMB\x01\xff\xff\xff\x96\xbe\x96\xcf") def test_unpack_packets_lost(self): self.assertEqual(unpack_packets_lost(b"\x80\x00\x00"), -8388608) self.assertEqual(unpack_packets_lost(b"\xff\xff\xff"), -1) self.assertEqual(unpack_packets_lost(b"\x00\x00\x00"), 0) self.assertEqual(unpack_packets_lost(b"\x00\x00\x01"), 1) self.assertEqual(unpack_packets_lost(b"\x7f\xff\xff"), 8388607) def test_unpack_remb_fci(self): # junk with self.assertRaises(ValueError): unpack_remb_fci(b"JUNK") # exponent = 0, mantissa = 0 bitrate, ssrcs = unpack_remb_fci(b"REMB\x01\x00\x00\x00\x96\xbe\x96\xcf") self.assertEqual(bitrate, 0) self.assertEqual(ssrcs, [2529072847]) # exponent = 0, mantissa = 0x3ffff bitrate, ssrcs = unpack_remb_fci(b"REMB\x01\x03\xff\xff\x96\xbe\x96\xcf") self.assertEqual(bitrate, 0x3FFFF) self.assertEqual(ssrcs, [2529072847]) # exponent = 1, mantissa = 0 bitrate, ssrcs = unpack_remb_fci(b"REMB\x01\x06\x00\x00\x96\xbe\x96\xcf") self.assertEqual(bitrate, 0x40000) self.assertEqual(ssrcs, [2529072847]) # 4160000 bps bitrate, ssrcs = unpack_remb_fci(b"REMB\x01\x13\xf7\xa0\x96\xbe\x96\xcf") self.assertEqual(bitrate, 4160000) self.assertEqual(ssrcs, [2529072847]) # exponent = 63, mantissa = 0x3ffff bitrate, ssrcs = unpack_remb_fci(b"REMB\x01\xff\xff\xff\x96\xbe\x96\xcf") self.assertEqual(bitrate, 0x3FFFF << 63) self.assertEqual(ssrcs, [2529072847]) def test_unpack_header_extensions(self): # none self.assertEqual(unpack_header_extensions(0, None), []) # one-byte, value self.assertEqual(unpack_header_extensions(0xBEDE, b"\x900"), [(9, b"0")]) # one-byte, value, padding, value self.assertEqual( unpack_header_extensions(0xBEDE, b"\x900\x00\x00\x301"), [(9, b"0"), (3, b"1")], ) # one-byte, value, value self.assertEqual( unpack_header_extensions(0xBEDE, b"\x10\xc18sdparta_0"), [(1, b"\xc1"), (3, b"sdparta_0")], ) # two-byte, value self.assertEqual(unpack_header_extensions(0x1000, b"\xff\x010"), [(255, b"0")]) # two-byte, value (1 byte), padding, value (2 bytes) self.assertEqual( unpack_header_extensions(0x1000, b"\xff\x010\x00\xf0\x0212"), [(255, b"0"), (240, b"12")], ) def test_unpack_header_extensions_bad(self): # one-byte, value (truncated) with self.assertRaises(ValueError) as cm: unpack_header_extensions(0xBEDE, b"\x90") self.assertEqual( str(cm.exception), "RTP one-byte header extension value is truncated" ) # two-byte (truncated) with self.assertRaises(ValueError) as cm: unpack_header_extensions(0x1000, b"\xff") self.assertEqual( str(cm.exception), "RTP two-byte header extension is truncated" ) # two-byte, value (truncated) with self.assertRaises(ValueError) as cm: unpack_header_extensions(0x1000, b"\xff\x020") self.assertEqual( str(cm.exception), "RTP two-byte header extension value is truncated" ) def test_pack_header_extensions(self): # none self.assertEqual(pack_header_extensions([]), (0, b"")) # one-byte, single value self.assertEqual( pack_header_extensions([(9, b"0")]), (0xBEDE, b"\x900\x00\x00") ) # one-byte, two values self.assertEqual( pack_header_extensions([(1, b"\xc1"), (3, b"sdparta_0")]), (0xBEDE, b"\x10\xc18sdparta_0"), ) # two-byte, single value self.assertEqual( pack_header_extensions([(255, b"0")]), (0x1000, b"\xff\x010\x00") ) def test_map_header_extensions(self): data = bytearray( [ 0x90, 0x64, 0x00, 0x58, 0x65, 0x43, 0x12, 0x78, 0x12, 0x34, 0x56, 0x78, # SSRC 0xBE, 0xDE, 0x00, 0x08, # Extension of size 8x32bit words. 0x40, 0xDA, # AudioLevel. 0x22, 0x01, 0x56, 0xCE, # TransmissionOffset. 0x62, 0x12, 0x34, 0x56, # AbsoluteSendTime. 0x81, 0xCE, 0xAB, # TransportSequenceNumber. 0xA0, 0x03, # VideoRotation. 0xB2, 0x12, 0x48, 0x76, # PlayoutDelayLimits. 0xC2, 0x72, 0x74, 0x78, # RtpStreamId 0xD5, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6D, # RepairedRtpStreamId 0x00, 0x00, # Padding to 32bit boundary. ] ) extensions_map = rtp.HeaderExtensionsMap() extensions_map.configure( RTCRtpParameters( headerExtensions=[ RTCRtpHeaderExtensionParameters( id=2, uri="urn:ietf:params:rtp-hdrext:toffset" ), RTCRtpHeaderExtensionParameters( id=4, uri="urn:ietf:params:rtp-hdrext:ssrc-audio-level" ), RTCRtpHeaderExtensionParameters( id=6, uri="http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time", ), RTCRtpHeaderExtensionParameters( id=8, uri="http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01", ), RTCRtpHeaderExtensionParameters( id=12, uri="urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id" ), RTCRtpHeaderExtensionParameters( id=13, uri="urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id", ), ] ) ) packet = RtpPacket.parse(data, extensions_map) # check mapped values self.assertEqual(packet.extensions.abs_send_time, 0x123456) self.assertEqual(packet.extensions.audio_level, (True, 90)) self.assertEqual(packet.extensions.mid, None) self.assertEqual(packet.extensions.repaired_rtp_stream_id, "stream") self.assertEqual(packet.extensions.rtp_stream_id, "rtx") self.assertEqual(packet.extensions.transmission_offset, 0x156CE) self.assertEqual(packet.extensions.transport_sequence_number, 0xCEAB) # TODO: check packet.serialize(extensions_map) def test_rtx(self): extensions_map = rtp.HeaderExtensionsMap() extensions_map.configure( RTCRtpParameters( headerExtensions=[ RTCRtpHeaderExtensionParameters( id=9, uri="urn:ietf:params:rtp-hdrext:sdes:mid" ) ] ) ) data = load("rtp_with_sdes_mid.bin") packet = RtpPacket.parse(data, extensions_map) # wrap / unwrap RTX rtx = wrap_rtx(packet, payload_type=112, sequence_number=12345, ssrc=1234) recovered = unwrap_rtx(rtx, payload_type=111, ssrc=4084547440) # check roundtrip self.assertEqual(recovered.version, packet.version) self.assertEqual(recovered.marker, packet.marker) self.assertEqual(recovered.payload_type, packet.payload_type) self.assertEqual(recovered.sequence_number, packet.sequence_number) self.assertEqual(recovered.timestamp, packet.timestamp) self.assertEqual(recovered.ssrc, packet.ssrc) self.assertEqual(recovered.csrc, packet.csrc) self.assertEqual(recovered.extensions, packet.extensions) self.assertEqual(recovered.payload, packet.payload) def test_compute_audio_level_dbov(self): num_samples = 960 # 20ms @ 48kHz # test a frame of all zeroes (-127 dBov, the minimum value) silent_frame = create_audio_frame(lambda n: 0.0, num_samples, 0) self.assertEqual(rtp.compute_audio_level_dbov(silent_frame), -127) # test a 50Hz square wave (0 dBov, the maximum value) square_frame = create_audio_frame( lambda n: 1.0 if n < num_samples / 2 else -1.0, num_samples, 0 ) self.assertEqual(rtp.compute_audio_level_dbov(square_frame), 0) # test a 50Hz sine wave (-3 dBov, the maximum value for a sine wave) sine_frame = create_audio_frame( lambda n: math.sin(2 * math.pi * n / num_samples), num_samples, 0 ) self.assertEqual(rtp.compute_audio_level_dbov(sine_frame), -3) aiortc-1.3.0/tests/test_sdp.py000066400000000000000000002174361417604566400163670ustar00rootroot00000000000000from unittest import TestCase from aiortc.rtcrtpparameters import ( RTCRtcpFeedback, RTCRtpCodecParameters, RTCRtpHeaderExtensionParameters, ) from aiortc.sdp import GroupDescription, SessionDescription, SsrcDescription from .utils import lf2crlf class SdpTest(TestCase): maxDiff = None def test_audio_chrome(self): d = SessionDescription.parse( lf2crlf( """v=0 o=- 863426017819471768 2 IN IP4 127.0.0.1 s=- t=0 0 a=group:BUNDLE audio a=msid-semantic: WMS TF6VRif1dxuAfe5uefrV2953LhUZt1keYvxU m=audio 45076 UDP/TLS/RTP/SAVPF 111 103 104 9 0 8 106 105 13 110 112 113 126 c=IN IP4 192.168.99.58 a=rtcp:9 IN IP4 0.0.0.0 a=candidate:2665802302 1 udp 2122262783 2a02:a03f:3eb0:e000:b0aa:d60a:cff2:933c 38475 typ host generation 0 network-id 2 network-cost 10 a=candidate:1039001212 1 udp 2122194687 192.168.99.58 45076 typ host generation 0 network-id 1 network-cost 10 a=candidate:3496416974 1 tcp 1518283007 2a02:a03f:3eb0:e000:b0aa:d60a:cff2:933c 9 typ host tcptype active generation 0 network-id 2 network-cost 10 a=candidate:1936595596 1 tcp 1518214911 192.168.99.58 9 typ host tcptype active generation 0 network-id 1 network-cost 10 a=ice-ufrag:5+Ix a=ice-pwd:uK8IlylxzDMUhrkVzdmj0M+v a=ice-options:trickle a=fingerprint:sha-256 6B:8B:5D:EA:59:04:20:23:29:C8:87:1C:CC:87:32:BE:DD:8C:66:A5:8E:50:55:EA:8C:D3:B6:5C:09:5E:D6:BC a=setup:actpass a=mid:audio a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level a=sendrecv a=rtcp-mux a=rtpmap:111 opus/48000/2 a=rtcp-fb:111 transport-cc a=fmtp:111 minptime=10;useinbandfec=1 a=rtpmap:103 ISAC/16000 a=rtpmap:104 ISAC/32000 a=rtpmap:9 G722/8000 a=rtpmap:0 PCMU/8000 a=rtpmap:8 PCMA/8000 a=rtpmap:106 CN/32000 a=rtpmap:105 CN/16000 a=rtpmap:13 CN/8000 a=rtpmap:110 telephone-event/48000 a=rtpmap:112 telephone-event/32000 a=rtpmap:113 telephone-event/16000 a=rtpmap:126 telephone-event/8000 a=ssrc:1944796561 cname:/vC4ULAr8vHNjXmq a=ssrc:1944796561 msid:TF6VRif1dxuAfe5uefrV2953LhUZt1keYvxU ec1eb8de-8df8-4956-ae81-879e5d062d12 a=ssrc:1944796561 mslabel:TF6VRif1dxuAfe5uefrV2953LhUZt1keYvxU a=ssrc:1944796561 label:ec1eb8de-8df8-4956-ae81-879e5d062d12""" ) ) self.assertEqual( d.group, [GroupDescription(semantic="BUNDLE", items=["audio"])] ) self.assertEqual( d.msid_semantic, [ GroupDescription( semantic="WMS", items=["TF6VRif1dxuAfe5uefrV2953LhUZt1keYvxU"] ) ], ) self.assertEqual(d.host, None) self.assertEqual(d.name, "-") self.assertEqual(d.origin, "- 863426017819471768 2 IN IP4 127.0.0.1") self.assertEqual(d.time, "0 0") self.assertEqual(d.version, 0) self.assertEqual(len(d.media), 1) self.assertEqual(d.media[0].kind, "audio") self.assertEqual(d.media[0].host, "192.168.99.58") self.assertEqual(d.media[0].port, 45076) self.assertEqual(d.media[0].profile, "UDP/TLS/RTP/SAVPF") self.assertEqual(d.media[0].direction, "sendrecv") self.assertEqual(d.media[0].msid, None) self.assertEqual( d.media[0].rtp.codecs, [ RTCRtpCodecParameters( mimeType="audio/opus", clockRate=48000, channels=2, payloadType=111, rtcpFeedback=[RTCRtcpFeedback(type="transport-cc")], parameters={"minptime": 10, "useinbandfec": 1}, ), RTCRtpCodecParameters( mimeType="audio/ISAC", clockRate=16000, channels=1, payloadType=103 ), RTCRtpCodecParameters( mimeType="audio/ISAC", clockRate=32000, channels=1, payloadType=104 ), RTCRtpCodecParameters( mimeType="audio/G722", clockRate=8000, channels=1, payloadType=9 ), RTCRtpCodecParameters( mimeType="audio/PCMU", clockRate=8000, channels=1, payloadType=0 ), RTCRtpCodecParameters( mimeType="audio/PCMA", clockRate=8000, channels=1, payloadType=8 ), RTCRtpCodecParameters( mimeType="audio/CN", clockRate=32000, channels=1, payloadType=106 ), RTCRtpCodecParameters( mimeType="audio/CN", clockRate=16000, channels=1, payloadType=105 ), RTCRtpCodecParameters( mimeType="audio/CN", clockRate=8000, channels=1, payloadType=13 ), RTCRtpCodecParameters( mimeType="audio/telephone-event", clockRate=48000, channels=1, payloadType=110, ), RTCRtpCodecParameters( mimeType="audio/telephone-event", clockRate=32000, channels=1, payloadType=112, ), RTCRtpCodecParameters( mimeType="audio/telephone-event", clockRate=16000, channels=1, payloadType=113, ), RTCRtpCodecParameters( mimeType="audio/telephone-event", clockRate=8000, channels=1, payloadType=126, ), ], ) self.assertEqual( d.media[0].rtp.headerExtensions, [ RTCRtpHeaderExtensionParameters( id=1, uri="urn:ietf:params:rtp-hdrext:ssrc-audio-level" ) ], ) self.assertEqual(d.media[0].rtp.muxId, "audio") self.assertEqual(d.media[0].rtcp_host, "0.0.0.0") self.assertEqual(d.media[0].rtcp_port, 9) self.assertEqual(d.media[0].rtcp_mux, True) # ssrc self.assertEqual( d.media[0].ssrc, [ SsrcDescription( ssrc=1944796561, cname="/vC4ULAr8vHNjXmq", msid="TF6VRif1dxuAfe5uefrV2953LhUZt1keYvxU ec1eb8de-8df8-4956-ae81-879e5d062d12", mslabel="TF6VRif1dxuAfe5uefrV2953LhUZt1keYvxU", label="ec1eb8de-8df8-4956-ae81-879e5d062d12", ) ], ) self.assertEqual(d.media[0].ssrc_group, []) # formats self.assertEqual( d.media[0].fmt, [111, 103, 104, 9, 0, 8, 106, 105, 13, 110, 112, 113, 126] ) self.assertEqual(d.media[0].sctpmap, {}) self.assertEqual(d.media[0].sctp_port, None) # ice self.assertEqual(len(d.media[0].ice_candidates), 4) self.assertEqual(d.media[0].ice_candidates_complete, False) self.assertEqual(d.media[0].ice_options, "trickle") self.assertEqual(d.media[0].ice.iceLite, False) self.assertEqual(d.media[0].ice.usernameFragment, "5+Ix") self.assertEqual(d.media[0].ice.password, "uK8IlylxzDMUhrkVzdmj0M+v") # dtls self.assertEqual(len(d.media[0].dtls.fingerprints), 1) self.assertEqual(d.media[0].dtls.fingerprints[0].algorithm, "sha-256") self.assertEqual( d.media[0].dtls.fingerprints[0].value, "6B:8B:5D:EA:59:04:20:23:29:C8:87:1C:CC:87:32:BE:DD:8C:66:A5:8E:50:55:EA:8C:D3:B6:5C:09:5E:D6:BC", ) self.assertEqual(d.media[0].dtls.role, "auto") self.assertEqual( str(d), lf2crlf( """v=0 o=- 863426017819471768 2 IN IP4 127.0.0.1 s=- t=0 0 a=group:BUNDLE audio a=msid-semantic:WMS TF6VRif1dxuAfe5uefrV2953LhUZt1keYvxU m=audio 45076 UDP/TLS/RTP/SAVPF 111 103 104 9 0 8 106 105 13 110 112 113 126 c=IN IP4 192.168.99.58 a=sendrecv a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level a=mid:audio a=rtcp:9 IN IP4 0.0.0.0 a=rtcp-mux a=ssrc:1944796561 cname:/vC4ULAr8vHNjXmq a=ssrc:1944796561 msid:TF6VRif1dxuAfe5uefrV2953LhUZt1keYvxU ec1eb8de-8df8-4956-ae81-879e5d062d12 a=ssrc:1944796561 mslabel:TF6VRif1dxuAfe5uefrV2953LhUZt1keYvxU a=ssrc:1944796561 label:ec1eb8de-8df8-4956-ae81-879e5d062d12 a=rtpmap:111 opus/48000/2 a=rtcp-fb:111 transport-cc a=fmtp:111 minptime=10;useinbandfec=1 a=rtpmap:103 ISAC/16000 a=rtpmap:104 ISAC/32000 a=rtpmap:9 G722/8000 a=rtpmap:0 PCMU/8000 a=rtpmap:8 PCMA/8000 a=rtpmap:106 CN/32000 a=rtpmap:105 CN/16000 a=rtpmap:13 CN/8000 a=rtpmap:110 telephone-event/48000 a=rtpmap:112 telephone-event/32000 a=rtpmap:113 telephone-event/16000 a=rtpmap:126 telephone-event/8000 a=candidate:2665802302 1 udp 2122262783 2a02:a03f:3eb0:e000:b0aa:d60a:cff2:933c 38475 typ host a=candidate:1039001212 1 udp 2122194687 192.168.99.58 45076 typ host a=candidate:3496416974 1 tcp 1518283007 2a02:a03f:3eb0:e000:b0aa:d60a:cff2:933c 9 typ host tcptype active a=candidate:1936595596 1 tcp 1518214911 192.168.99.58 9 typ host tcptype active a=ice-ufrag:5+Ix a=ice-pwd:uK8IlylxzDMUhrkVzdmj0M+v a=ice-options:trickle a=fingerprint:sha-256 6B:8B:5D:EA:59:04:20:23:29:C8:87:1C:CC:87:32:BE:DD:8C:66:A5:8E:50:55:EA:8C:D3:B6:5C:09:5E:D6:BC a=setup:actpass """ ), ) def test_audio_firefox(self): d = SessionDescription.parse( lf2crlf( """v=0 o=mozilla...THIS_IS_SDPARTA-58.0.1 4934139885953732403 1 IN IP4 0.0.0.0 s=- t=0 0 a=sendrecv a=fingerprint:sha-256 EB:A9:3E:50:D7:E3:B3:86:0F:7B:01:C1:EB:D6:AF:E4:97:DE:15:05:A8:DE:7B:83:56:C7:4B:6E:9D:75:D4:17 a=group:BUNDLE sdparta_0 a=ice-options:trickle a=msid-semantic:WMS * m=audio 45274 UDP/TLS/RTP/SAVPF 109 9 0 8 101 c=IN IP4 192.168.99.58 a=candidate:0 1 UDP 2122187007 192.168.99.58 45274 typ host a=candidate:2 1 UDP 2122252543 2a02:a03f:3eb0:e000:b0aa:d60a:cff2:933c 47387 typ host a=candidate:3 1 TCP 2105458943 192.168.99.58 9 typ host tcptype active a=candidate:4 1 TCP 2105524479 2a02:a03f:3eb0:e000:b0aa:d60a:cff2:933c 9 typ host tcptype active a=candidate:0 2 UDP 2122187006 192.168.99.58 38612 typ host a=candidate:2 2 UDP 2122252542 2a02:a03f:3eb0:e000:b0aa:d60a:cff2:933c 54301 typ host a=candidate:3 2 TCP 2105458942 192.168.99.58 9 typ host tcptype active a=candidate:4 2 TCP 2105524478 2a02:a03f:3eb0:e000:b0aa:d60a:cff2:933c 9 typ host tcptype active a=candidate:1 1 UDP 1685921791 1.2.3.4 37264 typ srflx raddr 192.168.99.58 rport 37264 a=candidate:1 2 UDP 1685921790 1.2.3.4 52902 typ srflx raddr 192.168.99.58 rport 52902 a=sendrecv a=end-of-candidates a=extmap:1/sendonly urn:ietf:params:rtp-hdrext:ssrc-audio-level a=extmap:2 urn:ietf:params:rtp-hdrext:sdes:mid a=fmtp:109 maxplaybackrate=48000;stereo=1;useinbandfec=1 a=fmtp:101 0-15 a=ice-pwd:f9b83487285016f7492197a5790ceee5 a=ice-ufrag:403a81e1 a=ice-options:trickle a=mid:sdparta_0 a=msid:{dee771c7-671a-451e-b847-f86f8e87c7d8} {12692dea-686c-47ca-b3e9-48f38fc92b78} a=rtcp:38612 IN IP4 192.168.99.58 a=rtcp-mux a=rtpmap:109 opus/48000/2 a=rtpmap:9 G722/8000/1 a=rtpmap:0 PCMU/8000 a=rtpmap:8 PCMA/8000 a=rtpmap:101 telephone-event/8000 a=setup:actpass a=ssrc:882128807 cname:{ed463ac5-dabf-44d4-8b9f-e14318427b2b} """ ) ) self.assertEqual( d.group, [GroupDescription(semantic="BUNDLE", items=["sdparta_0"])] ) self.assertEqual( d.msid_semantic, [GroupDescription(semantic="WMS", items=["*"])] ) self.assertEqual(d.host, None) self.assertEqual(d.name, "-") self.assertEqual( d.origin, "mozilla...THIS_IS_SDPARTA-58.0.1 4934139885953732403 1 IN IP4 0.0.0.0", ) self.assertEqual(d.time, "0 0") self.assertEqual(d.version, 0) self.assertEqual(len(d.media), 1) self.assertEqual(d.media[0].kind, "audio") self.assertEqual(d.media[0].host, "192.168.99.58") self.assertEqual(d.media[0].port, 45274) self.assertEqual(d.media[0].profile, "UDP/TLS/RTP/SAVPF") self.assertEqual(d.media[0].direction, "sendrecv") self.assertEqual( d.media[0].msid, "{dee771c7-671a-451e-b847-f86f8e87c7d8} " "{12692dea-686c-47ca-b3e9-48f38fc92b78}", ) self.assertEqual( d.media[0].rtp.codecs, [ RTCRtpCodecParameters( mimeType="audio/opus", clockRate=48000, channels=2, payloadType=109, parameters={ "maxplaybackrate": 48000, "stereo": 1, "useinbandfec": 1, }, ), RTCRtpCodecParameters( mimeType="audio/G722", clockRate=8000, channels=1, payloadType=9 ), RTCRtpCodecParameters( mimeType="audio/PCMU", clockRate=8000, channels=1, payloadType=0 ), RTCRtpCodecParameters( mimeType="audio/PCMA", clockRate=8000, channels=1, payloadType=8 ), RTCRtpCodecParameters( mimeType="audio/telephone-event", clockRate=8000, channels=1, payloadType=101, parameters={"0-15": None}, ), ], ) self.assertEqual( d.media[0].rtp.headerExtensions, [ RTCRtpHeaderExtensionParameters( id=1, uri="urn:ietf:params:rtp-hdrext:ssrc-audio-level" ), RTCRtpHeaderExtensionParameters( id=2, uri="urn:ietf:params:rtp-hdrext:sdes:mid" ), ], ) self.assertEqual(d.media[0].rtp.muxId, "sdparta_0") self.assertEqual(d.media[0].rtcp_host, "192.168.99.58") self.assertEqual(d.media[0].rtcp_port, 38612) self.assertEqual(d.media[0].rtcp_mux, True) self.assertEqual( d.webrtc_track_id(d.media[0]), "{12692dea-686c-47ca-b3e9-48f38fc92b78}" ) # ssrc self.assertEqual( d.media[0].ssrc, [ SsrcDescription( ssrc=882128807, cname="{ed463ac5-dabf-44d4-8b9f-e14318427b2b}" ) ], ) self.assertEqual(d.media[0].ssrc_group, []) # formats self.assertEqual(d.media[0].fmt, [109, 9, 0, 8, 101]) self.assertEqual(d.media[0].sctpmap, {}) self.assertEqual(d.media[0].sctp_port, None) # ice self.assertEqual(len(d.media[0].ice_candidates), 10) self.assertEqual(d.media[0].ice_candidates_complete, True) self.assertEqual(d.media[0].ice_options, "trickle") self.assertEqual(d.media[0].ice.iceLite, False) self.assertEqual(d.media[0].ice.usernameFragment, "403a81e1") self.assertEqual(d.media[0].ice.password, "f9b83487285016f7492197a5790ceee5") # dtls self.assertEqual(len(d.media[0].dtls.fingerprints), 1) self.assertEqual(d.media[0].dtls.fingerprints[0].algorithm, "sha-256") self.assertEqual( d.media[0].dtls.fingerprints[0].value, "EB:A9:3E:50:D7:E3:B3:86:0F:7B:01:C1:EB:D6:AF:E4:97:DE:15:05:A8:DE:7B:83:56:C7:4B:6E:9D:75:D4:17", ) self.assertEqual(d.media[0].dtls.role, "auto") self.assertEqual( str(d), lf2crlf( """v=0 o=mozilla...THIS_IS_SDPARTA-58.0.1 4934139885953732403 1 IN IP4 0.0.0.0 s=- t=0 0 a=group:BUNDLE sdparta_0 a=msid-semantic:WMS * m=audio 45274 UDP/TLS/RTP/SAVPF 109 9 0 8 101 c=IN IP4 192.168.99.58 a=sendrecv a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level a=extmap:2 urn:ietf:params:rtp-hdrext:sdes:mid a=mid:sdparta_0 a=msid:{dee771c7-671a-451e-b847-f86f8e87c7d8} {12692dea-686c-47ca-b3e9-48f38fc92b78} a=rtcp:38612 IN IP4 192.168.99.58 a=rtcp-mux a=ssrc:882128807 cname:{ed463ac5-dabf-44d4-8b9f-e14318427b2b} a=rtpmap:109 opus/48000/2 a=fmtp:109 maxplaybackrate=48000;stereo=1;useinbandfec=1 a=rtpmap:9 G722/8000 a=rtpmap:0 PCMU/8000 a=rtpmap:8 PCMA/8000 a=rtpmap:101 telephone-event/8000 a=fmtp:101 0-15 a=candidate:0 1 UDP 2122187007 192.168.99.58 45274 typ host a=candidate:2 1 UDP 2122252543 2a02:a03f:3eb0:e000:b0aa:d60a:cff2:933c 47387 typ host a=candidate:3 1 TCP 2105458943 192.168.99.58 9 typ host tcptype active a=candidate:4 1 TCP 2105524479 2a02:a03f:3eb0:e000:b0aa:d60a:cff2:933c 9 typ host tcptype active a=candidate:0 2 UDP 2122187006 192.168.99.58 38612 typ host a=candidate:2 2 UDP 2122252542 2a02:a03f:3eb0:e000:b0aa:d60a:cff2:933c 54301 typ host a=candidate:3 2 TCP 2105458942 192.168.99.58 9 typ host tcptype active a=candidate:4 2 TCP 2105524478 2a02:a03f:3eb0:e000:b0aa:d60a:cff2:933c 9 typ host tcptype active a=candidate:1 1 UDP 1685921791 1.2.3.4 37264 typ srflx raddr 192.168.99.58 rport 37264 a=candidate:1 2 UDP 1685921790 1.2.3.4 52902 typ srflx raddr 192.168.99.58 rport 52902 a=end-of-candidates a=ice-ufrag:403a81e1 a=ice-pwd:f9b83487285016f7492197a5790ceee5 a=ice-options:trickle a=fingerprint:sha-256 EB:A9:3E:50:D7:E3:B3:86:0F:7B:01:C1:EB:D6:AF:E4:97:DE:15:05:A8:DE:7B:83:56:C7:4B:6E:9D:75:D4:17 a=setup:actpass """ ), ) def test_audio_freeswitch(self): d = SessionDescription.parse( lf2crlf( """v=0 o=FreeSWITCH 1538380016 1538380017 IN IP4 1.2.3.4 s=FreeSWITCH c=IN IP4 1.2.3.4 t=0 0 a=msid-semantic: WMS lyNSTe6w2ijnMrDEiqTHFyhqjdAag3ys m=audio 16628 UDP/TLS/RTP/SAVPF 8 101 a=rtpmap:8 PCMA/8000 a=rtpmap:101 telephone-event/8000 a=ptime:20 a=fingerprint:sha-256 35:5A:BC:8E:CD:F8:CD:EB:36:00:BB:C4:C3:33:54:B5:9B:70:3C:E9:C4:33:8F:39:3C:4B:5B:5C:AD:88:12:2B a=setup:active a=rtcp-mux a=rtcp:16628 IN IP4 1.2.3.4 a=ice-ufrag:75EDuLTEOkEUd3cu a=ice-pwd:5dvb9SbfooWc49814CupdeTS a=candidate:0560693492 1 udp 659136 1.2.3.4 16628 typ host generation 0 a=end-of-candidates a=ssrc:2690029308 cname:rbaag6w9fGmRXQm6 a=ssrc:2690029308 msid:lyNSTe6w2ijnMrDEiqTHFyhqjdAag3ys a0 a=ssrc:2690029308 mslabel:lyNSTe6w2ijnMrDEiqTHFyhqjdAag3ys a=ssrc:2690029308 label:lyNSTe6w2ijnMrDEiqTHFyhqjdAag3ysa0""" ) ) self.assertEqual(d.group, []) self.assertEqual( d.msid_semantic, [ GroupDescription( semantic="WMS", items=["lyNSTe6w2ijnMrDEiqTHFyhqjdAag3ys"] ) ], ) self.assertEqual(d.host, "1.2.3.4") self.assertEqual(d.name, "FreeSWITCH") self.assertEqual(d.origin, "FreeSWITCH 1538380016 1538380017 IN IP4 1.2.3.4") self.assertEqual(d.time, "0 0") self.assertEqual(d.version, 0) self.assertEqual(len(d.media), 1) self.assertEqual(d.media[0].kind, "audio") self.assertEqual(d.media[0].host, None) self.assertEqual(d.media[0].port, 16628) self.assertEqual(d.media[0].profile, "UDP/TLS/RTP/SAVPF") self.assertEqual(d.media[0].direction, None) self.assertEqual( d.media[0].rtp.codecs, [ RTCRtpCodecParameters( mimeType="audio/PCMA", clockRate=8000, channels=1, payloadType=8 ), RTCRtpCodecParameters( mimeType="audio/telephone-event", clockRate=8000, channels=1, payloadType=101, ), ], ) self.assertEqual(d.media[0].rtp.headerExtensions, []) self.assertEqual(d.media[0].rtp.muxId, "") self.assertEqual(d.media[0].rtcp_host, "1.2.3.4") self.assertEqual(d.media[0].rtcp_port, 16628) self.assertEqual(d.media[0].rtcp_mux, True) # ssrc self.assertEqual( d.media[0].ssrc, [ SsrcDescription( ssrc=2690029308, cname="rbaag6w9fGmRXQm6", msid="lyNSTe6w2ijnMrDEiqTHFyhqjdAag3ys a0", mslabel="lyNSTe6w2ijnMrDEiqTHFyhqjdAag3ys", label="lyNSTe6w2ijnMrDEiqTHFyhqjdAag3ysa0", ) ], ) self.assertEqual(d.media[0].ssrc_group, []) # formats self.assertEqual(d.media[0].fmt, [8, 101]) self.assertEqual(d.media[0].sctpmap, {}) self.assertEqual(d.media[0].sctp_port, None) # ice self.assertEqual(len(d.media[0].ice_candidates), 1) self.assertEqual(d.media[0].ice_candidates_complete, True) self.assertEqual(d.media[0].ice_options, None) self.assertEqual(d.media[0].ice.iceLite, False) self.assertEqual(d.media[0].ice.usernameFragment, "75EDuLTEOkEUd3cu") self.assertEqual(d.media[0].ice.password, "5dvb9SbfooWc49814CupdeTS") # dtls self.assertEqual(len(d.media[0].dtls.fingerprints), 1) self.assertEqual(d.media[0].dtls.fingerprints[0].algorithm, "sha-256") self.assertEqual( d.media[0].dtls.fingerprints[0].value, "35:5A:BC:8E:CD:F8:CD:EB:36:00:BB:C4:C3:33:54:B5:9B:70:3C:E9:C4:33:8F:39:3C:4B:5B:5C:AD:88:12:2B", ) self.assertEqual(d.media[0].dtls.role, "client") self.assertEqual( str(d), lf2crlf( """v=0 o=FreeSWITCH 1538380016 1538380017 IN IP4 1.2.3.4 s=FreeSWITCH c=IN IP4 1.2.3.4 t=0 0 a=msid-semantic:WMS lyNSTe6w2ijnMrDEiqTHFyhqjdAag3ys m=audio 16628 UDP/TLS/RTP/SAVPF 8 101 a=rtcp:16628 IN IP4 1.2.3.4 a=rtcp-mux a=ssrc:2690029308 cname:rbaag6w9fGmRXQm6 a=ssrc:2690029308 msid:lyNSTe6w2ijnMrDEiqTHFyhqjdAag3ys a0 a=ssrc:2690029308 mslabel:lyNSTe6w2ijnMrDEiqTHFyhqjdAag3ys a=ssrc:2690029308 label:lyNSTe6w2ijnMrDEiqTHFyhqjdAag3ysa0 a=rtpmap:8 PCMA/8000 a=rtpmap:101 telephone-event/8000 a=candidate:0560693492 1 udp 659136 1.2.3.4 16628 typ host a=end-of-candidates a=ice-ufrag:75EDuLTEOkEUd3cu a=ice-pwd:5dvb9SbfooWc49814CupdeTS a=fingerprint:sha-256 35:5A:BC:8E:CD:F8:CD:EB:36:00:BB:C4:C3:33:54:B5:9B:70:3C:E9:C4:33:8F:39:3C:4B:5B:5C:AD:88:12:2B a=setup:active """ ), ) def test_audio_freeswitch_no_dtls(self): d = SessionDescription.parse( lf2crlf( """v=0 o=FreeSWITCH 1538380016 1538380017 IN IP4 1.2.3.4 s=FreeSWITCH c=IN IP4 1.2.3.4 t=0 0 a=msid-semantic: WMS lyNSTe6w2ijnMrDEiqTHFyhqjdAag3ys m=audio 16628 UDP/TLS/RTP/SAVPF 8 101 a=rtpmap:8 PCMA/8000 a=rtpmap:101 telephone-event/8000 a=ptime:20 a=rtcp-mux a=rtcp:16628 IN IP4 1.2.3.4 a=ice-ufrag:75EDuLTEOkEUd3cu a=ice-pwd:5dvb9SbfooWc49814CupdeTS a=candidate:0560693492 1 udp 659136 1.2.3.4 16628 typ host generation 0 a=end-of-candidates a=ssrc:2690029308 cname:rbaag6w9fGmRXQm6 a=ssrc:2690029308 msid:lyNSTe6w2ijnMrDEiqTHFyhqjdAag3ys a0 a=ssrc:2690029308 mslabel:lyNSTe6w2ijnMrDEiqTHFyhqjdAag3ys a=ssrc:2690029308 label:lyNSTe6w2ijnMrDEiqTHFyhqjdAag3ysa0""" ) ) self.assertEqual(d.group, []) self.assertEqual( d.msid_semantic, [ GroupDescription( semantic="WMS", items=["lyNSTe6w2ijnMrDEiqTHFyhqjdAag3ys"] ) ], ) self.assertEqual(d.host, "1.2.3.4") self.assertEqual(d.name, "FreeSWITCH") self.assertEqual(d.origin, "FreeSWITCH 1538380016 1538380017 IN IP4 1.2.3.4") self.assertEqual(d.time, "0 0") self.assertEqual(d.version, 0) self.assertEqual(len(d.media), 1) self.assertEqual(d.media[0].kind, "audio") self.assertEqual(d.media[0].host, None) self.assertEqual(d.media[0].port, 16628) self.assertEqual(d.media[0].profile, "UDP/TLS/RTP/SAVPF") self.assertEqual(d.media[0].direction, None) self.assertEqual( d.media[0].rtp.codecs, [ RTCRtpCodecParameters( mimeType="audio/PCMA", clockRate=8000, channels=1, payloadType=8 ), RTCRtpCodecParameters( mimeType="audio/telephone-event", clockRate=8000, channels=1, payloadType=101, ), ], ) self.assertEqual(d.media[0].rtp.headerExtensions, []) self.assertEqual(d.media[0].rtp.muxId, "") self.assertEqual(d.media[0].rtcp_host, "1.2.3.4") self.assertEqual(d.media[0].rtcp_port, 16628) self.assertEqual(d.media[0].rtcp_mux, True) # ssrc self.assertEqual( d.media[0].ssrc, [ SsrcDescription( ssrc=2690029308, cname="rbaag6w9fGmRXQm6", msid="lyNSTe6w2ijnMrDEiqTHFyhqjdAag3ys a0", mslabel="lyNSTe6w2ijnMrDEiqTHFyhqjdAag3ys", label="lyNSTe6w2ijnMrDEiqTHFyhqjdAag3ysa0", ) ], ) self.assertEqual(d.media[0].ssrc_group, []) # formats self.assertEqual(d.media[0].fmt, [8, 101]) self.assertEqual(d.media[0].sctpmap, {}) self.assertEqual(d.media[0].sctp_port, None) # ice self.assertEqual(len(d.media[0].ice_candidates), 1) self.assertEqual(d.media[0].ice_candidates_complete, True) self.assertEqual(d.media[0].ice_options, None) self.assertEqual(d.media[0].ice.iceLite, False) self.assertEqual(d.media[0].ice.usernameFragment, "75EDuLTEOkEUd3cu") self.assertEqual(d.media[0].ice.password, "5dvb9SbfooWc49814CupdeTS") # dtls self.assertEqual(d.media[0].dtls, None) self.assertEqual( str(d), lf2crlf( """v=0 o=FreeSWITCH 1538380016 1538380017 IN IP4 1.2.3.4 s=FreeSWITCH c=IN IP4 1.2.3.4 t=0 0 a=msid-semantic:WMS lyNSTe6w2ijnMrDEiqTHFyhqjdAag3ys m=audio 16628 UDP/TLS/RTP/SAVPF 8 101 a=rtcp:16628 IN IP4 1.2.3.4 a=rtcp-mux a=ssrc:2690029308 cname:rbaag6w9fGmRXQm6 a=ssrc:2690029308 msid:lyNSTe6w2ijnMrDEiqTHFyhqjdAag3ys a0 a=ssrc:2690029308 mslabel:lyNSTe6w2ijnMrDEiqTHFyhqjdAag3ys a=ssrc:2690029308 label:lyNSTe6w2ijnMrDEiqTHFyhqjdAag3ysa0 a=rtpmap:8 PCMA/8000 a=rtpmap:101 telephone-event/8000 a=candidate:0560693492 1 udp 659136 1.2.3.4 16628 typ host a=end-of-candidates a=ice-ufrag:75EDuLTEOkEUd3cu a=ice-pwd:5dvb9SbfooWc49814CupdeTS """ ), ) def test_audio_dtls_session_level(self): d = SessionDescription.parse( lf2crlf( """v=0 o=- 863426017819471768 2 IN IP4 127.0.0.1 s=- t=0 0 a=fingerprint:sha-256 6B:8B:5D:EA:59:04:20:23:29:C8:87:1C:CC:87:32:BE:DD:8C:66:A5:8E:50:55:EA:8C:D3:B6:5C:09:5E:D6:BC a=setup:actpass m=audio 45076 UDP/TLS/RTP/SAVPF 0 8 c=IN IP4 192.168.99.58 a=rtcp:9 IN IP4 0.0.0.0 a=candidate:2665802302 1 udp 2122262783 2a02:a03f:3eb0:e000:b0aa:d60a:cff2:933c 38475 typ host generation 0 network-id 2 network-cost 10 a=candidate:1039001212 1 udp 2122194687 192.168.99.58 45076 typ host generation 0 network-id 1 network-cost 10 a=ice-ufrag:5+Ix a=ice-pwd:uK8IlylxzDMUhrkVzdmj0M+v a=mid:audio a=sendrecv a=rtcp-mux a=rtpmap:0 PCMU/8000 a=rtpmap:8 PCMA/8000""" ) ) self.assertEqual(d.group, []) self.assertEqual(d.msid_semantic, []) self.assertEqual(d.host, None) self.assertEqual(d.name, "-") self.assertEqual(d.origin, "- 863426017819471768 2 IN IP4 127.0.0.1") self.assertEqual(d.time, "0 0") self.assertEqual(d.version, 0) self.assertEqual(len(d.media), 1) self.assertEqual(d.media[0].kind, "audio") self.assertEqual(d.media[0].host, "192.168.99.58") self.assertEqual(d.media[0].port, 45076) self.assertEqual(d.media[0].profile, "UDP/TLS/RTP/SAVPF") self.assertEqual(d.media[0].direction, "sendrecv") self.assertEqual(d.media[0].msid, None) self.assertEqual( d.media[0].rtp.codecs, [ RTCRtpCodecParameters( mimeType="audio/PCMU", clockRate=8000, channels=1, payloadType=0 ), RTCRtpCodecParameters( mimeType="audio/PCMA", clockRate=8000, channels=1, payloadType=8 ), ], ) self.assertEqual(d.media[0].rtp.headerExtensions, []) self.assertEqual(d.media[0].rtp.muxId, "audio") self.assertEqual(d.media[0].rtcp_host, "0.0.0.0") self.assertEqual(d.media[0].rtcp_port, 9) self.assertEqual(d.media[0].rtcp_mux, True) # ssrc self.assertEqual(d.media[0].ssrc, []) self.assertEqual(d.media[0].ssrc_group, []) # formats self.assertEqual(d.media[0].fmt, [0, 8]) self.assertEqual(d.media[0].sctpmap, {}) self.assertEqual(d.media[0].sctp_port, None) # ice self.assertEqual(len(d.media[0].ice_candidates), 2) self.assertEqual(d.media[0].ice_candidates_complete, False) self.assertEqual(d.media[0].ice_options, None) self.assertEqual(d.media[0].ice.iceLite, False) self.assertEqual(d.media[0].ice.usernameFragment, "5+Ix") self.assertEqual(d.media[0].ice.password, "uK8IlylxzDMUhrkVzdmj0M+v") # dtls self.assertEqual(len(d.media[0].dtls.fingerprints), 1) self.assertEqual(d.media[0].dtls.fingerprints[0].algorithm, "sha-256") self.assertEqual( d.media[0].dtls.fingerprints[0].value, "6B:8B:5D:EA:59:04:20:23:29:C8:87:1C:CC:87:32:BE:DD:8C:66:A5:8E:50:55:EA:8C:D3:B6:5C:09:5E:D6:BC", ) self.assertEqual(d.media[0].dtls.role, "auto") self.assertEqual( str(d), lf2crlf( """v=0 o=- 863426017819471768 2 IN IP4 127.0.0.1 s=- t=0 0 m=audio 45076 UDP/TLS/RTP/SAVPF 0 8 c=IN IP4 192.168.99.58 a=sendrecv a=mid:audio a=rtcp:9 IN IP4 0.0.0.0 a=rtcp-mux a=rtpmap:0 PCMU/8000 a=rtpmap:8 PCMA/8000 a=candidate:2665802302 1 udp 2122262783 2a02:a03f:3eb0:e000:b0aa:d60a:cff2:933c 38475 typ host a=candidate:1039001212 1 udp 2122194687 192.168.99.58 45076 typ host a=ice-ufrag:5+Ix a=ice-pwd:uK8IlylxzDMUhrkVzdmj0M+v a=fingerprint:sha-256 6B:8B:5D:EA:59:04:20:23:29:C8:87:1C:CC:87:32:BE:DD:8C:66:A5:8E:50:55:EA:8C:D3:B6:5C:09:5E:D6:BC a=setup:actpass """ ), ) def test_audio_ice_lite(self): d = SessionDescription.parse( lf2crlf( """v=0 o=- 863426017819471768 2 IN IP4 127.0.0.1 s=- t=0 0 a=ice-lite m=audio 45076 UDP/TLS/RTP/SAVPF 0 8 c=IN IP4 192.168.99.58 a=rtcp:9 IN IP4 0.0.0.0 a=candidate:2665802302 1 udp 2122262783 2a02:a03f:3eb0:e000:b0aa:d60a:cff2:933c 38475 typ host generation 0 network-id 2 network-cost 10 a=candidate:1039001212 1 udp 2122194687 192.168.99.58 45076 typ host generation 0 network-id 1 network-cost 10 a=ice-ufrag:5+Ix a=ice-pwd:uK8IlylxzDMUhrkVzdmj0M+v a=fingerprint:sha-256 6B:8B:5D:EA:59:04:20:23:29:C8:87:1C:CC:87:32:BE:DD:8C:66:A5:8E:50:55:EA:8C:D3:B6:5C:09:5E:D6:BC a=setup:actpass a=mid:audio a=sendrecv a=rtcp-mux a=rtpmap:0 PCMU/8000 a=rtpmap:8 PCMA/8000""" ) ) self.assertEqual(d.group, []) self.assertEqual(d.msid_semantic, []) self.assertEqual(d.host, None) self.assertEqual(d.name, "-") self.assertEqual(d.origin, "- 863426017819471768 2 IN IP4 127.0.0.1") self.assertEqual(d.time, "0 0") self.assertEqual(d.version, 0) self.assertEqual(len(d.media), 1) self.assertEqual(d.media[0].kind, "audio") self.assertEqual(d.media[0].host, "192.168.99.58") self.assertEqual(d.media[0].port, 45076) self.assertEqual(d.media[0].profile, "UDP/TLS/RTP/SAVPF") self.assertEqual(d.media[0].direction, "sendrecv") self.assertEqual(d.media[0].msid, None) self.assertEqual( d.media[0].rtp.codecs, [ RTCRtpCodecParameters( mimeType="audio/PCMU", clockRate=8000, channels=1, payloadType=0 ), RTCRtpCodecParameters( mimeType="audio/PCMA", clockRate=8000, channels=1, payloadType=8 ), ], ) self.assertEqual(d.media[0].rtp.headerExtensions, []) self.assertEqual(d.media[0].rtp.muxId, "audio") self.assertEqual(d.media[0].rtcp_host, "0.0.0.0") self.assertEqual(d.media[0].rtcp_port, 9) self.assertEqual(d.media[0].rtcp_mux, True) # ssrc self.assertEqual(d.media[0].ssrc, []) self.assertEqual(d.media[0].ssrc_group, []) # formats self.assertEqual(d.media[0].fmt, [0, 8]) self.assertEqual(d.media[0].sctpmap, {}) self.assertEqual(d.media[0].sctp_port, None) # ice self.assertEqual(len(d.media[0].ice_candidates), 2) self.assertEqual(d.media[0].ice_candidates_complete, False) self.assertEqual(d.media[0].ice_options, None) self.assertEqual(d.media[0].ice.iceLite, True) self.assertEqual(d.media[0].ice.usernameFragment, "5+Ix") self.assertEqual(d.media[0].ice.password, "uK8IlylxzDMUhrkVzdmj0M+v") # dtls self.assertEqual(len(d.media[0].dtls.fingerprints), 1) self.assertEqual(d.media[0].dtls.fingerprints[0].algorithm, "sha-256") self.assertEqual( d.media[0].dtls.fingerprints[0].value, "6B:8B:5D:EA:59:04:20:23:29:C8:87:1C:CC:87:32:BE:DD:8C:66:A5:8E:50:55:EA:8C:D3:B6:5C:09:5E:D6:BC", ) self.assertEqual(d.media[0].dtls.role, "auto") self.assertEqual( str(d), lf2crlf( """v=0 o=- 863426017819471768 2 IN IP4 127.0.0.1 s=- t=0 0 a=ice-lite m=audio 45076 UDP/TLS/RTP/SAVPF 0 8 c=IN IP4 192.168.99.58 a=sendrecv a=mid:audio a=rtcp:9 IN IP4 0.0.0.0 a=rtcp-mux a=rtpmap:0 PCMU/8000 a=rtpmap:8 PCMA/8000 a=candidate:2665802302 1 udp 2122262783 2a02:a03f:3eb0:e000:b0aa:d60a:cff2:933c 38475 typ host a=candidate:1039001212 1 udp 2122194687 192.168.99.58 45076 typ host a=ice-ufrag:5+Ix a=ice-pwd:uK8IlylxzDMUhrkVzdmj0M+v a=fingerprint:sha-256 6B:8B:5D:EA:59:04:20:23:29:C8:87:1C:CC:87:32:BE:DD:8C:66:A5:8E:50:55:EA:8C:D3:B6:5C:09:5E:D6:BC a=setup:actpass """ ), ) def test_audio_ice_session_level_credentials(self): d = SessionDescription.parse( lf2crlf( """v=0 o=- 863426017819471768 2 IN IP4 127.0.0.1 s=- t=0 0 a=ice-ufrag:5+Ix a=ice-pwd:uK8IlylxzDMUhrkVzdmj0M+v m=audio 45076 UDP/TLS/RTP/SAVPF 0 8 c=IN IP4 192.168.99.58 a=rtcp:9 IN IP4 0.0.0.0 a=candidate:2665802302 1 udp 2122262783 2a02:a03f:3eb0:e000:b0aa:d60a:cff2:933c 38475 typ host generation 0 network-id 2 network-cost 10 a=candidate:1039001212 1 udp 2122194687 192.168.99.58 45076 typ host generation 0 network-id 1 network-cost 10 a=fingerprint:sha-256 6B:8B:5D:EA:59:04:20:23:29:C8:87:1C:CC:87:32:BE:DD:8C:66:A5:8E:50:55:EA:8C:D3:B6:5C:09:5E:D6:BC a=setup:actpass a=mid:audio a=sendrecv a=rtcp-mux a=rtpmap:0 PCMU/8000 a=rtpmap:8 PCMA/8000""" ) ) self.assertEqual(d.group, []) self.assertEqual(d.msid_semantic, []) self.assertEqual(d.host, None) self.assertEqual(d.name, "-") self.assertEqual(d.origin, "- 863426017819471768 2 IN IP4 127.0.0.1") self.assertEqual(d.time, "0 0") self.assertEqual(d.version, 0) self.assertEqual(len(d.media), 1) self.assertEqual(d.media[0].kind, "audio") self.assertEqual(d.media[0].host, "192.168.99.58") self.assertEqual(d.media[0].port, 45076) self.assertEqual(d.media[0].profile, "UDP/TLS/RTP/SAVPF") self.assertEqual(d.media[0].direction, "sendrecv") self.assertEqual(d.media[0].msid, None) self.assertEqual( d.media[0].rtp.codecs, [ RTCRtpCodecParameters( mimeType="audio/PCMU", clockRate=8000, channels=1, payloadType=0 ), RTCRtpCodecParameters( mimeType="audio/PCMA", clockRate=8000, channels=1, payloadType=8 ), ], ) self.assertEqual(d.media[0].rtp.headerExtensions, []) self.assertEqual(d.media[0].rtp.muxId, "audio") self.assertEqual(d.media[0].rtcp_host, "0.0.0.0") self.assertEqual(d.media[0].rtcp_port, 9) self.assertEqual(d.media[0].rtcp_mux, True) # ssrc self.assertEqual(d.media[0].ssrc, []) self.assertEqual(d.media[0].ssrc_group, []) # formats self.assertEqual(d.media[0].fmt, [0, 8]) self.assertEqual(d.media[0].sctpmap, {}) self.assertEqual(d.media[0].sctp_port, None) # ice self.assertEqual(len(d.media[0].ice_candidates), 2) self.assertEqual(d.media[0].ice_candidates_complete, False) self.assertEqual(d.media[0].ice_options, None) self.assertEqual(d.media[0].ice.iceLite, False) self.assertEqual(d.media[0].ice.usernameFragment, "5+Ix") self.assertEqual(d.media[0].ice.password, "uK8IlylxzDMUhrkVzdmj0M+v") # dtls self.assertEqual(len(d.media[0].dtls.fingerprints), 1) self.assertEqual(d.media[0].dtls.fingerprints[0].algorithm, "sha-256") self.assertEqual( d.media[0].dtls.fingerprints[0].value, "6B:8B:5D:EA:59:04:20:23:29:C8:87:1C:CC:87:32:BE:DD:8C:66:A5:8E:50:55:EA:8C:D3:B6:5C:09:5E:D6:BC", ) self.assertEqual(d.media[0].dtls.role, "auto") self.assertEqual( str(d), lf2crlf( """v=0 o=- 863426017819471768 2 IN IP4 127.0.0.1 s=- t=0 0 m=audio 45076 UDP/TLS/RTP/SAVPF 0 8 c=IN IP4 192.168.99.58 a=sendrecv a=mid:audio a=rtcp:9 IN IP4 0.0.0.0 a=rtcp-mux a=rtpmap:0 PCMU/8000 a=rtpmap:8 PCMA/8000 a=candidate:2665802302 1 udp 2122262783 2a02:a03f:3eb0:e000:b0aa:d60a:cff2:933c 38475 typ host a=candidate:1039001212 1 udp 2122194687 192.168.99.58 45076 typ host a=ice-ufrag:5+Ix a=ice-pwd:uK8IlylxzDMUhrkVzdmj0M+v a=fingerprint:sha-256 6B:8B:5D:EA:59:04:20:23:29:C8:87:1C:CC:87:32:BE:DD:8C:66:A5:8E:50:55:EA:8C:D3:B6:5C:09:5E:D6:BC a=setup:actpass """ ), ) def test_datachannel_firefox(self): d = SessionDescription.parse( lf2crlf( """v=0 o=mozilla...THIS_IS_SDPARTA-58.0.1 7514673380034989017 0 IN IP4 0.0.0.0 s=- t=0 0 a=sendrecv a=fingerprint:sha-256 39:4A:09:1E:0E:33:32:85:51:03:49:95:54:0B:41:09:A2:10:60:CC:39:8F:C0:C4:45:FC:37:3A:55:EA:11:74 a=group:BUNDLE sdparta_0 a=ice-options:trickle a=msid-semantic:WMS * m=application 45791 DTLS/SCTP 5000 c=IN IP4 192.168.99.58 a=candidate:0 1 UDP 2122187007 192.168.99.58 45791 typ host a=candidate:1 1 UDP 2122252543 2a02:a03f:3eb0:e000:b0aa:d60a:cff2:933c 44087 typ host a=candidate:2 1 TCP 2105458943 192.168.99.58 9 typ host tcptype active a=candidate:3 1 TCP 2105524479 2a02:a03f:3eb0:e000:b0aa:d60a:cff2:933c 9 typ host tcptype active a=sendrecv a=end-of-candidates a=ice-pwd:d30a5aec4dd81f07d4ff3344209400ab a=ice-ufrag:9889e0c4 a=mid:sdparta_0 a=sctpmap:5000 webrtc-datachannel 256 a=setup:actpass a=max-message-size:1073741823 """ ) ) self.assertEqual( d.group, [GroupDescription(semantic="BUNDLE", items=["sdparta_0"])] ) self.assertEqual( d.msid_semantic, [GroupDescription(semantic="WMS", items=["*"])] ) self.assertEqual(d.host, None) self.assertEqual(d.name, "-") self.assertEqual( d.origin, "mozilla...THIS_IS_SDPARTA-58.0.1 7514673380034989017 0 IN IP4 0.0.0.0", ) self.assertEqual(d.time, "0 0") self.assertEqual(d.version, 0) self.assertEqual(len(d.media), 1) self.assertEqual(d.media[0].kind, "application") self.assertEqual(d.media[0].host, "192.168.99.58") self.assertEqual(d.media[0].port, 45791) self.assertEqual(d.media[0].profile, "DTLS/SCTP") self.assertEqual(d.media[0].fmt, ["5000"]) # sctp self.assertEqual(d.media[0].sctpmap, {5000: "webrtc-datachannel 256"}) self.assertEqual(d.media[0].sctp_port, None) self.assertIsNotNone(d.media[0].sctpCapabilities) self.assertEqual(d.media[0].sctpCapabilities.maxMessageSize, 1073741823) # ice self.assertEqual(len(d.media[0].ice_candidates), 4) self.assertEqual(d.media[0].ice_candidates_complete, True) self.assertEqual(d.media[0].ice_options, "trickle") self.assertEqual(d.media[0].ice.iceLite, False) self.assertEqual(d.media[0].ice.usernameFragment, "9889e0c4") self.assertEqual(d.media[0].ice.password, "d30a5aec4dd81f07d4ff3344209400ab") # dtls self.assertEqual(len(d.media[0].dtls.fingerprints), 1) self.assertEqual(d.media[0].dtls.fingerprints[0].algorithm, "sha-256") self.assertEqual( d.media[0].dtls.fingerprints[0].value, "39:4A:09:1E:0E:33:32:85:51:03:49:95:54:0B:41:09:A2:10:60:CC:39:8F:C0:C4:45:FC:37:3A:55:EA:11:74", ) self.assertEqual(d.media[0].dtls.role, "auto") self.assertEqual( str(d), lf2crlf( """v=0 o=mozilla...THIS_IS_SDPARTA-58.0.1 7514673380034989017 0 IN IP4 0.0.0.0 s=- t=0 0 a=group:BUNDLE sdparta_0 a=msid-semantic:WMS * m=application 45791 DTLS/SCTP 5000 c=IN IP4 192.168.99.58 a=sendrecv a=mid:sdparta_0 a=sctpmap:5000 webrtc-datachannel 256 a=max-message-size:1073741823 a=candidate:0 1 UDP 2122187007 192.168.99.58 45791 typ host a=candidate:1 1 UDP 2122252543 2a02:a03f:3eb0:e000:b0aa:d60a:cff2:933c 44087 typ host a=candidate:2 1 TCP 2105458943 192.168.99.58 9 typ host tcptype active a=candidate:3 1 TCP 2105524479 2a02:a03f:3eb0:e000:b0aa:d60a:cff2:933c 9 typ host tcptype active a=end-of-candidates a=ice-ufrag:9889e0c4 a=ice-pwd:d30a5aec4dd81f07d4ff3344209400ab a=ice-options:trickle a=fingerprint:sha-256 39:4A:09:1E:0E:33:32:85:51:03:49:95:54:0B:41:09:A2:10:60:CC:39:8F:C0:C4:45:FC:37:3A:55:EA:11:74 a=setup:actpass """ ), ) def test_datachannel_firefox_63(self): d = SessionDescription.parse( lf2crlf( """v=0 o=mozilla...THIS_IS_SDPARTA-58.0.1 7514673380034989017 0 IN IP4 0.0.0.0 s=- t=0 0 a=sendrecv a=fingerprint:sha-256 39:4A:09:1E:0E:33:32:85:51:03:49:95:54:0B:41:09:A2:10:60:CC:39:8F:C0:C4:45:FC:37:3A:55:EA:11:74 a=group:BUNDLE sdparta_0 a=ice-options:trickle a=msid-semantic:WMS * m=application 45791 UDP/DTLS/SCTP webrtc-datachannel c=IN IP4 192.168.99.58 a=candidate:0 1 UDP 2122187007 192.168.99.58 45791 typ host a=candidate:1 1 UDP 2122252543 2a02:a03f:3eb0:e000:b0aa:d60a:cff2:933c 44087 typ host a=candidate:2 1 TCP 2105458943 192.168.99.58 9 typ host tcptype active a=candidate:3 1 TCP 2105524479 2a02:a03f:3eb0:e000:b0aa:d60a:cff2:933c 9 typ host tcptype active a=sendrecv a=end-of-candidates a=ice-pwd:d30a5aec4dd81f07d4ff3344209400ab a=ice-ufrag:9889e0c4 a=mid:sdparta_0 a=sctp-port:5000 a=setup:actpass a=max-message-size:1073741823 """ ) ) self.assertEqual( d.group, [GroupDescription(semantic="BUNDLE", items=["sdparta_0"])] ) self.assertEqual( d.msid_semantic, [GroupDescription(semantic="WMS", items=["*"])] ) self.assertEqual(d.host, None) self.assertEqual(d.name, "-") self.assertEqual( d.origin, "mozilla...THIS_IS_SDPARTA-58.0.1 7514673380034989017 0 IN IP4 0.0.0.0", ) self.assertEqual(d.time, "0 0") self.assertEqual(d.version, 0) self.assertEqual(len(d.media), 1) self.assertEqual(d.media[0].kind, "application") self.assertEqual(d.media[0].host, "192.168.99.58") self.assertEqual(d.media[0].port, 45791) self.assertEqual(d.media[0].profile, "UDP/DTLS/SCTP") self.assertEqual(d.media[0].fmt, ["webrtc-datachannel"]) # sctp self.assertEqual(d.media[0].sctpmap, {}) self.assertEqual(d.media[0].sctp_port, 5000) self.assertIsNotNone(d.media[0].sctpCapabilities) self.assertEqual(d.media[0].sctpCapabilities.maxMessageSize, 1073741823) # ice self.assertEqual(len(d.media[0].ice_candidates), 4) self.assertEqual(d.media[0].ice_candidates_complete, True) self.assertEqual(d.media[0].ice_options, "trickle") self.assertEqual(d.media[0].ice.iceLite, False) self.assertEqual(d.media[0].ice.usernameFragment, "9889e0c4") self.assertEqual(d.media[0].ice.password, "d30a5aec4dd81f07d4ff3344209400ab") # dtls self.assertEqual(len(d.media[0].dtls.fingerprints), 1) self.assertEqual(d.media[0].dtls.fingerprints[0].algorithm, "sha-256") self.assertEqual( d.media[0].dtls.fingerprints[0].value, "39:4A:09:1E:0E:33:32:85:51:03:49:95:54:0B:41:09:A2:10:60:CC:39:8F:C0:C4:45:FC:37:3A:55:EA:11:74", ) self.assertEqual(d.media[0].dtls.role, "auto") self.assertEqual( str(d), lf2crlf( """v=0 o=mozilla...THIS_IS_SDPARTA-58.0.1 7514673380034989017 0 IN IP4 0.0.0.0 s=- t=0 0 a=group:BUNDLE sdparta_0 a=msid-semantic:WMS * m=application 45791 UDP/DTLS/SCTP webrtc-datachannel c=IN IP4 192.168.99.58 a=sendrecv a=mid:sdparta_0 a=sctp-port:5000 a=max-message-size:1073741823 a=candidate:0 1 UDP 2122187007 192.168.99.58 45791 typ host a=candidate:1 1 UDP 2122252543 2a02:a03f:3eb0:e000:b0aa:d60a:cff2:933c 44087 typ host a=candidate:2 1 TCP 2105458943 192.168.99.58 9 typ host tcptype active a=candidate:3 1 TCP 2105524479 2a02:a03f:3eb0:e000:b0aa:d60a:cff2:933c 9 typ host tcptype active a=end-of-candidates a=ice-ufrag:9889e0c4 a=ice-pwd:d30a5aec4dd81f07d4ff3344209400ab a=ice-options:trickle a=fingerprint:sha-256 39:4A:09:1E:0E:33:32:85:51:03:49:95:54:0B:41:09:A2:10:60:CC:39:8F:C0:C4:45:FC:37:3A:55:EA:11:74 a=setup:actpass """ ), ) def test_video_chrome(self): d = SessionDescription.parse( lf2crlf( """v=0 o=- 5195484278799753993 2 IN IP4 127.0.0.1 s=- t=0 0 a=group:BUNDLE video a=msid-semantic: WMS bbgewhUzS6hvFDlSlrhQ6zYlwW7ttRrK8QeQ m=video 34955 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 102 c=IN IP4 10.101.2.67 a=rtcp:9 IN IP4 0.0.0.0 a=candidate:638323114 1 udp 2122260223 10.101.2.67 34955 typ host generation 0 network-id 2 network-cost 10 a=candidate:1754264922 1 tcp 1518280447 10.101.2.67 9 typ host tcptype active generation 0 network-id 2 network-cost 10 a=ice-ufrag:9KhP a=ice-pwd:mlPea2xBCmFmNLfmy/jlqw1D a=ice-options:trickle a=fingerprint:sha-256 30:4A:BF:65:23:D1:99:AB:AE:9F:FD:5D:B1:08:4F:09:7C:9F:F2:CC:50:16:13:81:1B:5D:DD:D0:98:45:81:1E a=setup:actpass a=mid:video a=extmap:2 urn:ietf:params:rtp-hdrext:toffset a=extmap:3 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time a=extmap:4 urn:3gpp:video-orientation a=extmap:5 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01 a=extmap:6 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay a=extmap:7 http://www.webrtc.org/experiments/rtp-hdrext/video-content-type a=extmap:8 http://www.webrtc.org/experiments/rtp-hdrext/video-timing a=sendrecv a=rtcp-mux a=rtcp-rsize a=rtpmap:96 VP8/90000 a=rtcp-fb:96 goog-remb a=rtcp-fb:96 transport-cc a=rtcp-fb:96 ccm fir a=rtcp-fb:96 nack a=rtcp-fb:96 nack pli a=rtpmap:97 rtx/90000 a=fmtp:97 apt=96 a=rtpmap:98 VP9/90000 a=rtcp-fb:98 goog-remb a=rtcp-fb:98 transport-cc a=rtcp-fb:98 ccm fir a=rtcp-fb:98 nack a=rtcp-fb:98 nack pli a=rtpmap:99 rtx/90000 a=fmtp:99 apt=98 a=rtpmap:100 red/90000 a=rtpmap:101 rtx/90000 a=fmtp:101 apt=100 a=rtpmap:102 ulpfec/90000 a=ssrc-group:FID 1845476211 3305256354 a=ssrc:1845476211 cname:9iW3jspLCZJ5WjOZ a=ssrc:1845476211 msid:bbgewhUzS6hvFDlSlrhQ6zYlwW7ttRrK8QeQ 420c6f28-439d-4ead-b93c-94e14c0a16b4 a=ssrc:1845476211 mslabel:bbgewhUzS6hvFDlSlrhQ6zYlwW7ttRrK8QeQ a=ssrc:1845476211 label:420c6f28-439d-4ead-b93c-94e14c0a16b4 a=ssrc:3305256354 cname:9iW3jspLCZJ5WjOZ a=ssrc:3305256354 msid:bbgewhUzS6hvFDlSlrhQ6zYlwW7ttRrK8QeQ 420c6f28-439d-4ead-b93c-94e14c0a16b4 a=ssrc:3305256354 mslabel:bbgewhUzS6hvFDlSlrhQ6zYlwW7ttRrK8QeQ a=ssrc:3305256354 label:420c6f28-439d-4ead-b93c-94e14c0a16b4 """ ) ) self.assertEqual( d.group, [GroupDescription(semantic="BUNDLE", items=["video"])] ) self.assertEqual( d.msid_semantic, [ GroupDescription( semantic="WMS", items=["bbgewhUzS6hvFDlSlrhQ6zYlwW7ttRrK8QeQ"] ) ], ) self.assertEqual(d.host, None) self.assertEqual(d.name, "-") self.assertEqual(d.origin, "- 5195484278799753993 2 IN IP4 127.0.0.1") self.assertEqual(d.time, "0 0") self.assertEqual(d.version, 0) self.assertEqual(len(d.media), 1) self.assertEqual(d.media[0].kind, "video") self.assertEqual(d.media[0].host, "10.101.2.67") self.assertEqual(d.media[0].port, 34955) self.assertEqual(d.media[0].profile, "UDP/TLS/RTP/SAVPF") self.assertEqual(d.media[0].direction, "sendrecv") self.assertEqual(d.media[0].msid, None) self.assertEqual( d.media[0].rtp.codecs, [ RTCRtpCodecParameters( mimeType="video/VP8", clockRate=90000, payloadType=96, rtcpFeedback=[ RTCRtcpFeedback(type="goog-remb"), RTCRtcpFeedback(type="transport-cc"), RTCRtcpFeedback(type="ccm", parameter="fir"), RTCRtcpFeedback(type="nack"), RTCRtcpFeedback(type="nack", parameter="pli"), ], ), RTCRtpCodecParameters( mimeType="video/rtx", clockRate=90000, payloadType=97, parameters={"apt": 96}, ), RTCRtpCodecParameters( mimeType="video/VP9", clockRate=90000, payloadType=98, rtcpFeedback=[ RTCRtcpFeedback(type="goog-remb"), RTCRtcpFeedback(type="transport-cc"), RTCRtcpFeedback(type="ccm", parameter="fir"), RTCRtcpFeedback(type="nack"), RTCRtcpFeedback(type="nack", parameter="pli"), ], ), RTCRtpCodecParameters( mimeType="video/rtx", clockRate=90000, payloadType=99, parameters={"apt": 98}, ), RTCRtpCodecParameters( mimeType="video/red", clockRate=90000, payloadType=100 ), RTCRtpCodecParameters( mimeType="video/rtx", clockRate=90000, payloadType=101, parameters={"apt": 100}, ), RTCRtpCodecParameters( mimeType="video/ulpfec", clockRate=90000, payloadType=102 ), ], ) self.assertEqual( d.media[0].rtp.headerExtensions, [ RTCRtpHeaderExtensionParameters( id=2, uri="urn:ietf:params:rtp-hdrext:toffset" ), RTCRtpHeaderExtensionParameters( id=3, uri="http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time", ), RTCRtpHeaderExtensionParameters(id=4, uri="urn:3gpp:video-orientation"), RTCRtpHeaderExtensionParameters( id=5, uri="http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01", ), RTCRtpHeaderExtensionParameters( id=6, uri="http://www.webrtc.org/experiments/rtp-hdrext/playout-delay", ), RTCRtpHeaderExtensionParameters( id=7, uri="http://www.webrtc.org/experiments/rtp-hdrext/video-content-type", ), RTCRtpHeaderExtensionParameters( id=8, uri="http://www.webrtc.org/experiments/rtp-hdrext/video-timing", ), ], ) self.assertEqual(d.media[0].rtp.muxId, "video") self.assertEqual(d.media[0].rtcp_host, "0.0.0.0") self.assertEqual(d.media[0].rtcp_port, 9) self.assertEqual(d.media[0].rtcp_mux, True) self.assertEqual(d.webrtc_track_id(d.media[0]), None) # ssrc self.assertEqual( d.media[0].ssrc, [ SsrcDescription( ssrc=1845476211, cname="9iW3jspLCZJ5WjOZ", msid="bbgewhUzS6hvFDlSlrhQ6zYlwW7ttRrK8QeQ 420c6f28-439d-4ead-b93c-94e14c0a16b4", mslabel="bbgewhUzS6hvFDlSlrhQ6zYlwW7ttRrK8QeQ", label="420c6f28-439d-4ead-b93c-94e14c0a16b4", ), SsrcDescription( ssrc=3305256354, cname="9iW3jspLCZJ5WjOZ", msid="bbgewhUzS6hvFDlSlrhQ6zYlwW7ttRrK8QeQ 420c6f28-439d-4ead-b93c-94e14c0a16b4", mslabel="bbgewhUzS6hvFDlSlrhQ6zYlwW7ttRrK8QeQ", label="420c6f28-439d-4ead-b93c-94e14c0a16b4", ), ], ) self.assertEqual( d.media[0].ssrc_group, [GroupDescription(semantic="FID", items=[1845476211, 3305256354])], ) # formats self.assertEqual(d.media[0].fmt, [96, 97, 98, 99, 100, 101, 102]) self.assertEqual(d.media[0].sctpmap, {}) self.assertEqual(d.media[0].sctp_port, None) # ice self.assertEqual(len(d.media[0].ice_candidates), 2) self.assertEqual(d.media[0].ice_candidates_complete, False) self.assertEqual(d.media[0].ice_options, "trickle") self.assertEqual(d.media[0].ice.iceLite, False) self.assertEqual(d.media[0].ice.usernameFragment, "9KhP") self.assertEqual(d.media[0].ice.password, "mlPea2xBCmFmNLfmy/jlqw1D") # dtls self.assertEqual(len(d.media[0].dtls.fingerprints), 1) self.assertEqual(d.media[0].dtls.fingerprints[0].algorithm, "sha-256") self.assertEqual( d.media[0].dtls.fingerprints[0].value, "30:4A:BF:65:23:D1:99:AB:AE:9F:FD:5D:B1:08:4F:09:7C:9F:F2:CC:50:16:13:81:1B:5D:DD:D0:98:45:81:1E", ) self.assertEqual(d.media[0].dtls.role, "auto") self.assertEqual( str(d), lf2crlf( """v=0 o=- 5195484278799753993 2 IN IP4 127.0.0.1 s=- t=0 0 a=group:BUNDLE video a=msid-semantic:WMS bbgewhUzS6hvFDlSlrhQ6zYlwW7ttRrK8QeQ m=video 34955 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 102 c=IN IP4 10.101.2.67 a=sendrecv a=extmap:2 urn:ietf:params:rtp-hdrext:toffset a=extmap:3 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time a=extmap:4 urn:3gpp:video-orientation a=extmap:5 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01 a=extmap:6 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay a=extmap:7 http://www.webrtc.org/experiments/rtp-hdrext/video-content-type a=extmap:8 http://www.webrtc.org/experiments/rtp-hdrext/video-timing a=mid:video a=rtcp:9 IN IP4 0.0.0.0 a=rtcp-mux a=ssrc-group:FID 1845476211 3305256354 a=ssrc:1845476211 cname:9iW3jspLCZJ5WjOZ a=ssrc:1845476211 msid:bbgewhUzS6hvFDlSlrhQ6zYlwW7ttRrK8QeQ 420c6f28-439d-4ead-b93c-94e14c0a16b4 a=ssrc:1845476211 mslabel:bbgewhUzS6hvFDlSlrhQ6zYlwW7ttRrK8QeQ a=ssrc:1845476211 label:420c6f28-439d-4ead-b93c-94e14c0a16b4 a=ssrc:3305256354 cname:9iW3jspLCZJ5WjOZ a=ssrc:3305256354 msid:bbgewhUzS6hvFDlSlrhQ6zYlwW7ttRrK8QeQ 420c6f28-439d-4ead-b93c-94e14c0a16b4 a=ssrc:3305256354 mslabel:bbgewhUzS6hvFDlSlrhQ6zYlwW7ttRrK8QeQ a=ssrc:3305256354 label:420c6f28-439d-4ead-b93c-94e14c0a16b4 a=rtpmap:96 VP8/90000 a=rtcp-fb:96 goog-remb a=rtcp-fb:96 transport-cc a=rtcp-fb:96 ccm fir a=rtcp-fb:96 nack a=rtcp-fb:96 nack pli a=rtpmap:97 rtx/90000 a=fmtp:97 apt=96 a=rtpmap:98 VP9/90000 a=rtcp-fb:98 goog-remb a=rtcp-fb:98 transport-cc a=rtcp-fb:98 ccm fir a=rtcp-fb:98 nack a=rtcp-fb:98 nack pli a=rtpmap:99 rtx/90000 a=fmtp:99 apt=98 a=rtpmap:100 red/90000 a=rtpmap:101 rtx/90000 a=fmtp:101 apt=100 a=rtpmap:102 ulpfec/90000 a=candidate:638323114 1 udp 2122260223 10.101.2.67 34955 typ host a=candidate:1754264922 1 tcp 1518280447 10.101.2.67 9 typ host tcptype active a=ice-ufrag:9KhP a=ice-pwd:mlPea2xBCmFmNLfmy/jlqw1D a=ice-options:trickle a=fingerprint:sha-256 30:4A:BF:65:23:D1:99:AB:AE:9F:FD:5D:B1:08:4F:09:7C:9F:F2:CC:50:16:13:81:1B:5D:DD:D0:98:45:81:1E a=setup:actpass """ ), ) def test_video_firefox(self): d = SessionDescription.parse( lf2crlf( """v=0 o=mozilla...THIS_IS_SDPARTA-61.0 8964514366714082732 0 IN IP4 0.0.0.0 s=- t=0 0 a=sendrecv a=fingerprint:sha-256 AF:9E:29:99:AC:F6:F6:A2:86:A7:2E:A5:83:94:21:7F:F1:39:C5:E3:8F:E4:08:04:D9:D8:70:6D:6C:A2:A1:D5 a=group:BUNDLE sdparta_0 a=ice-options:trickle a=msid-semantic:WMS * m=video 42738 UDP/TLS/RTP/SAVPF 120 121 c=IN IP4 192.168.99.7 a=candidate:0 1 UDP 2122252543 192.168.99.7 42738 typ host a=candidate:1 1 TCP 2105524479 192.168.99.7 9 typ host tcptype active a=candidate:0 2 UDP 2122252542 192.168.99.7 52914 typ host a=candidate:1 2 TCP 2105524478 192.168.99.7 9 typ host tcptype active a=sendrecv a=end-of-candidates a=extmap:3 urn:ietf:params:rtp-hdrext:sdes:mid a=extmap:4 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time a=extmap:5 urn:ietf:params:rtp-hdrext:toffset a=fmtp:120 max-fs=12288;max-fr=60 a=fmtp:121 max-fs=12288;max-fr=60 a=ice-pwd:c43b0306087bb4de15f70e4405c4dafe a=ice-ufrag:1a0e6b24 a=mid:sdparta_0 a=msid:{38c9a1f0-d360-4ad8-afe3-4d7f6d4ae4e1} {d27161f3-ab5d-4aff-9dd8-4a24bfbe56d4} a=rtcp:52914 IN IP4 192.168.99.7 a=rtcp-fb:120 nack a=rtcp-fb:120 nack pli a=rtcp-fb:120 ccm fir a=rtcp-fb:120 goog-remb a=rtcp-fb:121 nack a=rtcp-fb:121 nack pli a=rtcp-fb:121 ccm fir a=rtcp-fb:121 goog-remb a=rtcp-mux a=rtpmap:120 VP8/90000 a=rtpmap:121 VP9/90000 a=setup:actpass a=ssrc:3408404552 cname:{6f52d07e-17ef-42c5-932b-3b57c64fe049} """ ) ) self.assertEqual( d.group, [GroupDescription(semantic="BUNDLE", items=["sdparta_0"])] ) self.assertEqual( d.msid_semantic, [GroupDescription(semantic="WMS", items=["*"])] ) self.assertEqual(d.host, None) self.assertEqual(d.name, "-") self.assertEqual( d.origin, "mozilla...THIS_IS_SDPARTA-61.0 8964514366714082732 0 IN IP4 0.0.0.0", ) self.assertEqual(d.time, "0 0") self.assertEqual(d.version, 0) self.assertEqual(len(d.media), 1) self.assertEqual(d.media[0].kind, "video") self.assertEqual(d.media[0].host, "192.168.99.7") self.assertEqual(d.media[0].port, 42738) self.assertEqual(d.media[0].profile, "UDP/TLS/RTP/SAVPF") self.assertEqual(d.media[0].direction, "sendrecv") self.assertEqual( d.media[0].msid, "{38c9a1f0-d360-4ad8-afe3-4d7f6d4ae4e1} " "{d27161f3-ab5d-4aff-9dd8-4a24bfbe56d4}", ) self.assertEqual( d.media[0].rtp.codecs, [ RTCRtpCodecParameters( mimeType="video/VP8", clockRate=90000, payloadType=120, rtcpFeedback=[ RTCRtcpFeedback(type="nack"), RTCRtcpFeedback(type="nack", parameter="pli"), RTCRtcpFeedback(type="ccm", parameter="fir"), RTCRtcpFeedback(type="goog-remb"), ], parameters={"max-fs": 12288, "max-fr": 60}, ), RTCRtpCodecParameters( mimeType="video/VP9", clockRate=90000, payloadType=121, rtcpFeedback=[ RTCRtcpFeedback(type="nack"), RTCRtcpFeedback(type="nack", parameter="pli"), RTCRtcpFeedback(type="ccm", parameter="fir"), RTCRtcpFeedback(type="goog-remb"), ], parameters={"max-fs": 12288, "max-fr": 60}, ), ], ) self.assertEqual( d.media[0].rtp.headerExtensions, [ RTCRtpHeaderExtensionParameters( id=3, uri="urn:ietf:params:rtp-hdrext:sdes:mid" ), RTCRtpHeaderExtensionParameters( id=4, uri="http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time", ), RTCRtpHeaderExtensionParameters( id=5, uri="urn:ietf:params:rtp-hdrext:toffset" ), ], ) self.assertEqual(d.media[0].rtp.muxId, "sdparta_0") self.assertEqual(d.media[0].rtcp_host, "192.168.99.7") self.assertEqual(d.media[0].rtcp_port, 52914) self.assertEqual(d.media[0].rtcp_mux, True) self.assertEqual( d.webrtc_track_id(d.media[0]), "{d27161f3-ab5d-4aff-9dd8-4a24bfbe56d4}" ) # formats self.assertEqual(d.media[0].fmt, [120, 121]) self.assertEqual(d.media[0].sctpmap, {}) self.assertEqual(d.media[0].sctp_port, None) # ice self.assertEqual(len(d.media[0].ice_candidates), 4) self.assertEqual(d.media[0].ice_candidates_complete, True) self.assertEqual(d.media[0].ice_options, "trickle") self.assertEqual(d.media[0].ice.iceLite, False) self.assertEqual(d.media[0].ice.usernameFragment, "1a0e6b24") self.assertEqual(d.media[0].ice.password, "c43b0306087bb4de15f70e4405c4dafe") # dtls self.assertEqual(len(d.media[0].dtls.fingerprints), 1) self.assertEqual(d.media[0].dtls.fingerprints[0].algorithm, "sha-256") self.assertEqual( d.media[0].dtls.fingerprints[0].value, "AF:9E:29:99:AC:F6:F6:A2:86:A7:2E:A5:83:94:21:7F:F1:39:C5:E3:8F:E4:08:04:D9:D8:70:6D:6C:A2:A1:D5", ) self.assertEqual(d.media[0].dtls.role, "auto") self.assertEqual( str(d), lf2crlf( """v=0 o=mozilla...THIS_IS_SDPARTA-61.0 8964514366714082732 0 IN IP4 0.0.0.0 s=- t=0 0 a=group:BUNDLE sdparta_0 a=msid-semantic:WMS * m=video 42738 UDP/TLS/RTP/SAVPF 120 121 c=IN IP4 192.168.99.7 a=sendrecv a=extmap:3 urn:ietf:params:rtp-hdrext:sdes:mid a=extmap:4 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time a=extmap:5 urn:ietf:params:rtp-hdrext:toffset a=mid:sdparta_0 a=msid:{38c9a1f0-d360-4ad8-afe3-4d7f6d4ae4e1} {d27161f3-ab5d-4aff-9dd8-4a24bfbe56d4} a=rtcp:52914 IN IP4 192.168.99.7 a=rtcp-mux a=ssrc:3408404552 cname:{6f52d07e-17ef-42c5-932b-3b57c64fe049} a=rtpmap:120 VP8/90000 a=rtcp-fb:120 nack a=rtcp-fb:120 nack pli a=rtcp-fb:120 ccm fir a=rtcp-fb:120 goog-remb a=fmtp:120 max-fs=12288;max-fr=60 a=rtpmap:121 VP9/90000 a=rtcp-fb:121 nack a=rtcp-fb:121 nack pli a=rtcp-fb:121 ccm fir a=rtcp-fb:121 goog-remb a=fmtp:121 max-fs=12288;max-fr=60 a=candidate:0 1 UDP 2122252543 192.168.99.7 42738 typ host a=candidate:1 1 TCP 2105524479 192.168.99.7 9 typ host tcptype active a=candidate:0 2 UDP 2122252542 192.168.99.7 52914 typ host a=candidate:1 2 TCP 2105524478 192.168.99.7 9 typ host tcptype active a=end-of-candidates a=ice-ufrag:1a0e6b24 a=ice-pwd:c43b0306087bb4de15f70e4405c4dafe a=ice-options:trickle a=fingerprint:sha-256 AF:9E:29:99:AC:F6:F6:A2:86:A7:2E:A5:83:94:21:7F:F1:39:C5:E3:8F:E4:08:04:D9:D8:70:6D:6C:A2:A1:D5 a=setup:actpass """ ), ) def test_video_session_star_rtcp_fb(self): d = SessionDescription.parse( lf2crlf( """v=0 o=mozilla...THIS_IS_SDPARTA-61.0 8964514366714082732 0 IN IP4 0.0.0.0 s=- t=0 0 a=group:BUNDLE sdparta_0 a=msid-semantic:WMS * m=video 42738 UDP/TLS/RTP/SAVPF 120 121 c=IN IP4 192.168.99.7 a=sendrecv a=extmap:3 urn:ietf:params:rtp-hdrext:sdes:mid a=extmap:4 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time a=extmap:5 urn:ietf:params:rtp-hdrext:toffset a=mid:sdparta_0 a=msid:{38c9a1f0-d360-4ad8-afe3-4d7f6d4ae4e1} {d27161f3-ab5d-4aff-9dd8-4a24bfbe56d4} a=rtcp:52914 IN IP4 192.168.99.7 a=rtcp-mux a=ssrc:3408404552 cname:{6f52d07e-17ef-42c5-932b-3b57c64fe049} a=rtpmap:120 VP8/90000 a=fmtp:120 max-fs=12288;max-fr=60 a=rtpmap:121 VP9/90000 a=fmtp:121 max-fs=12288;max-fr=60 a=rtcp-fb:* nack a=rtcp-fb:* nack pli a=rtcp-fb:* goog-remb a=candidate:0 1 UDP 2122252543 192.168.99.7 42738 typ host a=candidate:1 1 TCP 2105524479 192.168.99.7 9 typ host tcptype active a=candidate:0 2 UDP 2122252542 192.168.99.7 52914 typ host a=candidate:1 2 TCP 2105524478 192.168.99.7 9 typ host tcptype active a=end-of-candidates a=ice-ufrag:1a0e6b24 a=ice-pwd:c43b0306087bb4de15f70e4405c4dafe a=ice-options:trickle a=fingerprint:sha-256 AF:9E:29:99:AC:F6:F6:A2:86:A7:2E:A5:83:94:21:7F:F1:39:C5:E3:8F:E4:08:04:D9:D8:70:6D:6C:A2:A1:D5 a=setup:actpass """ ) ) self.assertEqual( d.media[0].rtp.codecs, [ RTCRtpCodecParameters( mimeType="video/VP8", clockRate=90000, payloadType=120, rtcpFeedback=[ RTCRtcpFeedback(type="nack"), RTCRtcpFeedback(type="nack", parameter="pli"), RTCRtcpFeedback(type="goog-remb"), ], parameters={"max-fs": 12288, "max-fr": 60}, ), RTCRtpCodecParameters( mimeType="video/VP9", clockRate=90000, payloadType=121, rtcpFeedback=[ RTCRtcpFeedback(type="nack"), RTCRtcpFeedback(type="nack", parameter="pli"), RTCRtcpFeedback(type="goog-remb"), ], parameters={"max-fs": 12288, "max-fr": 60}, ), ], ) def test_safari(self): d = SessionDescription.parse( lf2crlf( """ v=0 o=- 8148572839875102105 2 IN IP4 127.0.0.1 s=- t=0 0 a=group:BUNDLE audio video data a=msid-semantic: WMS cb7e185b-6110-4f65-b027-ddb8b5fa78c7 m=audio 61015 UDP/TLS/RTP/SAVPF 111 103 9 102 0 8 105 13 110 113 126 c=IN IP4 1.2.3.4 a=rtcp:9 IN IP4 0.0.0.0 a=candidate:3317362580 1 udp 2113937151 192.168.0.87 61015 typ host generation 0 network-cost 999 a=candidate:3103151263 1 udp 2113939711 2a01:e0a:151:dc10:a8cb:5e93:9627:557c 61016 typ host generation 0 network-cost 999 a=candidate:842163049 1 udp 1677729535 1.2.3.4 61015 typ srflx raddr 192.168.0.87 rport 61015 generation 0 network-cost 999 a=ice-ufrag:XSmV a=ice-pwd:Ss5xY4RMFEJASRvK5TIPgLN9 a=ice-options:trickle a=fingerprint:sha-256 F2:68:A5:17:E7:85:D6:4E:23:F1:5D:02:39:9E:0F:B5:EA:C0:BD:FC:F5:27:3E:38:9B:BA:4E:AF:8B:35:AF:89 a=setup:actpass a=mid:audio a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level a=sendrecv a=rtcp-mux a=rtpmap:111 opus/48000/2 a=rtcp-fb:111 transport-cc a=fmtp:111 minptime=10;useinbandfec=1 a=rtpmap:103 ISAC/16000 a=rtpmap:9 G722/8000 a=rtpmap:102 ILBC/8000 a=rtpmap:0 PCMU/8000 a=rtpmap:8 PCMA/8000 a=rtpmap:105 CN/16000 a=rtpmap:13 CN/8000 a=rtpmap:110 telephone-event/48000 a=rtpmap:113 telephone-event/16000 a=rtpmap:126 telephone-event/8000 a=ssrc:205815247 cname:JTNiIZ6eJ7ghkHaB a=ssrc:205815247 msid:cb7e185b-6110-4f65-b027-ddb8b5fa78c7 f473166a-7fe5-4ab6-a3af-c5eb806a13b9 a=ssrc:205815247 mslabel:cb7e185b-6110-4f65-b027-ddb8b5fa78c7 a=ssrc:205815247 label:f473166a-7fe5-4ab6-a3af-c5eb806a13b9 m=video 51044 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 127 125 104 c=IN IP4 1.2.3.4 a=rtcp:9 IN IP4 0.0.0.0 a=candidate:3317362580 1 udp 2113937151 192.168.0.87 51044 typ host generation 0 network-cost 999 a=candidate:3103151263 1 udp 2113939711 2a01:e0a:151:dc10:a8cb:5e93:9627:557c 51045 typ host generation 0 network-cost 999 a=candidate:842163049 1 udp 1677729535 82.64.133.208 51044 typ srflx raddr 192.168.0.87 rport 51044 generation 0 network-cost 999 a=ice-ufrag:XSmV a=ice-pwd:Ss5xY4RMFEJASRvK5TIPgLN9 a=ice-options:trickle a=fingerprint:sha-256 F2:68:A5:17:E7:85:D6:4E:23:F1:5D:02:39:9E:0F:B5:EA:C0:BD:FC:F5:27:3E:38:9B:BA:4E:AF:8B:35:AF:89 a=setup:actpass a=mid:video a=extmap:2 urn:ietf:params:rtp-hdrext:toffset a=extmap:3 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time a=extmap:4 urn:3gpp:video-orientation a=extmap:5 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01 a=extmap:6 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay a=extmap:7 http://www.webrtc.org/experiments/rtp-hdrext/video-content-type a=extmap:8 http://www.webrtc.org/experiments/rtp-hdrext/video-timing a=extmap:10 http://tools.ietf.org/html/draft-ietf-avtext-framemarking-07 a=sendrecv a=rtcp-mux a=rtcp-rsize a=rtpmap:96 H264/90000 a=rtcp-fb:96 goog-remb a=rtcp-fb:96 transport-cc a=rtcp-fb:96 ccm fir a=rtcp-fb:96 nack a=rtcp-fb:96 nack pli a=fmtp:96 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640c1f a=rtpmap:97 rtx/90000 a=fmtp:97 apt=96 a=rtpmap:98 H264/90000 a=rtcp-fb:98 goog-remb a=rtcp-fb:98 transport-cc a=rtcp-fb:98 ccm fir a=rtcp-fb:98 nack a=rtcp-fb:98 nack pli a=fmtp:98 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f a=rtpmap:99 rtx/90000 a=fmtp:99 apt=98 a=rtpmap:100 VP8/90000 a=rtcp-fb:100 goog-remb a=rtcp-fb:100 transport-cc a=rtcp-fb:100 ccm fir a=rtcp-fb:100 nack a=rtcp-fb:100 nack pli a=rtpmap:101 rtx/90000 a=fmtp:101 apt=100 a=rtpmap:127 red/90000 a=rtpmap:125 rtx/90000 a=fmtp:125 apt=127 a=rtpmap:104 ulpfec/90000 a=ssrc-group:FID 11942296 149700150 a=ssrc:11942296 cname:JTNiIZ6eJ7ghkHaB a=ssrc:11942296 msid:cb7e185b-6110-4f65-b027-ddb8b5fa78c7 bd201f69-1364-40da-828f-cc695ff54a37 a=ssrc:11942296 mslabel:cb7e185b-6110-4f65-b027-ddb8b5fa78c7 a=ssrc:11942296 label:bd201f69-1364-40da-828f-cc695ff54a37 a=ssrc:149700150 cname:JTNiIZ6eJ7ghkHaB a=ssrc:149700150 msid:cb7e185b-6110-4f65-b027-ddb8b5fa78c7 bd201f69-1364-40da-828f-cc695ff54a37 a=ssrc:149700150 mslabel:cb7e185b-6110-4f65-b027-ddb8b5fa78c7 a=ssrc:149700150 label:bd201f69-1364-40da-828f-cc695ff54a37 m=application 60277 DTLS/SCTP 5000 c=IN IP4 1.2.3.4 a=candidate:3317362580 1 udp 2113937151 192.168.0.87 60277 typ host generation 0 network-cost 999 a=candidate:3103151263 1 udp 2113939711 2a01:e0a:151:dc10:a8cb:5e93:9627:557c 60278 typ host generation 0 network-cost 999 a=candidate:842163049 1 udp 1677729535 82.64.133.208 60277 typ srflx raddr 192.168.0.87 rport 60277 generation 0 network-cost 999 a=ice-ufrag:XSmV a=ice-pwd:Ss5xY4RMFEJASRvK5TIPgLN9 a=ice-options:trickle a=fingerprint:sha-256 F2:68:A5:17:E7:85:D6:4E:23:F1:5D:02:39:9E:0F:B5:EA:C0:BD:FC:F5:27:3E:38:9B:BA:4E:AF:8B:35:AF:89 a=setup:actpass a=mid:data a=sctpmap:5000 webrtc-datachannel 1024 """ ) ) self.assertEqual( d.group, [GroupDescription(semantic="BUNDLE", items=["audio", "video", "data"])], ) self.assertEqual( d.msid_semantic, [ GroupDescription( semantic="WMS", items=["cb7e185b-6110-4f65-b027-ddb8b5fa78c7"] ) ], ) self.assertEqual(d.host, None) self.assertEqual(d.name, "-") self.assertEqual(d.origin, "- 8148572839875102105 2 IN IP4 127.0.0.1") self.assertEqual(d.time, "0 0") self.assertEqual(d.version, 0) self.assertEqual(len(d.media), 3) self.assertEqual(d.media[0].kind, "audio") self.assertEqual(d.media[0].host, "1.2.3.4") self.assertEqual(d.media[0].port, 61015) self.assertEqual(d.media[0].profile, "UDP/TLS/RTP/SAVPF") self.assertEqual(d.media[0].direction, "sendrecv") self.assertEqual(d.media[0].msid, None) self.assertEqual(d.webrtc_track_id(d.media[0]), None) self.assertEqual(d.media[1].kind, "video") self.assertEqual(d.media[1].host, "1.2.3.4") self.assertEqual(d.media[1].port, 51044) self.assertEqual(d.media[1].profile, "UDP/TLS/RTP/SAVPF") self.assertEqual(d.media[1].direction, "sendrecv") self.assertEqual(d.media[1].msid, None) self.assertEqual(d.webrtc_track_id(d.media[0]), None) self.assertEqual(d.media[2].kind, "application") self.assertEqual(d.media[2].host, "1.2.3.4") self.assertEqual(d.media[2].port, 60277) self.assertEqual(d.media[2].profile, "DTLS/SCTP") self.assertEqual(d.media[2].direction, None) self.assertEqual(d.media[2].msid, None) aiortc-1.3.0/tests/test_utils.py000066400000000000000000000040201417604566400167200ustar00rootroot00000000000000from unittest import TestCase from aiortc.utils import ( uint16_add, uint16_gt, uint16_gte, uint32_add, uint32_gt, uint32_gte, ) class UtilsTest(TestCase): def test_uint16_add(self): self.assertEqual(uint16_add(0, 1), 1) self.assertEqual(uint16_add(1, 1), 2) self.assertEqual(uint16_add(1, 2), 3) self.assertEqual(uint16_add(65534, 1), 65535) self.assertEqual(uint16_add(65535, 1), 0) self.assertEqual(uint16_add(65535, 3), 2) def test_uint16_gt(self): self.assertFalse(uint16_gt(0, 1)) self.assertFalse(uint16_gt(1, 1)) self.assertTrue(uint16_gt(2, 1)) self.assertTrue(uint16_gt(32768, 1)) self.assertFalse(uint16_gt(32769, 1)) self.assertFalse(uint16_gt(65535, 1)) def test_uint16_gte(self): self.assertFalse(uint16_gte(0, 1)) self.assertTrue(uint16_gte(1, 1)) self.assertTrue(uint16_gte(2, 1)) self.assertTrue(uint16_gte(32768, 1)) self.assertFalse(uint16_gte(32769, 1)) self.assertFalse(uint16_gte(65535, 1)) def test_uint32_add(self): self.assertEqual(uint32_add(0, 1), 1) self.assertEqual(uint32_add(1, 1), 2) self.assertEqual(uint32_add(1, 2), 3) self.assertEqual(uint32_add(4294967294, 1), 4294967295) self.assertEqual(uint32_add(4294967295, 1), 0) self.assertEqual(uint32_add(4294967295, 3), 2) def test_uint32_gt(self): self.assertFalse(uint32_gt(0, 1)) self.assertFalse(uint32_gt(1, 1)) self.assertTrue(uint32_gt(2, 1)) self.assertTrue(uint32_gt(2147483648, 1)) self.assertFalse(uint32_gt(2147483649, 1)) self.assertFalse(uint32_gt(4294967295, 1)) def test_uint32_gte(self): self.assertFalse(uint32_gte(0, 1)) self.assertTrue(uint32_gte(1, 1)) self.assertTrue(uint32_gte(2, 1)) self.assertTrue(uint32_gte(2147483648, 1)) self.assertFalse(uint32_gte(2147483649, 1)) self.assertFalse(uint32_gte(4294967295, 1)) aiortc-1.3.0/tests/test_vpx.py000066400000000000000000000234301417604566400164030ustar00rootroot00000000000000import fractions from unittest import TestCase from aiortc.codecs import get_decoder, get_encoder from aiortc.codecs.vpx import ( Vp8Decoder, Vp8Encoder, VpxPayloadDescriptor, _vpx_assert, number_of_threads, ) from aiortc.rtcrtpparameters import RTCRtpCodecParameters from .codecs import CodecTestCase VP8_CODEC = RTCRtpCodecParameters( mimeType="video/VP8", clockRate=90000, payloadType=100 ) class VpxPayloadDescriptorTest(TestCase): def test_no_picture_id(self): descr, rest = VpxPayloadDescriptor.parse(b"\x10") self.assertEqual(descr.partition_start, 1) self.assertEqual(descr.partition_id, 0) self.assertEqual(descr.picture_id, None) self.assertEqual(descr.tl0picidx, None) self.assertEqual(descr.tid, None) self.assertEqual(descr.keyidx, None) self.assertEqual(bytes(descr), b"\x10") self.assertEqual(repr(descr), "VpxPayloadDescriptor(S=1, PID=0, pic_id=None)") self.assertEqual(rest, b"") def test_short_picture_id_17(self): """ From RFC 7741 - 4.6.3 """ descr, rest = VpxPayloadDescriptor.parse(b"\x90\x80\x11") self.assertEqual(descr.partition_start, 1) self.assertEqual(descr.partition_id, 0) self.assertEqual(descr.picture_id, 17) self.assertEqual(descr.tl0picidx, None) self.assertEqual(descr.tid, None) self.assertEqual(descr.keyidx, None) self.assertEqual(bytes(descr), b"\x90\x80\x11") self.assertEqual(repr(descr), "VpxPayloadDescriptor(S=1, PID=0, pic_id=17)") self.assertEqual(rest, b"") def test_short_picture_id_127(self): descr, rest = VpxPayloadDescriptor.parse(b"\x90\x80\x7f") self.assertEqual(descr.partition_start, 1) self.assertEqual(descr.partition_id, 0) self.assertEqual(descr.picture_id, 127) self.assertEqual(descr.tl0picidx, None) self.assertEqual(descr.tid, None) self.assertEqual(descr.keyidx, None) self.assertEqual(bytes(descr), b"\x90\x80\x7f") self.assertEqual(rest, b"") def test_long_picture_id_128(self): descr, rest = VpxPayloadDescriptor.parse(b"\x90\x80\x80\x80") self.assertEqual(descr.partition_start, 1) self.assertEqual(descr.partition_id, 0) self.assertEqual(descr.picture_id, 128) self.assertEqual(descr.tl0picidx, None) self.assertEqual(descr.tid, None) self.assertEqual(descr.keyidx, None) self.assertEqual(bytes(descr), b"\x90\x80\x80\x80") self.assertEqual(rest, b"") def test_long_picture_id_4711(self): """ From RFC 7741 - 4.6.5 """ descr, rest = VpxPayloadDescriptor.parse(b"\x90\x80\x92\x67") self.assertEqual(descr.partition_start, 1) self.assertEqual(descr.partition_id, 0) self.assertEqual(descr.picture_id, 4711) self.assertEqual(descr.tl0picidx, None) self.assertEqual(descr.tid, None) self.assertEqual(descr.keyidx, None) self.assertEqual(bytes(descr), b"\x90\x80\x92\x67") self.assertEqual(rest, b"") def test_tl0picidx(self): descr, rest = VpxPayloadDescriptor.parse(b"\x90\xc0\x92\x67\x81") self.assertEqual(descr.partition_start, 1) self.assertEqual(descr.partition_id, 0) self.assertEqual(descr.picture_id, 4711) self.assertEqual(descr.tl0picidx, 129) self.assertEqual(descr.tid, None) self.assertEqual(descr.keyidx, None) self.assertEqual(bytes(descr), b"\x90\xc0\x92\x67\x81") self.assertEqual(rest, b"") def test_tid(self): descr, rest = VpxPayloadDescriptor.parse(b"\x90\x20\xe0") self.assertEqual(descr.partition_start, 1) self.assertEqual(descr.partition_id, 0) self.assertEqual(descr.picture_id, None) self.assertEqual(descr.tl0picidx, None) self.assertEqual(descr.tid, (3, 1)) self.assertEqual(descr.keyidx, None) self.assertEqual(bytes(descr), b"\x90\x20\xe0") self.assertEqual(rest, b"") def test_keyidx(self): descr, rest = VpxPayloadDescriptor.parse(b"\x90\x10\x1f") self.assertEqual(descr.partition_start, 1) self.assertEqual(descr.partition_id, 0) self.assertEqual(descr.picture_id, None) self.assertEqual(descr.tl0picidx, None) self.assertEqual(descr.tid, None) self.assertEqual(descr.keyidx, 31) self.assertEqual(bytes(descr), b"\x90\x10\x1f") self.assertEqual(rest, b"") def test_truncated(self): with self.assertRaises(ValueError) as cm: VpxPayloadDescriptor.parse(b"") self.assertEqual(str(cm.exception), "VPX descriptor is too short") with self.assertRaises(ValueError) as cm: VpxPayloadDescriptor.parse(b"\x80") self.assertEqual( str(cm.exception), "VPX descriptor has truncated extended bits" ) with self.assertRaises(ValueError) as cm: VpxPayloadDescriptor.parse(b"\x80\x80") self.assertEqual(str(cm.exception), "VPX descriptor has truncated PictureID") with self.assertRaises(ValueError) as cm: VpxPayloadDescriptor.parse(b"\x80\x80\x80") self.assertEqual( str(cm.exception), "VPX descriptor has truncated long PictureID" ) with self.assertRaises(ValueError) as cm: VpxPayloadDescriptor.parse(b"\x80\x40") self.assertEqual(str(cm.exception), "VPX descriptor has truncated TL0PICIDX") with self.assertRaises(ValueError) as cm: VpxPayloadDescriptor.parse(b"\x80\x20") self.assertEqual(str(cm.exception), "VPX descriptor has truncated T/K") with self.assertRaises(ValueError) as cm: VpxPayloadDescriptor.parse(b"\x80\x10") self.assertEqual(str(cm.exception), "VPX descriptor has truncated T/K") class Vp8Test(CodecTestCase): def test_assert(self): with self.assertRaises(Exception) as cm: _vpx_assert(1) self.assertEqual(str(cm.exception), "libvpx error: Unspecified internal error") def test_decoder(self): decoder = get_decoder(VP8_CODEC) self.assertTrue(isinstance(decoder, Vp8Decoder)) def test_encoder(self): encoder = get_encoder(VP8_CODEC) self.assertTrue(isinstance(encoder, Vp8Encoder)) frame = self.create_video_frame(width=640, height=480, pts=0) payloads, timestamp = encoder.encode(frame) self.assertEqual(len(payloads), 1) self.assertTrue(len(payloads[0]) < 1300) self.assertEqual(timestamp, 0) # change resolution frame = self.create_video_frame(width=320, height=240, pts=3000) payloads, timestamp = encoder.encode(frame) self.assertEqual(len(payloads), 1) self.assertTrue(len(payloads[0]) < 1300) self.assertEqual(timestamp, 3000) def test_encoder_rgb(self): encoder = get_encoder(VP8_CODEC) self.assertTrue(isinstance(encoder, Vp8Encoder)) frame = self.create_video_frame(width=640, height=480, pts=0, format="rgb24") payloads, timestamp = encoder.encode(frame) self.assertEqual(len(payloads), 1) self.assertTrue(len(payloads[0]) < 1300) self.assertEqual(timestamp, 0) def test_encoder_large(self): encoder = get_encoder(VP8_CODEC) self.assertTrue(isinstance(encoder, Vp8Encoder)) # first keyframe frame = self.create_video_frame(width=2560, height=1920, pts=0) payloads, timestamp = encoder.encode(frame) self.assertEqual(len(payloads), 7) self.assertEqual(len(payloads[0]), 1300) self.assertEqual(timestamp, 0) # delta frame frame = self.create_video_frame(width=2560, height=1920, pts=3000) payloads, timestamp = encoder.encode(frame) self.assertEqual(len(payloads), 1) self.assertTrue(len(payloads[0]) < 1300) self.assertEqual(timestamp, 3000) # force keyframe frame = self.create_video_frame(width=2560, height=1920, pts=6000) payloads, timestamp = encoder.encode(frame, force_keyframe=True) self.assertEqual(len(payloads), 7) self.assertEqual(len(payloads[0]), 1300) self.assertEqual(timestamp, 6000) def test_encoder_target_bitrate(self): encoder = get_encoder(VP8_CODEC) self.assertTrue(isinstance(encoder, Vp8Encoder)) self.assertEqual(encoder.target_bitrate, 500000) frame = self.create_video_frame(width=640, height=480, pts=0) payloads, timestamp = encoder.encode(frame) self.assertEqual(len(payloads), 1) self.assertTrue(len(payloads[0]) < 1300) self.assertEqual(timestamp, 0) # change target bitrate encoder.target_bitrate = 600000 self.assertEqual(encoder.target_bitrate, 600000) frame = self.create_video_frame(width=640, height=480, pts=3000) payloads, timestamp = encoder.encode(frame) self.assertEqual(len(payloads), 1) self.assertTrue(len(payloads[0]) < 1300) self.assertEqual(timestamp, 3000) def test_number_of_threads(self): self.assertEqual(number_of_threads(1920 * 1080, 16), 8) self.assertEqual(number_of_threads(1920 * 1080, 8), 3) self.assertEqual(number_of_threads(1920 * 1080, 4), 2) self.assertEqual(number_of_threads(1920 * 1080, 2), 1) def test_roundtrip_1280_720(self): self.roundtrip_video(VP8_CODEC, 1280, 720) def test_roundtrip_960_540(self): self.roundtrip_video(VP8_CODEC, 960, 540) def test_roundtrip_640_480(self): self.roundtrip_video(VP8_CODEC, 640, 480) def test_roundtrip_640_480_time_base(self): self.roundtrip_video(VP8_CODEC, 640, 480, time_base=fractions.Fraction(1, 9000)) def test_roundtrip_320_240(self): self.roundtrip_video(VP8_CODEC, 320, 240) aiortc-1.3.0/tests/utils.py000066400000000000000000000054361417604566400156750ustar00rootroot00000000000000import asyncio import contextlib import functools import logging import os from aiortc.rtcdtlstransport import RTCCertificate, RTCDtlsTransport def lf2crlf(x): return x.replace("\n", "\r\n") class ClosedDtlsTransport: state = "closed" class DummyConnection: def __init__(self, rx_queue, tx_queue): self.closed = False self.loss_cursor = 0 self.loss_pattern = None self.rx_queue = rx_queue self.tx_queue = tx_queue async def close(self): if not self.closed: await self.rx_queue.put(None) self.closed = True async def recv(self): if self.closed: raise ConnectionError data = await self.rx_queue.get() if data is None: raise ConnectionError return data async def send(self, data): if self.closed: raise ConnectionError if self.loss_pattern is not None: lost = self.loss_pattern[self.loss_cursor] self.loss_cursor = (self.loss_cursor + 1) % len(self.loss_pattern) if lost: return await self.tx_queue.put(data) class DummyIceTransport: def __init__(self, connection, role): self._connection = connection self.role = role async def stop(self): await self._connection.close() async def _recv(self): return await self._connection.recv() async def _send(self, data): await self._connection.send(data) def asynctest(coro): @functools.wraps(coro) def wrap(*args, **kwargs): asyncio.run(coro(*args, **kwargs)) return wrap def dummy_connection_pair(): queue_a = asyncio.Queue() queue_b = asyncio.Queue() return ( DummyConnection(rx_queue=queue_a, tx_queue=queue_b), DummyConnection(rx_queue=queue_b, tx_queue=queue_a), ) def dummy_ice_transport_pair(): connection_a, connection_b = dummy_connection_pair() return ( DummyIceTransport(connection_a, "controlling"), DummyIceTransport(connection_b, "controlled"), ) @contextlib.asynccontextmanager async def dummy_dtls_transport_pair(): ice_a, ice_b = dummy_ice_transport_pair() dtls_a = RTCDtlsTransport(ice_a, [RTCCertificate.generateCertificate()]) dtls_b = RTCDtlsTransport(ice_b, [RTCCertificate.generateCertificate()]) await asyncio.gather( dtls_b.start(dtls_a.getLocalParameters()), dtls_a.start(dtls_b.getLocalParameters()), ) try: yield (dtls_a, dtls_b) finally: await dtls_a.stop() await dtls_b.stop() def load(name: str) -> bytes: path = os.path.join(os.path.dirname(__file__), name) with open(path, "rb") as fp: return fp.read() if os.environ.get("AIORTC_DEBUG"): logging.basicConfig(level=logging.DEBUG)