Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ dev-dependencies = [
"pyjwt",
"ruff",
"ty",
"blend2d-py",
]

[tool.ruff]
Expand Down
22 changes: 22 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,25 @@
- `TEST_LIBWEBRTC_LOG`
- デフォルトは `none` です
- `none`, `verbose`, `info`, `warning`, `error` のいずれかを指定してください

## 簡易 CLI 実行

`tests/cli.py` で `SoraClient` を手軽に実行できます。

### Blend2D で 120fps・解像度 960x540・厳密ペーシング

```bash
uv run tests/cli.py --role sendonly --video --fake-video \
--fake-video-type blend2d --video-codec-type VP9 \
--video-width 960 --video-height 540 --framerate 120 --precise-timing
```

### 備考

- `--duration` を省略すると無制限で動作します(Ctrl-C で終了)。
- フレームレートは `--framerate`(または `--fps`)で指定できます
- 未指定は 30 fps
- 解像度は `--video-width`/`--video-height` で指定
- デフォルト 960x540
- フレームペーシングをより厳密にするには `--precise-timing` を付与
- CPU 使用率が上がります
121 changes: 121 additions & 0 deletions tests/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
from __future__ import annotations

import argparse
import time

from client import SoraClient, SoraRole
from conftest import Settings


def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Run SoraClient quickly from CLI")

p.add_argument(
"--role",
required=True,
choices=[r.value for r in SoraRole],
help="Connection role",
)

# メディア設定
p.add_argument("--audio", action="store_true", help="Enable audio")
p.add_argument("--video", action="store_true", help="Enable video")
p.add_argument(
"--video-codec-type", choices=["VP8", "VP9", "H264", "H265", "AV1"], help="Video codec type"
)
p.add_argument("--video-bit-rate", type=int, help="Video bitrate (bps)")
p.add_argument("--video-width", type=int, default=960, help="Video width (default 960)")
p.add_argument("--video-height", type=int, default=540, help="Video height (default 540)")

# フェイクメディア
p.add_argument("--fake-audio", action="store_true", help="Send fake audio")
p.add_argument("--fake-video", action="store_true", help="Send fake video")
p.add_argument(
"--fake-video-type",
choices=["random", "blend2d"],
help="Fake video generator type. Default(None)=black frame",
)

# その他
p.add_argument(
"--signaling-url",
action="append",
help="Signaling URL (repeatable or comma-separated)",
)
p.add_argument("--channel-id", help="Override channel_id (settings)")
p.add_argument(
"--framerate",
"--fps",
dest="framerate",
type=int,
help="Fake video framerate (fps)",
)
p.add_argument(
"--precise-timing",
action="store_true",
help="Use tighter frame pacing (may increase CPU)",
)
p.add_argument(
"--duration",
type=float,
help="Run duration in seconds; omit for unlimited (CTRL-C to stop)",
)

return p.parse_args()


def main() -> int:
args = parse_args()

# signaling-url は複数指定またはカンマ区切り両対応
signaling_urls: list[str] | None = None
if args.signaling_url:
signaling_urls = []
for ent in args.signaling_url:
signaling_urls.extend([x.strip() for x in ent.split(",") if x.strip()])

settings = Settings(channel_id=args.channel_id, signaling_urls=signaling_urls)

client = SoraClient(
settings,
role=SoraRole(args.role),
audio=True if args.audio else False,
video=True if args.video else False,
video_codec_type=args.video_codec_type,
video_bit_rate=args.video_bit_rate,
video_width=args.video_width,
video_height=args.video_height,
fake_video_type=args.fake_video_type, # None の場合は黒フレーム
video_fps=args.framerate,
precise_timing=args.precise_timing,
)

print(
f"Connecting: role={args.role} channel_id={settings.channel_id} "
f"signaling_urls={settings.signaling_urls} audio={args.audio} video={args.video} "
f"fake_audio={args.fake_audio} fake_video={args.fake_video} fake_video_type={args.fake_video_type}"
)
client.connect(fake_audio=args.fake_audio, fake_video=args.fake_video)
print(f"Connected: connection_id={client.connection_id}")

start = time.perf_counter()
try:
while True:
if (
args.duration is not None
and args.duration > 0
and (time.perf_counter() - start) >= args.duration
):
break
time.sleep(0.2)
except KeyboardInterrupt:
pass
finally:
client.disconnect()
print("Disconnected")

return 0


if __name__ == "__main__":
raise SystemExit(main())
94 changes: 76 additions & 18 deletions tests/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,23 @@ def __init__(
audio_output_frequency: int = 16000,
video_width: int = 640,
video_height: int = 480,
# fake video の描画タイプ: None | "random" | "blend2d"
fake_video_type: Optional[str] = None,
# 動画のフレームレート (None または <=0 の場合は 30fps)
video_fps: Optional[int] = None,
# フレームペーシングを厳密化(高CPUになる可能性あり)
precise_timing: bool = False,
):
self._signaling_urls = settings.signaling_urls
self._role = role.value
self._channel_id = settings.channel_id

# JWT の channel_id を self._channel_id に合わせる
claims: dict[str, Any] = {}
if jwt_private_claims is not None:
access_token = settings.access_token(**jwt_private_claims)
else:
access_token = settings.access_token()
claims.update(jwt_private_claims)
claims.update({"channel_id": self._channel_id})
access_token = settings.access_token(**claims)

