diff --git a/examples/.env.template b/.env.template similarity index 100% rename from examples/.env.template rename to .env.template diff --git a/.github/workflows/examples-e2e-test.yml b/.github/workflows/e2e-test.yml similarity index 90% rename from .github/workflows/examples-e2e-test.yml rename to .github/workflows/e2e-test.yml index b56fde0a..30fb7183 100644 --- a/.github/workflows/examples-e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -1,4 +1,4 @@ -name: examples-e2e-test +name: e2e-test on: push: @@ -17,7 +17,7 @@ env: TEST_SECRET_KEY: ${{ secrets.TEST_SECRET_KEY }} jobs: - e2e_ubuntu_test: + e2e_test_ubuntu: if: >- ${{ contains(github.event.head_commit.message, '[e2e]') || @@ -28,10 +28,7 @@ jobs: python_version: ["3.10", "3.11", "3.12"] os: ["ubuntu-22.04", "ubuntu-24.04"] runs-on: ${{ matrix.os}} - defaults: - run: - working-directory: ./examples - timeout-minutes: 20 + timeout-minutes: 30 steps: - uses: actions/checkout@v4 # libx11-dev は Ubuntu 24.04 の時に必要になる模様 @@ -47,12 +44,13 @@ jobs: - uses: eifinger/setup-rye@v4 - run: rye pin ${{ matrix.python_version }} - run: rye sync + - run: rye run python run.py ${{ matrix.os }}_x86_64 - run: rye run pytest tests/test_openh264.py -s - run: rye run pytest tests/test_messaging.py -s - run: rye run pytest tests/test_sendonly_recvonly.py -s - run: rye run pytest tests/test_vad.py -s - e2e_macos_test: + e2e_test_macos: if: >- ${{ contains(github.event.head_commit.message, '[e2e]') || @@ -64,10 +62,7 @@ jobs: # macos-13 は test_macos.py が上手くテストが動かないのでスキップ os: ["macos-14"] runs-on: ${{ matrix.os }} - defaults: - run: - working-directory: ./examples - timeout-minutes: 20 + timeout-minutes: 30 steps: - uses: actions/checkout@v4 - name: Download openh264 @@ -79,13 +74,14 @@ jobs: - uses: eifinger/setup-rye@v4 - run: rye pin ${{ matrix.python_version }} - run: rye sync + - run: rye run python run.py macos_arm64 - run: rye run pytest tests/test_macos.py -s - run: rye run pytest tests/test_openh264.py -s - run: rye run pytest tests/test_messaging.py -s - run: rye run pytest tests/test_sendonly_recvonly.py -s - run: rye run pytest tests/test_vad.py -s - e2e_windows_test: + e2e_test_windows: if: >- ${{ contains(github.event.head_commit.message, '[e2e]') || @@ -95,10 +91,7 @@ jobs: matrix: python_version: ["3.10", "3.11", "3.12"] runs-on: windows-2022 - defaults: - run: - working-directory: ./examples - timeout-minutes: 20 + timeout-minutes: 30 env: # Python を強制的に UTF-8 で利用するおまじない PYTHONUTF8: 1 @@ -115,12 +108,13 @@ jobs: - uses: eifinger/setup-rye@v4 - run: rye pin ${{ matrix.python_version }} - run: rye sync + - run: rye run python run.py windows_x86_64 - run: rye run pytest tests/test_messaging.py -s - run: rye run pytest tests/test_sendonly_recvonly.py -s - run: rye run pytest tests/test_vad.py -s slack_notify_succeeded: - needs: [e2e_ubuntu_test, e2e_macos_test, e2e_windows_test] + needs: [e2e_test_ubuntu, e2e_test_macos, e2e_test_windows] runs-on: ubuntu-latest if: success() steps: @@ -134,7 +128,7 @@ jobs: SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} slack_notify_failed: - needs: [e2e_ubuntu_test, e2e_macos_test, e2e_windows_test] + needs: [e2e_test_ubuntu, e2e_test_macos, e2e_test_windows] runs-on: ubuntu-latest if: failure() steps: diff --git a/.gitignore b/.gitignore index a1e34b44..aeb95d76 100644 --- a/.gitignore +++ b/.gitignore @@ -2,12 +2,15 @@ /_build /_source /_package -__pycache__/ +__pycache__ .pytest_cache/ .venv/ *.egg-info/ /_skbuild build +.ruff_cache +.mypy_cache +*.pyc src/sora_sdk/*.so src/sora_sdk/*.dll @@ -19,9 +22,9 @@ src/sora_sdk/py.typed /dist /wheelhouse -# E2E テスト用 -.env - +# examples / E2E テスト用 +.env* +!.env.template # libopenh264 -libopenh264* \ No newline at end of file +libopenh264* diff --git a/examples/.gitignore b/examples/.gitignore deleted file mode 100644 index 929da49b..00000000 --- a/examples/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -.venv -.ruff_cache -.mypy_cache -*.pyc -__pycache__ -.env* -!.env.template \ No newline at end of file diff --git a/examples/.python-version b/examples/.python-version deleted file mode 100644 index 9919bf8c..00000000 --- a/examples/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.10.13 diff --git a/examples/README.md b/examples/README.md index d2070810..11f3b249 100644 --- a/examples/README.md +++ b/examples/README.md @@ -12,15 +12,6 @@ rye sync ``` -## サンプルの種類 - -- media_sendonly -- media_recvonly -- messaging_sendrecv -- messaging_sendonly -- messaging_recvonly -- hideface_sender - ## サンプルコードの実行 `.env.template` をコピーして `.env` に必要な変数を設定してください。 @@ -29,8 +20,8 @@ rye sync cp .env.template .env ``` -例えば `media_recvonly` サンプルを実行する場合は以下のコマンドを実行してください。 +例えば `media_sendonly.py` を実行する場合は以下のコマンドを実行してください。 ```bash -rye run media_recvonly +rye run python3 src/media_sendonly.py ``` diff --git a/examples/pyproject.toml b/examples/pyproject.toml index f68e189a..edb83eba 100644 --- a/examples/pyproject.toml +++ b/examples/pyproject.toml @@ -7,36 +7,32 @@ dependencies = [ "opencv-python~=4.10.0.84", "opencv-python-headless~=4.10.0.84", "sounddevice~=0.4.7", - "sora-sdk>=2024.4.0.dev0", + "sora-sdk>=2024.3.0", "mediapipe~=0.10.14", "python-dotenv>=1.0.1", "numpy>=2.0.1", + "pillow>=10.4.0", ] readme = "README.md" requires-python = ">= 3.10" -[project.scripts] -media_sendonly = "media.sendonly:sendonly" -media_recvonly = "media.recvonly:recvonly" -media_vad = "media.vad:vad" -messaging_sendrecv = "messaging.sendrecv:sendrecv" -messaging_sendonly = "messaging.sendonly:sendonly" -messaging_recvonly = "messaging.recvonly:recvonly" -hideface_sender = "ml.hideface_sender:hideface_sender" - [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.rye] managed = true -dev-dependencies = ["ruff>=0.6", "mypy>=1.10.0", "pytest>=8.3"] +dev-dependencies = [ + "ruff>=0.6", + "mypy>=1.11.2", + "pytest>=8.3", +] [tool.hatch.metadata] allow-direct-references = true [tool.hatch.build.targets.wheel] -packages = ["src/media", "src/messaging", "src/ml"] +packages = ["src"] [tool.ruff] target-version = "py310" diff --git a/examples/requirements-dev.lock b/examples/requirements-dev.lock index 0113cb38..cf340c54 100644 --- a/examples/requirements-dev.lock +++ b/examples/requirements-dev.lock @@ -14,9 +14,9 @@ absl-py==2.1.0 # via mediapipe attrs==24.2.0 # via mediapipe -cffi==1.17.0 +cffi==1.17.1 # via sounddevice -contourpy==1.2.1 +contourpy==1.3.0 # via matplotlib cycler==0.12.1 # via matplotlib @@ -33,19 +33,19 @@ jax==0.4.31 jaxlib==0.4.31 # via jax # via mediapipe -kiwisolver==1.4.5 +kiwisolver==1.4.7 # via matplotlib matplotlib==3.9.2 # via mediapipe mediapipe==0.10.14 # via sora-sdk-samples -ml-dtypes==0.4.0 +ml-dtypes==0.5.0 # via jax # via jaxlib -mypy==1.11.1 +mypy==1.11.2 mypy-extensions==1.0.0 # via mypy -numpy==2.1.0 +numpy==2.1.1 # via contourpy # via jax # via jaxlib @@ -71,21 +71,22 @@ packaging==24.1 # via pytest pillow==10.4.0 # via matplotlib + # via sora-sdk-samples pluggy==1.5.0 # via pytest protobuf==4.25.4 # via mediapipe pycparser==2.22 # via cffi -pyparsing==3.1.2 +pyparsing==3.1.4 # via matplotlib -pytest==8.3.2 +pytest==8.3.3 python-dateutil==2.9.0.post0 # via matplotlib python-dotenv==1.0.1 # via sora-sdk-samples -ruff==0.6.1 -scipy==1.14.0 +ruff==0.6.5 +scipy==1.14.1 # via jax # via jaxlib six==1.16.0 diff --git a/examples/requirements.lock b/examples/requirements.lock index 4ab49541..67c7c53a 100644 --- a/examples/requirements.lock +++ b/examples/requirements.lock @@ -14,9 +14,9 @@ absl-py==2.1.0 # via mediapipe attrs==24.2.0 # via mediapipe -cffi==1.17.0 +cffi==1.17.1 # via sounddevice -contourpy==1.2.1 +contourpy==1.3.0 # via matplotlib cycler==0.12.1 # via matplotlib @@ -29,16 +29,16 @@ jax==0.4.31 jaxlib==0.4.31 # via jax # via mediapipe -kiwisolver==1.4.5 +kiwisolver==1.4.7 # via matplotlib matplotlib==3.9.2 # via mediapipe mediapipe==0.10.14 # via sora-sdk-samples -ml-dtypes==0.4.0 +ml-dtypes==0.5.0 # via jax # via jaxlib -numpy==2.1.0 +numpy==2.1.1 # via contourpy # via jax # via jaxlib @@ -63,17 +63,18 @@ packaging==24.1 # via matplotlib pillow==10.4.0 # via matplotlib + # via sora-sdk-samples protobuf==4.25.4 # via mediapipe pycparser==2.22 # via cffi -pyparsing==3.1.2 +pyparsing==3.1.4 # via matplotlib python-dateutil==2.9.0.post0 # via matplotlib python-dotenv==1.0.1 # via sora-sdk-samples -scipy==1.14.0 +scipy==1.14.1 # via jax # via jaxlib six==1.16.0 diff --git a/examples/src/__init__.py b/examples/src/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/examples/src/ml/hideface_sender.py b/examples/src/hideface_sender.py similarity index 96% rename from examples/src/ml/hideface_sender.py rename to examples/src/hideface_sender.py index 599c2d4f..821ab6c4 100644 --- a/examples/src/ml/hideface_sender.py +++ b/examples/src/hideface_sender.py @@ -6,10 +6,10 @@ from threading import Event from typing import Any, Optional -import cv2 -import mediapipe as mp +import cv2 # type: ignore +import mediapipe as mp # type: ignore import numpy as np -from cv2.typing import MatLike +from cv2.typing import MatLike # type: ignore from dotenv import load_dotenv from PIL import Image from sora_sdk import Sora, SoraSignalingErrorCode, SoraVideoSource @@ -46,7 +46,7 @@ def __init__( :param video_fps: ビデオのフレームレート :param video_fourcc: ビデオの FOURCC コード """ - self.mp_face_detection = mp.solutions.face_detection + self.mp_face_detection = mp.solutions.face_detection # type: ignore self._sora = Sora(openh264=None) self._video_source: SoraVideoSource = self._sora.create_video_source() @@ -192,7 +192,10 @@ def run(self) -> None: self._video_capture.release() def run_one_frame( - self, face_detection: mp.solutions.face_detection.FaceDetection, angle: int, frame: MatLike + self, + face_detection: mp.solutions.face_detection.FaceDetection, # type: ignore + angle: int, + frame: MatLike, # type: ignore ) -> int: """ 1フレームの処理を行います。 @@ -283,7 +286,7 @@ def hideface_sender() -> None: video_fps = int(os.getenv("SORA_VIDEO_FPS", "30")) video_fourcc = os.getenv("SORA_VIDEO_FOURCC", "MJPG") - camera_id = int(os.getenv("SORA_CAMERA_ID", "0")) + camera_id = int(os.getenv("SORA_CAMERA_ID", "1")) streamer = LogoStreamer( signaling_urls=signaling_urls, diff --git a/examples/src/media/recvonly.py b/examples/src/media/recvonly.py deleted file mode 100644 index ec08b309..00000000 --- a/examples/src/media/recvonly.py +++ /dev/null @@ -1,38 +0,0 @@ -import json -import os - -from dotenv import load_dotenv -from media import Recvonly - - -def recvonly() -> None: - """ - 環境変数を使用して Recvonly インスタンスを設定し実行します。 - - :raises ValueError: 必要な環境変数が設定されていない場合 - """ - load_dotenv() - - if not (raw_signaling_urls := os.getenv("SORA_SIGNALING_URLS")): - raise ValueError("環境変数 SORA_SIGNALING_URLS が設定されていません") - signaling_urls = raw_signaling_urls.split(",") - - if not (channel_id := os.getenv("SORA_CHANNEL_ID")): - raise ValueError("環境変数 SORA_CHANNEL_ID が設定されていません") - - metadata = None - if raw_metadata := os.getenv("SORA_METADATA"): - metadata = json.loads(raw_metadata) - - openh264_path = os.getenv("OPENH264_PATH") - - use_hwa = bool(os.getenv("USE_HWA", "True")) - - recvonly = Recvonly( - signaling_urls, channel_id, metadata=metadata, openh264_path=openh264_path, use_hwa=use_hwa - ) - recvonly.run() - - -if __name__ == "__main__": - recvonly() diff --git a/examples/src/media/sendonly.py b/examples/src/media/sendonly.py deleted file mode 100644 index 63f16530..00000000 --- a/examples/src/media/sendonly.py +++ /dev/null @@ -1,112 +0,0 @@ -import json -import os -import platform - -import cv2 -from dotenv import load_dotenv -from media import Sendonly - - -def get_video_capture( - camera_id: int, - video_width: int, - video_height: int, - video_fps: int, - video_fourcc: str, -) -> cv2.VideoCapture: - """ - ビデオキャプチャの設定を行います。 - - :param camera_id: 使用するカメラの ID - :param video_width: ビデオの幅 - :param video_height: ビデオの高さ - :param video_fps: ビデオのフレームレート - :param video_fourcc: ビデオの FOURCC コード - """ - - if platform.system() == "Windows": - # CAP_DSHOW を設定しないと、カメラの起動がめちゃめちゃ遅くなる - video_capture = cv2.VideoCapture(camera_id, cv2.CAP_DSHOW) - else: - video_capture = cv2.VideoCapture(camera_id) - - if video_width is not None: - video_capture.set(cv2.CAP_PROP_FRAME_WIDTH, video_width) - if video_height is not None: - video_capture.set(cv2.CAP_PROP_FRAME_HEIGHT, video_height) - if video_fourcc is not None: - video_capture.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*video_fourcc)) - if video_fps is not None: - video_capture.set(cv2.CAP_PROP_FPS, video_fps) - - # Ubuntu → FOURCC を設定すると FPS が初期化される - # Windows → FPS を設定すると FOURCC が初期化される - # ので、両方に対応するため2回設定する - if video_fourcc is not None: - fourcc = cv2.VideoWriter_fourcc(*video_fourcc) - target_fourcc = video_capture.get(cv2.CAP_PROP_FOURCC) - if fourcc != target_fourcc: - video_capture.set(cv2.CAP_PROP_FOURCC, fourcc) - if video_fps is not None: - if video_fps != int(video_capture.get(cv2.CAP_PROP_FPS)): - video_capture.set(cv2.CAP_PROP_FPS, video_fps) - - return video_capture - - -def sendonly() -> None: - """ - 環境変数を使用して Sendonly インスタンスを設定し実行します。 - - :raises ValueError: 必要な環境変数が設定されていない場合 - """ - load_dotenv() - - if not (raw_signaling_urls := os.getenv("SORA_SIGNALING_URLS")): - raise ValueError("環境変数 SORA_SIGNALING_URLS が設定されていません") - signaling_urls = raw_signaling_urls.split(",") - - if not (channel_id := os.getenv("SORA_CHANNEL_ID")): - raise ValueError("環境変数 SORA_CHANNEL_ID が設定されていません") - - metadata = None - if raw_metadata := os.getenv("SORA_METADATA"): - metadata = json.loads(raw_metadata) - - video_codec_type = os.getenv("SORA_VIDEO_CODEC_TYPE", "VP9") - video_bit_rate = int(os.getenv("SORA_VIDEO_BIT_RATE", "500")) - video_width = int(os.getenv("SORA_VIDEO_WIDTH", "640")) - video_height = int(os.getenv("SORA_VIDEO_HEIGHT", "360")) - video_fps = int(os.getenv("SORA_VIDEO_FPS", "30")) - video_fourcc = os.getenv("SORA_VIDEO_FOURCC", "MJPG") - - camera_id = int(os.getenv("SORA_CAMERA_ID", "0")) - - # OpenCV を利用したビデオキャプチャの設定 - video_capture = get_video_capture( - camera_id=camera_id, - video_width=video_width, - video_height=video_height, - video_fps=video_fps, - video_fourcc=video_fourcc, - ) - - openh264_path = os.getenv("OPENH264_PATH") - - use_hwa = bool(os.getenv("USE_HWA", "True")) - - sendonly = Sendonly( - signaling_urls, - channel_id, - metadata=metadata, - video_codec_type=video_codec_type, - video_bit_rate=video_bit_rate, - openh264_path=openh264_path, - use_hwa=use_hwa, - video_capture=video_capture, - ) - sendonly.run() - - -if __name__ == "__main__": - sendonly() diff --git a/examples/src/media_recvonly.py b/examples/src/media_recvonly.py new file mode 100644 index 00000000..03b64ac0 --- /dev/null +++ b/examples/src/media_recvonly.py @@ -0,0 +1,252 @@ +import json +import os +import queue +from threading import Event +from typing import Any, Optional + +import cv2 # type: ignore +import sounddevice # type: ignore +from dotenv import load_dotenv +from numpy import ndarray +from sora_sdk import ( + Sora, + SoraAudioSink, + SoraConnection, + SoraMediaTrack, + SoraSignalingErrorCode, + SoraVideoFrame, + SoraVideoSink, +) + + +class Recvonly: + """Sora からビデオと音声ストリームを受信するためのクラス。""" + + def __init__( + self, + signaling_urls: list[str], + channel_id: str, + metadata: Optional[dict[str, Any]] = None, + data_channel_signaling: Optional[bool] = None, + openh264_path: Optional[str] = None, + use_hwa: Optional[bool] = False, + output_frequency: int = 16000, + output_channels: int = 1, + ): + """ + Recvonly インスタンスを初期化します。 + + このクラスは Sora への接続を設定し、音声とビデオトラックを受信し、 + ビデオフレームの表示と音声の再生を行うメソッドを提供します。 + + :param signaling_urls: Sora シグナリング URL のリスト + :param channel_id: 接続するチャンネル ID + :param metadata: 接続のためのオプションのメタデータ + :param openh264: OpenH264 ライブラリへのパス + :param output_frequency: 音声出力周波数(Hz)、デフォルトは 16000 + :param output_channels: 音声出力チャンネル数、デフォルトは 1 + """ + self._signaling_urls: list[str] = signaling_urls + self._channel_id: str = channel_id + + self._output_frequency: int = output_frequency + self._output_channels: int = output_channels + + self._sora: Sora = Sora(openh264=openh264_path, use_hardware_encoder=use_hwa) + self._connection: SoraConnection = self._sora.create_connection( + signaling_urls=signaling_urls, + role="recvonly", + channel_id=channel_id, + metadata=metadata, + data_channel_signaling=data_channel_signaling, + ) + self._connection_id: Optional[str] = None + + self._connected: Event = Event() + self._switched: bool = False + self._closed: Event = Event() + self._default_connection_timeout_s: float = 10.0 + + self._audio_sink: Optional[SoraAudioSink] = None + self._video_sink: Optional[SoraVideoSink] = None + + self._q_out: queue.Queue = queue.Queue() + + self._connection.on_set_offer = self._on_set_offer + self._connection.on_switched = self._on_switched + self._connection.on_notify = self._on_notify + self._connection.on_disconnect = self._on_disconnect + self._connection.on_track = self._on_track + + def connect(self) -> None: + """ + Sora への接続を確立します。 + + :raises AssertionError: タイムアウト期間内に接続が確立できなかった場合 + """ + self._connection.connect() + + assert self._connected.wait( + self._default_connection_timeout_s + ), "Could not connect to Sora." + + def disconnect(self) -> None: + """Sora から切断します。""" + self._connection.disconnect() + + def get_stats(self): + raw_stats = self._connection.get_stats() + return json.loads(raw_stats) + + @property + def connected(self) -> bool: + return self._connected.is_set() + + @property + def switched(self) -> bool: + """データチャネルシグナリングへの切り替えが完了しているかどうかを示すブール値。""" + return self._switched + + @property + def closed(self): + """接続が閉じられているかどうかを示すブール値。""" + return self._closed.is_set() + + def _on_set_offer(self, raw_message: str) -> None: + """ + オファー設定イベントを処理します。 + + :param raw_message: オファーを含む生のメッセージ + """ + message: dict[str, Any] = json.loads(raw_message) + if message["type"] == "offer": + self._connection_id = message["connection_id"] + + def _on_switched(self, raw_message: str) -> None: + message = json.loads(raw_message) + if message["type"] == "switched": + print(f"Switched to DataChannel Signaling: connection_id={self._connection_id}") + self._switched = True + + def _on_notify(self, raw_message: str) -> None: + """ + Sora からの通知イベントを処理します。 + + :param raw_message: 生の通知メッセージ + """ + message: dict[str, Any] = json.loads(raw_message) + if ( + message["type"] == "notify" + and message["event_type"] == "connection.created" + and message["connection_id"] == self._connection_id + ): + print(f"Connected Sora: connection_id={self._connection_id}") + self._connected.set() + + def _on_disconnect(self, error_code: SoraSignalingErrorCode, message: str) -> None: + """ + 切断イベントを処理します。 + + :param error_code: 切断のエラーコード + :param message: 切断メッセージ + """ + print(f"Disconnected Sora: error_code='{error_code}' message='{message}'") + self._connected.clear() + self._closed.is_set() + + def _on_video_frame(self, frame: SoraVideoFrame) -> None: + """ + 受信したビデオフレームを処理します。 + + :param frame: 受信したビデオフレーム + """ + self._q_out.put(frame) + + def _on_track(self, track: SoraMediaTrack) -> None: + """ + 新しいメディアトラックを処理します。 + + :param track: 新しいメディアトラック + """ + if track.kind == "audio": + self._audio_sink = SoraAudioSink(track, self._output_frequency, self._output_channels) + if track.kind == "video": + self._video_sink = SoraVideoSink(track) + self._video_sink.on_frame = self._on_video_frame + + def _callback( + self, outdata: ndarray, frames: int, time: Any, status: sounddevice.CallbackFlags + ) -> None: + """ + 音声出力のためのコールバック関数。 + + :param outdata: 音声データを格納する出力バッファ + :param frames: 処理するフレーム数 + :param time: タイミング情報(未使用) + :param status: ストリームのステータス + """ + if self._audio_sink is not None: + success, data = self._audio_sink.read(frames) + if success: + if data.shape[0] != frames: + print("Audio data is insufficient: ", data.shape, frames) + outdata[:] = data + else: + print("Unable to obtain audio data") + + def run(self) -> None: + """ビデオフレームの受信と表示、および音声の再生を行うメインループ。""" + with sounddevice.OutputStream( + channels=self._output_channels, + callback=self._callback, + samplerate=self._output_frequency, + dtype="int16", + ): + self.connect() + try: + while self._connected.is_set(): + try: + frame = self._q_out.get(timeout=1) + except queue.Empty: + continue + cv2.imshow("frame", frame.data()) + if cv2.waitKey(1) & 0xFF == ord("q"): + break + except KeyboardInterrupt: + pass + finally: + self.disconnect() + cv2.destroyAllWindows() + + +def recvonly() -> None: + """ + 環境変数を使用して Recvonly インスタンスを設定し実行します。 + + :raises ValueError: 必要な環境変数が設定されていない場合 + """ + load_dotenv() + + if not (raw_signaling_urls := os.getenv("SORA_SIGNALING_URLS")): + raise ValueError("環境変数 SORA_SIGNALING_URLS が設定されていません") + signaling_urls = raw_signaling_urls.split(",") + + if not (channel_id := os.getenv("SORA_CHANNEL_ID")): + raise ValueError("環境変数 SORA_CHANNEL_ID が設定されていません") + + metadata = None + if raw_metadata := os.getenv("SORA_METADATA"): + metadata = json.loads(raw_metadata) + + openh264_path = os.getenv("OPENH264_PATH") + + use_hwa = bool(os.getenv("USE_HWA", "True")) + + recvonly = Recvonly( + signaling_urls, channel_id, metadata=metadata, openh264_path=openh264_path, use_hwa=use_hwa + ) + recvonly.run() + + +if __name__ == "__main__": + recvonly() diff --git a/examples/src/media/__init__.py b/examples/src/media_sendonly.py similarity index 59% rename from examples/src/media/__init__.py rename to examples/src/media_sendonly.py index db9154d4..17495e0e 100644 --- a/examples/src/media/__init__.py +++ b/examples/src/media_sendonly.py @@ -1,24 +1,22 @@ import json -import queue +import os +import platform import threading import time from threading import Event from typing import Any, Optional -import cv2 +import cv2 # type: ignore import numpy -import sounddevice +import sounddevice # type: ignore +from dotenv import load_dotenv from numpy import ndarray from sora_sdk import ( Sora, - SoraAudioSink, SoraConnection, - SoraMediaTrack, SoraSignalingDirection, SoraSignalingErrorCode, SoraSignalingType, - SoraVideoFrame, - SoraVideoSink, ) @@ -308,201 +306,106 @@ def run(self) -> None: self._video_capture.release() -class Recvonly: - """Sora からビデオと音声ストリームを受信するためのクラス。""" - - def __init__( - self, - signaling_urls: list[str], - channel_id: str, - metadata: Optional[dict[str, Any]] = None, - data_channel_signaling: Optional[bool] = None, - openh264_path: Optional[str] = None, - use_hwa: Optional[bool] = False, - output_frequency: int = 16000, - output_channels: int = 1, - ): - """ - Recvonly インスタンスを初期化します。 - - このクラスは Sora への接続を設定し、音声とビデオトラックを受信し、 - ビデオフレームの表示と音声の再生を行うメソッドを提供します。 - - :param signaling_urls: Sora シグナリング URL のリスト - :param channel_id: 接続するチャンネル ID - :param metadata: 接続のためのオプションのメタデータ - :param openh264: OpenH264 ライブラリへのパス - :param output_frequency: 音声出力周波数(Hz)、デフォルトは 16000 - :param output_channels: 音声出力チャンネル数、デフォルトは 1 - """ - self._signaling_urls: list[str] = signaling_urls - self._channel_id: str = channel_id - - self._output_frequency: int = output_frequency - self._output_channels: int = output_channels - - self._sora: Sora = Sora(openh264=openh264_path, use_hardware_encoder=use_hwa) - self._connection: SoraConnection = self._sora.create_connection( - signaling_urls=signaling_urls, - role="recvonly", - channel_id=channel_id, - metadata=metadata, - data_channel_signaling=data_channel_signaling, - ) - self._connection_id: Optional[str] = None - - self._connected: Event = Event() - self._switched: bool = False - self._closed: Event = Event() - self._default_connection_timeout_s: float = 10.0 - - self._audio_sink: Optional[SoraAudioSink] = None - self._video_sink: Optional[SoraVideoSink] = None - - self._q_out: queue.Queue = queue.Queue() - - self._connection.on_set_offer = self._on_set_offer - self._connection.on_switched = self._on_switched - self._connection.on_notify = self._on_notify - self._connection.on_disconnect = self._on_disconnect - self._connection.on_track = self._on_track - - def connect(self) -> None: - """ - Sora への接続を確立します。 - - :raises AssertionError: タイムアウト期間内に接続が確立できなかった場合 - """ - self._connection.connect() - - assert self._connected.wait( - self._default_connection_timeout_s - ), "Could not connect to Sora." - - def disconnect(self) -> None: - """Sora から切断します。""" - self._connection.disconnect() - - def get_stats(self): - raw_stats = self._connection.get_stats() - return json.loads(raw_stats) - - @property - def connected(self) -> bool: - return self._connected.is_set() - - @property - def switched(self) -> bool: - """データチャネルシグナリングへの切り替えが完了しているかどうかを示すブール値。""" - return self._switched - - @property - def closed(self): - """接続が閉じられているかどうかを示すブール値。""" - return self._closed.is_set() - - def _on_set_offer(self, raw_message: str) -> None: - """ - オファー設定イベントを処理します。 - - :param raw_message: オファーを含む生のメッセージ - """ - message: dict[str, Any] = json.loads(raw_message) - if message["type"] == "offer": - self._connection_id = message["connection_id"] - - def _on_switched(self, raw_message: str) -> None: - message = json.loads(raw_message) - if message["type"] == "switched": - print(f"Switched to DataChannel Signaling: connection_id={self._connection_id}") - self._switched = True - - def _on_notify(self, raw_message: str) -> None: - """ - Sora からの通知イベントを処理します。 - - :param raw_message: 生の通知メッセージ - """ - message: dict[str, Any] = json.loads(raw_message) - if ( - message["type"] == "notify" - and message["event_type"] == "connection.created" - and message["connection_id"] == self._connection_id - ): - print(f"Connected Sora: connection_id={self._connection_id}") - self._connected.set() - - def _on_disconnect(self, error_code: SoraSignalingErrorCode, message: str) -> None: - """ - 切断イベントを処理します。 - - :param error_code: 切断のエラーコード - :param message: 切断メッセージ - """ - print(f"Disconnected Sora: error_code='{error_code}' message='{message}'") - self._connected.clear() - self._closed.is_set() - - def _on_video_frame(self, frame: SoraVideoFrame) -> None: - """ - 受信したビデオフレームを処理します。 - - :param frame: 受信したビデオフレーム - """ - self._q_out.put(frame) - - def _on_track(self, track: SoraMediaTrack) -> None: - """ - 新しいメディアトラックを処理します。 +def get_video_capture( + camera_id: int, + video_width: int, + video_height: int, + video_fps: int, + video_fourcc: str, +) -> cv2.VideoCapture: + """ + ビデオキャプチャの設定を行います。 - :param track: 新しいメディアトラック - """ - if track.kind == "audio": - self._audio_sink = SoraAudioSink(track, self._output_frequency, self._output_channels) - if track.kind == "video": - self._video_sink = SoraVideoSink(track) - self._video_sink.on_frame = self._on_video_frame - - def _callback( - self, outdata: ndarray, frames: int, time: Any, status: sounddevice.CallbackFlags - ) -> None: - """ - 音声出力のためのコールバック関数。 + :param camera_id: 使用するカメラの ID + :param video_width: ビデオの幅 + :param video_height: ビデオの高さ + :param video_fps: ビデオのフレームレート + :param video_fourcc: ビデオの FOURCC コード + """ - :param outdata: 音声データを格納する出力バッファ - :param frames: 処理するフレーム数 - :param time: タイミング情報(未使用) - :param status: ストリームのステータス - """ - if self._audio_sink is not None: - success, data = self._audio_sink.read(frames) - if success: - if data.shape[0] != frames: - print("Audio data is insufficient: ", data.shape, frames) - outdata[:] = data - else: - print("Unable to obtain audio data") + if platform.system() == "Windows": + # CAP_DSHOW を設定しないと、カメラの起動がめちゃめちゃ遅くなる + video_capture = cv2.VideoCapture(camera_id, cv2.CAP_DSHOW) + else: + video_capture = cv2.VideoCapture(camera_id) + + if video_width is not None: + video_capture.set(cv2.CAP_PROP_FRAME_WIDTH, video_width) + if video_height is not None: + video_capture.set(cv2.CAP_PROP_FRAME_HEIGHT, video_height) + if video_fourcc is not None: + video_capture.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*video_fourcc)) + if video_fps is not None: + video_capture.set(cv2.CAP_PROP_FPS, video_fps) + + # Ubuntu → FOURCC を設定すると FPS が初期化される + # Windows → FPS を設定すると FOURCC が初期化される + # ので、両方に対応するため2回設定する + if video_fourcc is not None: + fourcc = cv2.VideoWriter_fourcc(*video_fourcc) + target_fourcc = video_capture.get(cv2.CAP_PROP_FOURCC) + if fourcc != target_fourcc: + video_capture.set(cv2.CAP_PROP_FOURCC, fourcc) + if video_fps is not None: + if video_fps != int(video_capture.get(cv2.CAP_PROP_FPS)): + video_capture.set(cv2.CAP_PROP_FPS, video_fps) + + return video_capture + + +def sendonly() -> None: + """ + 環境変数を使用して Sendonly インスタンスを設定し実行します。 - def run(self) -> None: - """ビデオフレームの受信と表示、および音声の再生を行うメインループ。""" - with sounddevice.OutputStream( - channels=self._output_channels, - callback=self._callback, - samplerate=self._output_frequency, - dtype="int16", - ): - self.connect() - try: - while self._connected.is_set(): - try: - frame = self._q_out.get(timeout=1) - except queue.Empty: - continue - cv2.imshow("frame", frame.data()) - if cv2.waitKey(1) & 0xFF == ord("q"): - break - except KeyboardInterrupt: - pass - finally: - self.disconnect() - cv2.destroyAllWindows() + :raises ValueError: 必要な環境変数が設定されていない場合 + """ + load_dotenv() + + if not (raw_signaling_urls := os.getenv("SORA_SIGNALING_URLS")): + raise ValueError("環境変数 SORA_SIGNALING_URLS が設定されていません") + signaling_urls = raw_signaling_urls.split(",") + + if not (channel_id := os.getenv("SORA_CHANNEL_ID")): + raise ValueError("環境変数 SORA_CHANNEL_ID が設定されていません") + + metadata = None + if raw_metadata := os.getenv("SORA_METADATA"): + metadata = json.loads(raw_metadata) + + video_codec_type = os.getenv("SORA_VIDEO_CODEC_TYPE", "VP9") + video_bit_rate = int(os.getenv("SORA_VIDEO_BIT_RATE", "500")) + video_width = int(os.getenv("SORA_VIDEO_WIDTH", "640")) + video_height = int(os.getenv("SORA_VIDEO_HEIGHT", "360")) + video_fps = int(os.getenv("SORA_VIDEO_FPS", "30")) + video_fourcc = os.getenv("SORA_VIDEO_FOURCC", "MJPG") + + camera_id = int(os.getenv("SORA_CAMERA_ID", "0")) + + # OpenCV を利用したビデオキャプチャの設定 + video_capture = get_video_capture( + camera_id=camera_id, + video_width=video_width, + video_height=video_height, + video_fps=video_fps, + video_fourcc=video_fourcc, + ) + + openh264_path = os.getenv("OPENH264_PATH") + + use_hwa = bool(os.getenv("USE_HWA", "True")) + + sendonly = Sendonly( + signaling_urls, + channel_id, + metadata=metadata, + video_codec_type=video_codec_type, + video_bit_rate=video_bit_rate, + openh264_path=openh264_path, + use_hwa=use_hwa, + video_capture=video_capture, + ) + sendonly.run() + + +if __name__ == "__main__": + sendonly() diff --git a/examples/src/messaging/__init__.py b/examples/src/messaging.py similarity index 82% rename from examples/src/messaging/__init__.py rename to examples/src/messaging.py index 88320451..2c04b26e 100644 --- a/examples/src/messaging/__init__.py +++ b/examples/src/messaging.py @@ -1,9 +1,11 @@ import json +import os import random import time from threading import Event from typing import Any, Optional +from dotenv import load_dotenv from sora_sdk import Sora, SoraConnection, SoraSignalingErrorCode @@ -180,3 +182,43 @@ def _on_data_channel(self, label: str): # データチャネルの準備ができたのでフラグを立てる self._is_data_channel_ready = True break + + +def sendrecv(): + # .env ファイルを読み込む + load_dotenv() + + # 必須引数 + if not (raw_signaling_urls := os.getenv("SORA_SIGNALING_URLS")): + raise ValueError("環境変数 SORA_SIGNALING_URLS が設定されていません") + signaling_urls = raw_signaling_urls.split(",") + + if not (channel_id := os.getenv("SORA_CHANNEL_ID")): + raise ValueError("環境変数 SORA_CHANNEL_ID が設定されていません") + + if not (messaging_label := os.getenv("SORA_MESSAGING_LABEL")): + raise ValueError("環境変数 SORA_MESSAGING_LABEL が設定されていません") + + # オプション引数 + metadata = None + if raw_metadata := os.getenv("SORA_METADATA"): + metadata = json.loads(raw_metadata) + + data_channels = [{"label": messaging_label, "direction": "sendrecv"}] + messaging_sendrecv = Messaging(signaling_urls, channel_id, data_channels, metadata) + + # Sora に接続する + messaging_sendrecv.connect() + try: + while not messaging_sendrecv.closed: + # input で入力された文字列を utf-8 でエンコードして送信 + message = input() + messaging_sendrecv.send(message.encode("utf-8")) + except KeyboardInterrupt: + pass + finally: + messaging_sendrecv.disconnect() + + +if __name__ == "__main__": + sendrecv() diff --git a/examples/src/messaging/recvonly.py b/examples/src/messaging/recvonly.py deleted file mode 100644 index 3163fe07..00000000 --- a/examples/src/messaging/recvonly.py +++ /dev/null @@ -1,52 +0,0 @@ -import json -import os -import time - -from dotenv import load_dotenv - -from messaging import Messaging - - -def recvonly(): - # .env ファイルを読み込む - load_dotenv() - - # 必須引数 - if not (raw_signaling_urls := os.getenv("SORA_SIGNALING_URLS")): - raise ValueError("環境変数 SORA_SIGNALING_URLS が設定されていません") - signaling_urls = raw_signaling_urls.split(",") - - if not (channel_id := os.getenv("SORA_CHANNEL_ID")): - raise ValueError("環境変数 SORA_CHANNEL_ID が設定されていません") - - if not (messaging_label := os.getenv("SORA_MESSAGING_LABEL")): - raise ValueError("環境変数 SORA_MESSAGING_LABEL が設定されていません") - - # オプション引数 - metadata = None - if raw_metadata := os.getenv("SORA_METADATA"): - metadata = json.loads(raw_metadata) - - data_channels = [{"label": messaging_label, "direction": "recvonly"}] - messaging_recvonly = Messaging( - signaling_urls, - channel_id, - data_channels, - metadata, - ) - - # Sora に接続する - messaging_recvonly.connect() - try: - # Ctrl+C が押される or 切断されるまでメッセージ受信を待機 - while not messaging_recvonly.closed: - time.sleep(0.01) - except KeyboardInterrupt: - pass - finally: - # Sora から切断する(すでに切断済みの場合には無視される) - messaging_recvonly.disconnect() - - -if __name__ == "__main__": - recvonly() diff --git a/examples/src/messaging/sendonly.py b/examples/src/messaging/sendonly.py deleted file mode 100644 index 45eb93ab..00000000 --- a/examples/src/messaging/sendonly.py +++ /dev/null @@ -1,46 +0,0 @@ -import json -import os - -from dotenv import load_dotenv - -from messaging import Messaging - - -def sendonly(): - # .env ファイルを読み込む - load_dotenv() - - # 必須引数 - if not (raw_signaling_urls := os.getenv("SORA_SIGNALING_URLS")): - raise ValueError("環境変数 SORA_SIGNALING_URLS が設定されていません") - signaling_urls = raw_signaling_urls.split(",") - - if not (channel_id := os.getenv("SORA_CHANNEL_ID")): - raise ValueError("環境変数 SORA_CHANNEL_ID が設定されていません") - - if not (messaging_label := os.getenv("SORA_MESSAGING_LABEL")): - raise ValueError("環境変数 SORA_MESSAGING_LABEL が設定されていません") - - # オプション引数 - metadata = None - if raw_metadata := os.getenv("SORA_METADATA"): - metadata = json.loads(raw_metadata) - - data_channels = [{"label": messaging_label, "direction": "sendonly"}] - messaging_sendonly = Messaging(signaling_urls, channel_id, data_channels, metadata) - - # Sora に接続する - messaging_sendonly.connect() - try: - while not messaging_sendonly.closed: - # input で入力された文字列を utf-8 でエンコードして送信 - message = input("Enter キーを押すと送信します: ") - messaging_sendonly.send(message.encode("utf-8")) - except KeyboardInterrupt: - pass - finally: - messaging_sendonly.disconnect() - - -if __name__ == "__main__": - sendonly() diff --git a/examples/src/messaging/sendrecv.py b/examples/src/messaging/sendrecv.py deleted file mode 100644 index fc5a479b..00000000 --- a/examples/src/messaging/sendrecv.py +++ /dev/null @@ -1,46 +0,0 @@ -import json -import os - -from dotenv import load_dotenv - -from messaging import Messaging - - -def sendrecv(): - # .env ファイルを読み込む - load_dotenv() - - # 必須引数 - if not (raw_signaling_urls := os.getenv("SORA_SIGNALING_URLS")): - raise ValueError("環境変数 SORA_SIGNALING_URLS が設定されていません") - signaling_urls = raw_signaling_urls.split(",") - - if not (channel_id := os.getenv("SORA_CHANNEL_ID")): - raise ValueError("環境変数 SORA_CHANNEL_ID が設定されていません") - - if not (messaging_label := os.getenv("SORA_MESSAGING_LABEL")): - raise ValueError("環境変数 SORA_MESSAGING_LABEL が設定されていません") - - # オプション引数 - metadata = None - if raw_metadata := os.getenv("SORA_METADATA"): - metadata = json.loads(raw_metadata) - - data_channels = [{"label": messaging_label, "direction": "sendrecv"}] - messaging_sendrecv = Messaging(signaling_urls, channel_id, data_channels, metadata) - - # Sora に接続する - messaging_sendrecv.connect() - try: - while not messaging_sendrecv.closed: - # input で入力された文字列を utf-8 でエンコードして送信 - message = input() - messaging_sendrecv.send(message.encode("utf-8")) - except KeyboardInterrupt: - pass - finally: - messaging_sendrecv.disconnect() - - -if __name__ == "__main__": - sendrecv() diff --git a/examples/src/ml/shiguremaru.png b/examples/src/shiguremaru.png similarity index 100% rename from examples/src/ml/shiguremaru.png rename to examples/src/shiguremaru.png diff --git a/examples/src/media/vad.py b/examples/src/vad.py similarity index 100% rename from examples/src/media/vad.py rename to examples/src/vad.py diff --git a/examples/sync.sh b/examples/sync.sh deleted file mode 100755 index 6c9c4d77..00000000 --- a/examples/sync.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash - -# ローカルインストールしたキャッシュを削除した上で rye sync するスクリプト。 -# -# `rye add sora_sdk --path ` でローカルで書き換えた sora-python-sdk を -# 利用可能だが、キャッシュが残っていると一生更新されないため、キャッシュディレクトリを削除する。 -# -# 毎回どこのディレクトリを消せばいいか忘れてしまうので、このスクリプトで対応する。 - -set -ex - -case "`uname`" in - "Darwin" ) CACHE_DIR=~/Library/Caches/pip ;; - "Linux" ) CACHE_DIR=~/.cache/pip ;; - * ) exit 1 ;; -esac - -rm -rf $CACHE_DIR/wheels -rye sync diff --git a/examples/tests/test_vad.py b/examples/tests/test_vad.py deleted file mode 100644 index d78712ce..00000000 --- a/examples/tests/test_vad.py +++ /dev/null @@ -1,48 +0,0 @@ -import sys -import time -import uuid - -from media import Sendonly -from media.vad import VAD - - -def test_vad(setup): - signaling_urls = setup.get("signaling_urls") - channel_id_prefix = setup.get("channel_id_prefix") - metadata = setup.get("metadata") - - channel_id = f"{channel_id_prefix}_{__name__}_{sys._getframe().f_code.co_name}_{uuid.uuid4()}" - - sendonly = Sendonly(signaling_urls, channel_id, metadata=metadata) - sendonly.connect(fake_audio=True) - - vad = VAD(signaling_urls, channel_id, metadata=metadata) - vad.connect() - - time.sleep(5) - - sendonly_stats = sendonly.get_stats() - vad_stats = vad.get_stats() - - sendonly.disconnect() - vad.disconnect() - - # codec が無かったら StopIteration 例外が上がる - sendonly_codec_stats = next(s for s in sendonly_stats if s.get("type") == "codec") - assert sendonly_codec_stats["mimeType"] == "audio/opus" - - # outbound-rtp が無かったら StopIteration 例外が上がる - outbound_rtp_stats = next(s for s in sendonly_stats if s.get("type") == "outbound-rtp") - # audio には encoderImplementation が無い - assert outbound_rtp_stats["bytesSent"] > 0 - assert outbound_rtp_stats["packetsSent"] > 0 - - # codec が無かったら StopIteration 例外が上がる - vad_codec_stats = next(s for s in vad_stats if s.get("type") == "codec") - assert vad_codec_stats["mimeType"] == "audio/opus" - - # outbound-rtp が無かったら StopIteration 例外が上がる - inbound_rtp_stats = next(s for s in vad_stats if s.get("type") == "inbound-rtp") - # audio には decoderImplementation が無い - assert inbound_rtp_stats["bytesReceived"] > 0 - assert inbound_rtp_stats["packetsReceived"] > 0 diff --git a/pyproject.toml b/pyproject.toml index 510fe465..c9599f12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", ] requires-python = ">= 3.10" +dependencies = ["pillow>=10.4.0"] [project.urls] Source = "https://github.com/shiguredo/sora-python-sdk" @@ -50,24 +51,24 @@ Discord = "https://discord.gg/shiguredo" # - https://setuptools.pypa.io/en/latest/build_meta.html # - setuptools の build-system サポートについて解説されていて参考になる [build-system] -requires = ["setuptools>=69.2", "wheel~=0.43.0"] +requires = ["setuptools>=75.0", "wheel~=0.44.0"] build-backend = "setuptools.build_meta" [tool.rye] dev-dependencies = [ "nanobind~=2.1.0", - "setuptools>=69.2", - "build~=1.2.1", - "wheel~=0.43.0", - "auditwheel~=6.0.0", + "setuptools>=75.0", + "build~=1.2.2", + "wheel~=0.44.0", + "auditwheel~=6.1.0", "ruff>=0.6", - "typing-extensions>=4.12.2", + "typing-extensions>=4.12", + "pytest>=8.3", + "python-dotenv>=1.0", + "numpy>=2.1", + "mypy>=1.11", ] -# examples でローカルの sora_sdk を利用したい場合は以下を有効にする -# [tool.rye.workspace] -# packages = ["examples"] - [tool.ruff] target-version = "py310" line-length = 100 diff --git a/requirements-dev.lock b/requirements-dev.lock index 59f79baa..90adbd66 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -10,19 +10,37 @@ # universal: false -e file:. -auditwheel==6.0.0 +auditwheel==6.1.0 build==1.2.2 +exceptiongroup==1.2.2 + # via pytest +iniconfig==2.0.0 + # via pytest +mypy==1.11.2 +mypy-extensions==1.0.0 + # via mypy nanobind==2.1.0 +numpy==2.1.1 packaging==24.1 # via auditwheel # via build + # via pytest +pillow==10.4.0 + # via sora-sdk +pluggy==1.5.0 + # via pytest pyelftools==0.31 # via auditwheel pyproject-hooks==1.1.0 # via build +pytest==8.3.3 +python-dotenv==1.0.1 ruff==0.6.5 setuptools==75.0.0 tomli==2.0.1 # via build + # via mypy + # via pytest typing-extensions==4.12.2 -wheel==0.43.0 + # via mypy +wheel==0.44.0 diff --git a/requirements.lock b/requirements.lock index 505fd45b..2c5a8a15 100644 --- a/requirements.lock +++ b/requirements.lock @@ -10,3 +10,5 @@ # universal: false -e file:. +pillow==10.4.0 + # via sora-sdk diff --git a/examples/tests/README.md b/tests/README.md similarity index 100% rename from examples/tests/README.md rename to tests/README.md diff --git a/tests/client.py b/tests/client.py new file mode 100644 index 00000000..c9b1c27b --- /dev/null +++ b/tests/client.py @@ -0,0 +1,596 @@ +import json +import queue +import random +import threading +import time +from threading import Event +from typing import Any, Optional + +import numpy + +from sora_sdk import ( + Sora, + SoraAudioSink, + SoraConnection, + SoraMediaTrack, + SoraSignalingDirection, + SoraSignalingErrorCode, + SoraSignalingType, + SoraVideoFrame, + SoraVideoSink, +) + + +class Sendonly: + def __init__( + self, + signaling_urls: list[str], + channel_id: str, + metadata: Optional[dict[str, Any]] = None, + audio: Optional[bool] = None, + video: Optional[bool] = None, + video_codec_type: Optional[str] = None, + video_bit_rate: Optional[int] = None, + data_channel_signaling: Optional[bool] = None, + openh264_path: Optional[str] = None, + use_hwa: bool = False, + audio_channels: int = 1, + audio_sample_rate: int = 16000, + ): + self._signaling_urls: list[str] = signaling_urls + self._channel_id: str = channel_id + + self._audio_channels: int = audio_channels + self._audio_sample_rate: int = audio_sample_rate + + self._sora: Sora = Sora(openh264=openh264_path, use_hardware_encoder=use_hwa) + + self._fake_audio_thread: Optional[threading.Thread] = None + self._fake_video_thread: Optional[threading.Thread] = None + + self._audio_source = self._sora.create_audio_source( + self._audio_channels, self._audio_sample_rate + ) + self._video_source = self._sora.create_video_source() + + self._connection: SoraConnection = self._sora.create_connection( + signaling_urls=signaling_urls, + role="sendonly", + channel_id=channel_id, + metadata=metadata, + audio=audio, + video=video, + video_codec_type=video_codec_type, + video_bit_rate=video_bit_rate, + data_channel_signaling=data_channel_signaling, + audio_source=self._audio_source, + video_source=self._video_source, + ) + self._connection_id: Optional[str] = None + + self._connected: Event = Event() + self._switched: bool = False + self._closed: Event = Event() + self._default_connection_timeout_s: float = 10.0 + + # signaling message + self._connect_message: Optional[dict[str, Any]] = None + self._redirect_message: Optional[dict[str, Any]] = None + self._offer_message: Optional[dict[str, Any]] = None + self._answer_message: Optional[dict[str, Any]] = None + self._candidate_messages: list[dict[str, Any]] = [] + self._re_offer_messages: list[dict[str, Any]] = [] + self._re_answer_messages: list[dict[str, Any]] = [] + + # callback + self._connection.on_signaling_message = self._on_signaling_message + self._connection.on_set_offer = self._on_set_offer + self._connection.on_switched = self._on_switched + self._connection.on_notify = self._on_notify + self._connection.on_disconnect = self._on_disconnect + + def connect(self, fake_audio=False, fake_video=False) -> None: + self._connection.connect() + + if fake_audio: + self._fake_audio_thread = threading.Thread(target=self._fake_audio_loop, daemon=True) + self._fake_audio_thread.start() + + if fake_video: + self._fake_video_thread = threading.Thread(target=self._fake_video_loop, daemon=True) + self._fake_video_thread.start() + + assert self._connected.wait( + self._default_connection_timeout_s + ), "Could not connect to Sora." + + def disconnect(self) -> None: + """Sora から切断します。""" + self._connection.disconnect() + + def get_stats(self): + raw_stats = self._connection.get_stats() + return json.loads(raw_stats) + + @property + def connect_message(self) -> Optional[dict[str, Any]]: + return self._connect_message + + @property + def redirect_message(self) -> Optional[dict[str, Any]]: + return self._redirect_message + + @property + def offer_message(self) -> Optional[dict[str, Any]]: + return self._offer_message + + @property + def answer_message(self) -> Optional[dict[str, Any]]: + return self._answer_message + + @property + def candidate_messages(self) -> list[dict[str, Any]]: + return self._candidate_messages + + @property + def re_offer_messages(self) -> list[dict[str, Any]]: + return self._re_offer_messages + + @property + def re_answer_messages(self) -> list[dict[str, Any]]: + return self._re_answer_messages + + @property + def connected(self) -> bool: + return self._connected.is_set() + + @property + def switched(self) -> bool: + return self._switched + + def _fake_audio_loop(self): + while not self._closed.is_set(): + time.sleep(0.02) + self._audio_source.on_data(numpy.zeros((320, 1), dtype=numpy.int16)) + + def _fake_video_loop(self): + while not self._closed.is_set(): + time.sleep(1.0 / 30) + self._video_source.on_captured(numpy.zeros((480, 640, 3), dtype=numpy.uint8)) + + def _on_signaling_message( + self, + signaling_type: SoraSignalingType, + signaling_direction: SoraSignalingDirection, + raw_message: str, + ): + print(raw_message) + message: dict[str, Any] = json.loads(raw_message) + match message["type"]: + case "connect": + assert signaling_type == SoraSignalingType.WEBSOCKET + assert signaling_direction == SoraSignalingDirection.SENT + self._connect_message = message + case "redirect": + assert signaling_type == SoraSignalingType.WEBSOCKET + assert signaling_direction == SoraSignalingDirection.RECEIVED + self._redirect_message = message + case "offer": + assert signaling_type == SoraSignalingType.WEBSOCKET + assert signaling_direction == SoraSignalingDirection.RECEIVED + self._offer_message = message + case "answer": + assert signaling_type == SoraSignalingType.WEBSOCKET + assert signaling_direction == SoraSignalingDirection.SENT + self._answer_message = message + case "candidate": + self._candidate_messages.append(message) + case "re-offer": + self._re_offer_messages.append(message) + case "re-answer": + self._re_answer_messages.append(message) + case _: + NotImplementedError(f"Unknown signaling message type: {message['type']}") + + def _on_set_offer(self, raw_message: str) -> None: + message: dict[str, Any] = json.loads(raw_message) + if message["type"] == "offer": + self._connection_id = message["connection_id"] + + def _on_switched(self, raw_message: str) -> None: + message = json.loads(raw_message) + if message["type"] == "switched": + print(f"Switched to DataChannel Signaling: connection_id={self._connection_id}") + self._switched = True + + def _on_notify(self, raw_message: str) -> None: + message: dict[str, Any] = json.loads(raw_message) + if ( + message["type"] == "notify" + and message["event_type"] == "connection.created" + and message["connection_id"] == self._connection_id + ): + print(f"Connected Sora: connection_id={self._connection_id}") + self._connected.set() + + def _on_disconnect(self, error_code: SoraSignalingErrorCode, message: str) -> None: + print(f"Disconnected Sora: error_code='{error_code}' message='{message}'") + self._connected.clear() + self._closed.set() + + if self._fake_audio_thread is not None: + self._fake_audio_thread.join(timeout=10) + + if self._fake_video_thread is not None: + self._fake_video_thread.join(timeout=10) + + +class Recvonly: + def __init__( + self, + signaling_urls: list[str], + channel_id: str, + metadata: Optional[dict[str, Any]] = None, + data_channel_signaling: Optional[bool] = None, + openh264_path: Optional[str] = None, + use_hwa: Optional[bool] = False, + output_frequency: int = 16000, + output_channels: int = 1, + ): + self._signaling_urls: list[str] = signaling_urls + self._channel_id: str = channel_id + + self._output_frequency: int = output_frequency + self._output_channels: int = output_channels + + self._sora: Sora = Sora(openh264=openh264_path, use_hardware_encoder=use_hwa) + self._connection: SoraConnection = self._sora.create_connection( + signaling_urls=signaling_urls, + role="recvonly", + channel_id=channel_id, + metadata=metadata, + data_channel_signaling=data_channel_signaling, + ) + self._connection_id: Optional[str] = None + + self._connected: Event = Event() + self._switched: bool = False + self._closed: Event = Event() + self._default_connection_timeout_s: float = 10.0 + + self._audio_sink: Optional[SoraAudioSink] = None + self._video_sink: Optional[SoraVideoSink] = None + + self._q_out: queue.Queue = queue.Queue() + + # signaling message + self._connect_message: Optional[dict[str, Any]] = None + self._redirect_message: Optional[dict[str, Any]] = None + self._offer_message: Optional[dict[str, Any]] = None + self._answer_message: Optional[dict[str, Any]] = None + self._candidate_messages: list[dict[str, Any]] = [] + self._re_offer_messages: list[dict[str, Any]] = [] + self._re_answer_messages: list[dict[str, Any]] = [] + + # callback + self._connection.on_signaling_message = self._on_signaling_message + self._connection.on_set_offer = self._on_set_offer + self._connection.on_switched = self._on_switched + self._connection.on_notify = self._on_notify + self._connection.on_disconnect = self._on_disconnect + self._connection.on_track = self._on_track + + def connect(self) -> None: + self._connection.connect() + + assert self._connected.wait( + self._default_connection_timeout_s + ), "Could not connect to Sora." + + def disconnect(self) -> None: + self._connection.disconnect() + + def get_stats(self): + raw_stats = self._connection.get_stats() + return json.loads(raw_stats) + + @property + def connect_message(self) -> Optional[dict[str, Any]]: + return self._connect_message + + @property + def redirect_message(self) -> Optional[dict[str, Any]]: + return self._redirect_message + + @property + def offer_message(self) -> Optional[dict[str, Any]]: + return self._offer_message + + @property + def answer_message(self) -> Optional[dict[str, Any]]: + return self._answer_message + + @property + def candidate_messages(self) -> list[dict[str, Any]]: + return self._candidate_messages + + @property + def re_offer_messages(self) -> list[dict[str, Any]]: + return self._re_offer_messages + + @property + def re_answer_messages(self) -> list[dict[str, Any]]: + return self._re_answer_messages + + @property + def connected(self) -> bool: + return self._connected.is_set() + + @property + def switched(self) -> bool: + return self._switched + + @property + def closed(self): + return self._closed.is_set() + + def _on_signaling_message( + self, + signaling_type: SoraSignalingType, + signaling_direction: SoraSignalingDirection, + raw_message: str, + ): + print(raw_message) + message: dict[str, Any] = json.loads(raw_message) + match message["type"]: + case "connect": + assert signaling_type == SoraSignalingType.WEBSOCKET + assert signaling_direction == SoraSignalingDirection.SENT + self._connect_message = message + case "redirect": + assert signaling_type == SoraSignalingType.WEBSOCKET + assert signaling_direction == SoraSignalingDirection.RECEIVED + self._redirect_message = message + case "offer": + assert signaling_type == SoraSignalingType.WEBSOCKET + assert signaling_direction == SoraSignalingDirection.RECEIVED + self._offer_message = message + case "answer": + assert signaling_type == SoraSignalingType.WEBSOCKET + assert signaling_direction == SoraSignalingDirection.SENT + self._answer_message = message + case "candidate": + self._candidate_messages.append(message) + case "re-offer": + self._re_offer_messages.append(message) + case "re-answer": + self._re_answer_messages.append(message) + case _: + NotImplementedError(f"Unknown signaling message type: {message['type']}") + + def _on_set_offer(self, raw_message: str) -> None: + message: dict[str, Any] = json.loads(raw_message) + if message["type"] == "offer": + self._connection_id = message["connection_id"] + + def _on_switched(self, raw_message: str) -> None: + message = json.loads(raw_message) + if message["type"] == "switched": + print(f"Switched to DataChannel Signaling: connection_id={self._connection_id}") + self._switched = True + + def _on_notify(self, raw_message: str) -> None: + message: dict[str, Any] = json.loads(raw_message) + if ( + message["type"] == "notify" + and message["event_type"] == "connection.created" + and message["connection_id"] == self._connection_id + ): + print(f"Connected Sora: connection_id={self._connection_id}") + self._connected.set() + + def _on_disconnect(self, error_code: SoraSignalingErrorCode, message: str) -> None: + print(f"Disconnected Sora: error_code='{error_code}' message='{message}'") + self._connected.clear() + self._closed.is_set() + + def _on_video_frame(self, frame: SoraVideoFrame) -> None: + self._q_out.put(frame) + + def _on_track(self, track: SoraMediaTrack) -> None: + if track.kind == "audio": + self._audio_sink = SoraAudioSink(track, self._output_frequency, self._output_channels) + if track.kind == "video": + self._video_sink = SoraVideoSink(track) + self._video_sink.on_frame = self._on_video_frame + + +class Messaging: + def __init__( + self, + signaling_urls: list[str], + channel_id: str, + data_channels: list[dict[str, Any]], + metadata: Optional[dict[str, Any]] = None, + ): + self._data_channels = data_channels + + self._sora = Sora() + self._connection: SoraConnection = self._sora.create_connection( + signaling_urls=signaling_urls, + role="sendrecv", + channel_id=channel_id, + metadata=metadata, + audio=False, + video=False, + data_channels=self._data_channels, + data_channel_signaling=True, + ) + self._connection_id: Optional[str] = None + + self._connected = Event() + self._switched: bool = False + self._closed = Event() + self._default_connection_timeout_s: float = 10.0 + + self._label = data_channels[0]["label"] + self._sendable_data_channels: set = set() + self._is_data_channel_ready = False + + self.sender_id = random.randint(1, 10000) + + # signaling message + self._connect_message: Optional[dict[str, Any]] = None + self._redirect_message: Optional[dict[str, Any]] = None + self._offer_message: Optional[dict[str, Any]] = None + self._answer_message: Optional[dict[str, Any]] = None + self._candidate_messages: list[dict[str, Any]] = [] + self._re_offer_messages: list[dict[str, Any]] = [] + self._re_answer_messages: list[dict[str, Any]] = [] + + # callback + self._connection.on_signaling_message = self._on_signaling_message + self._connection.on_set_offer = self._on_set_offer + self._connection.on_switched = self._on_switched + self._connection.on_notify = self._on_notify + self._connection.on_data_channel = self._on_data_channel + self._connection.on_message = self._on_message + self._connection.on_disconnect = self._on_disconnect + + @property + def closed(self): + return self._closed.is_set() + + def connect(self): + self._connection.connect() + + assert self._connected.wait( + self._default_connection_timeout_s + ), "Could not connect to Sora." + + def disconnect(self): + self._connection.disconnect() + + def get_stats(self): + raw_stats = self._connection.get_stats() + stats = json.loads(raw_stats) + return stats + + @property + def connect_message(self) -> Optional[dict[str, Any]]: + return self._connect_message + + @property + def redirect_message(self) -> Optional[dict[str, Any]]: + return self._redirect_message + + @property + def offer_message(self) -> Optional[dict[str, Any]]: + return self._offer_message + + @property + def answer_message(self) -> Optional[dict[str, Any]]: + return self._answer_message + + @property + def candidate_messages(self) -> list[dict[str, Any]]: + return self._candidate_messages + + @property + def re_offer_messages(self) -> list[dict[str, Any]]: + return self._re_offer_messages + + @property + def re_answer_messages(self) -> list[dict[str, Any]]: + return self._re_answer_messages + + @property + def connected(self) -> bool: + return self._connected.is_set() + + @property + def switched(self) -> bool: + return self._switched + + def send(self, data: bytes): + # on_data_channel() が呼ばれるまではデータチャネルの準備ができていないので待機 + while not self._is_data_channel_ready and not self._closed.is_set(): + time.sleep(0.01) + + self._connection.send_data_channel(self._label, data) + + def _on_signaling_message( + self, + signaling_type: SoraSignalingType, + signaling_direction: SoraSignalingDirection, + raw_message: str, + ): + print(raw_message) + message: dict[str, Any] = json.loads(raw_message) + match message["type"]: + case "connect": + assert signaling_type == SoraSignalingType.WEBSOCKET + assert signaling_direction == SoraSignalingDirection.SENT + self._connect_message = message + case "redirect": + assert signaling_type == SoraSignalingType.WEBSOCKET + assert signaling_direction == SoraSignalingDirection.RECEIVED + self._redirect_message = message + case "offer": + assert signaling_type == SoraSignalingType.WEBSOCKET + assert signaling_direction == SoraSignalingDirection.RECEIVED + self._offer_message = message + case "answer": + assert signaling_type == SoraSignalingType.WEBSOCKET + assert signaling_direction == SoraSignalingDirection.SENT + self._answer_message = message + case "candidate": + self._candidate_messages.append(message) + case "re-offer": + self._re_offer_messages.append(message) + case "re-answer": + self._re_answer_messages.append(message) + case _: + NotImplementedError(f"Unknown signaling message type: {message['type']}") + + def _on_set_offer(self, raw_message: str): + message: dict[str, Any] = json.loads(raw_message) + if message["type"] == "offer": + # "type": "offer" に入ってくる自分の connection_id を保存する + self._connection_id = message["connection_id"] + + def _on_switched(self, raw_message: str): + message: dict[str, Any] = json.loads(raw_message) + if message["type"] == "switched": + self._switched = True + + def _on_notify(self, raw_message: str): + message: dict[str, Any] = json.loads(raw_message) + # "type": "notify" の "connection.created" で通知される connection_id が + # 自分の connection_id と一致する場合に接続完了とする + if ( + message["type"] == "notify" + and message["event_type"] == "connection.created" + and message["connection_id"] == self._connection_id + ): + print(f"Connected Sora: connection_id={self._connection_id}") + self._connected.set() + + def _on_disconnect(self, error_code: SoraSignalingErrorCode, message: str): + print(f"Disconnected Sora: error_code='{error_code}' message='{message}'") + self._connected.clear() + self._closed.set() + + def _on_message(self, label: str, data: bytes): + print(f"Received message: label={label}, data={data.decode('utf-8')}") + + def _on_data_channel(self, label: str): + for data_channel in self._data_channels: + if data_channel["label"] != label: + continue + + if data_channel["direction"] in ["sendrecv", "sendonly"]: + self._sendable_data_channels.add(label) + # データチャネルの準備ができたのでフラグを立てる + self._is_data_channel_ready = True + break diff --git a/examples/tests/conftest.py b/tests/conftest.py similarity index 100% rename from examples/tests/conftest.py rename to tests/conftest.py diff --git a/examples/tests/test_macos.py b/tests/test_macos.py similarity index 96% rename from examples/tests/test_macos.py rename to tests/test_macos.py index 87b1e6ad..abfd94bc 100644 --- a/examples/tests/test_macos.py +++ b/tests/test_macos.py @@ -3,8 +3,7 @@ import uuid import pytest - -from media import Recvonly, Sendonly +from client import Recvonly, Sendonly """ GitHub Actions で Video Toolbox を送受信で利用しようとするとエラーになるので、 @@ -32,8 +31,10 @@ def test_macos_h264_sendonly(setup): time.sleep(5) - assert sendonly.connect_message.get("channel_id") == channel_id - assert sendonly.connect_message.get("video").get("codec_type") == "H264" + assert sendonly.connect_message is not None + assert sendonly.connect_message["channel_id"] == channel_id + assert "video" in sendonly.connect_message + assert sendonly.connect_message["video"]["codec_type"] == "H264" sendonly_stats = sendonly.get_stats() diff --git a/examples/tests/test_messaging.py b/tests/test_messaging.py similarity index 98% rename from examples/tests/test_messaging.py rename to tests/test_messaging.py index 4c1d98ce..bb7b40e5 100644 --- a/examples/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -2,7 +2,7 @@ import time import uuid -from messaging import Messaging +from client import Messaging def test_messaging(setup): diff --git a/examples/tests/test_openh264.py b/tests/test_openh264.py similarity index 98% rename from examples/tests/test_openh264.py rename to tests/test_openh264.py index f53c05a0..0078246c 100644 --- a/examples/tests/test_openh264.py +++ b/tests/test_openh264.py @@ -2,7 +2,7 @@ import time import uuid -from media import Recvonly, Sendonly +from client import Recvonly, Sendonly def test_openh264_sendonly_recvonly(setup): diff --git a/examples/tests/test_sendonly_recvonly.py b/tests/test_sendonly_recvonly.py similarity index 99% rename from examples/tests/test_sendonly_recvonly.py rename to tests/test_sendonly_recvonly.py index 80a5eeeb..c33cdf9d 100644 --- a/examples/tests/test_sendonly_recvonly.py +++ b/tests/test_sendonly_recvonly.py @@ -2,7 +2,7 @@ import time import uuid -from media import Recvonly, Sendonly +from client import Recvonly, Sendonly def test_sendonly_recvonly_opus(setup): diff --git a/examples/tests/test_signaling_message.py b/tests/test_signaling_message.py similarity index 69% rename from examples/tests/test_signaling_message.py rename to tests/test_signaling_message.py index 0c732b5b..b229fb8e 100644 --- a/examples/tests/test_signaling_message.py +++ b/tests/test_signaling_message.py @@ -2,7 +2,7 @@ import time import uuid -from media import Sendonly +from client import Sendonly def test_signaling_message(setup): @@ -15,11 +15,11 @@ def test_signaling_message(setup): sendonly = Sendonly( signaling_urls, channel_id, - audio=False, + audio=True, video=True, metadata=metadata, ) - sendonly.connect(fake_audio=True) + sendonly.connect(fake_video=True, fake_audio=True) time.sleep(5) @@ -27,4 +27,8 @@ def test_signaling_message(setup): assert sendonly.offer_message is not None assert sendonly.answer_message is not None + assert sendonly.connect_message["audio"] is True + assert sendonly.connect_message["video"] is True + assert sendonly.connect_message["metadata"] == metadata + sendonly.disconnect() diff --git a/tests/test_vad.py b/tests/test_vad.py new file mode 100644 index 00000000..8bdb7148 --- /dev/null +++ b/tests/test_vad.py @@ -0,0 +1,148 @@ +import json +import sys +import time +import uuid +from threading import Event +from typing import Any, Optional + +from client import Sendonly + +from sora_sdk import ( + Sora, + SoraAudioFrame, + SoraAudioStreamSink, + SoraMediaTrack, + SoraVAD, +) + + +class VAD: + def __init__( + self, signaling_urls: list[str], channel_id: str, metadata: Optional[dict[str, Any]] + ): + self._signaling_urls: list[str] = signaling_urls + self._channel_id: str = channel_id + + self._vad = SoraVAD() + + self._connection_id: str + + # 接続した + self._connected: Event = Event() + # 終了 + self._closed = Event() + + self._audio_output_frequency: int = 24000 + self._audio_output_channels: int = 1 + + self._sora = Sora() + + self._connection = self._sora.create_connection( + signaling_urls=signaling_urls, + role="recvonly", + channel_id=channel_id, + metadata=metadata, + audio=True, + video=False, + ) + + self._connection.on_set_offer = self._on_set_offer + self._connection.on_notify = self._on_notify + self._connection.on_disconnect = self._on_disconnect + + self._connection.on_track = self._on_track + + def connect(self): + self._connection.connect() + + # _connected が set されるまで 30 秒待つ + assert self._connected.wait(30) + + return self + + def disconnect(self): + self._connection.disconnect() + + def get_stats(self): + raw_stats = self._connection.get_stats() + stats = json.loads(raw_stats) + return stats + + def _on_set_offer(self, raw_offer): + offer = json.loads(raw_offer) + if offer["type"] == "offer": + self._connection_id = offer["connection_id"] + print(f"Received 'Offer': connection_id={self._connection_id}") + + def _on_notify(self, raw_message): + message = json.loads(raw_message) + if ( + message["type"] == "notify" + and message["event_type"] == "connection.created" + and message["connection_id"] == self._connection_id + ): + print(f"Connected Sora: connection_id={self._connection_id}") + self._connected.set() + + def _on_disconnect(self, error_code, message): + print(f"Disconnected Sora: error_code='{error_code}' message='{message}'") + self._closed = True + self._connected.clear() + + def _on_frame(self, frame: SoraAudioFrame): + # frame が音声である確率を求める + voice_probability = self._vad.analyze(frame) + if voice_probability > 0.95: # 0.95 は libwebrtc の判定値 + print(f"Voice! voice_probability={voice_probability}") + else: + pass + + def _on_track(self, track: SoraMediaTrack): + if track.kind == "audio": + # SoraAudioStreamSink + self._audio_stream_sink = SoraAudioStreamSink( + track, self._audio_output_frequency, self._audio_output_channels + ) + self._audio_stream_sink.on_frame = self._on_frame + + +def test_vad(setup): + signaling_urls = setup.get("signaling_urls") + channel_id_prefix = setup.get("channel_id_prefix") + metadata = setup.get("metadata") + + channel_id = f"{channel_id_prefix}_{__name__}_{sys._getframe().f_code.co_name}_{uuid.uuid4()}" + + sendonly = Sendonly(signaling_urls, channel_id, metadata=metadata) + sendonly.connect(fake_audio=True) + + vad = VAD(signaling_urls, channel_id, metadata=metadata) + vad.connect() + + time.sleep(5) + + sendonly_stats = sendonly.get_stats() + vad_stats = vad.get_stats() + + sendonly.disconnect() + vad.disconnect() + + # codec が無かったら StopIteration 例外が上がる + sendonly_codec_stats = next(s for s in sendonly_stats if s.get("type") == "codec") + assert sendonly_codec_stats["mimeType"] == "audio/opus" + + # outbound-rtp が無かったら StopIteration 例外が上がる + outbound_rtp_stats = next(s for s in sendonly_stats if s.get("type") == "outbound-rtp") + # audio には encoderImplementation が無い + assert outbound_rtp_stats["bytesSent"] > 0 + assert outbound_rtp_stats["packetsSent"] > 0 + + # codec が無かったら StopIteration 例外が上がる + vad_codec_stats = next(s for s in vad_stats if s.get("type") == "codec") + assert vad_codec_stats["mimeType"] == "audio/opus" + + # outbound-rtp が無かったら StopIteration 例外が上がる + inbound_rtp_stats = next(s for s in vad_stats if s.get("type") == "inbound-rtp") + # audio には decoderImplementation が無い + assert inbound_rtp_stats["bytesReceived"] > 0 + assert inbound_rtp_stats["packetsReceived"] > 0