# secret が設定されていない場合は access_token が存在しない
if access_token is not None:
Expand Down Expand Up @@ -105,6 +113,14 @@ def __init__(

self._video_width: int = video_width
self._video_height: int = video_height
# None の場合は真っ黒画面
self._fake_video_type: Optional[str] = fake_video_type or None
# FPS は 30 をデフォルト
if video_fps is None or video_fps <= 0:
self._video_fps = 30
else:
self._video_fps = int(video_fps)
self._precise_timing = precise_timing

if settings.libwebrtc_log is not None:
sora_sdk.enable_libwebrtc_log(settings.libwebrtc_log)
Expand Down Expand Up @@ -228,9 +244,9 @@ def connect(self, fake_audio=False, fake_video=False) -> "SoraClient":
self._connection.connect()

try:
assert self._connected.wait(
self._default_connection_timeout_s
), "Could not connect to Sora."
assert self._connected.wait(self._default_connection_timeout_s), (
"Could not connect to Sora."
)
except Exception as e:
self._connection.disconnect()
raise e
Expand Down Expand Up @@ -348,22 +364,64 @@ def _fake_audio_loop(self):
self._audio_source.on_data(numpy.zeros((320, 1), dtype=numpy.int16))

def _fake_video_loop(self):
# フレームレート
fps = self._video_fps

# Blend2D デモ指定時は専用のジェネレータを利用
generator = None
if self._fake_video_type == "blend2d":
try:
from fake_video_blend2d import Blend2DFakeGenerator # type: ignore

generator = Blend2DFakeGenerator(self._video_width, self._video_height, fps)
except Exception as e:
# 失敗時はランダム画像にフォールバック
print(f"Blend2D fake video init failed: {e}. Fallback to random frames.")
generator = None

frame_interval = 1.0 / max(1, fps)
next_frame_at = time.perf_counter()

while not self._disconnected.is_set():
time.sleep(1.0 / 30)
if self._video_source is not None:
# self._video_source.on_captured(
# numpy.zeros((self._video_height, self._video_width, 3), dtype=numpy.uint8)
# )

# お試し randint
def generate_random_image():
if self._video_source is None:
time.sleep(frame_interval)
next_frame_at = time.perf_counter() + frame_interval
continue

now = time.perf_counter()
if now < next_frame_at:
sleep_for = next_frame_at - now
if self._precise_timing and sleep_for > 0.0015:
# ざっくり寝てから短時間ビジーウェイト
time.sleep(sleep_for - 0.001)
while time.perf_counter() < next_frame_at:
pass
else:
time.sleep(sleep_for)
now = time.perf_counter()

# フレーム生成と送出
if generator is not None:
frame_bgr = generator.next_bgr()
self._video_source.on_captured(frame_bgr)
else:
if self._fake_video_type == "random":
random_color = numpy.random.randint(0, 256, size=(3,), dtype=numpy.uint8)
return numpy.full(
frame = numpy.full(
(self._video_height, self._video_width, 3), random_color, dtype=numpy.uint8
)

random_image = generate_random_image()
self._video_source.on_captured(random_image)
else:
frame = numpy.zeros(
(self._video_height, self._video_width, 3), dtype=numpy.uint8
)
self._video_source.on_captured(frame)

# 次フレームの予定時刻を更新(ドリフト対策)
next_frame_at += frame_interval
late_by = time.perf_counter() - next_frame_at
if late_by > frame_interval:
# かなり遅延している場合はリセット(追いつけないときの暴走防止)
next_frame_at = time.perf_counter() + frame_interval

def _on_signaling_message(
self,
Expand Down
14 changes: 12 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@


class Settings:
def __init__(self):
def __init__(self, channel_id: str | None = None, signaling_urls: list[str] | None = None):
# .env ファイルから環境変数を読み込む
self._load_env_file(".env")

Expand All @@ -27,6 +27,12 @@ def __init__(self):
self.openh264_path = os.getenv("OPENH264_PATH")
self.libwebrtc_log = self._parse_libwebrtc_log(os.getenv("TEST_LIBWEBRTC_LOG"))
self.channel_id_suffix = str(uuid.uuid4())
# CLI などから channel_id を明示指定するための上書き値
self._channel_id_override = channel_id
# CLI などから signaling_urls を明示指定するための上書き値
if signaling_urls is not None:
# 空文字などが混ざっていた場合に備えて軽くフィルタ
self.signaling_urls = [u.strip() for u in signaling_urls if u and u.strip()]

def _load_env_file(self, env_file: str) -> None:
"""環境変数ファイルを読み込む"""
Expand Down Expand Up @@ -85,7 +91,11 @@ def _parse_libwebrtc_log(self, value: str | None) -> SoraLoggingSeverity | None:

@property
def channel_id(self) -> str:
"""TEST_CHANNEL_ID_PREFIX と TEST_CHANNEL_ID_SUFFIX を組み合わせて channel_id を生成する"""
"""TEST_CHANNEL_ID_PREFIX と TEST_CHANNEL_ID_SUFFIX を組み合わせて channel_id を生成する。
_channel_id_override が設定されている場合はそれを優先する。
"""
if self._channel_id_override:
return self._channel_id_override
return f"{self.channel_id_prefix}_{self.channel_id_suffix}"

def access_token(self, **kwargs: Any) -> str | None:
Expand Down
Loading
Loading