# Sora Flutter SDK ドキュメント ## 概要 Sora Flutter SDK は時雨堂の [WebRTC SFU Sora](https://sora.shiguredo.jp) を Flutter アプリケーションから利用するための SDK です。 ## LLM 向け LLM が読み込みやすい形式でドキュメントを提供しています。 - 目次は [llms.txt](/llms.txt) にあります - 全文は [llms-full.txt](/llms-full.txt) にあります ## 問い合わせについて Sora Flutter SDK の質問などについては Discord の `#sora-sdk-faq` をご利用ください。 ただし、 Sora のライセンス契約の有無に関わらず、応答時間と問題の解決を保証しませんのでご了承ください。 Sora Flutter SDK に対する有償のサポートについては提供しておりません。 - [Sora Flutter SDK 概要](overview.html) - [リリースノート](release.html) - [FAQ](faq.html) - [クイックスタート](quickstart.html) - [実装ガイド](getting_started.html) - [シグナリング設定](signaling.html) - [DataChannel 経由のシグナリング](datachannel_signaling.html) - [音声の送受信](audio.html) - [映像を描画する](video_rendering.html) - [カメラを利用して映像を送信する](camera_video_source.html) - [外部映像入力を利用する](external_video_source.html) - [外部カメラパッケージを利用する](external_camera_package.html) - [リアルタイムメッセージング機能](messaging.html) - [RPC 機能](rpc.html) - [タイムアウト機能](timeout.html) - [統計情報](stats.html) - [イベント](event.html) - [メディアデバイス](media_device.html) - [Android での利用方法](android.html) - [iOS / iPadOS での利用方法](ios_ipados.html) - [macOS での利用方法](macos.html) - [Windows での利用方法](windows.html) - [Linux での利用方法](linux.html) # Sora Flutter SDK 概要 Sora Flutter SDK は [株式会社時雨堂](https://shiguredo.jp) の [WebRTC SFU Sora](https://sora.shiguredo.jp) の Flutter 専用クライアントフレームワークです。 `iOS` 、 `iPadOS` 、 `macOS` 、 `Android` 、 `Windows` 、 `Linux` のクロスプラットフォームに対応しており、 WebRTC の複雑な接続処理を肩代わりする Dart API を提供します。 各プラットフォームで共通のソースコードを用いて、 Sora を利用するアプリケーションを最小限の実装で開発できます。 ## プラットフォーム | プラットフォーム | 対応状況 | | --- | --- | | `iOS` | iOS 16 以降に対応 | | `iPadOS` | iPadOS 16 以降に対応 | | `macOS` | macOS 15 以降に対応 | | `Android` | Android 10 (API 29) 以降に対応 | | `Windows` | 実験的対応 (Windows 10 20H2 以降 (x86_64)) | | `Linux` | 実験的対応 (Ubuntu 24.04 (x86_64)) | | ブラウザ | 今後対応予定 | ここでの対応状況は、各プラットフォームで `Sora Flutter SDK` を利用したアプリケーションをビルドして実行できることを示します。 機能ごとの対応範囲や利用条件は、使用するプラットフォームと機能の説明をご確認ください。 ## 主な仕様・特徴 ### ピア接続処理 `SoraConnection` を介して Sora との接続を確立し、メディアチャネルの通信を管理します。 タイムアウト設定や接続状態の監視、切断後の再接続にも対応しています。 ### メディアデバイス管理 `MediaDevices` API により、各プラットフォームのカメラ・マイクを統一的なインターフェースで扱えます。 音声入力デバイス、音声出力デバイス、映像入力デバイスの列挙や、 `getUserMedia()` による送信用 `MediaStream` の生成が可能です。 ### 映像レンダリング ローカル映像およびリモート映像のレンダリングは Flutter の `Texture` ウィジェットで行います。 `LocalVideoTrack` や `SoraTrackEvent` から取得した `textureId` を `Texture` に渡すだけで、プラットフォームを問わず統一された描画が得られます。 ### 音声再生 受信音声は SDK と各プラットフォームの標準経路で自動的に再生されます。 アプリケーション側で音声再生用の `Widget` やプレーヤーを別途用意する必要はありません。 受信音声トラックの追加または削除の検知は `SoraConnection.events` で行えます。 ### DataChannel 経由のシグナリング WebSocket によるシグナリングに加えて、 DataChannel 経由のシグナリングに対応しています。 接続開始時は WebSocket を使い、 Sora 側の条件が揃うと DataChannel ベースのシグナリングへ切り替わります。 ### リアルタイムメッセージング DataChannel を利用したリアルタイムメッセージング機能により、同一チャネルに参加しているクライアント同士でメッセージの送受信ができます。 メッセージにはラベルを指定でき、同一チャネル内で複数種類のメッセージを扱えます。 ### RPC 機能 DataChannel 経由で Sora の一部 HTTP API をクライアント側から呼び出せます。 接続確立後に `SoraConnection.rpc()` で利用可能なメソッドを呼び出し、サイマルキャストの視聴画質変更などが行えます。 ### 外部映像入力 SDK 管理のカメラ入力の代わりに、アプリケーション側で用意した任意の映像フレームを送信できます。 画面キャプチャ、動画ファイル再生、合成映像、テストパターンなど、カメラ以外の映像ソースを扱う場合に利用します。 ### イベント通知 `SoraConnection.events` で接続状態の変化、リモートトラックの追加/削除、シグナリング通知、DataChannel メッセージ、タイムアウトなどを統合的に購読できます。 `SoraConnection.debugEvents` では構造化されたログとタイムラインを購読でき、デバッグに役立ちます。 ## 非対応の機能 次の機能は現時点では非対応です。 - 音声のステレオ送受信 ( Sora の実験的機能である Opus の詳細パラメータで `stereo` を指定する場合を除く) - 端末のカメラ・マイクのプライバシーインジケーターを消灯させるミュート - リモート音声ごとの再生音量変更 - 再生先スピーカーの明示的な切り替え - `TURN` 機能を無効にした接続 ( `turn = false` ) - 自己署名証明書 ## ソースコード ## 開発者ツール ## 問い合わせについて Sora Flutter SDK の質問などについては Discord の `#sora-sdk-faq` チャンネルにお願いします。 ただし、 Sora のライセンス契約の有無に関わらず、応答時間と問題の解決を保証しませんのでご了承ください。 Sora Flutter SDK に対する有償のサポートについては提供しておりません。 またビルドやパッケージングに関する質問に対しては、コミュニティ管理者は回答は行いません。 # リリースノート **CHANGE** : 後方互換性のない変更 **UPDATE** : 後方互換性がある変更 **ADD** : 後方互換性がある追加 **FIX** : バグ修正 ## 2026.1.0 **日時**: 2026-09-16 **対象 SDK**: `2026.1.0` **対応 Sora**: `2025.2.0` 以降 **対応 libwebrtc**: `m150(7871)` `Sora Flutter SDK` の最初のリリースです。 # FAQ ## 一般 ### Sora Flutter SDK のライセンスはなんですか? Sora Flutter SDK はオープンソースライセンス [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0.html) で配布しています。 ### Sora Flutter SDK は WebRTC SFU Sora 以外の SFU にも利用できますか? WebRTC SFU Sora のシグナリング方式に対応している SFU であれば利用できます。 ### Sora Flutter SDK はどのプラットフォームに対応していますか? 対応プラットフォームについては [プラットフォーム](overview.html#プラットフォーム) をご確認ください。 ### サンプルコードはありますか? サンプルコードはありませんが、 Sora に接続できる開発者ツール実装を用意しています。 をご確認ください。 ### ソースコードをダウンロードする際の注意点はありますか? `git clone` などでソースコードをダウンロードする場合は、タグを指定するか `main` ブランチを選択してください。 ### Dart 以外の言語で使えますか? 使えません。 Sora Flutter SDK は Dart 専用です。 ## 設定 ### Sora Flutter SDK が対応している音声コーデックを教えてください Sora Flutter SDK は以下の音声コーデックに、すべてのプラットフォームで対応しています。 - Opus ### Sora Flutter SDK が対応している映像コーデックを教えてください 映像コーデックの実際の対応状況は、プラットフォームと端末 / OS バージョンに依存します。SDK は次の映像エンコーダー / デコーダーを利用します。 | バックエンド | 対応プラットフォーム | エンコード | デコード | | --- | --- | --- | --- | | ソフトウェア | 全プラットフォーム | `VP8` / `VP9` / `AV1` | `VP8` / `VP9` / `AV1` | | `Apple VideoToolbox` | `iOS` / `macOS` | `H.264` / `H.265` | `H.264` / `H.265` | | `Android MediaCodec` | `Android` | `H.264` / `H.265` / `VP8` / `VP9` / `AV1` | `H.264` / `H.265` / `VP8` / `VP9` / `AV1` | > **注釈** > > ハードウェアエンコード / デコードの実際の対応状況は、端末と OS バージョンに依存します。上記の表にないプラットフォームで `H.264` / `H.265` を利用できるとは限りません。 `Sora.supportedVideoCodecTypes` で、実行中のプラットフォームのデコーダーが対応するコーデックを確認できます。 ```dart final codec = VideoCodecType.h264; final supportedCodecs = Sora.supportedVideoCodecTypes; if (!supportedCodecs.contains(codec)) { throw UnsupportedError('このプラットフォームのデコーダーは対応していません'); } ``` この API は実際にハードウェア / ソフトウェアデコードが可能なコーデックを返します。送信側のエンコーダー対応を保証する API ではないため、 `videoCodecType` に指定したコーデックで送信できることを確認する用途には使えません。 ### `H.264` と `H.265` を利用する条件はありますか? あります。 `H.264` / `H.265` のハードウェアエンコード / デコードを利用するには、 `iOS` / `macOS` デバイスが `Apple VideoToolbox` に、 `Android` デバイスが `Android MediaCodec` に対応している必要があります。端末や OS バージョンによって利用できない場合があります。 ### ハードウェアアクセラレーターを利用するにはどうすればいいですか? ハードウェアアクセラレーターを利用する際は以下の条件を全て満たしている必要があります。 - デバイスが利用したいコーデックのハードウェアアクセラレーターを搭載していること - SDK が対象プラットフォームのハードウェアエンコーダー / デコーダーに対応していること Sora Flutter SDK では、 `iOS` / `macOS` は `Apple VideoToolbox` 、 `Android` は `Android MediaCodec` を利用します。 `Windows` / `Linux` の `H.264` / `H.265` 対応は、このハードウェア対応表には含まれません。 ### 受信したリモート映像の黒画面が相手の切断後も残り続けるのはなぜですか? 受信側が対応していないコーデックの映像を受信すると、画面が真っ黒のままで映像が表示されない状態になります。この黒画面が相手の切断後も残り続けることがあります。 非対応コーデックの映像の場合、answer で拒否するため映像フレームが届かず、映像が映らない状態が続きます。このとき、 `SoraTrackEvent` で映像トラックが通知されるため、 `textureId` を取得することはできます。 通常は相手が切断した際、 `SoraRemoveTrackEvent` が映像トラック削除時に通知され、 画面表示更新に利用することができます。しかし answer で拒否した映像トラックについては `libwebrtc` の挙動で通知されません。その結果、黒画面のままトラックが表示に残り続けます。 そのため、アプリケーション側で切断したコネクションのトラックが表示に残らないように対処してください。削除のタイミングと方法はアプリケーションの設計に合わせてください。例えば、相手の切断時に `connection.destroyed` 通知を受けて該当するコネクションのトラックを削除する方法があります。 また、自分側から切断した場合は、 SDK が全リモート映像トラックを破棄し、 `SoraRemoveTrackEvent` が通知されます。 Sora Flutter SDK の DevTools では、 `connection.destroyed` 通知で切断したコネクションのトラックを削除しています。実装は [devtools](https://github.com/shiguredo/sora-flutter-sdk/tree/develop/devtools) をご確認ください。 > **注釈** > > `SoraRemoveTrackEvent` を受けて映像トラックを取り除く実装は、 [削除されたリモートトラックの映像を取り除く](video_rendering.html#削除されたリモートトラックの映像を取り除く) をご確認ください。 > **注釈** > > シグナリング通知を無効にしている場合は `connection.destroyed` が通知されません。詳細は Sora のドキュメント [シグナリング通知](https://sora-doc.shiguredo.jp/SIGNALING_NOTIFY) をご確認ください。 ### 特定の解像度が利用できません `Android` / `iOS` / `macOS` デバイスのハードウェアアクセラレーターの問題で、指定した解像度を利用できないことがあります。 エンコーダー/デコーダーの初期化時に例外が発生したり、 エンコード/デコード処理が失敗して映像が正常に送受信されないケースがある場合があります。 > **ヒント** > > 160x120 など低めの解像度で問題が起きることが多いです。 > **注釈** > > 問題が発生した場合は Discord へフィードバックして頂けると助かります。 ### 自分の接続のコネクション ID を取得することはできますか? できます。 `SoraConnection` の `connectionId` プロパティで取得できます。 `connectionId` はサーバーから offer を受信したタイミングで更新されます。接続前や offer が未到達のときは `null` です。 ```dart conn.events.listen((event) { if (event case SoraConnectionStateChangedEvent(state: SoraConnectedState())) { // 接続後にコネクション ID を取得する print('connection_id: ${conn.connectionId}'); } }); ``` ### 受信したストリームと Sora のコネクション ID を紐づける方法はありますか? `conn.events` の `SoraTrackEvent` から受信したトラック ( `RemoteMediaStreamTrack` ) が通知されます。 `RemoteMediaStreamTrack` の `connectionId` フィールドに、そのストリームを配信しているクライアントのコネクション ID が設定されています。 ```dart conn.events.listen((event) { if (event case SoraTrackEvent(:final track)) { // track.connectionId で配信元のコネクション ID を取得できる print('track: ${track.trackId}, connection_id: ${track.connectionId}'); } }); ``` ### 受信したストリーム毎に音量を変更する方法はありますか? Sora Flutter SDK の公開 API では、リモート音声ごとの再生音量変更はできません。 受信音声は SDK とプラットフォームの標準経路で再生され、出力のルーティングは OS と `libwebrtc` の挙動に従います。 詳細は [音声の送受信](audio.html) をご確認ください。 ### 音声のステレオ送受信に対応していますか? 音声の送信、受信ともに対応していません。ただし、Sora の実験的機能である Opus の詳細パラメータで `stereo` / `sprop_stereo` を指定することでステレオを利用できます。利用する場合は、事前に Sora のサポートへ連絡してください。詳細は [シグナリング設定](signaling.html) をご確認ください。 ### Sora の TURN 機能を無効にして利用できますか? できません。 Sora Flutter SDK は Sora を `turn = false` に設定して利用することはできません。 ### 接続/切断処理を連続して実行しても安全ですか? Sora Flutter SDK では `connect()` 呼び出し時に既存の接続を自動で切断し、 `disconnect()` も内部で直列化されています。 そのため、ユーザーが接続/切断ボタンを連続してタップした場合でも、SDK 側で処理の衝突は防がれます。 ただし、意図しないタイミングでの切断や再接続を避けるため、UI 側でのボタン連打抑止の実装を推奨します。 ### Sora への切断後に同じ `SoraConnection` インスタンスで再接続できますか? できます。 `disconnect()` を呼んだあと、同じインスタンスで再度 `connect()` を呼ぶことができます。 完全に使い終わった場合は、 `disconnect()` の後に `dispose()` を呼んでリソースを破棄してください。 ### 接続に失敗した場合のタイムアウト時間を変更できますか? できます。 `SoraConnectionConfig` の `timeoutOptions` で `SoraTimeoutOptions` を指定してください。 - `connectionTimeout`: WebSocket 接続から PeerConnection 接続完了まで (デフォルト: 30 秒) - `disconnectWaitTimeout`: `disconnect()` のリソース解放待機 (デフォルト: 10 秒) - `signalingCandidateTimeout`: WebSocket 接続確立時間 (デフォルト: 5 秒) 詳細は [タイムアウト機能](timeout.html) をご確認ください。 ### 音声・映像の送信をミュートで無効化できますか? `setAudioEnabled(false)` や `setVideoEnabled(false)` で音声・映像トラックの送信を無効化できます。 ただし、これらの API はトラックの `enabled` を切り替えるものであり、マイクやカメラのハードウェア自体を停止するわけではありません。 そのため、端末のプライバシーインジケーター (ランプ) の消灯は保証しません。 詳細は [メディアデバイス](media_device.html) をご確認ください。 ### 受信した音声の音量が小さいです ( `iOS` / `iPadOS` ) `iOS` / `iPadOS` では `AVAudioSession` の category が `.playAndRecord` に設定されるため、受信音量が小さくなる場合があります。 これは `iOS` の仕様です。必要に応じて接続後に `AVAudioSession` の category を変更してください。 ### 自己署名証明書は利用できますか? できません。優先実装にて対応可能です。 ### 音声の再生先 (スピーカー) を選択できますか? Sora Flutter SDK の公開 API では、再生先スピーカーの明示的な切り替えはできません。 音声の再生先は、基本的に各プラットフォームの標準ルーティングに従います。 - `Android`: `Android` / `libwebrtc` の音声ルーティングに従います - `iOS` / `iPadOS`: `AVAudioSession` の音声ルートに従います。 `AirPlay` や `Bluetooth` などの再生先をユーザーに選ばせる場合は、アプリケーション側で `AVRoutePickerView` を利用してください - `macOS`: `OS` / `CoreAudio` の既定音声出力へ再生されます 現在利用可能な音声出力デバイスの一覧は `MediaDevices.enumerateAudioOutputDevices()` で取得できます。 詳細は [音声の送受信](audio.html) をご確認ください。 ## リアルタイムメッセージング機能 ### いつ sendDataChannelMessage() を呼べばよいですか? `connect()` 呼び出し後、 `SoraDataChannelOpenEvent` で対象 label の open を確認してから送ってください。DataChannel が未確立のまま送ると、内部実装上は該当 channel が見つからず送信されません。 ```dart import 'dart:convert'; import 'dart:typed_data'; conn.events.listen((event) { if (event is SoraDataChannelOpenEvent && event.event.label == '#messaging') { conn.sendDataChannelMessage( '#messaging', Uint8List.fromList(utf8.encode('ready')), ); } }); ``` ### `compress: true` のとき、アプリケーション側で圧縮や展開は必要ですか? 不要です。 送信時は SDK が `compress` 設定に従って `sendDataChannelMessage()` の payload を圧縮し、受信時は SDK が展開したうえで `SoraDataChannelMessageEvent` に渡します。 ## サポート ### Sora Flutter SDK の有償サポートは提供していますか? Sora Flutter SDK の有償サポートは提供しておりません。 ### 質問やバグ報告はどこで行えますか? Sora Flutter SDK についての質問やバグ報告は Discord の `#sora-sdk-faq` チャンネルにお願いします。 ただし、 Sora のライセンス契約の有無に関わらず、応答時間と問題の解決を保証しませんのでご了承ください。 ### 質問やバグ報告にあたり、どのような情報を記載すればよいですか? 次の開発環境のバージョンを「メジャーバージョン、マイナーバージョン、メンテナンスバージョン」まで含めて記載してください。 - 利用している Sora のバージョン - 利用している Sora Flutter SDK のバージョン - 利用している Flutter SDK のバージョン - 利用している Dart のバージョン - 動作させている OS とそのバージョン ( `iOS` / `Android` / `macOS` / `Windows` など) - 動作させているデバイスのモデル名 ( `Android` や `iOS` 端末の場合) - 開発環境のバージョン ( `Android Studio` / `Xcode` など) ## リリース ### リリースサイクルを教えてください Sora のメジャーバージョンアップ後に、 Sora へ追従を目的としたバージョンアップを行います。 ただし、具体的なリリースサイクルはありません。 # クイックスタート この章では、`devtools` を使って `Sora Flutter SDK` の動作を確認するまでの最短手順を説明します。 `devtools` は `Sora` への接続確認や `SDK` の動作検証に使える `Flutter` アプリケーションです。 ソースコードは次のリポジトリで配布しています。 - [sora-flutter-sdk](https://github.com/shiguredo/sora-flutter-sdk) > **注釈** > > アプリケーションをゼロから実装する手順については [実装ガイド](getting_started.html) をご確認ください。 ## 用意するもの - `Dart` `3.11.1` 以上と `Flutter` `3.44.0` 以上がセットアップ済みであること> `Flutter` のセットアップについては [Flutter ドキュメント](https://docs.flutter.dev/) をご確認ください。 - 利用する `Sora` 環境があり、接続できる状態であること> `Sora` については [Sora ドキュメント](https://sora-doc.shiguredo.jp/) をご確認ください。 - 接続先の `Sora` シグナリング URL が分かっていること - 接続先のチャネル ID が決まっていること ### 動作環境 | プラットフォーム | 要件 | | --- | --- | | `Android` | Android 10 (API 29) 以降の実機 | | `iOS` / `iPadOS` | iOS 16 / iPadOS 16 以降の実機 (シミュレーターは非推奨) | | `macOS` | macOS 15 以降、Apple Silicon | | `Windows` | Windows 10 20H2 以降、x86_64 | | `Linux` | Ubuntu 24.04、x86_64 | ## ソースコードの入手 ```bash $ git clone https://github.com/shiguredo/sora-flutter-sdk.git $ cd sora-flutter-sdk $ git checkout develop $ cd devtools ``` > **注釈** > > この手順は `sora-flutter-sdk` の `develop` ブランチを対象にしています。 ## 依存関係の取得 以降のコマンドは `SDK` の `devtools` ディレクトリで実行します。 ```bash $ flutter pub get ``` ## 接続情報の設定 接続する `Sora` のシグナリング URL とチャネル ID を設定します。 設定ファイルのテンプレートをコピーし、環境に合わせて編集してください。 ```bash $ cp lib/configs/environment.example.dart lib/configs/environment.dart ``` `lib/configs/environment.dart` を開き、`Environment.urls` と `Environment.channelId` を設定します。 `Environment.urls` はシグナリング URL のリストです。 ```dart class Environment { static const urls = ['wss://sora.example.com/signaling']; static const channelId = 'sora-example-channel-id'; } ``` `environment.dart` は `devtools` の `.gitignore` で除外されています。 接続先を設定したこのファイルを `Git` の管理対象へ追加しないでください。 ロール、音声、映像の設定は、起動後に `Connect` ボタンから開く接続設定画面で変更します。 既定値は `Role` が `sendrecv` 、`Connect Audio` と `Connect Video` が `true` です。 ## 起動 端末をマシンに接続するか、エミュレーター / シミュレーターを起動した状態で実行します。 ```bash # 接続されているデバイス一覧を確認する $ flutter devices # デバイスを指定して起動する $ flutter run -d ``` デバイスが 1 つだけの場合は `-d` を省略できます。 ```bash $ flutter run ``` ## Sora に接続する アプリケーションが起動したら、接続設定を確認して `Connect` ボタンを押すと `Sora` に接続します。 - `Role` を `sendrecv` にすると、音声と映像を送受信します - `Role` を `recvonly` にすると、カメラとマイクを使わずに受信します - 音声だけを送信する場合は、`sendrecv` または `sendonly` にして `Connect Audio` を `true` 、`Connect Video` を `false` にします `sendrecv` で音声と映像を送信する場合は、カメラとマイクが必要です。 実機での確認を推奨します。 エミュレーター / シミュレーターに入力デバイスがない場合は、`recvonly` または映像を無効にした設定で確認してください。 `recvonly` で受信するには、同じチャネルへ映像または音声を送信する別のクライアントが必要です。 - `Android` ではカメラとマイクの権限許可を求めるダイアログが表示されます。許可してください。 - `iOS` / `iPadOS` では初回起動時にカメラとマイクの権限許可を求めるダイアログが表示されます。許可してください。 - `macOS` ではシステム環境設定からカメラとマイクのアクセスを許可する必要があります。 接続に成功すると、画面の接続状態に `State: connected` と表示されます。 映像送信を有効にした場合はローカル映像も表示されます。 接続できなかった場合は `Sora` のシグナリング URL やネットワークの状態を確認してください。 双方向でビデオチャットするには、別のクライアントから同じチャネル ID で接続してください。 `Sora DevTools` や別の `Sora SDK` を使ったクライアントと相互に通信できます。 ## 関連ページ - [実装ガイド](getting_started.html) をご確認ください - [Sora 開発者ツール (DevTools)](https://sora-doc.shiguredo.jp/DEVTOOLS) をご確認ください - [Sora ドキュメント](https://sora-doc.shiguredo.jp/) をご確認ください # 実装ガイド ## 概要 Sora Flutter SDK を利用して Sora に接続する方法について説明します。 ## 前提 - Flutter がセットアップ済みであること> Flutter のセットアップ方法については [Flutter ドキュメント](https://docs.flutter.dev/) をご確認ください。 - 利用する Sora 環境があり、接続できる状態であること> Sora については [Sora ドキュメント](https://sora-doc.shiguredo.jp/) をご確認ください。 - 接続先の Sora シグナリング URL が分かっていること - 接続先のチャネル ID が決まっていること ## 1. パッケージを追加する `sora_sdk` は `pub.dev` から追加して利用できます。 ```bash flutter pub add sora_sdk ``` または `pubspec.yaml` に直接追加します。 ```yaml dependencies: sora_sdk: ^2026.1.0 ``` 追加後に依存関係を取得してください。 ```bash flutter pub get ``` ## 2. SDK を import する 接続処理を書く Dart ファイルの先頭に、次の import を追加します。 最初は `lib/main.dart` や、接続処理を実装するファイルに追加してください。 ```dart import 'package:sora_sdk/sora_sdk.dart'; ``` ## 3. 接続設定を作成する `role` には接続時の送受信方式を指定します。 ```dart final config = SoraConnectionConfig( signalingUrls: [ 'wss://sora.example.com/signaling', ], channelId: 'example-channel', role: SoraRole.sendrecv, video: true, audio: true, ); ``` `signalingUrls` は `List` で複数指定できます。 `Sora Flutter SDK` は先頭から順番に `WebSocket` 接続を試みます。 接続を試みた URL が不正な場合は、その時点で接続処理を中止し、残りの URL は試しません。 不正な URL の例は次のとおりです。 - `not-a-url` :URL の形式になっていない - `https://sora.example.com/signaling` :`ws` / `wss` 以外で始まる - `wss:///signaling` :接続先のホスト名がない URL の検証に成功し、候補 URL ごとの `WebSocket` 接続に失敗した場合、またはタイムアウトした場合は、次の URL を試します。 ## 4. SoraConnection を生成する ```dart final conn = await Sora.createConnection(config); ``` `Sora.createConnection()` の時点で、プラットフォーム側のクライアント初期化と WebRTC クライアントの生成が行われます。 ## 5. 接続状態を確認する 接続状態の変化やリモートトラックの追加を確認したい場合は、接続前に `conn.events` でイベントを購読します。 イベント購読の例です。下記以外にも購読可能なイベントがあります。 イベントの一覧や発火タイミングの詳細は [イベント](event.html) をご確認ください。 ```dart final subscription = conn.events.listen((event) { switch (event) { case SoraConnectionStateChangedEvent(:final state): // 接続状態の変化を確認する print('connection state: $state'); if (state case SoraDisconnectedState(:final closeInfo)) { if (closeInfo != null) { // 切断時に close code / reason を取得できた場合 print('close: ${closeInfo.code} ${closeInfo.reason}'); } } case SoraConnectionErrorEvent(:final code, :final message): // 接続エラーは別イベントで通知される print('connection error: ${code ?? 'unknown'} ${message ?? ''}'); case SoraTrackEvent(:final track): if (track.kind == 'video') { // 受信した映像トラックの追加を確認する print('remote video added: texture=${track.textureId}'); } else { // 受信した音声トラックの追加を確認する print('remote audio added: ${track.trackId}'); } case SoraRemoveTrackEvent(:final track): // 受信していたトラックの削除を確認する print('remote track removed: ${track.trackId}'); default: break; } }); ``` `StreamSubscription` を使う場合はファイル先頭に `import 'dart:async';` が必要です。 ## 6. Sora に接続する `connect()` に `MediaStream` を渡すかどうかは、送信設定で決まります。 - `role` が `recvonly` の場合は `MediaStream` を渡さない - `role` が `sendonly` / `sendrecv` の場合- `audio` と `video` が両方 `false` の場合は `MediaStream` を渡さない - 上記以外(音声または映像を送信する)の場合は `MediaStream` を渡す ```dart final stream = await MediaDevices.getUserMedia( const GetUserMediaOptions(audio: true, video: true), ); final localTextureId = await stream.getVideoTracks().first.textureId; await conn.connect(stream); ``` 接続が完了すると、 `conn.events` から `SoraConnectionStateChangedEvent` として `SoraConnectedState` が通知されます。 ## 7. Flutter で映像をレンダリングする ローカル映像は `LocalVideoTrack.textureId` 、リモート映像は `conn.events` の `SoraTrackEvent` (`kind == 'video'`) で受け取る `textureId` を、それぞれ `Texture` ウィジェットに渡します。 映像レンダリング詳細については [映像を描画する](video_rendering.html) をご確認ください。 ```dart class VideoView extends StatelessWidget { const VideoView({super.key, required this.textureId}); final int textureId; @override Widget build(BuildContext context) { return Texture(textureId: textureId); } } ``` 複数のリモート映像が来る可能性があるため、リスト管理にしておくと扱いやすくなります。 ## 8. Sora 接続の切断とリソース破棄 Sora から切断するには `disconnect()` を呼びます。 接続を再利用する場合は、切断後も同じインスタンスを使って再度 `connect()` を呼ぶことができます。 完全に使い終わったら、 `disconnect()` の後に `dispose()` を呼んでリソースを破棄してください。 ```dart await conn.disconnect(); await conn.dispose(); ``` 購読している `StreamSubscription` も破棄する必要があります。 `getUserMedia()` で作成した `LocalMediaStream` や track の解放方法は [メディアデバイス](media_device.html) をご確認ください。 ```dart for (final subscription in subscriptions) { await subscription.cancel(); } ``` ## 最小サンプル Sora に `recvonly` で接続し、映像を受信したときに `textureId` をログ出力する最小サンプルです。 ```dart import 'dart:async'; import 'package:sora_sdk/sora_sdk.dart'; Future startSora() async { final conn = await Sora.createConnection( SoraConnectionConfig( signalingUrls: [ 'wss://sora.example.com/signaling', ], channelId: 'example-channel', role: SoraRole.recvonly, ), ); final subscriptions = >[ conn.events.listen((event) { switch (event) { case SoraConnectionStateChangedEvent(:final state): print('state=$state'); if (state case SoraDisconnectedState(:final closeInfo)) { if (closeInfo != null) { print('close=${closeInfo.code} ${closeInfo.reason}'); } } case SoraConnectionErrorEvent(:final code, :final message): print('error=${code ?? 'unknown'} ${message ?? ''}'); case SoraTrackEvent(:final track): if (track.kind == 'video') { print('remote texture=${track.textureId}'); } default: break; } }), ]; try { await conn.connect(); } catch (_) { for (final subscription in subscriptions) { await subscription.cancel(); } await conn.dispose(); rethrow; } // 必要に応じて後で切断する await conn.disconnect(); for (final subscription in subscriptions) { await subscription.cancel(); } await conn.dispose(); } ``` ## 関連ページ - 接続オプション: [シグナリング設定](signaling.html) - イベント: [イベント](event.html) - 映像描画 (`Texture`): [映像を描画する](video_rendering.html) - OS 別の権限や注意点: [プラットフォーム](overview.html#プラットフォーム) と以下の OS 向けドキュメント- [Android での利用方法](android.html) - [iOS / iPadOS での利用方法](ios_ipados.html) - [macOS での利用方法](macos.html) - よくある質問: [FAQ](faq.html) - カメラ / マイク: [メディアデバイス](media_device.html) - 音声: [音声の送受信](audio.html) - DataChannel の利用: [DataChannel 経由のシグナリング](datachannel_signaling.html) と [リアルタイムメッセージング機能](messaging.html) - RPC 機能: [RPC 機能](rpc.html) # シグナリング設定 ## 概要 Sora に接続するにはシグナリングの設定が必要です。シグナリングとは、 Sora とクライアント間で接続のネゴシエーションを行うための仕組みです。 Sora のシグナリングの詳細については Sora のドキュメント [WebSocket 経由のシグナリング](https://sora-doc.shiguredo.jp/SIGNALING) をご確認ください。 ## 必須項目 Sora に接続するには `シグナリング URL` と `チャネル ID` 、 `ロール` を指定する必要があります。 ```dart final config = SoraConnectionConfig( signalingUrls: [ 'wss://example.com/signaling', ], channelId: 'example-channel', role: SoraRole.sendrecv, ); ``` ## シグナリング URL **項目**: `signalingUrls` **型**: `List` Sora に接続するシグナリング URL をリストで指定してください。 接続先が一つの場合でもリストで指定してください。 - **URL の検証エラー**- 接続を試みた URL の構文が不正な場合、スキームが `ws` / `wss` ではない場合、またはホスト名がない場合は、 `connect()` が `ArgumentError` を投げ、残りの URL は試しません - **接続失敗**- URL の検証に成功しても `WebSocket` 接続を確立できない場合は、次の URL を試します - 最後に試した URL でもタイムアウト以外の接続失敗が発生した場合は、その接続エラーを返します - **タイムアウト**- 候補 URL への `WebSocket` 接続が `signalingCandidateTimeout` を超えた場合は、次の URL を試します - 最後の候補 URL がタイムアウトした場合は、 `connect()` が `StateError` を投げます ### シグナリング URL を一つだけ設定する例 ```dart final config = SoraConnectionConfig( signalingUrls: [ 'wss://example.com/signaling', ], channelId: 'example-channel', role: SoraRole.sendrecv, ); ``` ### シグナリング URL を複数設定する例 ```dart final config = SoraConnectionConfig( // wss://1.example.com/signaling から順番に接続を試みます signalingUrls: [ 'wss://1.example.com/signaling', 'wss://2.example.com/signaling', 'wss://3.example.com/signaling', ], channelId: 'example-channel', role: SoraRole.sendrecv, ); ``` ## チャネル ID **項目**: `channelId` **型**: `String` 接続する Sora のチャネル ID を文字列で指定してください。 Sora の仕様では、チャネル ID に指定できる長さは 1 バイト以上 255 バイト以下です。 SDK は空文字列だけを `ArgumentError` として検証し、長さの上限は検証しません。 上限を超える値を指定した場合の接続可否は Sora の仕様に従います。 ## ロール **項目**: `role` **型**: `SoraRole` Sora に接続する際のロールを指定してください。 ### SoraRole.sendrecv 双方向での通信を行いたい場合は `SoraRole.sendrecv` を指定します。 ### SoraRole.sendonly 音声や映像のみを配信するだけで、視聴はしない場合は `SoraRole.sendonly` を指定します。 ### SoraRole.recvonly 配信はせず、音声や映像を視聴するだけの場合は `SoraRole.recvonly` を指定します。 ## 音声 **項目**: `audio` **型**: `bool?` **デフォルト**: `null` 音声を利用するかどうかを指定します。 `null` で追加設定がない場合は `connect` メッセージの `audio` キーを省略し、 Sora のデフォルト (`true`) を適用します。 この項目はロールによって挙動が変わります。 - `SoraRole.sendrecv` と `SoraRole.sendonly` の場合は、音声を配信するかどうか - `SoraRole.recvonly` の場合は、音声を受信するかどうか この項目はあくまでクライアントと Sora の間で音声をやりとりするかどうかの指定であり、音声デバイスから取得した音声を送るかどうかや、 受信した音声を再生するかどうかとは別の設定です。これらは [メディアデバイス](media_device.html) と [音声の送受信](audio.html) をご確認ください。 ```dart final config = SoraConnectionConfig( signalingUrls: [ 'wss://example.com/signaling', ], channelId: 'example-channel', role: SoraRole.sendrecv, audio: true, ); ``` ### 音声コーデック指定 **項目**: `audioCodecType` **型**: `AudioCodecType?` **デフォルト**: `null` 未指定の場合は Sora 側のデフォルトコーデックが利用されます。 Sora は音声コーデックに `AudioCodecType.opus` のみを利用するため、この項目を明示的に指定する必要はありません。 ```dart final config = SoraConnectionConfig( signalingUrls: [ 'wss://example.com/signaling', ], channelId: 'example-channel', role: SoraRole.sendrecv, audio: true, audioCodecType: AudioCodecType.opus, ); ``` ### 音声ビットレート指定 **項目**: `audioBitRate` **型**: `int?` **デフォルト**: `null` 音声ビットレートは kbps 単位の整数で指定する最大ビットレートです。指定可能な範囲は 6 〜 510 kbps です。 `null` の場合は `bit_rate` を `connect` メッセージに含めず、 `Sora` 側の設定に委ねます。 値は `SoraConnectionConfig.toMap()` の実行時に検証され、範囲外の値を指定すると `RangeError` が送出されます。 ```dart final config = SoraConnectionConfig( signalingUrls: ['wss://sora.example.com/signaling'], channelId: 'example-channel', role: SoraRole.sendrecv, audio: true, audioBitRate: 32, ); // 511 を指定すると、toMap() の実行時に RangeError が送出されます。 // config.toMap(); ``` 詳細は Sora の [オーディオビットレート指定](https://sora-doc.shiguredo.jp/WEBSOCKET_SIGNALING#オーディオビットレート指定) をご確認ください。 ### 音声ストリーミングの言語コード **項目**: `audioStreamingLanguageCode` **型**: `String?` **デフォルト**: `null` 音声ストリーミングの言語コードを文字列で指定します。指定すると `connect` メッセージの `audio_streaming_language_code` に設定します。 `null` の場合はキーを送信しません。 `audio` が `false` の場合は音声を扱わないため送信しません。 Sora は指定された文字列をそのまま言語コードとして扱うため、 SDK は形式や長さを検証しません。 ```dart final config = SoraConnectionConfig( signalingUrls: ['wss://sora.example.com/signaling'], channelId: 'example-channel', role: SoraRole.sendrecv, audio: true, audioStreamingLanguageCode: 'ja-JP', ); ``` 詳細は Sora の [音声ストリーミング機能](https://sora-doc.shiguredo.jp/AUDIO_STREAMING) をご確認ください。 ### Opus の詳細パラメータ **項目**: `audioOpusParamsChannels` / `audioOpusParamsMaxplaybackrate` / `audioOpusParamsMinptime` / `audioOpusParamsPtime` / `audioOpusParamsStereo` / `audioOpusParamsSpropStereo` / `audioOpusParamsUseinbandfec` / `audioOpusParamsUsedtx` | フィールド | 型 | 説明 | | --- | --- | --- | | `audioOpusParamsChannels` | `int?` | Opus の `channels` (1 〜 8) | | `audioOpusParamsMaxplaybackrate` | `int?` | Opus の `maxplaybackrate` (8000 〜 48000 Hz) | | `audioOpusParamsMinptime` | `int?` | Opus の `minptime` (3 〜 120 ms) | | `audioOpusParamsPtime` | `int?` | Opus の `ptime` (ms)。 Sora の仕様に範囲が定義されていないため、 SDK は範囲を検証しません | | `audioOpusParamsStereo` | `bool?` | Opus の `stereo` | | `audioOpusParamsSpropStereo` | `bool?` | Opus の `sprop_stereo` | | `audioOpusParamsUseinbandfec` | `bool?` | Opus の `useinbandfec` | | `audioOpusParamsUsedtx` | `bool?` | Opus の `usedtx` 。有効にすると録画がおかしくなります | 指定した値は `connect` メッセージの `audio.opus_params` に含まれます。 `null` の項目はキーを含めません。 `audioOpusParams` を 1 つでも指定した場合は、 `codec_type` に `OPUS` が併記されます。 `channels` / `maxplaybackrate` / `minptime` は、 `SoraConnectionConfig.toMap()` の実行時に検証され、値が範囲外の場合に `RangeError` が送出されます。 > **重要** > > Opus の詳細パラメータの指定は Sora の実験的機能です。利用する場合は、事前に Sora のサポートへ連絡してください。 ```dart final config = SoraConnectionConfig( signalingUrls: ['wss://sora.example.com/signaling'], channelId: 'example-channel', role: SoraRole.sendrecv, audio: true, audioOpusParamsChannels: 2, audioOpusParamsStereo: true, ); ``` 詳細は Sora の [オーディオの Opus 設定指定](https://sora-doc.shiguredo.jp/WEBSOCKET_SIGNALING#オーディオの-opus-設定指定) をご確認ください。 ## 映像 **項目**: `video` **型**: `bool?` **デフォルト**: `null` ```dart final config = SoraConnectionConfig( signalingUrls: ['wss://sora.example.com/signaling'], channelId: 'example-channel', role: SoraRole.sendrecv, video: true, ); ``` 映像を利用するかどうかを指定します。 `null` で追加設定がない場合は `connect` メッセージの `video` キーを省略し、 Sora のデフォルト (`true`) を適用します。 この項目はロールによって挙動が変わります。 - `SoraRole.sendrecv` と `SoraRole.sendonly` の場合は、映像を配信するかどうか - `SoraRole.recvonly` の場合は、映像を受信するかどうか この項目はあくまでクライアントと Sora の間で映像をやりとりするかどうかの指定であり、映像デバイスから取得した映像を送るかどうかや、 受信した映像を再生するかどうかとは別の設定です。これらは [メディアデバイス](media_device.html) と [映像を描画する](video_rendering.html) をご確認ください。 ### 映像コーデック指定 **項目**: `videoCodecType` **型**: `VideoCodecType?` **デフォルト**: `null` 映像コーデックには `VideoCodecType.{vp8, vp9, av1, h264, h265}` が指定できます。未指定の場合は Sora 側で指定しているデフォルト映像コーデックが利用されます。 ```dart final config = SoraConnectionConfig( signalingUrls: ['wss://sora.example.com/signaling'], channelId: 'example-channel', role: SoraRole.sendrecv, video: true, videoCodecType: VideoCodecType.vp9, ); ``` ### 映像ビットレート指定 **項目**: `videoBitRate` **型**: `int?` **デフォルト**: `null` 映像ビットレートは kbps 単位の整数で指定する最大ビットレートです。指定可能な範囲は 1 〜 50000 kbps です。 `null` の場合は `bit_rate` を `connect` メッセージに含めず、 Sora 側の設定が使われます。 値は `SoraConnectionConfig.toMap()` の実行時に検証され、範囲外の値を指定すると `RangeError` が送出されます。最適なビットレートは映像の内容、解像度、フレームレート、コーデックによって異なります。 ```dart final config = SoraConnectionConfig( signalingUrls: ['wss://sora.example.com/signaling'], channelId: 'example-channel', role: SoraRole.sendrecv, video: true, videoBitRate: 1500, // kbps ); ``` ### コーデック固有パラメータ コーデック毎に固有のパラメータを指定できます。 #### videoVp9Params ```dart final config = SoraConnectionConfig( signalingUrls: ['wss://sora.example.com/signaling'], channelId: 'example-channel', role: SoraRole.sendrecv, video: true, videoCodecType: VideoCodecType.vp9, videoVp9Params: { 'profileId': 1, }, ); ``` 詳細は Sora のドキュメント [ビデオの VP9 設定指定](https://sora-doc.shiguredo.jp/WEBSOCKET_SIGNALING#ビデオの-vp9-設定指定) をご確認ください。 #### videoAv1Params 詳細は Sora のドキュメント [ビデオの AV1 設定指定](https://sora-doc.shiguredo.jp/WEBSOCKET_SIGNALING#ビデオの-av1-設定指定) をご確認ください。 #### videoH264Params 詳細は Sora のドキュメント [ビデオの H.264 設定指定](https://sora-doc.shiguredo.jp/WEBSOCKET_SIGNALING#ビデオの-h-264-設定指定) をご確認ください。 #### videoH265Params 詳細は Sora のドキュメント [ビデオの H265 設定指定](https://sora-doc.shiguredo.jp/WEBSOCKET_SIGNALING#ビデオの-h-265-設定指定) をご確認ください。 ## 転送フィルター **項目**: `forwardingFilters` **型**: `List>?` **デフォルト**: `null` `forwardingFilters` は、 `Sora` がこの接続へ転送する音声 / 映像の条件を指定します。 Sora の仕様では `SoraRole.sendrecv` または `SoraRole.recvonly` を対象としており、 `SoraRole.sendonly` では利用できません。 SDK は role による検証を行わないため、 `SoraRole.sendonly` を指定しても `forwarding_filters` は `connect` メッセージに含まれます。 シグナリング接続時に転送フィルターを利用するには、 `Sora` の `sora.conf` で `signaling_forwarding_filters = true` を設定する必要があります。 音声だけをこの接続へ転送する設定例は次のとおりです。 ```dart final config = SoraConnectionConfig( signalingUrls: [ 'wss://example.com/signaling', ], channelId: 'example-channel', role: SoraRole.sendrecv, forwardingFilters: >[ { 'action': 'allow', 'rules': >>[ >>[ { 'field': 'kind', 'operator': 'is_in', 'values': ['audio'], }, ], ], }, ], ); ``` この例では、 `kind` が `audio` のパケットだけを許可します。 `rules` の外側のリストは OR、内側のリストは AND を表します。 `forwardingFilters` のリストに複数の `Map` を指定すると、 `connect` メッセージにも複数の転送フィルターが含まれます。 複数の転送フィルターを指定する場合は、各フィルターに `name` と `priority` を指定し、 `name` は重複させないでください。 単一のフィルターで利用できるキーと値、複数のフィルターの指定方法については、 `Sora` のドキュメント [転送フィルター機能](https://sora-doc.shiguredo.jp/FORWARDING_FILTER) と [マルチ転送フィルター機能](https://sora-doc.shiguredo.jp/MULTI_FORWARDING_FILTER) をご確認ください。 SDK の `forwardingFilters` は、 `connect` メッセージでは `forwarding_filters` という名前になります。 SDK は `Map` のキーや値を変換、検証しません。 - `null` を指定した場合は、 `connect` メッセージに `forwarding_filters` を含めません - 空リストを指定した場合は、 `connect` メッセージに `forwarding_filters: []` を含めます - 1 つ以上の `Map` を指定した場合は、指定した `Map` を `forwarding_filters` の配列として含めます 空リストはキーを省略する設定とは異なりますが、この接続単位の転送フィルターは指定しません。 チャネル単位の転送フィルターを含む最終的な挙動は、 `Sora` の仕様に従います。 転送フィルターは音声 / 映像の転送だけを対象とし、 `DataChannel` を利用したメッセージングには適用されません。 また、シグナリング接続時に指定した転送フィルターは、接続したコネクションにだけ影響します。 ## 認証メタデータ **項目**: `metadata` **型**: `Object?` **デフォルト**: `null` Sora に接続する際に Sora の認証ウェブフックに渡すメタデータを指定します。 ```dart final config = SoraConnectionConfig( signalingUrls: ['wss://sora.example.com/signaling'], channelId: 'example-channel', role: SoraRole.sendrecv, video: true, videoCodecType: VideoCodecType.vp9, metadata: { 'jwt': 'a.b.c', }, ); ``` > **注釈** > > Sora の認証ウェブフックは接続単位で認証するための Sora 側の仕組みです。 ## クライアント ID **項目**: `clientId` **型**: `String?` **デフォルト**: `null` Sora に接続する際に、自分の接続に任意のクライアント ID を指定することができます。 ## バンドル ID **項目**: `bundleId` **型**: `String?` **デフォルト**: `null` Sora に接続する際に、自分の接続に任意のバンドル ID を指定することができます。 接続の束ね方を制御したい場合に利用します。 ## サイマルキャスト **項目**: `simulcast` **型**: `bool?` **デフォルト**: `null` サイマルキャストは同一映像で複数の画質の映像を同時に配信する仕組みです。 詳細は Sora のドキュメント [サイマルキャスト機能](https://sora-doc.shiguredo.jp/SIMULCAST) をご確認ください。 ### 送信側 サイマルキャストで同時に送れる映像の本数は、コーデック、解像度、ビットレートに依存しています。また仕様は WebRTC ライブラリによって異なります。 Sora の SDK で採用している WebRTC ライブラリである libwebrtc の仕様は Sora ドキュメントの [サイマルキャストの仕様](https://sora-doc.shiguredo.jp/SIMULCAST#67a936) をご確認ください。 ### 視聴側 **項目**: `simulcastRequestRid` **型**: `SimulcastRequestRid?` **デフォルト**: `null` `none` / `r0` / `r1` / `r2` のいずれかを指定します。 `null` の場合は `simulcast_request_rid` を `connect` メッセージに含めません。 サイマルキャストを視聴する側は接続時にどの画質の映像を受信するかを指定できます。これは接続後に Sora の HTTP API や [RPC 機能](rpc.html) を使って変更することができます。 ```dart final config = SoraConnectionConfig( signalingUrls: ['wss://sora.example.com/signaling'], channelId: 'example-channel', role: SoraRole.recvonly, simulcast: true, simulcastRequestRid: SimulcastRequestRid.r1, ); ``` ## スポットライト **項目**: `spotlight` **型**: `bool?` **デフォルト**: `null` スポットライト機能は「直近で話をしている N 人」の映像の画質を上げる機能です。スポットライト機能を有効にすると、 Sora は話者の切り替わりを検知して、直近で話をしている N 人の映像の画質を上げます。 詳細は Sora のドキュメント [スポットライト機能](https://sora-doc.shiguredo.jp/SPOTLIGHT) をご確認ください。 ### スポットライトがフォーカスした際に利用する映像の ID **項目**: `spotlightFocusRid` **型**: `SpotlightRid?` **デフォルト**: Sora の設定に依存 音声が一定時間以上続いた場合、その話者にスポットライトがフォーカスします。 その際に利用する映像の rid を指定します。rid は Sora 側で設定している spotlight_encodings の rid と一致させる必要があります。 > rid は `none` / `r0` / `r1` / `r2` のいずれかを指定します。 ### スポットライトがアンフォーカスした際に利用する映像の ID **項目**: `spotlightUnfocusRid` **型**: `SpotlightRid?` **デフォルト**: Sora の設定に依存 音声が一定時間以上続かなかったり、他の話者が話し始めた場合、スポットライトがアンフォーカスします。 その際に利用する映像の ID を指定します。ID は Sora 側で設定している spotlight_encodings の rid と一致させる必要があります。 > rid は `none` / `r0` / `r1` / `r2` のいずれかを指定します。 ## シグナリング通知メタデータ - signalingNotifyMetadata - 型: `Object?` - デフォルト: `null` - シグナリング通知に含めるメタデータを指定します ## タイムアウト ### timeoutOptions - 型: `SoraTimeoutOptions` - 省略可能。省略時は `SoraTimeoutOptions` のデフォルト値を使用します - WebRTC 接続ライフサイクルのタイムアウト設定です `SoraTimeoutOptions` では次の項目を設定できます。 - `connectionTimeout` (`Duration` 、デフォルト: 30 秒): WebSocket 接続から PeerConnection 接続完了までの待機時間 - `disconnectWaitTimeout` (`Duration` 、デフォルト: 10 秒): `disconnect()` のリソース解放待機時間 - `signalingCandidateTimeout` (`Duration` 、デフォルト: 5 秒): シグナリング URL への WebSocket 接続確立時間 ```dart final config = SoraConnectionConfig( signalingUrls: ['wss://sora.example.com/signaling'], channelId: 'example-channel', role: SoraRole.sendrecv, timeoutOptions: SoraTimeoutOptions( connectionTimeout: Duration(seconds: 60), disconnectWaitTimeout: Duration(seconds: 15), signalingCandidateTimeout: Duration(seconds: 10), ), ); ``` 詳しくは [タイムアウト機能](timeout.html) をご確認ください。 # DataChannel 経由のシグナリング ## 概要 Sora Flutter SDK における Sora の DataChannel をどのようにシグナリングや通知に利用するかを説明します。 ## DataChannel 経由のシグナリングとは Sora Flutter SDK は、 `dataChannelSignaling: true` を指定した場合、接続の開始時点では WebSocket を利用し、その後の状態によって DataChannel ベースのシグナリングへ切り替えます。 また Sora は、 DataChannel をシグナリングだけでなく、通知やプッシュ、統計、RPC 機能にも利用します。 Sora 接続におけるシグナリングの切り替わりは以下のような流れになります。 1. 接続開始時は WebSocket 経由でシグナリングする 2. `dataChannelSignaling: true` を指定している場合、Sora 側の条件が揃うと `switched` メッセージが届く 3. 以後のシグナリングや通知の一部は DataChannel ベースで扱われる 4. `ignore_disconnect_websocket` が有効な構成では、切り替え後に WebSocket を閉じる > **注釈** > > DataChannel 経由のシグナリングの詳細については Sora のドキュメントの [DataChannel 経由のシグナリング](https://sora-doc.shiguredo.jp/DATA_CHANNEL_SIGNALING) をご確認ください。 ## DataChannel 経由のシグナリングに関する設定 ### dataChannelSignaling WebSocket の後段で DataChannel 経由のシグナリングを利用する場合に `true` を指定します。 デフォルトは `null` (未指定) で、この場合は Sora 側の設定が使われます。 `dataChannelSignaling` は接続時の要求値です。 `true` を指定した場合でも切り替わりは保証されません。 実際に切り替わったかどうかは `SoraSwitchedEvent` で確認します。 ```dart final config = SoraConnectionConfig( signalingUrls: ['wss://example.com/signaling'], channelId: 'example-channel', role: SoraRole.sendrecv, video: true, audio: true, dataChannelSignaling: true, ); ``` #### シグナリング切り替え時に発火するイベント DataChannel 経由のシグナリングに切り替わると `SoraSwitchedEvent` イベントが発火します。 ```dart conn.events.listen((event) { if (event case SoraSwitchedEvent(:final message)) { print(message); } }); ``` `message` には、 `ignore_disconnect_websocket` のような switched メッセージの内容が含まれています。 イベントの詳細については [イベント](event.html) をご確認ください。 ### ignoreDisconnectWebSocket DataChannel 経由のシグナリングへ切り替わった後、WebSocket 切断を無視する構成にしたい場合に `true` を指定します。 デフォルトは `null` (未指定) で、この場合は Sora 側の設定が使われます。 この値は connect メッセージの `ignore_disconnect_websocket` として Sora に渡されます。 SDK 側では、 `switched` メッセージに `ignore_disconnect_websocket: true` が含まれていた場合に、WebSocket を閉じます。 ```dart final config = SoraConnectionConfig( signalingUrls: ['wss://example.com/signaling'], channelId: 'example-channel', role: SoraRole.sendrecv, dataChannelSignaling: true, ignoreDisconnectWebSocket: true, ); ``` ## 関連ページ - [シグナリング設定](signaling.html) - [リアルタイムメッセージング機能](messaging.html) - [RPC 機能](rpc.html) - [イベント](event.html) # 音声の送受信 ## 概要 Sora Flutter SDK における音声の送信(音声入力デバイスや `PushAudio` による入力)と受信(再生)について説明します。 音声の送信には `MediaDevices.getUserMedia()` で音声入力デバイスを取得し、受信は `audio: true` で接続すると SDK とプラットフォームの標準経路で自動的に再生されます。 アプリケーション側で音声再生用の `Widget` やプレーヤーを別途用意する必要はありません。 ## 用語 ### 音声トラック 音声トラックは 1 接続に存在する音声のストリーミングデータを表します。 送信する音声トラックは Sora のルールにより 1 本です。受信する音声トラックは接続相手の数だけ存在します。 例えば 3 人で通話している場合、送信する音声トラックは 1 本、受信する音声トラックは 2 本です。 ## 音声を送信する 音声を送信するには `MediaDevices.getUserMedia()` で `audio: true` を指定して `LocalMediaStream` を生成し、`SoraConnection.connect()` に渡します。 ```dart // MediaStream を生成する。 // 映像は今回関係ないため false としている。 final stream = await MediaDevices.getUserMedia( const GetUserMediaOptions(audio: true, video: false), ); // 接続用の SoraConnection インスタンスを生成する。 final conn = await Sora.createConnection( SoraConnectionConfig( signalingUrls: ['wss://sora.example.com/signaling'], channelId: 'example-channel', role: SoraRole.sendonly, audio: true, ), ); // 接続すると SDK が自動で音声入力デバイスのキャプチャを開始する。 await conn.connect(stream); ``` 音声送信では `SoraConnectionConfig.audio` を `true` にするか、未指定にします。未指定時は Sora のデフォルト (`true`) が適用されます。 ロールは `SoraRole.sendonly` または `SoraRole.sendrecv` です。 `recvonly` では音声入力デバイスは使用されません。 権限などのプラットフォーム固有の注意は、 [プラットフォームごとの注意](audio.html#プラットフォームごとの注意) をご確認ください。 ### 音声入力デバイスを選択する `GetUserMediaOptions.audioDeviceId` に `MediaDevices.enumerateAudioInputDevices()` で取得したデバイス ID を指定することで、 使用する音声入力デバイスを選択できます。省略時はプラットフォーム標準の入力が使用されます。 ```dart // 利用可能な音声入力デバイスの一覧を取得する final audioInputDevices = await MediaDevices.enumerateAudioInputDevices(); // 特定の音声入力デバイスを選択する final stream = await MediaDevices.getUserMedia( GetUserMediaOptions( audio: true, video: false, audioDeviceId: audioInputDevices.first.deviceId, ), ); ``` > **注釈** > > `Android` では `AudioDeviceInfo.id` は接続状態に依存して変わることがあります。ID を保持しての長期利用は推奨しません。 ### 送信音声のソフトミュート 接続中に `setAudioEnabled(false)` を呼ぶことにより送信音声トラックは無効となり、 入力デバイスからの音声は配信されなくなります(ソフトミュート)。 このとき実際にはパケットを送っており、無音のフレーム(デジタルサイレンスパケット)を送り続ける状態となります。 解除するには `setAudioEnabled(true)` を呼びます。 ```dart // 送信音声を無効化する conn.setAudioEnabled(false); // 送信音声を再開する conn.setAudioEnabled(true); // 現在の状態を確認する print(conn.isAudioEnabled); ``` > **注釈** > > ソフトミュートでは音声入力デバイスは無効化されないため、端末のプライバシーインジケーターは消灯しません。 ### 音声デバイスを使わない場合 実音声デバイスを使わずに接続する場合は、 `SoraConnectionConfig.useAudioDevice` に `false` を指定します。 この設定では、macOS / Windows / Linux で SDK が実音声デバイスを初期化せず、音声データをプッシュするためのデバイスを使用します。 ```dart final conn = await Sora.createConnection( SoraConnectionConfig( signalingUrls: ['wss://sora.example.com/signaling'], channelId: 'example-channel', role: SoraRole.sendonly, audio: true, useAudioDevice: false, ), ); ``` 接続前に `MediaDevices` の API を呼び出す場合は、 `Sora.createConnection()` より前に設定値を明示してください。 `MediaDevices.setUseAudioDevice(false)` を使用します。 `useAudioDevice` は共有 `PeerConnectionFactory` の音声デバイス設定であり、個別の track の設定ではありません。 共有の `PeerConnectionFactory` が生成された後に、異なる `useAudioDevice` を指定すると `StateError` が送出されます。 同じプロセス内で複数の接続を作る場合は、音声デバイス設定を統一してください。 ## PushAudioDevice を利用する 実音声デバイスを使わずにアプリケーション側の PCM を送受信する場合は、 `PushAudioDevice` を利用します。 ### 任意の PCM 音声を送信する `macOS` 、 `Windows` 、 `Linux` では、`useAudioDevice: false` を指定すると `PushAudioDevice` が選択されます。 アプリケーション側で生成した 10 ms 分の `Int16List` を `PushAudio.pushPcm()` に渡すことで、音声入力デバイスを使わずに音声を送信できます。 `Android` では `useAudioDevice` が無視され、常に実音声デバイスが使われます。 `iOS` では `useAudioDevice: false` による `PushAudioDevice` の経路を利用できません。 `MediaDevices.setUseAudioDevice(false)` は、`MediaDevices.createMediaStream()` 、`MediaDevices.createAudioTrack()` 、`Sora.createConnection()` など共有 `PeerConnectionFactory` を生成する API より前に呼び出してください。 接続設定にも `useAudioDevice: false` を指定し、同じプロセス内の接続で音声デバイス設定を統一します。 次の例は、48 kHz・モノラルの 440 Hz 正弦波を 10 ms ごとに送信します。 `PushAudio.pushPcm()` に渡す PCM は、サンプルをチャンネル間でインターリーブした `Int16List` にしてください。 ```dart import 'dart:async'; import 'dart:math' as math; import 'dart:typed_data'; import 'package:sora_sdk/sora_sdk.dart'; Future sendPcm() async { const sampleRate = 48000; const channels = 1; const samplesPer10ms = sampleRate ~/ 100; // 共有 PeerConnectionFactory の生成前に PushAudioDevice を選択する。 MediaDevices.setUseAudioDevice(false); final stream = MediaDevices.createMediaStream(); final audioTrack = await MediaDevices.createAudioTrack(); stream.addTrack(audioTrack); final conn = await Sora.createConnection( SoraConnectionConfig( signalingUrls: ['wss://sora.example.com/signaling'], channelId: 'example-channel', role: SoraRole.sendonly, audio: true, video: false, useAudioDevice: false, ), ); Timer? timer; try { await conn.connect(stream); var phase = 0.0; final phaseStep = 2 * math.pi * 440 / sampleRate; final pcm = Int16List(samplesPer10ms * channels); timer = Timer.periodic(const Duration(milliseconds: 10), (_) { for (var i = 0; i < samplesPer10ms; i++) { pcm[i] = (math.sin(phase) * 12000).round(); phase += phaseStep; } PushAudio.pushPcm(pcm, sampleRate, channels); }); // 実際のアプリケーションでは、音声入力のコールバックなどから PCM を供給する。 await Future.delayed(const Duration(minutes: 1)); } finally { // タイマーを先に止め、PushAudio.dispose() 後に pushPcm() が呼ばれないようにする。 timer?.cancel(); // conn.dispose() は接続を切断してから後始末を行う。 await conn.dispose(); await audioTrack.dispose(); await stream.dispose(); PushAudio.dispose(); } } ``` `PushAudio.pushPcm()` の引数は、次の条件を満たす必要があります。 - `data` は 10 ms 分の符号付き 16 bit PCM ( `Int16List` ) にする - 1 チャンネルあたりのサンプル数は `sampleRate * 10 / 1000` にする - 複数チャンネルの場合はチャンネル間でインターリーブする - アプリケーション側の音声ソースから 10 ms 周期で呼び出す `sampleRate` と `channels` は、送信する PCM の形式に合わせて指定してください。 SDK の API ドキュメントでは通常の値として 48000 Hz、モノラルを示しています。 `Timer.periodic` は処理の流れを示す例であり、実際のアプリケーションでは音声入力のコールバックなど、音声ソースが提供するタイミングを利用してください。 ### PushAudio で受信 PCM を取得する `PushAudio.pullPcm()` は、`PushAudioDevice` が受信した音声を PCM として取り出す API です。 物理出力デバイスを使わずに受信音声のデコードを進めるため、アプリケーション側で 10 ms ごとに呼び出します。 `durationMs` の既定値は 10 ms です。 ```dart const sampleRate = 48000; const channels = 1; // 受信音声を取得する処理を 10 ms ごとに呼び出す。 final receivedPcm = PushAudio.pullPcm(sampleRate, channels); if (receivedPcm.isEmpty) { // playout が開始される前は空のリストになる。 return; } // receivedPcm をアプリケーション側の再生、保存、解析などの処理へ渡す。 ``` `pullPcm()` の `sampleRate` 、`channels` 、`durationMs` は正の値を指定してください。 指定した形式の PCM がまだ取得できない場合は空のリストが返ります。 `pullPcm()` は音声送信の設定を行わないため、送信する場合は別途 `createAudioTrack()` で生成したトラックを `LocalMediaStream` に追加してください。 ## 音声を再生する 音声を再生するには、接続設定で `audio: true` を指定するか、 `audio` を未指定にします。未指定時は Sora のデフォルト (`true`) が適用されます。 受信した音声の再生自体は SDK とプラットフォームの標準経路で自動的に行われるため、アプリケーション側で音声再生用の `Widget` やプレーヤーを用意する必要はありません。 一方で、再生対象の音声が来たことを検知したい場合は、リモート音声トラックの追加 / 削除をイベントで確認します。 ### 接続して受信音声を再生する 受信音声を再生する例です。 ```dart final config = SoraConnectionConfig( signalingUrls: ['wss://sora.example.com/signaling'], channelId: 'example-channel', role: SoraRole.recvonly, audio: true, video: false, ); final conn = await Sora.createConnection(config); await conn.connect(); ``` `recvonly` で音声受信のみを行う場合は、マイク利用の権限や設定等は不要です。 ### リモート音声トラックの追加 / 削除を検知する ここでのトラック検知は、アプリケーション側で音声を再生するための処理ではありません。 再生対象の音声が存在するか、どの接続相手の音声か、といった状態を把握するための確認手段です。 リモート音声トラックの追加 / 削除は `conn.events` で `RemoteMediaStreamTrack` の `kind == 'audio'` を判定して確認できます。 ```dart conn.events.listen((event) { switch (event) { case SoraTrackEvent(:final track): if (track.kind == 'audio') { print('audio track added: ${track.trackId} ${track.connectionId}'); } case SoraRemoveTrackEvent(:final track): if (track.kind == 'audio') { print('audio track removed: ${track.trackId}'); } default: break; } }); ``` `trackId` は複数のリモートトラックを扱うときの識別に使えます。 接続済みでも音声トラックが来ていなければ、再生対象の音声はありません。 #### 接続状態とあわせて見る 実際のアプリケーションでは、接続状態の変化とリモート音声トラックの追加をあわせて監視すると、受信を開始できたかを判断しやすくなります。 ```dart conn.events.listen((event) { switch (event) { case SoraConnectionStateChangedEvent(:final state): print('state: $state'); case SoraTrackEvent(:final track): if (track.kind == 'audio') { print('audio track added: ${track.trackId} ${track.connectionId}'); } case SoraRemoveTrackEvent(:final track): if (track.kind == 'audio') { print('audio track removed: ${track.trackId}'); } default: break; } }); ``` `SoraConnectedState` が通知されても、すぐにリモート音声トラックが追加されるとは限りません。 接続状態とトラックイベントを分けて確認することで、接続は完了したが再生対象の音声はまだ来ていない状態を区別できます。 イベントの詳細については [イベント](event.html) をご確認ください。 ### Sora 接続設定の `audio` と音声の関係 `audio: true` は Sora 接続で音声を扱う設定です。ロールによって意味が異なります。 | 構成 | 意味 | | --- | --- | | `recvonly` + `audio: true` | 受信音声を再生する | | `recvonly` + `audio: false` | 音声を受信しない | | `sendonly` + `audio: true` | 音声を送信する | | `sendonly` + `audio: false` | 音声を送信しない | | `sendrecv` + `audio: true` | 音声を送受信する | | `sendrecv` + `audio: false` | 音声を送信せず、受信する | `audio` を未指定にした場合も Sora のデフォルト (`true`) が適用されます。 `sendrecv` は `audio: false` でも受信音声を再生します。マイク利用等の音声入力とは切り分けて考える必要があります。 ## 音声の送受信についてできないこと Sora Flutter SDK の公開 API では、次のような音声制御はできません。 - 音声のステレオ送受信 ( Sora の実験的機能である Opus の詳細パラメータで `stereo` を指定する場合を除く) - 再生先スピーカーの選択 - イヤホン / スピーカーの切り替え制御 - リモート音声ごとの再生音量変更 - 受信音声だけを個別にミュート - 端末のマイクのプライバシーインジケーターを消灯させるミュート - `iOS` / `Android` で `PushAudioDevice` を使った任意の PCM 音声の送信 > **注釈** > > [シグナリング設定](signaling.html) の Opus の詳細パラメータで `stereo` / `sprop_stereo` を指定できます。このパラメータ指定は Sora の実験的機能のため、利用する場合は事前に Sora のサポートへ連絡してください。 受信音声は SDK とプラットフォームの標準経路で再生されます。出力のルーティングは OS と `libwebrtc` の挙動に従います。 ## プラットフォームごとの注意 ### Android - 音声出力のルーティングは Android / `libwebrtc` の標準挙動に従います - Bluetooth / 受話スピーカー / スピーカーの切り替えは通話用音声ルートの制約を受けるため、出力先を常に厳密指定できるとは限りません - マイクを利用する場合は `AndroidManifest.xml` に `RECORD_AUDIO` 権限の宣言と、ランタイム権限の取得が必要です - 詳細は [Android での利用方法](android.html) をご確認ください ### iOS / iPadOS - 音声の再生先は `AVAudioSession` の音声ルートに従います - AirPlay や Bluetooth などの再生先をユーザーに選ばせる場合は、アプリケーション側で `AVRoutePickerView` を利用してください - `AVAudioSession` の category が `.playAndRecord` に設定されるため、受信音量が小さくなる場合があります。これは `iOS` の仕様です。必要に応じて接続後に `AVAudioSession` の category を変更してください - マイクを利用する場合は `Info.plist` に `NSMicrophoneUsageDescription` の設定と、ランタイム権限の取得が必要です - 詳細は [iOS / iPadOS での利用方法](ios_ipados.html) をご確認ください ### macOS - 音声は OS / `CoreAudio` の既定音声出力へ再生されます - SDK から再生先を明示選択する public API はありません - マイクを利用する場合は `Info.plist` に `NSMicrophoneUsageDescription` を設定し、Entitlements に `com.apple.security.device.audio-input` を追加する必要があります - 詳細は [macOS での利用方法](macos.html) をご確認ください ## 関連ページ - [実装ガイド](getting_started.html) - [シグナリング設定](signaling.html) - [メディアデバイス](media_device.html) - [イベント](event.html) # 映像を描画する ## 概要 Sora Flutter SDK での映像レンダリングの方法について説明します。 描画までの流れは次のとおりです。 1. 映像の `textureId` を取得する- ローカル映像は `LocalVideoTrack.textureId` から `textureId` を取得する - リモート映像は `SoraTrackEvent` で受け取った `RemoteMediaStreamTrack.textureId` を使う 2. 取得した `textureId` を Flutter の `Texture` に渡して描画する SDK は `Texture` を直接使う方法に加えて、 `SoraLocalVideoWidget` / `SoraRemoteVideoWidget` を提供しています。 これらは `textureId` が未確定の間のプレースホルダー表示、親領域へのフィット、左右反転をまとめて扱えます。 イベント全体の一覧や `SoraConnection.events` の使い方は [イベント](event.html) をご確認ください。 ## TextureId を取得する ### ローカル映像 ローカル映像を表示するには、まず送信用の `MediaStream` を用意します。 `MediaStream` の作成方法は [メディアデバイス](media_device.html) をご確認ください。 ローカル映像の `textureId` は `LocalVideoTrack.textureId` ( `Future` ) で取得します。 ```dart final stream = await MediaDevices.getUserMedia( const GetUserMediaOptions(audio: true, video: true), ); final videoTrack = stream.getVideoTracks().first; final textureId = await videoTrack.textureId; ``` `createExternalVideoTrack()` で生成した外部映像入力用の `LocalVideoTrack` で `textureId` を取得すると `StateError` が送出されます。詳細は [外部映像入力を利用する](external_video_source.html) をご確認ください。 `SoraConnection.localVideo` ( `Stream` ) からも `textureId` を取得できます。 カメラのトラックが接続に設定され、キャプチャが開始されたタイミングで `SoraLocalVideoHandle` が emit されます。 `textureId` は常に有効な非負値です。 ```dart conn.localVideo.listen((handle) { setState(() { localTextureId = handle.textureId; }); }); ``` > **注釈** > > - `createExternalVideoTrack()` で生成した外部映像トラックを使う接続では emit されません > - `replaceVideoTrack()` で外部映像トラックに切り替えた場合や `disconnect()` の場合は、解除の通知が emit されません。 `SoraConnectionStateChangedEvent` などの状態遷移に合わせて、保持している `textureId` を破棄してください ### リモート映像 受信したリモート映像から `textureId` を取得する方法を説明します。 リモート映像を受信するまでの基本的な流れは [実装ガイド](getting_started.html) をご確認ください。また、 `conn.events` によりイベント購読しているものとします。 リモート映像は `conn.events` の `SoraTrackEvent` で受け取ります。トラックは `RemoteMediaStreamTrack` として通知され、 `kind == 'video'` の場合のみ `textureId` が non-null になります。 ```dart final remoteTextureIds = {}; conn.events.listen((event) { switch (event) { case SoraTrackEvent(:final track): final textureId = track.textureId; if (track.kind == 'video' && textureId != null) { setState(() { remoteTextureIds[track.trackId] = textureId; }); } default: break; } }); ``` `trackId` は、複数リモートトラックを識別する際に利用できます。 `RemoteMediaStreamTrack` の型や `SoraTrackEvent` の通知タイミングの詳細は [イベント](event.html) を参照してください。 ## Texture によるレンダリング `textureId` を Flutter の `Texture` ウィジェットに渡してレンダリングします。リモートトラックの `textureId` は `int?` なので、 `kind == 'video'` を確認してから利用します。 ```dart Widget buildVideo(int textureId) { return Texture(textureId: textureId); } final localVideo = buildVideo(textureId); final remoteVideo = buildVideo(track.textureId!); ``` 複数のリモート映像を並べて表示する場合は、受信したトラックのリストを `GridView` や `Wrap` などに流し込みます。 ```dart Widget buildRemoteVideos(Map remoteTextureIds) { return GridView.count( crossAxisCount: 2, children: remoteTextureIds.values .map((textureId) => Texture(textureId: textureId)) .toList(), ); } ``` ### 削除されたリモートトラックの映像を取り除く リモート側の切断等により `SoraRemoveTrackEvent` が通知された映像トラックの `textureId` は描画に使えなくなるため、対応する `Texture` をツリーから取り除きます。 ```dart // trackId をキーに textureId を管理するマップです final remoteTextureIds = {}; conn.events.listen((event) { switch (event) { case SoraTrackEvent(:final track): final textureId = track.textureId; if (track.kind == 'video' && textureId != null) { setState(() { remoteTextureIds[track.trackId] = textureId; }); } // SoraRemoveTrackEvent で該当する trackId をリストから削除します case SoraRemoveTrackEvent(:final track): if (track.kind == 'video') { setState(() { // trackId をキーに管理すると、SoraRemoveTrackEvent を受けたタイミングで該当する Texture を自然に表示から外せます remoteTextureIds.remove(track.trackId); }); } default: break; } }); ``` ## Widget によるレンダリング `SoraLocalVideoWidget` と `SoraRemoteVideoWidget` を使うと、 `textureId` の管理と `Texture` の組み立てを Widget に任せられます。 ### SoraRemoteVideoWidget `SoraTrackEvent` で受け取った `RemoteMediaStreamTrack` をそのまま渡します。 `track.textureId` が `null` の間は `placeholder` が表示されます。 ```dart Widget buildRemoteVideo(RemoteMediaStreamTrack track) { return SoraRemoteVideoWidget(track: track); } ``` | フィールド | 説明 | | --- | --- | | `track` | 表示するリモート映像トラック ( `kind == 'video'` )。 `kind == 'audio'` を渡した場合は debug 時に assert で検出されます | | `fit` | 親領域へのフィット方法。デフォルトは `BoxFit.contain` | | `mirror` | 水平反転するかどうか。デフォルトは `false` | | `placeholder` | `track.textureId` が `null` の間に表示する Widget。デフォルトは黒色の塗りつぶし | ### SoraLocalVideoWidget `textureId` を渡します。 `null` または負値のときは `placeholder` が表示されます。 `0` は有効な値として扱われます。 ```dart Widget buildLocalVideo(int? textureId) { return SoraLocalVideoWidget(textureId: textureId, mirror: true); } ``` `fit` / `mirror` / `placeholder` は `SoraRemoteVideoWidget` と同じです。 ## 関連ページ - [イベント](event.html) - [実装ガイド](getting_started.html) - [メディアデバイス](media_device.html) # カメラを利用して映像を送信する ## 概要 Sora Flutter SDK のデフォルトの映像入力は、 `libwebrtc` のカメラキャプチャを利用します。 `MediaDevices.getUserMedia()` で `LocalMediaStream` と `LocalVideoTrack` を生成し、 `SoraConnection.connect()` に `LocalMediaStream` を渡すと、SDK が接続時にカメラキャプチャを開始します。 接続前に `LocalVideoTrack.textureId` を取得してプレビューを表示する場合も、SDK がプラットフォーム側のカメラキャプチャを開始します。 `disconnect()` や `SoraConnection.dispose()` は接続を終了しますが、アプリケーション側で作成したトラックやストリームは破棄しません。 利用終了後は接続を切断してから `LocalVideoTrack.dispose()` を呼び出し、最後に `LocalMediaStream.dispose()` を呼び出してください。 本ページでは、 `getUserMedia()` を使った標準的なカメラ映像送信の設定と操作について説明します。 映像の描画については [映像を描画する](video_rendering.html) をご確認ください。 また、外部映像入力 ( pub.dev の `camera` パッケージや `createExternalVideoTrack` ) については [外部カメラパッケージを利用する](external_camera_package.html) および [外部映像入力を利用する](external_video_source.html) をご確認ください。 ## 基本の流れ 1. `MediaDevices.getUserMedia()` で `LocalMediaStream` を生成する 2. 必要に応じて接続前に `LocalVideoTrack.textureId` を取得してプレビューを表示する 3. `SoraConnection.connect(stream)` で接続する 4. 利用終了時に `disconnect()` を呼び出し、track を破棄してから `LocalMediaStream` を破棄する 次の例は、カメラ映像の送信を開始するための最小構成の完全な処理フローです。 `MediaStream` の生成から接続開始までをまとめて示しています。 ```dart import 'package:sora_sdk/sora_sdk.dart'; Future startVideo() async { // getUserMedia でカメラとマイクの MediaStream を生成する // ここでは音声は関係ないため false としている final stream = await MediaDevices.getUserMedia( const GetUserMediaOptions(audio: false, video: true), ); final conn = await Sora.createConnection( SoraConnectionConfig( signalingUrls: ['wss://sora.example.com/signaling'], channelId: 'example-channel', role: SoraRole.sendrecv, video: true, ), ); // 接続すると SDK が自動でカメラキャプチャを開始する await conn.connect(stream); } ``` > **注釈** > > 送信側のロール (`SoraRole.sendonly` / `SoraRole.sendrecv`) で映像を送信する場合は、 `video: true` を指定するか、 `video` を未指定にします。 > `video` を未指定 (`null`) にすると `connect` メッセージに `video` キーを含めず、 Sora のデフォルト (`true`) が使われます。 > `video: false` を指定すると映像は送信されません。音声も同様に `audio` で指定します。 > `recvonly` ではカメラは使用されません。 ## カメラデバイスを選択する `GetUserMediaOptions.videoDeviceId` に `MediaDevices.enumerateVideoInputDevices()` で取得したデバイス ID を指定することで、使用するカメラデバイスを選択できます。 省略時は SDK が既定のカメラ(多くの端末では前面カメラ)を使用します。 ```dart // 利用可能なカメラの一覧を取得する final cameras = await MediaDevices.enumerateVideoInputDevices(); // Android で背面カメラを選択する例 final backCamera = cameras.firstWhere( (c) => c.label.contains('Back'), ); final stream = await MediaDevices.getUserMedia( GetUserMediaOptions( audio: true, video: true, videoDeviceId: backCamera.deviceId, ), ); ``` > **注釈** > > この例は `Android` の `label` が `Back Camera` であることを前提にしています。 > `iOS` / `macOS` では `AVCaptureDevice.localizedName` が返るため、 `label` に `Back` が含まれるとは限りません。 > `iOS` / `macOS` で背面カメラを選ぶ場合は、プラットフォームのカメラ情報に応じて選択してください。 ## 解像度とフレームレートを指定する `GetUserMediaOptions` の `videoWidth` 、`videoHeight` 、`videoFrameRate` で希望する解像度とフレームレートを指定できます。 省略時のデフォルトは `640x480 @30fps` です。 指定した値はあくまで希望であり、デバイスが対応している範囲で最も近い値が選択されます。事前に `VideoInputDevice.supportedFormats()` でカメラの対応フォーマットを確認することを推奨します。 ```dart // カメラの対応フォーマットを確認する final formats = await backCamera.supportedFormats(); final stream = await MediaDevices.getUserMedia( GetUserMediaOptions( audio: true, video: true, videoDeviceId: backCamera.deviceId, videoWidth: 1280, videoHeight: 720, videoFrameRate: 30, ), ); ``` > **注釈** > > サイマルキャストで複数レイヤーの映像を送信する場合は、送信元の解像度が不足していると期待したレイヤー構成で送れないことがあります。 > 詳細は Sora ドキュメントの [サイマルキャスト機能の注意](https://sora-doc.shiguredo.jp/SIMULCAST#注意) をご確認ください。 ## カメラを切り替える 接続中に `replaceVideoTrack()` により、前面カメラと背面カメラを切り替えることができます。 ```dart import 'package:sora_sdk/sora_sdk.dart'; Future switchCamera( SoraConnection conn, LocalMediaStream stream, String videoDeviceId, ) async { final currentTracks = stream.getVideoTracks(); if (currentTracks.isEmpty) { throw StateError('差し替え元の映像トラックがありません'); } final oldTrack = currentTracks.first; // 切り替え先のカメラで一時的な MediaStream を生成する。 final newStream = await MediaDevices.getUserMedia( GetUserMediaOptions( audio: false, video: true, videoDeviceId: videoDeviceId, ), ); try { final newTracks = newStream.getVideoTracks(); if (newTracks.isEmpty) { throw StateError('差し替え先の映像トラックがありません'); } final newTrack = newTracks.first; try { // 成功すると newTrack は接続中の stream に追加されます。 await conn.replaceVideoTrack(stream, newTrack); } catch (error, stackTrace) { // 失敗時は newTrack が接続中の stream の所有になっていないため、 // 呼び出し側で明示的に破棄します。 try { await newTrack.dispose(); } catch (_) { // 元の差し替えエラーを保持します。 } Error.throwWithStackTrace(error, stackTrace); } // 成功時は newTrack が接続中の stream に移っているため破棄しません。 // replaceVideoTrack() は oldTrack を自動で破棄しないため、ここで破棄します。 await oldTrack.dispose(); } finally { // LocalMediaStream.dispose() は含まれる track を破棄しません。 // 成功時も失敗時も一時 stream 自身だけを解放します。 await newStream.dispose(); } } ``` ## 送信映像のソフトミュート 接続中に `setVideoEnabled(false)` を呼ぶことにより送信映像トラックは無効となり、 入力デバイスからの映像は配信されなくなります(ソフトミュート)。 このとき実際にはパケットを送っており、黒塗りフレームを送り続ける状態となります。 解除するには `setVideoEnabled(true)` を呼びます。 ```dart // 送信映像を無効化する conn.setVideoEnabled(false); // 送信映像を再開する conn.setVideoEnabled(true); // 現在の状態を確認する print(conn.isVideoEnabled); ``` > **注釈** > > これは送信映像トラックの `Enabled` 制御です。受信映像の表示停止や、ローカルプレビュー用 `Texture` の表示制御ではありません。 > また、ソフトミュートでは映像入力デバイスは無効化されないため、端末のプライバシーインジケーターは消灯しません。 ## ローカルプレビューを表示する `getUserMedia()` で取得した `LocalVideoTrack` の `textureId` を Flutter の `Texture` に渡すと、カメラ映像をローカルで表示できます。 接続前のカメラプレビューにも使えます。 接続前に `textureId` を取得すると、Flutter の `Texture` を表示するためのプラットフォーム側のテクスチャが確保され、カメラキャプチャも開始されます。 この処理は Sora への接続とは独立しているため、プレビューだけを表示する場合も、利用終了時に映像トラックを明示的に破棄してください。 ```dart final stream = await MediaDevices.getUserMedia( const GetUserMediaOptions(audio: false, video: true), ); final videoTrack = stream.getVideoTracks().first; final localTextureId = await videoTrack.textureId; ``` ```dart Widget buildLocalVideo(int textureId) { return Texture(textureId: textureId); } ``` `recvonly` では `LocalVideoTrack` 自体が生成されないため、ローカルプレビューは利用できません。 ## カメラキャプチャを停止してリソースを解放する `disconnect()` は Sora への送信と接続を終了しますが、アプリケーション側で作成した `LocalVideoTrack` の `dispose()` は呼び出しません。 `LocalVideoTrack.dispose()` は接続中には呼び出せないため、先に接続を切断してください。 `LocalMediaStream.dispose()` も含まれるトラックを破棄しないため、トラックを先に破棄してから、ストリームを破棄します。 ```dart await conn.disconnect(); await videoTrack.dispose(); await stream.dispose(); await conn.dispose(); ``` ## プラットフォーム別の注意 ### Android - カメラを利用する場合は `AndroidManifest.xml` に `CAMERA` 権限の宣言と、ランタイム権限の取得が必要です - エミュレーターではカメラデバイスが利用できないため、映像送信の動作確認には実機が必要です - 詳細は [Android での利用方法](android.html) をご確認ください ### iOS / iPadOS - カメラを利用する場合は `Info.plist` に `NSCameraUsageDescription` の設定と、ランタイム権限の取得が必要です - シミュレーターではカメラデバイスが利用できません - 詳細は [iOS / iPadOS での利用方法](ios_ipados.html) をご確認ください ### macOS - カメラを利用する場合は `Info.plist` に `NSCameraUsageDescription` を設定し、Entitlements に `com.apple.security.device.camera` を追加する必要があります - 詳細は [macOS での利用方法](macos.html) をご確認ください ## 関連ページ - [メディアデバイス](media_device.html) - [映像を描画する](video_rendering.html) - [実装ガイド](getting_started.html) - [外部カメラパッケージを利用する](external_camera_package.html) - [外部映像入力を利用する](external_video_source.html) # 外部映像入力を利用する ## 概要 Sora Flutter SDK では、SDK 管理のカメラ入力 ( `MediaDevices.getUserMedia()` 経由) の代わりにアプリケーション側で用意した任意の映像フレームを送信できます。 画面キャプチャ、動画ファイル再生、合成映像、テストパターンなど、カメラ以外の入力を扱うことができます。 アプリケーション自身の画面を送信する場合は、 `ReplayKit` などで取得したフレームを `writeFrame()` で外部映像入力として送信してください。 本ページでは、アプリケーション側で取得した画面映像を外部映像入力として送信する方法を説明します。 > **注釈** > > SDK 内蔵の `SoraCameraCapturer` は使用せず、 `writeFrame()` が `ExternalVideoFrame` をネイティブ側の `AdaptedVideoTrackSource` へ渡します。 ### AdaptedVideoTrackSource とは `AdaptedVideoTrackSource` は、WebRTC の映像トラックにフレームを渡すネイティブ側の映像ソースです。 `MediaDevices.createExternalVideoTrack()` が外部映像トラックごとに 1 つ生成し、生成した `LocalVideoTrack` に関連付けます。 ここでいう「適合」は、入力フレームを WebRTC の映像トラック側の条件に合わせ、必要に応じてフレームを破棄したり解像度を調整したりする処理です。 アプリケーションがこの型を直接生成・操作する必要はありません。 `writeFrame()` を呼び出すと、SDK は次の順にフレームを処理します。 ```text ExternalVideoFrame │ ▼ LocalVideoTrack.writeFrame() │ ▼ AdaptFrame (フレームを受け付けるか、適合後の解像度を決める) │ ▼ ネイティブ側の I420 バッファーへコピー (必要な場合は適合後の解像度へスケーリング) │ ▼ AdaptedVideoTrackSource の OnFrame │ ▼ WebRTC の映像トラック以降の処理 ``` `AdaptFrame` が `0` を返す場合は、現在の WebRTC の映像処理がフレームを必要としていない、または WebRTC の適合処理がフレームを破棄すると判断した状態です。 この場合はフレームをコピーせず、 `writeFrame()` は正常に戻ります。 `AdaptedVideoTrackSource` は、アプリケーションの `ExternalVideoFrame` を WebRTC の映像トラックへつなぐ境界です。 Sora へのネットワーク送信をアプリケーションが直接呼び出すための API ではありません。 ## 利用方法 外部映像入力を送信する基本的な流れは以下のようになります。 1. `MediaDevices.createMediaStream()` で `LocalMediaStream` を生成 2. 必要に応じて `createAudioTrack()` を作成し `stream.addTrack()` で追加する > **ヒント** > > マイク等の音声入力デバイスからの音声も併せて送信したい場合に音声トラックを生成する必要があります。 1. `MediaDevices.createExternalVideoTrack()` で映像トラックを作成し `stream.addTrack()` で追加する 2. `SoraConnection.connect(stream)` で接続する 3. 接続完了後に `track.writeFrame(ExternalVideoFrame(...))` を一定間隔で呼ぶ 4. 使い終わったらフレーム生成元を停止し、 `conn.disconnect()` → 各 track の `dispose()` → `stream.dispose()` → フレーム生成元の破棄 → `conn.dispose()` の順で解放する 以下の例では、後述の `buildSolidColorFrame()` で単色フレームを生成します。 ```dart import 'dart:async'; import 'dart:typed_data'; import 'package:sora_sdk/sora_sdk.dart'; Future startExternalVideo() async { final conn = await Sora.createConnection( SoraConnectionConfig( signalingUrls: ['wss://sora.example.com/signaling'], channelId: 'example-channel', role: SoraRole.sendonly, audio: true, video: true, ), ); // トラックを保持しない LocalMediaStream を生成 final stream = MediaDevices.createMediaStream(); // 音声入力が必要な場合は音声トラックを生成 final audio = await MediaDevices.createAudioTrack(); // 外部映像入力用の映像トラックを生成 final video = MediaDevices.createExternalVideoTrack(); // 音声・映像トラックを LocalMediaStream に関連付ける stream.addTrack(audio); stream.addTrack(video); await conn.connect(stream); // 接続完了後に一定間隔でフレームを投入する // 33ms は約 30 FPS 相当。フレームの生成負荷に応じて調整してください final timer = Timer.periodic(const Duration(milliseconds: 33), (_) { video.writeFrame(buildSolidColorFrame( width: 640, height: 480, y: 128, u: 128, v: 128, )); }); try { // 例として 10 秒間フレームを送信する await Future.delayed(const Duration(seconds: 10)); } finally { // 使い終わったらタイマーを止めてから解放する timer.cancel(); await conn.disconnect(); await video.dispose(); await audio.dispose(); await stream.dispose(); await conn.dispose(); } } ``` `LocalMediaStream.dispose()` は中のトラックを自動解放しません。 `LocalVideoTrack.dispose()` を明示的に呼ぶと、ネイティブ側の `AdaptedVideoTrackSource` の参照もまとめて `release` されます。 `disconnect()` と `SoraConnection.dispose()` は、アプリケーション側で作成した外部映像トラックや `LocalMediaStream` を自動では破棄しません。 `Timer` 、 `camera` パッケージ、動画デコーダーなどのフレーム生成元も SDK は管理しないため、先に停止してください。 > **注釈** > > `createExternalVideoTrack()` はネイティブ側に映像ソースを確保します。作成したトラックは必ず `dispose()` してください。 `dispose()` 内で `adaptedVideoTrackSourceRelease` が呼ばれます。 ## 切断後の再利用 `disconnect()` は外部映像トラックを接続から切り離しますが、 `dispose()` は呼び出しません。そのため、切断だけではトラックが破棄済みにならず、 `writeFrame()` が接続切断だけを理由に `StateError` になるとは限りません。 ただし、切断後に同じ外部映像トラックを新しい接続で再利用できることは保証しません。再接続する場合は、新しい `LocalMediaStream` と `createExternalVideoTrack()` で生成したトラックを用意してください。 `LocalVideoTrack.dispose()` を呼び出した後は、 `writeFrame()` を含むトラックの API を利用できません。 `LocalMediaStream.dispose()` もトラックを破棄しないため、各トラックを破棄してから `stream.dispose()` を呼び出してください。 ## SDK 内蔵のカメラ入力との違い `MediaDevices.getUserMedia()` を利用する場合は内部で自動的に `createCameraVideoTrack` で映像トラックが生成されます。 `createExternalVideoTrack` との違いは以下のようになります。 | 観点 | `createCameraVideoTrack` | `createExternalVideoTrack` | | --- | --- | --- | | フレーム生成元 | SDK 内蔵の `SoraCameraCapturer` | アプリケーション側 | | ローカルプレビュー | `LocalVideoTrack.textureId` で取得できる | SDK からは提供されない (アプリケーション側で描画) | | フレーム投入 | SDK が自動で実行 | `writeFrame()` をアプリケーションから呼ぶ | | `connect()` 時の動作 | SDK 内蔵のカメラキャプチャを開始 | カメラキャプチャを開始せず、アプリケーションから `writeFrame()` でフレームを投入 | ## 接続と送信の前提 - `role: recvonly` では `connect()` に stream を渡せません - 外部映像入力は送信側のロールで、 `video: true` を指定するか `video` を未指定にして使います。 `video: false` の場合は映像トラックを渡せません - `audio` トラックを含める場合は `audio: true` を指定するか未指定にし、含めない場合は `audio: false` にします ## writeFrame() を呼び出せるトラック `writeFrame()` は `MediaDevices.createExternalVideoTrack()` で生成したトラック専用です。 `MediaDevices.getUserMedia()` などで生成したカメラのトラックで呼び出すと `StateError` になります。 ## エラーハンドリング `writeFrame()` において、次のような場合に `StateError` が送出されます。 - トラックが `dispose()` 済み - `MediaDevices.createExternalVideoTrack()` で生成したトラック以外で `writeFrame()` を呼び出した場合 - 内部の映像ソース参照が `null` - `rotation` が `0` / `90` / `180` / `270` のいずれでもない - `width` / `height` が 0 以下 - `width` / `height` が 8192 を超える - `timestampUs` が負の値 - `yStride` が `width` 未満、または `uStride` / `vStride` が `(width + 1) ~/ 2` 未満 - `yPlane` / `uPlane` / `vPlane` のバイト列が、ストライドと高さから求まる長さに満たない `writeFrame()` は同期的な `void` メソッドです。SDK の Dart 層に送信キューはありません。 入力検証と `AdaptFrame` の判定を終えた後、受け付けたフレームの Y / U / V plane を呼び出し中にネイティブ側の `I420 バッファー` へコピーします。 呼び出しから戻った後は、元の Dart 側のバッファーを再利用できます。 `writeFrame()` の戻りは、受け付けられたフレームをネイティブ映像ソースへ同期的に引き渡したことだけを示します。 ネイティブ側の配送、エンコード、ネットワーク送信の完了や順序は保証しません。 複数のフレーム生成元から呼び出す場合に Dart 側の呼び出し順を制御したいときは、アプリケーション側で `writeFrame()` の呼び出しを直列化してください。 この直列化によって、ネイティブ側の配送、エンコード、ネットワーク送信の順序が保証されるわけではありません。 ```dart void onFrame(Uint8List y, Uint8List u, Uint8List v, int width, int height) { try { final frame = ExternalVideoFrame( width: width, height: height, yPlane: y, uPlane: u, vPlane: v, yStride: width, uStride: (width + 1) ~/ 2, vStride: (width + 1) ~/ 2, ); videoTrack.writeFrame(frame); } catch (error, stackTrace) { // エラー時も後続フレームの処理を継続する debugPrint('writeFrame error: $error\n$stackTrace'); } } ``` ## 外部映像入力トラックをローカルプレビューで描画する 外部映像トラックでは `LocalVideoTrack.textureId` が使えないため、アプリケーション側でフレームを描画する必要があります。 `writeFrame()` で投入した I420 フレームを `CustomPaint` と `CustomPainter` で描画する例です。 ```dart import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:sora_sdk/sora_sdk.dart'; /// 外部映像トラックに投入した I420 フレームをローカルでプレビューする例です。 /// /// ``MediaDevices.createExternalVideoTrack()`` で生成したトラックでは /// ``LocalVideoTrack.textureId`` が使えないため、 /// ``CustomPaint`` と ``CustomPainter`` でアプリケーション側が描画します。 /// /// 全体の流れ: /// 1. ``writeFrame()`` で投入するフレームと同じ I420 データを保持する /// 2. ``I420Painter`` で I420 → RGBA に変換して Canvas に描画する /// 3. 新しいフレームが来るたびに ``setState`` で再描画する class ExternalVideoPreview extends StatefulWidget { const ExternalVideoPreview({super.key}); @override ExternalVideoPreviewState createState() => ExternalVideoPreviewState(); } /// ``ExternalVideoPreview`` の状態を保持し、I420 フレームを更新します。 class ExternalVideoPreviewState extends State { // 最新の I420 フレームデータを保持します。 // 各プレーンのバイト列とサイズ情報です。 Uint8List? _yPlane; Uint8List? _uPlane; Uint8List? _vPlane; int _width = 0; int _height = 0; /// ``writeFrame`` で投入するフレームをプレビューにも反映します。 /// /// [frame]: ``writeFrame`` に渡した ``ExternalVideoFrame`` と同じデータ void updateFrame(ExternalVideoFrame frame) { if (!mounted) { return; } setState(() { _yPlane = frame.yPlane; _uPlane = frame.uPlane; _vPlane = frame.vPlane; _width = frame.width; _height = frame.height; }); } @override Widget build(BuildContext context) { if (_yPlane == null || _uPlane == null || _vPlane == null) { return const Center(child: Text('No frame')); } return CustomPaint( // 元映像のアスペクト比を維持して描画領域いっぱいに表示します。 size: Size.infinite, painter: I420Painter( yPlane: _yPlane!, uPlane: _uPlane!, vPlane: _vPlane!, width: _width, height: _height, ), ); } } /// I420 (YUV 4:2:0 planar) データを Canvas に描画する CustomPainter です。 /// /// I420 の各プレーンから RGBA ピクセルを計算し、 /// アスペクト比を保って描画領域にフィットさせます。 class I420Painter extends CustomPainter { I420Painter({ required this.yPlane, required this.uPlane, required this.vPlane, required this.width, required this.height, }); final Uint8List yPlane; final Uint8List uPlane; final Uint8List vPlane; final int width; final int height; // BT.601 フルスイング整数近似による YUV → RGB 変換の係数。 // Y = 0.257*R + 0.504*G + 0.098*B + 16 // U = -0.148*R - 0.291*G + 0.439*B + 128 // V = 0.439*R - 0.368*G - 0.071*B + 128 // 上記を整数演算で近似: `((a * c + 128) >> 8) + offset` の形。 static const _yScale = 298; static const _vRed = 409; static const _uGreen = 100; static const _vGreen = 208; static const _uBlue = 516; @override void paint(Canvas canvas, Size size) { if (width == 0 || height == 0) return; // この実装は学習・検証用の簡易例です。 // 毎フレームのバッファー確保、UI スレッドでの同期的なピクセル変換、 // decodeImageFromPixels による非同期 GPU アップロードはいずれも高負荷です。 // 本番利用では、変換済み画像のキャッシュや // バックグラウンド Isolate への変換処理のオフロードを推奨します。 // // アスペクト比を保って描画領域にフィットさせる。 final scaleX = size.width / width; final scaleY = size.height / height; final scale = scaleX < scaleY ? scaleX : scaleY; final destW = width * scale; final destH = height * scale; final offsetX = (size.width - destW) / 2; final offsetY = (size.height - destH) / 2; // I420 → RGBA の変換結果を保持するバッファー。 final pixels = Uint8List(width * height * 4); final chromaWidth = (width + 1) ~/ 2; final yStride = width; final uStride = chromaWidth; final vStride = chromaWidth; for (var row = 0; row < height; row++) { for (var col = 0; col < width; col++) { final y = yPlane[row * yStride + col]; final cx = col ~/ 2; final cy = row ~/ 2; final u = uPlane[cy * uStride + cx]; final v = vPlane[cy * vStride + cx]; // BT.601 の整数近似で YUV → RGB 変換。 // Y 成分から 16 を引き、U/V 成分から 128 を引いてから計算する。 final yy = (y - 16) * _yScale; final r = ((yy + (v - 128) * _vRed + 128) >> 8).clamp(0, 255); final g = ((yy - (u - 128) * _uGreen - (v - 128) * _vGreen + 128) >> 8) .clamp(0, 255); final b = ((yy + (u - 128) * _uBlue + 128) >> 8).clamp(0, 255); final dstIdx = (row * width + col) * 4; pixels[dstIdx] = r; pixels[dstIdx + 1] = g; pixels[dstIdx + 2] = b; pixels[dstIdx + 3] = 255; // alpha } } // RGBA ピクセルを Canvas に描画する。 ui.decodeImageFromPixels( pixels, width, height, ui.PixelFormat.rgba8888, (image) { canvas.drawImageRect( image, Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), Rect.fromLTWH(offsetX, offsetY, destW, destH), Paint(), ); image.dispose(); }, ); } @override bool shouldRepaint(covariant I420Painter oldDelegate) { return yPlane != oldDelegate.yPlane || uPlane != oldDelegate.uPlane || vPlane != oldDelegate.vPlane || width != oldDelegate.width || height != oldDelegate.height; } } ``` `ExternalVideoPreviewState.updateFrame()` に `writeFrame()` と同じ `ExternalVideoFrame` を渡すと、送信中のフレームをプレビューできます。 `GlobalKey` 経由で現在の State を取得する場合は、Widget が破棄されている可能性があるため、 `currentState?.updateFrame(frame)` のように呼び出してください。 ```dart final previewKey = GlobalKey(); await connectAndStartCamera( controller: controller, stream: stream, conn: conn, videoTrack: videoTrack, onFrame: (frame) { previewKey.currentState?.updateFrame(frame); }, ); // Widget ツリーの中に配置します。 ExternalVideoPreview(key: previewKey); ``` この例は学習・検証用の簡易実装です。本番利用では次の点に注意してください。 > **警告** > > 以下の処理がいずれも UI スレッドで毎フレーム実行されるため、高解像度や高フレームレートではパフォーマンスが大幅に低下します。 > > - フレームごとの `Uint8List(width * height * 4)` による大きなバッファー確保 (GC 負荷) > - I420 → RGBA への同期的なピクセル変換 > - `decodeImageFromPixels` による非同期 GPU アップロード (最低 1 フレームの表示遅延) > > 本番利用では、変換済み画像のキャッシュやバックグラウンド `Isolate` への変換処理のオフロードを推奨します。 ## I420 の取り扱い 色空間は I420 (YUV 4:2:0 planar) 固定です。他のフォーマットを扱う場合はアプリケーション側で I420 に変換してください。 ### plane サイズ - Y plane: 少なくとも `yStride * height` バイト必要 - chromaWidth = `(width + 1) ~/ 2` - chromaHeight = `(height + 1) ~/ 2` - U plane: 少なくとも `uStride * chromaHeight` バイト必要 - V plane: 少なくとも `vStride * chromaHeight` バイト必要 `width` が奇数の場合でも chromaWidth は切り上げになるため、stride は `(width + 1) ~/ 2` 以上にしてください。 ## カメラと外部映像トラックを切り替える `MediaDevices.getUserMedia()` などで生成したカメラの映像トラックで配信中に、 `MediaDevices.createExternalVideoTrack()` の映像トラックに差し替えることができます。また逆も可能です。 - カメラから外部映像トラックへの切り替え: プラットフォーム側のカメラが止まる。以降は `writeFrame()` でフレームを供給する必要がある - 外部映像トラックからカメラへの切り替え: プラットフォーム側のカメラ入力が再開し、 `LocalVideoTrack.textureId` からのローカルプレビューも再開する ```dart // 現在の映像トラックを取得する final previous = stream.getVideoTracks().first; // 新しい外部映像トラックを生成して差し替える final next = MediaDevices.createExternalVideoTrack(); await conn.replaceVideoTrack(stream, next); // 使わなくなったトラックは破棄する await previous.dispose(); ``` 差し替え前のトラックは `replaceVideoTrack()` 内でストリームから `removeTrack()` されますが、 `dispose()` は呼ばれません。使わなくなったトラックはアプリケーション側で `dispose()` してください。 ## コード例 ### テストパターン (単色塗りつぶし) ```dart ExternalVideoFrame buildSolidColorFrame({ required int width, required int height, required int y, required int u, required int v, }) { final chromaWidth = (width + 1) ~/ 2; final chromaHeight = (height + 1) ~/ 2; final yPlane = Uint8List(width * height)..fillRange(0, width * height, y); final uPlane = Uint8List(chromaWidth * chromaHeight) ..fillRange(0, chromaWidth * chromaHeight, u); final vPlane = Uint8List(chromaWidth * chromaHeight) ..fillRange(0, chromaWidth * chromaHeight, v); return ExternalVideoFrame( width: width, height: height, yPlane: yPlane, uPlane: uPlane, vPlane: vPlane, yStride: width, uStride: chromaWidth, vStride: chromaWidth, // 省略すると DateTime.now().microsecondsSinceEpoch が使われる timestampUs: DateTime.now().microsecondsSinceEpoch, // カメラセンサーの取り付け向きに応じて 0 / 90 / 180 / 270 を指定 rotation: 0, ); } ``` ### 一定フレームレートで書き込む `writeFrame()` の呼び出し間隔は、アプリケーションがフレームを供給する間隔です。これは実際の送信フレームレートを保証しません。 `Timer.periodic` や独立アイソレートのループで制御する構成が扱いやすくなります。 ```dart Timer? timer; void startPublishing(LocalVideoTrack track) { timer = Timer.periodic(const Duration(milliseconds: 33), (_) { track.writeFrame(buildSolidColorFrame( width: 640, height: 480, y: 128, u: 128, v: 128, )); }); } Future stopPublishing() async { timer?.cancel(); timer = null; } ``` フレームの生成がメインアイソレートを阻害する場合は、 `compute()` や独立 isolate での生成を検討してください。 `writeFrame()` 自体は Dart の呼び出し中に入力バッファーをネイティブ側の `I420 バッファー` へ同期的にコピーしてから戻るため、戻り後は入力バッファーを再利用できます。 ## 関連ページ - [メディアデバイス](media_device.html) - [カメラを利用して映像を送信する](camera_video_source.html) - [外部カメラパッケージを利用する](external_camera_package.html) - [映像を描画する](video_rendering.html) - [シグナリング設定](signaling.html) - [イベント](event.html) # 外部カメラパッケージを利用する ## 概要 Flutter 公式 `camera` パッケージで取得したフレームを Sora Flutter SDK の外部映像入力 API に流し込む方法を説明します。 `camera` パッケージが対応する全プラットフォーム(Android / iOS)で利用できますが、I420 への変換方法が異なります。パフォーマンスが出ない場合は libyuv の導入を検討してください。libyuv は FFI で呼び出す必要があります。 SDK の外部映像入力 API については [外部映像入力を利用する](external_video_source.html) を参照してください。本ページは `camera` パッケージ統合に特化した補足です。 > **注釈** > > Sora Flutter SDK のデフォルトの映像入力は `MediaDevices.getUserMedia()` で取得する内蔵カメラ( `libwebrtc` のカメラキャプチャ)です。 > 一方、本ページで説明する外部映像入力では `camera` パッケージ側でカメラ制御とフレーム取得を行い、変換後の I420 フレームを `writeFrame()` で SDK に渡します。 > `camera` パッケージの `CameraPreview` によるプレビューやフレームの加工が必要な場合に有用です。 ## 基本の流れ 1. `pubspec.yaml` に `camera` パッケージを追加する 2. `CameraController` を初期化し、プレビュー用に `CameraPreview` を表示する 3. `MediaDevices.createExternalVideoTrack()` で映像トラックを生成する 4. `SoraConnection.connect()` で接続を確立する(フレーム投入より先に接続を完了させる) 5. `CameraController.startImageStream()` でフレームを取得し、I420 に変換して `writeFrame()` で投入する 6. 切断時は `stopImageStream` → `disconnect` → 各 track の `dispose` → `stream.dispose` → `controller.dispose` → `conn.dispose` の順で解放する ```dart import 'dart:io'; import 'package:camera/camera.dart'; import 'package:sora_sdk/sora_sdk.dart'; import 'external_camera_android_converter.dart'; import 'external_camera_ios_converter.dart'; /// ``CameraImage`` をプラットフォームに応じて I420 へ変換します。 /// /// Android では ``ImageFormatGroup.yuv420`` または /// ``ImageFormatGroup.nv21``、iOS では single-planar の /// ``ImageFormatGroup.bgra8888`` を使用します。 /// iOS の bi-planar ``yuv420`` は ``UnsupportedError`` とします。 ExternalVideoFrame cameraImageToExternalVideoFrame( CameraImage image, { required int sensorOrientation, }) { if (Platform.isAndroid) { if (image.format.group != ImageFormatGroup.yuv420 && image.format.group != ImageFormatGroup.nv21) { throw UnsupportedError( 'Android camera image format ${image.format.group} is not supported. ' 'Use ImageFormatGroup.yuv420 or ImageFormatGroup.nv21.', ); } return yuv420ToI420(image, sensorOrientation); } if (Platform.isIOS) { if (image.format.group != ImageFormatGroup.bgra8888) { throw UnsupportedError( 'iOS camera image format ${image.format.group} is not supported. ' 'Use ImageFormatGroup.bgra8888.', ); } return bgra8888ToI420(image, sensorOrientation); } throw UnsupportedError( 'Camera image conversion is supported only on Android and iOS.', ); } ``` ```dart import 'dart:io'; import 'package:camera/camera.dart'; import 'package:sora_sdk/sora_sdk.dart'; import 'external_camera_error_handler.dart'; /// ``camera`` パッケージのフレームを Sora Flutter SDK の外部映像入力として /// 送信するサンプルです。 /// /// 全体の流れは次のとおりです。 /// 1. ``CameraController`` でカメラを初期化する /// 2. ``MediaDevices.createExternalVideoTrack()`` で外部映像トラックを生成する /// 3. ``SoraConnection`` を確立する /// 4. ``startImageStream`` でフレームを取得し、I420 に変換して送信する /// /// ``camera`` パッケージが出力するフレーム形式はプラットフォームによって /// 異なります。各形式から I420 への変換方法については、 /// ``external_camera_converter.dart``、 /// ``external_camera_android_converter.dart`` および /// ``external_camera_ios_converter.dart`` を参照してください。 Future startExternalCamera({ void Function(ExternalVideoFrame frame)? onFrame, }) async { // 利用可能なカメラの一覧を取得します。 // ここでは最初のカメラ(通常は前面または背面のいずれか)を使用します。 final cameras = await availableCameras(); if (cameras.isEmpty) { throw StateError('利用可能なカメラがありません'); } // CameraController を初期化します。 // - ResolutionPreset.medium: 480p 相当。高すぎる解像度を避け、 // 変換負荷とのバランスを取ります // - enableAudio: false: 音声は ``camera`` パッケージでは扱わず、 // ``sora_sdk`` の ``MediaDevices.getUserMedia()`` で別途取得します // - imageFormatGroup: プラットフォームに応じた形式を指定します // iOS では bgra8888 を要求します(devtools サンプルの既定動作) // Android では yuv420 を要求します(YUV_420_888 が取得されます) final controller = CameraController( cameras.first, ResolutionPreset.medium, enableAudio: false, imageFormatGroup: Platform.isIOS ? ImageFormatGroup.bgra8888 : ImageFormatGroup.yuv420, ); // カメラの初期化が完了するのを待ちます。 // 初期化前に ``startImageStream`` を呼ぶとエラーになります。 try { await controller.initialize(); } catch (_) { await controller.dispose(); rethrow; } // 空の LocalMediaStream を生成します。 // createMediaStream() はトラックを一切持たない stream を返すため、 // 後続の addTrack() で必要なトラックを追加します。 final stream = MediaDevices.createMediaStream(); // 外部映像入力用の LocalVideoTrack を生成します。 // このトラックは SDK 内蔵のカメラキャプチャではなく、 // アプリケーション側で writeFrame() を呼んでフレームを供給します。 // 注意: LocalVideoTrack.textureId は使えません。 // ローカルプレビューには ``camera`` パッケージの // CameraPreview ウィジェットを利用してください。 final video = MediaDevices.createExternalVideoTrack(); // 映像トラックを stream に関連付けます。 // 音声も送信する場合は createAudioTrack() で生成し、同様に追加します。 stream.addTrack(video); // SoraConnection を生成し、Sora に接続します。 // - role: SoraRole.sendonly: 配信のみ。視聴しない場合は sendonly を指定 // 送受信する場合は SoraRole.sendrecv を指定してください // - video: true を指定するか未指定にする: 映像を送信する // createExternalVideoTrack は video: true を指定するか未指定で、 // role が sendonly / sendrecv の場合に使用できます final conn = await Sora.createConnection( SoraConnectionConfig( signalingUrls: ['wss://sora.example.com/signaling'], channelId: 'example-channel', role: SoraRole.sendonly, video: true, ), ); // Sora に接続します。 // connect() の完了を待ってからフレーム投入を開始してください。 // 接続完了前に writeFrame() を呼ぶと、 // AdaptFrame がフレームをドロップする可能性があります。 await conn.connect(stream); // カメラからのフレーム取得を開始します。 // startImageStream のコールバックはカメラが新しいフレームを // キャプチャするたびに呼ばれます。 // - CameraImage: フレームの生データとメタデータを含みます // - sensorOrientation: カメラセンサーの取り付け向きです。 // 縦持ち端末では 90° または 270° になります。 // この値を ExternalVideoFrame.rotation に渡すことで // 配信映像の向きを補正します。 await controller.startImageStream((CameraImage image) { onImage(image, controller, video, onFrame: onFrame); }); } ``` > **注釈** > > `camera` パッケージを利用する場合、 `startImageStream` のコールバックがカメラのキャプチャレートに応じて駆動されるため、独立した `Timer.periodic` で `writeFrame()` を呼ぶ必要はありません。 `startImageStream` のコールバック内で直接 `writeFrame()` を呼んでください。 > 汎用的な外部映像入力(画面キャプチャやテストパターンなど)で `Timer.periodic` を用いるパターンは [外部映像入力を利用する](external_video_source.html) を参照してください。 ## プレビューについて 外部映像トラックでは `LocalVideoTrack.textureId` が使えないため、 `camera` パッケージの `CameraPreview` ウィジェットでプレビューします。 ```dart import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; /// 外部映像トラック利用時のプレビューウィジェットです。 /// /// ``MediaDevices.createExternalVideoTrack()`` で生成したトラックでは /// ``LocalVideoTrack.textureId`` が使えません。そのため、ローカルプレビューには /// ``camera`` パッケージの ``CameraPreview`` ウィジェットを使用します。 /// /// ``CameraPreview`` と ``startImageStream()`` は同時に使用できます。 /// プレビューはプラットフォーム側のテクスチャ上で描画され、 /// 画像ストリームとは独立して動作します。 /// /// 使用例: /// ```dart /// ExternalCameraPreview(controller: _controller) /// ``` class ExternalCameraPreview extends StatelessWidget { const ExternalCameraPreview({super.key, required this.controller}); /// ``camera`` パッケージの CameraController。 /// あらかじめ ``controller.initialize()`` を呼んで /// 初期化を完了させておく必要があります。 final CameraController controller; @override Widget build(BuildContext context) { // カメラが初期化されていない間はインジケーターを表示します。 // controller.value.isInitialized が false の状態で // CameraPreview を表示しようとするとエラーになります。 if (!controller.value.isInitialized) { return const Center(child: CircularProgressIndicator()); } // プラットフォーム側のテクスチャを用いてカメラのプレビューを描画します。 // SDK の Texture ウィジェットとは異なり、 // ``camera`` パッケージが独自にプラットフォーム側のテクスチャを管理します。 return CameraPreview(controller); } } ``` `CameraPreview` と `startImageStream()` は同時に使用できます。プレビューはプラットフォーム側のテクスチャを用いて描画され、画像ストリームとは独立して動作します。 ## I420 への変換 `camera` パッケージが出力するフレーム形式はプラットフォームごとに異なります。いずれも `ExternalVideoFrame` が要求する I420 (tri-planar YUV 4:2:0) ではないため、変換が必要です。 | プラットフォーム | 要求フォーマット | 実際の形式 | 変換内容 | | --- | --- | --- | --- | | Android | `yuv420` / `nv21` | `yuv420` は tri-planar YUV 4:2:0(Y, U, V の 3 プレーン)。 `nv21` は 1 プレーンの packed buffer または Y + VU の 2 プレーン | プレーン抽出、VU の U / V 分離、ピクセルストライド正規化。純粋 Dart で実用的 | | iOS | `bgra8888` | single-planar BGRA 32bit | 色空間変換 + 4:2:0 クロマサブサンプリング。純粋 Dart では高負荷 | | iOS | `yuv420` | bi-planar YUV 4:2:0 | `UnsupportedError`。このサンプルでは `bgra8888` を使用 | 以下、各形式の変換方法を示します。devtools サンプルでは Android に YUV420、iOS に BGRA を要求しています。 ### Android: YUV_420_888 / NV21 > **注釈** > > 本節の内容は **Android のみに適用されます**。Android の `ImageFormatGroup.yuv420` は tri-planar(Y, U, V の 3 プレーン)です。 `ImageFormatGroup.nv21` はカメラ実装によって、Y + VU の 2 プレーンまたは Y + VU を連結した 1 プレーンで到着します。 `ImageFormatGroup.yuv420` または `ImageFormatGroup.nv21` でフレームが到着します。 `yuv420` は 3 プレーンから I420 の各プレーンを抽出します。 `nv21` は VU interleaved の順序を U / V に入れ替えて I420 に変換します。いずれも行ストライドとピクセルストライドを検証し、パディングを除いて連続したプレーンへコピーします。 ```dart import 'dart:typed_data'; import 'package:camera/camera.dart'; import 'package:sora_sdk/sora_sdk.dart'; /// Android の ``camera`` パッケージが出力する YUV 4:2:0 を /// ``ExternalVideoFrame`` が要求する I420 形式に変換します。 /// /// ``ImageFormatGroup.yuv420`` は YUV_420_888 の 3 プレーン(Y, U, V)として /// 扱います。``ImageFormatGroup.nv21`` はカメラ実装によって、Y と VU の /// 2 プレーン、または Y + VU を連結した 1 プレーンとして到着します。 /// いずれも I420 の U / V プレーンへ正規化します。 ExternalVideoFrame yuv420ToI420(CameraImage image, int sensorOrientation) { _validateDimensions(image); switch (image.format.group) { case ImageFormatGroup.yuv420: return _convertYuv420(image, sensorOrientation); case ImageFormatGroup.nv21: return _convertNv21(image, sensorOrientation); default: throw UnsupportedError( 'Android camera image format ${image.format.group} is not supported. ' 'Use ImageFormatGroup.yuv420 or ImageFormatGroup.nv21.', ); } } ExternalVideoFrame _convertYuv420(CameraImage image, int sensorOrientation) { _requirePlaneCount(image, expected: 3); final width = image.width; final height = image.height; final yPlane = image.planes[0]; final uPlane = image.planes[1]; final vPlane = image.planes[2]; final yPixelStride = _pixelStride( image, yPlane, defaultValue: 1, label: 'plane 0 (Y)', ); if (yPixelStride != 1) { _unsupportedLayout( image, 'plane 0 (Y) pixel stride must be 1, received $yPixelStride', ); } final uPixelStride = _pixelStride( image, uPlane, defaultValue: 1, label: 'plane 1 (U)', ); final vPixelStride = _pixelStride( image, vPlane, defaultValue: 1, label: 'plane 2 (V)', ); if ((uPixelStride != 1 && uPixelStride != 2) || vPixelStride != uPixelStride) { _unsupportedLayout( image, 'U / V pixel stride must be the same value of 1 or 2, ' 'received U=$uPixelStride and V=$vPixelStride', ); } final chromaWidth = (width + 1) ~/ 2; final chromaHeight = (height + 1) ~/ 2; final yData = _copyPlane( image, yPlane, width, height, pixelStride: yPixelStride, label: 'plane 0 (Y)', ); final uData = _copyPlane( image, uPlane, chromaWidth, chromaHeight, pixelStride: uPixelStride, label: 'plane 1 (U)', ); final vData = _copyPlane( image, vPlane, chromaWidth, chromaHeight, pixelStride: vPixelStride, label: 'plane 2 (V)', ); return _buildFrame( image, sensorOrientation, yData, uData, vData, yStride: width, uStride: chromaWidth, vStride: chromaWidth, ); } ExternalVideoFrame _convertNv21(CameraImage image, int sensorOrientation) { switch (image.planes.length) { case 1: return _convertPackedNv21(image, sensorOrientation); case 2: return _convertTwoPlaneNv21(image, sensorOrientation); default: _unsupportedLayout( image, 'NV21 requires 1 packed plane or 2 planes (Y and VU), ' 'received ${image.planes.length}', ); } } ExternalVideoFrame _convertTwoPlaneNv21( CameraImage image, int sensorOrientation, ) { final width = image.width; final height = image.height; final chromaWidth = (width + 1) ~/ 2; final chromaHeight = (height + 1) ~/ 2; final yPlane = image.planes[0]; final vuPlane = image.planes[1]; final yPixelStride = _pixelStride( image, yPlane, defaultValue: 1, label: 'plane 0 (Y)', ); if (yPixelStride != 1) { _unsupportedLayout( image, 'plane 0 (Y) pixel stride must be 1, received $yPixelStride', ); } final vuPixelStride = _pixelStride( image, vuPlane, defaultValue: 1, label: 'plane 1 (VU)', ); if (vuPixelStride != 2) { _unsupportedLayout( image, 'plane 1 (VU) pixel stride must be 2, received $vuPixelStride', ); } final yData = _copyPlane( image, yPlane, width, height, pixelStride: yPixelStride, label: 'plane 0 (Y)', ); final uData = Uint8List(chromaWidth * chromaHeight); final vData = Uint8List(chromaWidth * chromaHeight); _validatePlane( image, vuPlane, chromaWidth, chromaHeight, pixelStride: vuPixelStride, sampleByteLength: 2, label: 'plane 1 (VU)', ); for (var row = 0; row < chromaHeight; row++) { final srcRowStart = row * vuPlane.bytesPerRow; final dstRowStart = row * chromaWidth; for (var col = 0; col < chromaWidth; col++) { final srcIndex = srcRowStart + col * vuPixelStride; final dstIndex = dstRowStart + col; // NV21 は VU の順序、I420 は U / V の順序です。 vData[dstIndex] = vuPlane.bytes[srcIndex]; uData[dstIndex] = vuPlane.bytes[srcIndex + 1]; } } return _buildFrame( image, sensorOrientation, yData, uData, vData, yStride: width, uStride: chromaWidth, vStride: chromaWidth, ); } ExternalVideoFrame _convertPackedNv21( CameraImage image, int sensorOrientation, ) { final width = image.width; final height = image.height; final plane = image.planes[0]; final pixelStride = _pixelStride( image, plane, defaultValue: 1, label: 'packed plane', ); if (pixelStride != 1) { _unsupportedLayout( image, 'packed NV21 pixel stride must be 1, received $pixelStride', ); } if (plane.bytesPerRow != width) { _unsupportedLayout( image, 'packed NV21 bytes per row must equal image width ($width), ' 'received ${plane.bytesPerRow}', ); } final chromaWidth = (width + 1) ~/ 2; final chromaHeight = (height + 1) ~/ 2; final yByteLength = width * height; final chromaRowLength = chromaWidth * 2; final requiredLength = yByteLength + chromaRowLength * chromaHeight; if (plane.bytes.length < requiredLength) { _unsupportedLayout( image, 'packed NV21 buffer is too short: required $requiredLength bytes, ' 'received ${plane.bytes.length}', ); } final yData = _copyPlane( image, plane, width, height, pixelStride: pixelStride, label: 'packed plane (Y)', ); final uData = Uint8List(chromaWidth * chromaHeight); final vData = Uint8List(chromaWidth * chromaHeight); final chromaOffset = yByteLength; for (var row = 0; row < chromaHeight; row++) { final srcRowStart = chromaOffset + row * chromaRowLength; final dstRowStart = row * chromaWidth; for (var col = 0; col < chromaWidth; col++) { final srcIndex = srcRowStart + col * 2; final dstIndex = dstRowStart + col; // NV21 は VU の順序、I420 は U / V の順序です。 vData[dstIndex] = plane.bytes[srcIndex]; uData[dstIndex] = plane.bytes[srcIndex + 1]; } } return _buildFrame( image, sensorOrientation, yData, uData, vData, yStride: width, uStride: chromaWidth, vStride: chromaWidth, ); } ExternalVideoFrame _buildFrame( CameraImage image, int sensorOrientation, Uint8List yData, Uint8List uData, Uint8List vData, { required int yStride, required int uStride, required int vStride, }) { return ExternalVideoFrame( width: image.width, height: image.height, yPlane: yData, uPlane: uData, vPlane: vData, yStride: yStride, uStride: uStride, vStride: vStride, rotation: sensorOrientation, timestampUs: DateTime.now().microsecondsSinceEpoch, ); } int _pixelStride( CameraImage image, Plane plane, { required int defaultValue, required String label, }) { final pixelStride = plane.bytesPerPixel ?? defaultValue; if (pixelStride <= 0) { _unsupportedLayout( image, '$label pixel stride must be positive, received $pixelStride', ); } return pixelStride; } Uint8List _copyPlane( CameraImage image, Plane plane, int width, int height, { required int pixelStride, required String label, }) { _validatePlane( image, plane, width, height, pixelStride: pixelStride, label: label, ); final src = plane.bytes; final srcRowStride = plane.bytesPerRow; final dst = Uint8List(width * height); for (var row = 0; row < height; row++) { final srcRowStart = row * srcRowStride; final dstRowStart = row * width; for (var col = 0; col < width; col++) { dst[dstRowStart + col] = src[srcRowStart + col * pixelStride]; } } return dst; } void _validateDimensions(CameraImage image) { if (image.width <= 0 || image.height <= 0) { _unsupportedLayout( image, 'image dimensions must be positive, received ' '${image.width}x${image.height}', ); } } void _requirePlaneCount(CameraImage image, {required int expected}) { if (image.planes.length != expected) { _unsupportedLayout( image, 'YUV_420_888 requires $expected planes, ' 'received ${image.planes.length}', ); } } void _validatePlane( CameraImage image, Plane plane, int width, int height, { required int pixelStride, int sampleByteLength = 1, required String label, }) { final minimumRowLength = (width - 1) * pixelStride + sampleByteLength; if (plane.bytesPerRow < minimumRowLength) { _unsupportedLayout( image, '$label bytes per row must be at least $minimumRowLength, ' 'received ${plane.bytesPerRow}', ); } final lastByteIndex = (height - 1) * plane.bytesPerRow + (width - 1) * pixelStride + sampleByteLength - 1; if (plane.bytes.length <= lastByteIndex) { _unsupportedLayout( image, '$label buffer is too short: requires byte index $lastByteIndex, ' 'received ${plane.bytes.length} bytes', ); } } Never _unsupportedLayout(CameraImage image, String detail) { throw UnsupportedError( 'Android camera image format ${image.format.group} has unsupported ' 'layout: $detail.', ); } ``` `ImageFormatGroup.nv21` の 2 プレーン経路では `planes[0]` を Y、 `planes[1]` を VU として扱い、VU の各ペアを I420 の V / U へ分離します。 1 プレーンの packed buffer では、Y の直後にある VU を同じ順序で分離します。プレーン数、行ストライド、ピクセルストライド、バッファ長が変換条件を満たさない場合は `UnsupportedError` を投げます。 ### iOS: bgra8888 iOS では `ImageFormatGroup.bgra8888` を要求し、I420 へ変換します。 #### bgra8888 形式 `ImageFormatGroup.bgra8888` でフレームが到着します。single-planar BGRA 32bit であり、I420 への変換には BGRA → YUV 色空間変換と 4:2:0 クロマサブサンプリングが必要です。純粋 Dart では高解像度・高フレームレートの処理に耐えられません。 devtools サンプルでは iOS に対して `ImageFormatGroup.bgra8888` を明示的に要求しています。これは学習・検証用の簡易実装であり、パフォーマンスが出ない場合は libyuv の導入を検討してください。 #### 変換コード `bgra8888ToI420` の変換コードを以下に示します。 ```dart import 'dart:typed_data'; import 'package:camera/camera.dart'; import 'package:sora_sdk/sora_sdk.dart'; /// iOS の ``camera`` パッケージが出力する BGRA 32bit を /// ``ExternalVideoFrame`` が要求する I420 (YUV 4:2:0 planar) 形式に変換します。 /// /// iOS で ``ImageFormatGroup.bgra8888`` を要求すると、 /// single-planar BGRA 32bit が到着します。 /// I420 への変換には以下の処理が必要です: /// 1. BGRA → YUV 色空間変換 /// 2. 4:2:0 クロマサブサンプリング(U/V を 2×2 ブロックごとに 1 つに間引く) /// /// **重要**: この実装は純粋 Dart であり、高解像度・高フレームレートでは /// パフォーマンスが不足します。本格的な利用には libyuv の導入を検討してください。 /// libyuv の BGRA を I420 に変換する C 関数を FFI で呼び出すことで、 /// 大幅な高速化が期待できます。 /// /// ``ImageFormatGroup.bgra8888`` を使用する理由: /// - devtools サンプルでは iOS に対して明示的に bgra8888 を要求しています /// - これは学習・検証用の簡易実装です /// - bi-planar の ``ImageFormatGroup.yuv420`` はこのサンプルでは扱いません ExternalVideoFrame bgra8888ToI420(CameraImage image, int sensorOrientation) { final width = image.width; final height = image.height; // BGRA データは単一プレーンのみです。 // 各ピクセルは B, G, R, A の 4 バイトで構成され、 // 行のバイト数は bytesPerRow (= width * 4 + パディング) です。 final src = image.planes[0].bytes; final srcStride = image.planes[0].bytesPerRow; // Y プレーン用のバッファー: width × height の full resolution final yPlane = Uint8List(width * height); // クロマプレーンのサイズを計算します。 // 4:2:0 サブサンプリングにより、U/V はそれぞれ 1/4 のサイズです。 final chromaWidth = (width + 1) ~/ 2; final chromaHeight = (height + 1) ~/ 2; // U/V プレーン用のバッファー: chromaWidth × chromaHeight final uPlane = Uint8List(chromaWidth * chromaHeight); final vPlane = Uint8List(chromaWidth * chromaHeight); // 2×2 ブロック単位で処理します。 // これは 4:2:0 クロマサブサンプリングに対応するためです。 // 各ブロックから: // - Y 値を 4 つ(各ピクセルから 1 つずつ) // - U 値を 1 つ(4 ピクセルの平均から計算) // - V 値を 1 つ(4 ピクセルの平均から計算) // を生成します。 for (var y = 0; y < height; y += 2) { for (var x = 0; x < width; x += 2) { // 2×2 ブロック内の 4 ピクセルを BGRA データから取得します。 // 画像端のピクセルは複製して扱います(境界処理)。 // // tl: top-left tr: top-right // bl: bottom-left br: bottom-right final tl = _bgraAt(src, srcStride, x, y); final tr = (x + 1 < width) ? _bgraAt(src, srcStride, x + 1, y) : tl; final bl = (y + 1 < height) ? _bgraAt(src, srcStride, x, y + 1) : tl; final br = (x + 1 < width && y + 1 < height) ? _bgraAt(src, srcStride, x + 1, y + 1) : tl; // 各ピクセルの Y 値を計算して Y プレーンに書き込みます。 // _yFromBgra は BT.601 の係数を用いて BGRA → Y 変換を行います。 yPlane[y * width + x] = _yFromBgra(tl); if (x + 1 < width) { yPlane[y * width + x + 1] = _yFromBgra(tr); } if (y + 1 < height) { yPlane[(y + 1) * width + x] = _yFromBgra(bl); if (x + 1 < width) { yPlane[(y + 1) * width + x + 1] = _yFromBgra(br); } } // 4 ピクセルの RGB 平均を計算してから U/V 値を算出します。 // これにより 4:2:0 クロマサブサンプリングを実現します。 final (r, g, b) = _avgPixel(tl, tr, bl, br); final cx = x ~/ 2; final cy = y ~/ 2; // _uFromRgb / _vFromRgb は BT.601 の係数を用いて // RGB → U/Cb および RGB → V/Cr 変換を行います。 uPlane[cy * chromaWidth + cx] = _uFromRgb(r, g, b); vPlane[cy * chromaWidth + cx] = _vFromRgb(r, g, b); } } return ExternalVideoFrame( width: width, height: height, yPlane: yPlane, uPlane: uPlane, vPlane: vPlane, yStride: width, uStride: chromaWidth, vStride: chromaWidth, rotation: sensorOrientation, timestampUs: DateTime.now().microsecondsSinceEpoch, ); } /// BGRA バイト列から指定位置 (x, y) のピクセル値を (B, G, R) として取得します。 /// /// BGRA は各ピクセルが 4 バイト(B, G, R, A の順)で構成されます。 /// Alpha チャンネルは I420 変換では使用しないため、返しません。 /// /// [bytes]: BGRA 形式のバイト列 /// [stride]: 1 行あたりのバイト数(パディングを含む) /// [x], [y]: 取得するピクセルの座標 (int, int, int) _bgraAt(Uint8List bytes, int stride, int x, int y) { // 各行の先頭からのオフセット: y * stride // ピクセルの先頭からのオフセット: x * 4 (1 ピクセル = 4 バイト) final idx = y * stride + x * 4; // bytes[idx] = B (Blue) // bytes[idx+1] = G (Green) // bytes[idx+2] = R (Red) // bytes[idx+3] = A (Alpha) ← 使用しません return (bytes[idx], bytes[idx + 1], bytes[idx + 2]); } /// BT.601 の係数を用いて BGRA ピクセルから Y (輝度) 値を計算します。 /// /// 変換式(BT.601 近似): /// Y = 0.257 * R + 0.504 * G + 0.098 * B + 16 /// /// 整数演算で近似しています: /// Y = ((66 * R + 129 * G + 25 * B + 128) >> 8) + 16 /// /// 各係数は 66/256 ≈ 0.2578, 129/256 ≈ 0.5039, 25/256 ≈ 0.0977 です。 int _yFromBgra((int, int, int) bgr) { final (b, g, r) = bgr; return ((66 * r + 129 * g + 25 * b + 128) >> 8) + 16; } /// BT.601 の係数を用いて RGB 値から U (Cb) 値を計算します。 /// /// 変換式(BT.601 近似): /// U = -0.148 * R - 0.291 * G + 0.439 * B + 128 /// /// 整数演算で近似: /// U = ((-38 * R - 74 * G + 112 * B + 128) >> 8) + 128 /// /// -38/256 ≈ -0.1484, -74/256 ≈ -0.2891, 112/256 ≈ 0.4375 int _uFromRgb(int r, int g, int b) => ((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128; /// BT.601 の係数を用いて RGB 値から V (Cr) 値を計算します。 /// /// 変換式(BT.601 近似): /// V = 0.439 * R - 0.368 * G - 0.071 * B + 128 /// /// 整数演算で近似: /// V = ((112 * R - 94 * G - 18 * B + 128) >> 8) + 128 /// /// 112/256 ≈ 0.4375, -94/256 ≈ -0.3672, -18/256 ≈ -0.0703 int _vFromRgb(int r, int g, int b) => ((112 * r - 94 * g - 18 * b + 128) >> 8) + 128; /// 4 ピクセルの RGB 平均を計算します。 /// /// 4:2:0 クロマサブサンプリングでは、 /// 2×2 ブロック内の 4 ピクセルから 1 つの U/V 値を生成します。 /// ここでは単純平均を用いています。 (int, int, int) _avgPixel( (int, int, int) tl, (int, int, int) tr, (int, int, int) bl, (int, int, int) br, ) { int avg(int a, int b, int c, int d) => (a + b + c + d) ~/ 4; final (b1, g1, r1) = tl; final (b2, g2, r2) = tr; final (b3, g3, r3) = bl; final (b4, g4, r4) = br; return (avg(r1, r2, r3, r4), avg(g1, g2, g3, g4), avg(b1, b2, b3, b4)); } ``` パフォーマンスが出ない場合は libyuv の導入を検討し、 BGRA を I420 に変換する C 関数を FFI で呼び出してください。 ## 回転への対応 `CameraDescription.sensorOrientation` はカメラセンサーの取り付け向きを表します。多くの端末ではセンサーが横向きに取り付けられているため、縦持ち時に 90° や 270° の値が返ります。この値を `ExternalVideoFrame.rotation` に渡すことで、配信映像の向きを補正できます。 ```dart final frame = cameraImageToExternalVideoFrame( image, sensorOrientation: controller.description.sensorOrientation, ); ``` > **注釈** > > この値はデバイスの回転に追従しない固定値です。縦横切替に追従させるには、アプリケーション側で `OrientationBuilder` 等を用いて現在の向きを検出し、差分で回転値を計算してください。 ## ライフサイクル管理 フレーム取得の開始と停止、接続の切断、各トラックと `LocalMediaStream` の破棄、カメラの破棄は以下の順序で行います。 `connectAndStartCamera()` は、カメラ初期化、Sora 接続、画像ストリーム開始のいずれかに失敗した場合も、生成済みのリソースを `disconnectAndDisposeCamera()` で解放してから元のエラーを再送出します。 Widget の破棄時やユーザー操作による停止時は、画像ストリームを停止してから同じ `disconnectAndDisposeCamera()` を呼び出してください。cleanup は複数回呼び出されても、完了済みの処理をスキップして後続の解放を続けます。 ```dart import 'package:camera/camera.dart'; import 'package:sora_sdk/sora_sdk.dart'; import 'external_camera_error_handler.dart'; /// カメラの初期化から接続、フレーム取得開始までの一連の流れを実行します。 /// /// 呼び出し順序には依存関係があります: /// 1. ``controller.initialize()`` → カメラの使用準備 /// 2. ``conn.connect(stream)`` → Sora への接続(先に接続を確立する) /// 3. ``controller.startImageStream()`` → フレーム取得開始 /// /// ``startImageStream`` は ``connect`` より後に呼んでください。 /// 接続完了前に ``writeFrame`` が呼ばれると /// AdaptFrame がフレームをドロップする可能性があるため、 /// 先に ``connect()`` の完了を待ってからフレーム投入を開始します。 /// [videoTrack] は ``stream`` に追加した外部映像トラックです。 /// [onFrame] を指定すると、``writeFrame`` に渡した同じフレームを /// プレビューなどへ通知します。 Future connectAndStartCamera({ required CameraController controller, required LocalMediaStream stream, required SoraConnection conn, required LocalVideoTrack videoTrack, void Function(ExternalVideoFrame frame)? onFrame, }) async { try { // カメラを初期化します。 // initialize() が完了するまで CameraPreview は表示できず、 // startImageStream も呼べません。 await controller.initialize(); // Sora に接続します。 // 先に接続を確立してからフレーム投入を開始します。 // 切断と破棄は必ず下記の disconnectAndDisposeCamera の順序で行ってください。 await conn.connect(stream); // フレーム取得を開始します。 // コールバック関数内で I420 変換と writeFrame() を呼び出します。 await controller.startImageStream((image) { onImage(image, controller, videoTrack, onFrame: onFrame); }); } catch (error, stackTrace) { // 初期化、接続、画像ストリーム開始のどこで失敗しても、 // 生成済みのリソースを同じ cleanup 経路で解放します。 try { await disconnectAndDisposeCamera( controller: controller, conn: conn, stream: stream, ); } catch (_) { // 元の失敗原因を保持するため、cleanup の失敗はここでは隠します。 } Error.throwWithStackTrace(error, stackTrace); } } /// フレーム取得の停止、切断、破棄を正しい順序で実行します。 /// /// **順序が重要です。** /// 順序を誤ると ``StateError`` が発生したり、 /// リソースリークの原因になります。 /// /// 画像ストリームが未開始の場合や、cleanup が複数回呼ばれた場合も、 /// 既に完了した処理をスキップして後続の解放を続けます。 /// 各ステップで失敗しても残りのリソースを解放し、最後に最初のエラーを再送出します。 Future disconnectAndDisposeCamera({ required CameraController controller, required SoraConnection conn, required LocalMediaStream stream, }) async { Object? firstError; StackTrace? firstStackTrace; var hasError = false; void rememberError(Object error, StackTrace stackTrace) { if (!hasError) { hasError = true; firstError = error; firstStackTrace = stackTrace; } } Future attempt(Future Function() action) async { try { await action(); } catch (error, stackTrace) { rememberError(error, stackTrace); } } // 1. フレーム取得を停止します。 // 切断後に writeFrame が呼ばれないよう、入力元を先に停止します。 // 未開始の場合は stopImageStream() を呼ばず、二重 cleanup も許容します。 await attempt(() async { if (controller.value.isInitialized && controller.value.isStreamingImages) { try { await controller.stopImageStream(); } on CameraException { // stop と別の cleanup が競合して既に停止している場合は許容します。 if (controller.value.isStreamingImages) { rethrow; } } } }); // 2. Sora との接続を切断します。 // disconnect は接続からトラックを切り離しますが、 // アプリケーション側で作成したトラックや stream は破棄しません。 await attempt(conn.disconnect); // 切断前に track の一覧を取得します。stream.dispose() 後は一覧を取得できません。 List tracks = const []; try { tracks = stream.getTracks(); } catch (error, stackTrace) { rememberError(error, stackTrace); } // 3. stream に含まれる各トラックを破棄します。 // LocalMediaStream.dispose() はトラックを自動では破棄しません。 for (final track in tracks) { await attempt(track.dispose); } // 4. stream 自身を破棄します。 await attempt(stream.dispose); // 5. カメラを破棄します。 // フレーム取得を停止し、トラックを破棄した後に呼んでください。 await attempt(controller.dispose); // 6. SoraConnection を破棄します。 // SoraConnection.dispose() も stream / track を自動では破棄しません。 // 破棄後は同じインスタンスで再接続できません。 await attempt(conn.dispose); // disconnect() が失敗して track が接続中のままになった場合に備えて、 // SoraConnection.dispose() 後にもう一度 track の破棄を試みます。 for (final track in tracks) { if (!track.isDisposed) { await attempt(track.dispose); } } if (hasError) { Error.throwWithStackTrace(firstError!, firstStackTrace ?? StackTrace.current); } } ``` フレーム取得の停止( `stopImageStream` )は `disconnect` より先に行ってください。入力元を停止せずに切断すると、切断処理とフレーム投入が競合する可能性があります。 `disconnect` は接続からトラックを切り離しますが、アプリケーション側で作成したトラックや `LocalMediaStream` は自動では破棄しません。切断後に各トラック、 `LocalMediaStream` 、 `CameraController` 、 `SoraConnection` を明示的に破棄してください。 ## エラーハンドリング `cameraImageToExternalVideoFrame` が `UnsupportedError` を投げるのは、Android の `yuv420` / `nv21` 、 iOS の `bgra8888` 以外のフレーム形式が来た場合、または対応形式でもプレーン構造が検証条件を満たさない場合です。 iOS の bi-planar `yuv420` は未対応です。 `writeFrame` が `StateError` を投げる条件は [外部映像入力を利用する](external_video_source.html) を参照してください。 フレーム変換や `writeFrame` の例外を放置すると後続のフレームが処理されなくなるため、アプリケーション側で例外ハンドリングし、エラー時はログ出力のうえ後続フレームの処理を継続する構成を推奨します。 ```dart import 'package:camera/camera.dart'; import 'package:flutter/foundation.dart'; import 'package:sora_sdk/sora_sdk.dart'; import 'external_camera_converter.dart'; /// ``startImageStream`` から呼ばれるフレーム受信コールバックです。 /// /// フレーム変換や ``writeFrame`` の例外を放置すると /// 後続のフレームが処理されなくなるため、 /// 必ず例外をキャッチしてログ出力し、 /// 後続フレームの処理を継続するようにしてください。 /// /// 発生しうる例外: /// - ``UnsupportedError``: 未対応のフレーム形式が到着した場合 /// (``cameraImageToExternalVideoFrame`` 内部でスロー) /// - ``StateError``: ``writeFrame`` が失敗した場合 /// 主な原因: /// - トラックが dispose 済み /// - ``createExternalVideoTrack`` で生成したトラック以外で /// ``writeFrame`` を呼び出した場合 /// - 内部の映像ソース参照が null /// - rotation が 0 / 90 / 180 / 270 のいずれでもない /// - width/height が 0 以下、または 8192 を超える /// - timestampUs が負の値 /// - stride が不足 /// - プレーンのバイト列が短すぎる /// /// [image]: カメラから取得したフレームデータ /// [controller]: カメラの制御インスタンス。 /// sensorOrientation の取得に使用します /// [videoTrack]: フレームの投入先となる外部映像トラック /// [onFrame]: ``writeFrame`` に渡したフレームを受け取る callback void onImage( CameraImage image, CameraController controller, LocalVideoTrack videoTrack, { void Function(ExternalVideoFrame frame)? onFrame, }) { try { // プラットフォームに応じた I420 変換を行います。 // cameraImageToExternalVideoFrame 内で Platform 判定を行い、 // 適切な変換関数を呼び出してください。 final frame = cameraImageToExternalVideoFrame( image, sensorOrientation: controller.description.sensorOrientation, ); // 変換したフレームを映像トラックに投入します。 // writeFrame は void を返すため、成否を戻り値で判別できません。 // 失敗時は StateError がスローされるため、 // この try-catch で捕捉します。 videoTrack.writeFrame(frame); onFrame?.call(frame); } catch (error, stackTrace) { // debugPrint でエラーをログに出力します。 // リリースビルドではdebugPrint の出力は抑制されるため、 // 必要に応じて Crashlytics 等のクラッシュレポートツールへの // 送信を追加してください。 debugPrint('Frame conversion/writeFrame error: $error\n$stackTrace'); // ここで rethrow しないことが重要です。 // rethrow すると startImageStream のコールバックループが停止し、 // 後続のフレームが処理されなくなります。 } } ``` ## 接続前の注意 - `writeFrame` は接続完了前でも呼び出せますが、 `AdaptFrame` がフレームをドロップする可能性があります。 `conn.connect()` の完了を待ってからフレーム投入を開始してください。 - 切断後も、破棄前のトラックと `LocalMediaStream` はアプリケーション側に残ります。切断後に同じ外部映像トラックを新しい接続で再利用できることは保証されないため、再接続時は新しい `LocalMediaStream` と `createExternalVideoTrack()` で生成したトラックを用意してください。 - `dispose()` 済みのトラックは再利用できません。 `dispose()` 後に `writeFrame()` を呼び出すと `StateError` になります。 - `createExternalVideoTrack()` は `role: sendonly / sendrecv` で使用できます - `video` は `true` を指定するか、未指定にします ## 制限事項 - iOS の `bgra8888` → `I420` 変換は純粋 Dart では高負荷です。パフォーマンスが出ない場合は `libyuv` の導入を検討してください。 - フレーム変換に毎フレーム Y/U/V プレーン相当のメモリ確保が発生します。高解像度時はヒープ負荷に注意してください。 - `CameraPreview` と `startImageStream` の同時使用は Android / iOS の `camera` パッケージでサポートされていますが、一部の古い端末では制限がある場合があります。 - `sensorOrientation` はデバイスの回転に動的追従しません。縦横切替が必要な場合はアプリケーション側で検出して補正してください。 ## サンプル実装 リポジトリ内の `devtools` アプリケーションに `camera` パッケージを使った external video track のサンプルが含まれています。 `devtools` の「External Video Track」トグルをオンにして接続すると、 `camera` パッケージのフレームを `writeFrame()` 経由で送信します。 - 変換ユーティリティ: `devtools/lib/src/devtools_external_video_source.dart` - カメラライフサイクル管理: `devtools/lib/src/devtools_external_camera_manager.dart` - 接続フローとの統合: `devtools/lib/main.dart` ## 関連ページ - [外部映像入力を利用する](external_video_source.html) - [メディアデバイス](media_device.html) - [シグナリング設定](signaling.html) - [映像を描画する](video_rendering.html) # リアルタイムメッセージング機能 ## 概要 Sora Flutter SDK では DataChannel を利用したリアルタイムメッセージング機能を利用することができます。 同一チャネルに参加しているクライアント同士でリアルタイムなメッセージを送受信することができます。 また、メッセージにはラベルを指定でき、同一チャネル内で複数の種類のメッセージを扱えます。 リアルタイムメッセージング機能の詳細は Sora のドキュメントの [リアルタイムメッセージング機能](https://sora-doc.shiguredo.jp/MESSAGING) をご確認ください。 > **注釈** > > リアルタイムメッセージング機能を利用するには、 Sora 側の設定で DataChannel シグナリングを有効にする必要があります。 > 詳細は [DataChannel 経由のシグナリング](datachannel_signaling.html) をご確認ください。 ## リアルタイムメッセージングの設定をする Sora Flutter SDK でリアルタイムメッセージング機能を利用するには、接続時の設定で dataChannels を指定する必要があります。 dataChannels には必須パラメータとして `label` と `direction` を指定します。 `label` はメッセージの経路を識別するための文字列で、 `direction` はその経路の方向を指定します。 この label には必ず `#` を先頭につける必要があります。 `#` をつけることで、Sora のメッセージング用の DataChannel であることを示しています。 `dataChannels` はリストなので、複数のラベルを同時に指定できます。 `label` は送受信時にそのまま使う識別子です。 `sendDataChannelMessage()` の第 1 引数にも同じ文字列を渡します。 また、ここで指定する `dataChannels` はアプリケーション向けメッセージング用です。 `signaling` や `notify` などの Sora / SDK 内部用 DataChannel を追加する必要はありません。 ```dart final config = SoraConnectionConfig( signalingUrls: ['wss://example.com/signaling'], channelId: 'example-channel', role: SoraRole.sendrecv, dataChannelSignaling: true, dataChannels: >[ { 'label': '#chat', 'direction': 'sendrecv', // max_retransmits と max_packet_life_time を省略すると完全信頼。 'ordered': true, }, { 'label': '#notifications', 'direction': 'recvonly', // 再送時間を 3000 ms に制限した部分信頼。 'ordered': false, 'max_packet_life_time': 3000, }, { 'label': '#telemetry', 'direction': 'sendonly', // 再送回数を 2 回に制限した部分信頼。 'max_retransmits': 2, }, ], ); ``` ### 順序保証 リアルタイムメッセージは `label` 毎に順序保証をするかどうかを指定できます。順序保証をする場合は `ordered: true` を指定します。 `ordered: false` を指定すると、到着順とは異なる順序で配信されることがあります。 `ordered` は順序だけを設定します。 `ordered: false` でも、 `max_retransmits` と `max_packet_life_time` を省略すれば完全信頼です。 ### 信頼性(再送制御) リアルタイムメッセージの信頼性は、 `max_retransmits` と `max_packet_life_time` で再送の上限を指定します。これらの項目を指定しない場合は、再送制限のない完全信頼のチャネルになります。 `max_retransmits` または `max_packet_life_time` のどちらか一方を指定すると部分信頼のチャネルになります。 - `max_retransmits` は再送回数の上限です。 `0` を指定すると再送しません - `max_packet_life_time` は送信および再送を試みる時間の上限をミリ秒で指定します `max_retransmits` と `max_packet_life_time` は同時に指定できません。両方を指定すると `Sora` / `WebRTC` 側で設定エラーになります。 ここでいう完全信頼は、接続中の `DataChannel` が再送制限なく配信を試みることを意味します。接続が切断された後もアプリケーションへ配信されることや、アプリケーション処理が完了することまで保証するものではありません。 `dataChannels` の各要素は `SoraConnectionConfig.dataChannels` から connect メッセージの `data_channels` へ渡されます。SDK は各要素の `ordered` 、 `max_retransmits` 、 `max_packet_life_time` を別の意味へ変換せず、これらの組み合わせも検証しません。キー名、値の型、同時指定の禁止などの制約は `Sora` / `WebRTC` の仕様に従って指定してください。 ### 圧縮機能 リアルタイムメッセージは `label` 毎に圧縮するかどうかを指定できます。圧縮する場合は `compress: true` を指定します。 `compress: true` を指定したラベルでは、 SDK が送信時に自動で圧縮し、受信時に自動で展開します。アプリケーション側で個別に圧縮処理を書く必要はありません。 ## メッセージを送信する メッセージは `sendDataChannelMessage()` で `label` を指定して送ることができます。 送信する `label` は、接続時に `dataChannels` へ設定したものと一致させます。 メッセージの実体は `Uint8List` です。文字列や JSON を送りたい場合は、アプリケーション側で UTF-8 エンコードしてから送信します。 以下のコード例では、接続済みの `SoraConnection` を `conn` として保持しているものとします。 ```dart import 'dart:convert'; import 'dart:typed_data'; final data = Uint8List.fromList( utf8.encode('{"type":"chat","text":"hello"}'), ); conn.sendDataChannelMessage('#chat', data); ``` DataChannel が開く前に送信すると取り扱いが分かりにくくなるため、通常は後述の `SoraDataChannelOpenEvent` を確認してから送信するのが安全です。 ## メッセージを受信する メッセージは `SoraDataChannelMessageEvent` イベント通知で受け取ることができます。 `message` には、送信したときの `label` と `data` が入ります。 `label` でメッセージの経路を識別でき、 `data` で送られてきたデータ内容を受け取ることができます。 `data` は `Uint8List` のため、文字列や JSON として扱いたい場合は UTF-8 でデコードします。 > **注釈** > > イベントについては [イベント](event.html) をご確認ください。 ```dart import 'dart:convert'; conn.events.listen((event) { if (event case SoraDataChannelMessageEvent(:final message)) { final text = utf8.decode(message.data); print('${message.label}: $text'); } }); ``` ## DataChannel が使えるようになったタイミング 利用可能になったことは `SoraDataChannelOpenEvent` イベント通知で確認できます。 `SoraDataChannelOpenEvent` は、どのラベルの DataChannel が開いたかを確認するためのイベントです。複数の `label` を設定している場合は、ラベルごとに監視すると分かりやすくなります。 > **注釈** > > イベントについては [イベント](event.html) をご確認ください。 ```dart conn.events.listen((event) { if (event case SoraDataChannelOpenEvent(:final event)) { print(event.label); print(event.direction); print(event.compress); } }); ``` ## リアルタイムメッセージのみを利用する 音声 / 映像を送受信せず、リアルタイムメッセージ機能のみを利用できます。 ただし、この場合は `Sora` の設定が必要です。 この構成では、通常 `audio: false` 、 `video: false` 、 `dataChannelSignaling: true` に加えて、必要な `dataChannels` を明示します。 `role` は `sendonly` ですが、音声 / 映像を送信しないため、 `MediaDevices.getUserMedia()` は不要です。 `connect()` は引数なしで呼び出します。 ```dart final config = SoraConnectionConfig( signalingUrls: ['wss://example.com/signaling'], channelId: 'example-channel', role: SoraRole.sendonly, audio: false, video: false, dataChannelSignaling: true, dataChannels: >[ { 'label': '#chat', 'direction': 'sendrecv', 'ordered': true, 'compress': true, }, ], ); final conn = await Sora.createConnection(config); // 音声 / 映像を送信しないため、MediaStream を渡さずに接続する。 await conn.connect(); ``` 詳細は `Sora` のドキュメントの [DataChannel を利用したメッセージングのみで接続する](https://sora-doc.shiguredo.jp/MESSAGING#3a3616) をご確認ください。 # RPC 機能 ## 概要 RPC 機能は DataChannel 経由で Sora の一部 HTTP API をクライアント側から呼び出しを行うための仕組みです。 RPC 機能の詳細については Sora ドキュメントの [RPC 機能](https://sora-doc.shiguredo.jp/RPC) をご確認ください。 > **警告** > > この機能は実験的機能のため、正式版では仕様が変更される可能性があります。 ## 前提条件 RPC を使う前提は次のとおりです。 - Sora 接続が確立していること 例: `await Sora.createConnection(config)` で作成した `SoraConnection` が、 `conn.connect()` で接続されている状態である - 利用したいメソッド名が、 Sora から払い出された `rpc_methods` に含まれていること - `rpc` DataChannel が使える状態になっていること ## 利用可能な RPC メソッド 利用可能なメソッド名は、 `SoraSignalingMessageEvent` イベント通知から取得することができます。 `event.data?['type']` が `offer` のとき、 `event.data?['rpc_methods']` にメソッド名のリストが入ります。 またメソッドは Sora の認証ウェブフックで払い出されたものだけが含まれます。 `SoraSignalingMessageEvent` の `event` プロパティが `SoraSignalingEvent` であり、シグナリングメッセージ本体はその `data` に入っています。 `data` は `null` の場合があるため、 `data?['type']` のように null 安全に確認します。 以下のコード例では、接続済みの `SoraConnection` を `conn` として保持しているものとします。 ```dart import 'dart:async'; import 'package:sora_sdk/sora_sdk.dart'; ``` ```dart void listenRpcMethods(SoraConnection conn) { conn.events.listen((event) { if (event case SoraSignalingMessageEvent(event: final signalingEvent)) { final data = signalingEvent.data; if (signalingEvent.direction == 'received' && data?['type'] == 'offer') { final methods = data?['rpc_methods']; if (methods is List) { for (final method in methods.whereType()) { print(method); } } } } }); } ``` > **注釈** > > イベントについては [イベント](event.html) をご確認ください。 ## RPC を送信する RPC リクエストを送信するには `rpc()` を実行します。利用可能なメソッド名は、 offer の `rpc_methods` に含まれる文字列をそのまま使います。 `SoraRpcOptions.timeout` はミリ秒単位で指定します。 `notification: true` を指定しない通常のリクエストでは、サーバーからのレスポンスを待ちます。 ```dart Future callRpc(SoraConnection conn) async { try { return await conn.rpc( // サンプルのためダミーのメソッド名です 'example.method', params: {'value': 123}, options: const SoraRpcOptions(timeout: 5000), ); } on SoraRpcError catch (error) { print('${error.code} ${error.message}'); } on TimeoutException { print('timeout'); } return null; } ``` ### Notification として送信する レスポンス不要で送る場合は `notification: true` を使います。 > **注釈** > > Notification の場合、サーバーは通知として受け取るためエラーコードやレスポンスは返しません。 ```dart Future sendNotification(SoraConnection conn) async { await conn.rpc( 'example.notify', params: {'message': 'hello'}, options: const SoraRpcOptions(notification: true), ); } ``` ### RequestSimulcastRid メソッドのリクエスト例 サイマルキャストにおいて受信側で希望する RID を変更したい場合の例です。 ```dart Future requestSimulcastRid(SoraConnection conn) { return conn.rpc( 'RequestSimulcastRid', params: { 'channel_id': 'example-channel', 'receiver_connection_id': conn.connectionId, 'sender_connection_id': '', 'rid': 'r1', }, ); } ``` ### PutSignalingNotifyMetadataItem メソッドのリクエスト例 シグナリング通知メタデータの 1 項目を更新したい場合の例です。 ```dart Future putSignalingNotifyMetadataItem(SoraConnection conn) { return conn.rpc( 'PutSignalingNotifyMetadataItem', params: { 'channel_id': 'example-channel', 'connection_id': conn.connectionId, 'key': 'display_name', 'value': 'Alice', }, ); } ``` ## エラーハンドリング - サーバーから RPC エラーが返ると `SoraRpcError` - タイムアウトを超えると `TimeoutException` - 切断時は待機中の RPC が `SoraRpcError(code: -1, message: 'Disconnected')` で完了 - `SoraConnection.dispose()` 後に `rpc()` を呼ぶと `StateError` `SoraConnectionErrorEvent` は接続自体のエラーを通知するイベントです。 RPC サーバーが返したエラーは `SoraRpcError` 、RPC の応答待ちが指定時間を超えた場合は `TimeoutException` で処理します。 ```dart Future callRpcWithErrorHandling(SoraConnection conn) async { try { await conn.rpc( 'example.method', options: const SoraRpcOptions(timeout: 3000), ); } on SoraRpcError catch (error) { print(error); } on TimeoutException { print('timeout'); } } ``` # タイムアウト機能 ## 概要 Sora Flutter SDK は WebRTC 接続のライフサイクル各段階でタイムアウト機能を提供します。これにより、無限待機を防ぎ、より堅牢なアプリケーションを構築できます。 タイムアウトを設定するには `SoraConnectionConfig` の `timeoutOptions` に `SoraTimeoutOptions` を指定します。 `timeoutOptions` は省略できますが、指定する値は `SoraTimeoutOptions` です。 ## 設定可能なタイムアウト ### connectionTimeout - 接続タイムアウト `WebRTC` 接続確立までの制限時間です。 `WebSocket` 接続の開始からピア接続の確立完了までにこの時間を超えた場合は、候補 URL 単位ではなく接続処理全体が中止されます。 **デフォルト値**: 30 秒 ### disconnectWaitTimeout - 切断待機タイムアウト 切断処理待機中の制限時間です。 `disconnect()` メソッド実行時にリソース解放が完了するまでの制限時間で、この時間を超過した場合は強制的にリソースが解放されます。 **デフォルト値**: 10 秒 ### signalingCandidateTimeout - シグナリング候補タイムアウト シグナリング候補 URL ごとの `WebSocket` 接続確立までの制限時間です。 候補 URL への接続がこの時間内に完了しない場合は、その候補を終了して次の URL を試します。 最後の候補 URL がタイムアウトした場合は、接続処理全体がエラーになります。 シグナリング URL のリダイレクト先への再接続にもこの設定が適用されますが、リダイレクト先がタイムアウトした場合は別の候補 URL を試しません。 複数の候補 URL を指定した場合、候補 URL の切り替えは SDK が自動で行います。すべての候補 URL がタイムアウトした場合だけ、利用者側で接続全体を再試行してください。 **デフォルト値**: 5 秒 ## 対象外のタイムアウト 次のタイムアウトは `SoraTimeoutOptions` では変更できません。 - `rpc()`: `SoraRpcOptions.timeout` (ミリ秒) で呼び出しごとに指定します。未指定の場合はタイムアウトしません。詳細は [RPC 機能](rpc.html) をご確認ください - `replaceVideoTrack()`: 映像トラックの差し替え処理に 10 秒の固定タイムアウトが適用されます - `getStats()`: 統計情報の取得に 5 秒の固定タイムアウトが適用されます。詳細は [統計情報](stats.html) をご確認ください ## 使用方法 ### デフォルトのタイムアウト値を使用する このとき SoraTimeoutOptions を設定する必要はありません。 ```dart final config = SoraConnectionConfig( signalingUrls: ['wss://example.com/signaling'], channelId: 'channel-id', role: SoraRole.sendrecv, ); ``` ### カスタムタイムアウト値を設定する ```dart final config = SoraConnectionConfig( signalingUrls: ['wss://example.com/signaling'], channelId: 'channel-id', role: SoraRole.sendrecv, timeoutOptions: SoraTimeoutOptions( connectionTimeout: Duration(seconds: 60), disconnectWaitTimeout: Duration(seconds: 15), signalingCandidateTimeout: Duration(seconds: 10), ), ); ``` ## エラーハンドリング 接続処理または切断処理を失敗させるタイムアウトが発生した場合は、 `connect()` / `disconnect()` が例外を投げたうえで、 `conn.events` の `SoraConnectionErrorEvent` でも通知されます。 - `connectionTimeout` 超過: `connect()` が `TimeoutException` を投げます - `disconnectWaitTimeout` 超過: `disconnect()` が `TimeoutException` を投げます - 最後の候補 URL またはリダイレクト先での `signalingCandidateTimeout` 超過: `connect()` が `StateError` を投げます 例外と `SoraConnectionErrorEvent` は同じタイムアウトに対して両方通知されます。接続処理の失敗の分岐は `connect()` / `disconnect()` の例外で行い、 `SoraConnectionErrorEvent` は接続状態の表示やログ出力に利用するのが基本です。 最後以外の候補 URL が `signalingCandidateTimeout` を超えても、後続の候補 URL への接続に成功した場合は例外を投げず、 `SoraConnectionErrorEvent` も通知しません。 リダイレクト先の `signalingCandidateTimeout` 超過では、別の候補 URL への切り替えは行われません。 `connect()` の完了前であれば `connect()` が `StateError` を投げ、接続完了後であれば `SoraConnectionErrorEvent` の通知だけが行われます。 タイムアウトの対象外のエラーもあります。次のエラーは例外と `SoraConnectionErrorEvent` のどちらか一方でのみ通知されます。 - `rpc()` のタイムアウト: `TimeoutException` が `rpc()` の呼び出し元へ返るのみです。 `SoraConnectionErrorEvent` は通知されません - 接続確立後に発生するエラー (例: `websocket_error` / `camera_open_error`): `connect()` の例外ではなく `SoraConnectionErrorEvent` でのみ通知されます `SoraTimeoutEvent` は WebSocket の reason が `TIMEOUT` の場合に通知されます。ただし、切断処理中や PeerConnection の接続確立後は通知されません。タイムアウトの検知は `SoraConnectionErrorEvent` と `connect()` / `disconnect()` の例外で行うのが基本です。 ### タイムアウトエラーコード - `connection_timeout` - 接続タイムアウト発生 - `disconnect_timeout` - 切断タイムアウト発生 - `signaling_candidate_timeout` - シグナリング候補タイムアウト発生 ### サンプルコード ```dart final conn = await Sora.createConnection(config); conn.events.listen((event) { if (event case SoraConnectionErrorEvent(:final code, :final message)) { switch (code) { case SoraErrorCode.connectionTimeout: print('接続タイムアウト: ${message ?? ''}'); case SoraErrorCode.disconnectTimeout: print('切断タイムアウト: ${message ?? ''}'); case SoraErrorCode.signalingCandidateTimeout: print('シグナリング接続タイムアウト: ${message ?? ''}'); } } }); ``` ## リトライロジック タイムアウトが発生した場合のリトライ処理例です。複数の候補 URL は SDK が自動で試行するため、この例は全候補 URL の接続に失敗した後の接続全体の再試行だけを扱います。 `conn.events` は `connect()` より前に購読してください。全候補 URL の `signalingCandidateTimeout` では、 `connect()` が `StateError` を投げるため、 `SoraConnectionErrorEvent.code` を確認して再試行対象を判定します。その他の `StateError` は再試行しません。 ```dart import 'dart:async'; import 'package:sora_sdk/sora_sdk.dart'; Future connectWithRetry( SoraConnectionConfig config, int maxRetries, ) async { int retries = 0; while (true) { final conn = await Sora.createConnection(config); var candidateTimeout = false; var connected = false; final subscription = conn.events.listen((event) { if (event is SoraConnectionErrorEvent && event.code == SoraErrorCode.signalingCandidateTimeout) { candidateTimeout = true; } }); try { await conn.connect(); connected = true; return conn; } on TimeoutException catch (error, stackTrace) { if (retries >= maxRetries) { Error.throwWithStackTrace(error, stackTrace); } retries++; print('接続タイムアウト (リトライ $retries/$maxRetries)'); } on StateError catch (error, stackTrace) { // StreamController は非同期でイベントを配信するため、 // connect() の例外を受け取った後にイベントを処理する時間を確保します。 await Future.delayed(Duration.zero); if (!candidateTimeout) { Error.throwWithStackTrace(error, stackTrace); } if (retries >= maxRetries) { Error.throwWithStackTrace(error, stackTrace); } retries++; print('シグナリング候補タイムアウト (リトライ $retries/$maxRetries)'); } finally { await subscription.cancel(); if (!connected) { await conn.dispose(); } } await Future.delayed(const Duration(seconds: 2)); } } ``` ## 推奨タイムアウト値 | 設定項目 | 推奨値 | 備考 | | --- | --- | --- | | `connectionTimeout` | 30-60 秒 | ネットワーク遅延を考慮 | | `disconnectWaitTimeout` | 10-15 秒 | リソース解放時間を確保 | | `signalingCandidateTimeout` | 5-10 秒 | URL 試行時間を確保 | タイムアウト値は環境やネットワーク状況に応じて調整してください。 # 統計情報 ## 概要 Sora Flutter SDK では、Sora との WebRTC 接続中にクライアントが送受信している音声・映像・データの状況(ビットレート、RTT、パケットロスなど)を統計情報として取得できます。 ## 用語 ### WebRTC 統計情報 WebRTC 統計情報とは [W3C Identifiers for WebRTC's Statistics API](https://www.w3.org/TR/webrtc-stats/) に定義されている標準仕様です。 `Sora Flutter SDK` が返す統計情報の `type` とメンバー名も、この仕様に基づいています。 ただし、SDK は `RTCStatsReport` を `Dart` のオブジェクトへ変換しているのではなく、`libwebrtc` の `RTCStatsReport::ToJson()` が生成した JSON 文字列を FFI で返しています。 そのため、JSON のルート構造、出力される `type` 、各メンバーの有無は、`libwebrtc` のバージョン、接続状態、メディア構成によって変わります。 ブラウザーの `RTCPeerConnection.getStats()` が返す `RTCStatsReport` の `Map` 構造を、そのまま前提にしないでください。 ## 統計情報取得方法 統計情報の取得には `SoraConnection.getStats()` を利用します。 以下のコード例では、次の import を共通で使用します。 ```dart import 'dart:async'; import 'dart:convert'; import 'package:sora_sdk/sora_sdk.dart'; ``` ```dart Future getInitialStats() async { final config = SoraConnectionConfig( signalingUrls: ['wss://sora.example.com/signaling'], channelId: 'example-channel', role: SoraRole.sendrecv, audio: true, video: true, ); final stream = await MediaDevices.getUserMedia( GetUserMediaOptions(audio: true, video: true), ); SoraConnection? conn; try { conn = await Sora.createConnection(config); await conn.connect(stream); // WebRTC 統計情報を JSON 文字列として取得する return await conn.getStats(); } finally { await conn?.dispose(); for (final track in stream.getTracks()) { await track.dispose(); } await stream.dispose(); } } ``` ### getStats() **戻り値**: `Future` 接続中の `PeerConnection` から WebRTC 統計情報を取得し、ネイティブの `Stats` レポートを JSON 文字列に変換して返します。 通常、JSON のルートは `Stats` レポートを要素とする配列です。 レポートが存在しない場合は `null` が返り、レポートが存在しても統計情報が空の場合は `[]` が返ることがあります。 互換性のため、解析側では `null` と空文字列の両方を先に処理してください。 > **注釈** > > `getStats()` の実行中に再度呼び出しても `StateError` は送出されません。進行中の統計取得処理を共有し、各呼び出しが同じ取得結果を待ちます。複数のネイティブ取得処理が同時に実行されることもありません。 #### 同時呼び出し 同時に取得したい処理が複数ある場合は、各呼び出しをそのまま待つことができます。 ```dart Future getStatsConcurrently(SoraConnection conn) async { final first = conn.getStats(); final second = conn.getStats(); final results = await Future.wait(>[ first, second, ]); } ``` `results` には同じ統計取得処理の結果が 2 件入ります。定期取得では、前回の取得完了後に次の `getStats()` を呼ぶ構成にすると、取得結果を順番に処理できます。 #### 状態ごとの戻り値と例外 - `PeerConnection` がまだ生成されていない場合や、切断後に取得した場合は `null` が返ります - 取得中に `disconnect()` が実行されると、進行中の `getStats()` は `StateError` で完了します - `SoraConnection.dispose()` 後に `getStats()` を呼ぶと `StateError` が送出されます - ネイティブ側のコールバックが到着しないまま孤立した `getStats()` が上限 (10 件) に達すると、 `StateError` が送出されます。コールバックが到着して孤立が解消されるまで新しい `getStats()` は失敗し、解消されない場合は接続を再生成する必要があります - ネイティブ側の応答が 5 秒以内に届かない場合は `TimeoutException` が送出されます ### 戻り値の JSON 構造 戻り値の JSON 文字列を `dart:convert` の `jsonDecode()` でデコードすると、Stats レポートの `List` が得られます。 各要素は `Map` 相当の JSON オブジェクトです。 `libwebrtc` 内部ではレポートを `id` で検索できる Map として管理していますが、`ToJson()` の出力は Map の値を並べた配列です。 配列の順序は契約されていないため、`id` を配列の位置の代わりに使用してください。 各 `Stats` レポートは、レポートを識別する `id` 、レポートの種類を表す `type` 、取得時刻を表す `timestamp` を持ちます。 その他のメンバーはレポートの種類や実装によって異なり、該当しないメンバーは出力されません。 ```dart List> decodeStats(String? raw) { if (raw == null || raw.isEmpty) { return const >[]; } final decoded = jsonDecode(raw); if (decoded is! List) { throw const FormatException('Stats JSON のルートが配列ではありません'); } return >[ for (final value in decoded) if (value is Map) Map.from(value), ]; } Future printStats(SoraConnection conn) async { final reports = decodeStats(await conn.getStats()); for (final report in reports) { print('${report['id']}: ${report['type']}'); } } ``` 例えば、ネイティブの `ToJson()` は次のような配列を返します。 ```json [ { "type": "peer-connection", "id": "P", "timestamp": 1787550022589501, "dataChannelsOpened": 0, "dataChannelsClosed": 0 } ] ``` `timestamp` はネイティブの JSON シリアライザーが出力する値です。 ブラウザーの `RTCStatsReport` と同じ単位や数値型になるとは限らないため、時間差の計算では SDK と `libwebrtc` のバージョンを確認してください。 標準仕様に定義されている代表的な `type`: | type | `W3C WebRTC Stats` 仕様上の説明 | | --- | --- | | `peer-connection` | `PeerConnection` 全体の統計。`dataChannelsOpened` / `dataChannelsClosed` などを参照できます | | `transport` | 接続トランスポートの統計。`bytesSent` / `bytesReceived` 、`dtlsState` 、`iceState` などを参照できます | | `certificate` | 接続に使用する証明書の統計。`fingerprint` や `fingerprintAlgorithm` などを参照できます | | `codec` | 利用中のコーデック情報。`mimeType` や `payloadType` を参照できます | | `inbound-rtp` | 受信中の RTP 統計。`bytesReceived` 、`packetsReceived` 、`packetsLost` 、`jitter` などを参照できます | | `outbound-rtp` | 送信中の RTP 統計。`bytesSent` 、`packetsSent` 、`framesEncoded` 、`frameWidth` / `frameHeight` などを参照できます | | `remote-inbound-rtp` | リモート側の受信 RTP 統計。`roundTripTime` などを参照できます | | `remote-outbound-rtp` | リモート側の送信 RTP 統計。`remoteTimestamp` や `roundTripTime` などを参照できます | | `candidate-pair` | ICE candidate pair の接続状態。`state` (`succeeded` / `failed` など) や `availableOutgoingBitrate` を参照できます | | `local-candidate` / `remote-candidate` | ICE candidate の統計。`address` 、`port` 、`protocol` などを参照できます | | `media-source` | 送信元のメディア統計。`kind` (`audio` / `video`) や `trackIdentifier` などを参照できます | | `media-playout` | 音声の再生統計。`kind` や再生遅延に関するメンバーを参照できます | | `data-channel` | `DataChannel` ごとの統計。`label` 、`state` 、`bytesSent` / `bytesReceived` などを参照できます | `track` は現行の `W3C RTCStatsType` に定義されておらず、現行 `libwebrtc` の Stats 型にも対応するレポート型はありません。 メディアトラックに関する情報は、`inbound-rtp` や `media-source` の `trackIdentifier` などのメンバーとして出力されます。 古い `libwebrtc` や別の実装が未知の `type` を返す可能性はあるため、`track` を含め、一覧にない `type` をエラーとして扱わないでください。 各 `type` が持つメンバーの詳細は [W3C WebRTC Stats 仕様](https://www.w3.org/TR/webrtc-stats/) をご確認ください。 標準仕様に定義された `type` でも、メンバーは接続状態やメディアの種類によって省略されます。 また、`libwebrtc` のバージョンによって、標準仕様にないメンバーが含まれることがあります。 ### 使用例 映像送信統計を取得する例: ```dart Future printVideoStats(SoraConnection conn) async { final raw = await conn.getStats(); if (raw == null || raw.isEmpty) { return; } final reports = decodeStats(raw); // type が outbound-rtp かつ kind が video のレポートを収集する for (final report in reports) { if (report['type'] != 'outbound-rtp') continue; if (report['kind'] != 'video') continue; print('codec: ${report['mimeType']}'); print('resolution: ${report['frameWidth']}x${report['frameHeight']}'); print('bytesSent: ${report['bytesSent']}'); print('packetsSent: ${report['packetsSent']}'); print('framesEncoded: ${report['framesEncoded']}'); } } ``` > **注釈** > > Stats レポートは配列の各要素に入ります。 > 配列にない要素、未知の `type`、未知のメンバーを前提にせず、必要な `type` だけを選択してください。 コーデック情報を取得する例: ```dart Future printCodecStats(SoraConnection conn) async { final raw = await conn.getStats(); if (raw == null || raw.isEmpty) { return; } final reports = decodeStats(raw); for (final report in reports) { if (report['type'] != 'codec') continue; print('mimeType: ${report['mimeType']}'); print('payloadType: ${report['payloadType']}'); } } ``` ### タイムアウト `getStats()` は内部で 5 秒のタイムアウトを持ちます。ネイティブ側のコールバックが応答しない場合に `TimeoutException` が送出されます。 ```dart Future getStatsWithTimeout(SoraConnection conn) async { try { final stats = await conn.getStats(); print(stats); } on TimeoutException { print('Stats retrieval timed out'); } } ``` # イベント ## 概要 Sora Flutter SDK のイベントは、主に `Stream` と `Stream` で提供されます。 `Stream` では、接続状態、リモートトラックの追加・削除、シグナリング、DataChannel (通知・メッセージング・RPC 等)、タイムアウトを購読できます。 `Stream` では、構造化されたログとタイムラインを購読できます。 > **重要** > > SoraConnection の生成方法については [実装ガイド](getting_started.html) を、接続オプションについては [シグナリング設定](signaling.html) をご確認ください。このページでは `conn = await Sora.createConnection(config)` で生成した `SoraConnection` インスタンス `conn` を用いて説明します。 > **注釈** > > DataChannel の種類の違い (シグナリング用とアプリケーション向けメッセージング) は [DataChannel 経由のシグナリング](datachannel_signaling.html) と [リアルタイムメッセージング機能](messaging.html) をご確認ください。 ## 統合イベント Stream `Stream` は統合イベント Stream です。イベント型ごとに `switch` で分岐して処理します。 ```dart conn.events.listen((event) { switch (event) { case SoraConnectionStateChangedEvent(:final state): print('state: $state'); case SoraConnectionErrorEvent(:final code, :final message): print('error: ${code ?? 'unknown'} ${message ?? ''}'); case SoraTrackEvent(:final track): print('track added: ${track.trackId}'); case SoraRemoveTrackEvent(:final track): print('track removed: ${track.trackId}'); case SoraNotifyEvent(:final message): print('notify: $message'); case SoraPushEvent(:final message): print('push: $message'); case SoraSwitchedEvent(:final message): print('switched: $message'); case SoraSignalingMessageEvent(:final event): print('signaling: ${event.transportType} ${event.direction}'); case SoraDataChannelOpenEvent(:final event): print('datachannel open: ${event.label}'); case SoraDataChannelMessageEvent(:final message): print('message: ${message.label}'); case SoraTimeoutEvent(): print('timeout'); } }); ``` ### 接続状態の変更 `SoraConnectionStateChangedEvent` は接続状態が変わったときに通知されます。 ```dart conn.events.listen((event) { if (event case SoraConnectionStateChangedEvent(:final state)) { switch (state) { case SoraConnectingState(): print('connecting'); case SoraConnectedState(): print('connected'); case SoraDisconnectedState(:final closeInfo): print('disconnected'); if (closeInfo != null) { print('close code: ${closeInfo.code}'); print('close reason: ${closeInfo.reason}'); } } } }); ``` 通知される状態は次の 3 種類です。 - `SoraConnectingState` - `SoraConnectedState` - `SoraDisconnectedState` #### SoraConnectingState 接続処理の開始後、シグナリング接続の確立に入ったタイミングで通知されます。 ```dart conn.events.listen((event) { if (event case SoraConnectionStateChangedEvent(state: SoraConnectingState())) { print('connecting'); } }); ``` #### SoraConnectedState Sora との PeerConnection が接続済になったタイミングで通知されます。 ```dart conn.events.listen((event) { if (event case SoraConnectionStateChangedEvent(state: SoraConnectedState())) { print('connected'); } }); ``` #### SoraDisconnectedState Sora から切断したタイミングで通知されます。 `closeInfo` を持つ場合は、切断時の code と reason を参照できます。 ```dart conn.events.listen((event) { if (event case SoraConnectionStateChangedEvent( state: SoraDisconnectedState(:final closeInfo), )) { print('disconnected'); if (closeInfo != null) { print('close code: ${closeInfo.code}'); print('close reason: ${closeInfo.reason}'); } } }); ``` > **注釈** > > - `closeInfo` は常に入るとは限りません > - 接続エラーが先に通知されたあとで、 `SoraDisconnectedState` が続く場合があります ### 接続エラー 接続エラーは `SoraConnectionErrorEvent` で通知されます。 ```dart conn.events.listen((event) { if (event case SoraConnectionErrorEvent(:final code, :final message)) { print('connection error: ${code ?? 'unknown'} ${message ?? ''}'); } }); ``` `code` にはエラー種別が、 `message` にはエラーの詳細が含まれます。 `message` は null になる可能性があるため、利用の際は null チェックを行ってください。 `retriable` にはリトライ可能かどうかが `bool?` で入ります。 `details` には `SoraConnectionErrorDetails?` として `attempts` (リトライ試行回数) と `platformError` (プラットフォーム固有のエラー情報) が入る場合があります。 > **注釈** > > 接続エラーには `connect()` / `disconnect()` が例外を投げる経路と、 `SoraConnectionErrorEvent` で通知される経路の 2 つがあります。 > たとえば接続タイムアウト ( `connectionTimeout` ) のときは `connect()` が `TimeoutException` を投げたうえで、 `SoraConnectionErrorEvent` も通知されます。 > 最後のシグナリング候補 URL またはリダイレクト先のタイムアウト ( `signalingCandidateTimeout` ) のときは `connect()` が `StateError` を投げます。 > エラーハンドリングは両方の経路を想定してください。 > 詳細は [タイムアウト機能](timeout.html) をご確認ください。 `SoraConnectionErrorEvent.code` に入る既知のエラーコードは、公開 API の `SoraErrorCode` が定義する次の 18 個です。 - `SoraErrorCode.eventChannelError`: イベントチャネルのエラー- エラーコード: `event_channel_error` - `SoraErrorCode.websocketError`: `WebSocket` のエラー- エラーコード: `websocket_error` - `SoraErrorCode.connectionTimeout`: WebRTC 接続確立のタイムアウト ( [タイムアウト機能](timeout.html) )- エラーコード: `connection_timeout` - `SoraErrorCode.disconnectTimeout`: 切断処理のタイムアウト ( [タイムアウト機能](timeout.html) )- エラーコード: `disconnect_timeout` - `SoraErrorCode.signalingCandidateTimeout`: シグナリング候補 URL の接続タイムアウト ( [タイムアウト機能](timeout.html) )- エラーコード: `signaling_candidate_timeout` - `SoraErrorCode.observerBridgeCreationFailed`: observer bridge の生成失敗- エラーコード: `observer_bridge_creation_failed` - `SoraErrorCode.observerBridgeObserverCreationFailed`: `PeerConnectionObserver` の生成失敗- エラーコード: `observer_bridge_observer_creation_failed` - `SoraErrorCode.audioInputInitializationFailed`: iOS の音声入力初期化失敗- エラーコード: `audio_input_initialization_failed` - `SoraErrorCode.cameraOpenError`: カメラのオープン失敗- エラーコード: `camera_open_error` - `SoraErrorCode.offerInvalid`: Sora から受け取った offer の SDP が `null` 、または `PeerConnection` を用意できない- エラーコード: `offer_invalid` - `SoraErrorCode.reofferInvalid`: Sora から受け取った re-offer の SDP が `null` 、または再利用する `PeerConnection` がない- エラーコード: `reoffer_invalid` - `SoraErrorCode.setRemoteDescriptionFailed`: リモート SDP の設定に失敗- エラーコード: `set_remote_description_failed` - `SoraErrorCode.createAnswerFailed`: answer の生成に失敗- エラーコード: `create_answer_failed` - `SoraErrorCode.setLocalDescriptionFailed`: ローカル SDP の設定に失敗- エラーコード: `set_local_description_failed` - `SoraErrorCode.createPeerConnectionFailed`: `PeerConnection` の生成に失敗- エラーコード: `create_peer_connection_failed` - `SoraErrorCode.addAudioTrackFailed`: ローカルの音声トラックの追加に失敗- エラーコード: `add_audio_track_failed` - `SoraErrorCode.addVideoTrackFailed`: ローカルの映像トラックの追加に失敗- エラーコード: `add_video_track_failed` - `SoraErrorCode.unexpectedNativeEvent`: ネイティブから想定外のイベントが届いた- エラーコード: `unexpected_native_event` `SoraConnectionErrorEvent.code` の型は `String?` です。イベントによっては `null` になる可能性があります。 この一覧は SDK が定義する既知の定数一覧であり、ネイティブの接続状態やプラットフォーム側のイベントから渡される実行時の文字列が、この 18 個に限定されることを示すものではありません。 #### 切断理由コード `SoraDisconnectReason` は、 `disconnect` メッセージの理由コードと、 SDK が切断を分類するためにネイティブから受け取る理由タグです。 `SoraConnectionErrorEvent.code` のエラーコード一覧には含まれません。 | 定数 | 値 | | --- | --- | | `SoraDisconnectReason.noError` | `NO-ERROR` | | `SoraDisconnectReason.websocketOnClose` | `WEBSOCKET-ONCLOSE` | | `SoraDisconnectReason.websocketOnError` | `WEBSOCKET-ONERROR` | | `SoraDisconnectReason.serverDisconnect` | `server_disconnect` | | `SoraDisconnectReason.peerConnectionFailed` | `peer_connection_failed` | | `SoraDisconnectReason.peerConnectionClosed` | `peer_connection_closed` | ### track リモートトラックが追加されたときは `SoraTrackEvent` が通知されます。 トラックは `RemoteMediaStreamTrack` として渡され、 `kind` で音声か映像かを判別できます。 - `kind == 'audio'`: 音声トラック (`textureId` は `null`) - `kind == 'video'`: 映像トラック (`textureId` は non-null) ```dart conn.events.listen((event) { if (event case SoraTrackEvent(:final track)) { if (track.kind == 'audio') { print('audio track added: ${track.trackId}'); } else { print('video track added: ${track.trackId}'); print('textureId: ${track.textureId}'); } } }); ``` `RemoteMediaStreamTrack` のフィールドは以下の通りです。 - `trackId`: トラック ID - `kind`: `audio` または `video` - `connectionId`: Sora の `connection_id` - `textureId`: `kind == 'video'` の場合のみ値を取得できます 映像トラックの追加イベントでは、 `textureId` をそのまま `Texture` に渡せます。 ```dart if (event case SoraTrackEvent(:final track) when track.kind == 'video') { return Texture(textureId: track.textureId!); } ``` > **注釈** > > - audio は libwebrtc の track 通知を受けた時点でイベントが流れます > - video は renderer の作成と sink attach が完了し、 `textureId` が確定してからイベントが流れます > - video の追加イベントは、libwebrtc が track を検知した瞬間ではなく、レンダリングに使える状態になってから流れます > - audio と video の厳密な相対順序は保証されません > - `connectionId` はリモートトラックの `trackId` (`{connectionId}-audio` / `{connectionId}-video`) から復元されます ### removetrack リモートトラックが削除されたときは `SoraRemoveTrackEvent` が通知されます。 ```dart conn.events.listen((event) { if (event case SoraRemoveTrackEvent(:final track)) { print('track removed: ${track.trackId}'); } }); ``` 削除イベントでも追加イベント時と同様に `RemoteMediaStreamTrack` 型となります。UI 側は通常 `trackId` で対象を特定して除去します。 接続単位でトラックを扱いたい場合は、 `SoraConnection.remoteMediaStreams` も利用できます。 `connectionId` をキーに `RemoteMediaStream` が入り、 `audioTrack` / `videoTrack` にそれぞれのトラックが格納されます。 > **注釈** > > - remote track の追加 / 削除を逐次扱いたい場合は `SoraTrackEvent` / `SoraRemoveTrackEvent` を利用します > - 接続単位で現在の状態を参照したい場合は `SoraConnection.remoteMediaStreams` を利用します > - ローカル映像は remote track event とは別で、 `LocalVideoTrack.textureId` や `SoraConnection.localVideo` から取得します ### notify Sora からシグナリング通知を受け取ったときは `SoraNotifyEvent` が通知されます。 Sora のシグナリング通知の詳細は Sora のドキュメント [シグナリング通知](https://sora-doc.shiguredo.jp/SIGNALING_NOTIFY) をご確認ください。 ```dart conn.events.listen((event) { if (event case SoraNotifyEvent(:final message)) { print('notify: $message'); } }); ``` `connection.created` などの通知をここで受け取ることができます。 > **注釈** > > シグナリング通知の詳細は [DataChannel 経由のシグナリング](datachannel_signaling.html) をご確認ください ### push Sora からのプッシュ通知を受け取ったときは `SoraPushEvent` が通知されます。 ```dart conn.events.listen((event) { if (event case SoraPushEvent(:final message)) { print('push: $message'); } }); ``` > **注釈** > > メッセージの設計はサーバー設定と運用に依存します ### switched シグナリングが WebSocket から DataChannel に切り替わったときは `SoraSwitchedEvent` が通知されます。 ```dart conn.events.listen((event) { if (event case SoraSwitchedEvent(:final message)) { print('switched: $message'); } }); ``` ### timeout PeerConnection の接続確立前に、WebSocket が reason `TIMEOUT` で閉じられた場合に `SoraTimeoutEvent` が通知されます。切断処理中は通知されません。 ```dart conn.events.listen((event) { if (event case SoraTimeoutEvent()) { print('timeout'); } }); ``` > **注釈** > > - タイムアウト関連のエラーコードは `SoraConnectionErrorEvent` にも通知されます > - 詳細は [タイムアウト機能](timeout.html) をご確認ください ### message DataChannel を利用したリアルタイムメッセージング機能でメッセージを受信したときは `SoraDataChannelMessageEvent` が通知されます。 ```dart conn.events.listen((event) { if (event case SoraDataChannelMessageEvent(:final message)) { print('label: ${message.label}'); print('data: ${message.data}'); } }); ``` > **注釈** > > 詳細は [リアルタイムメッセージング機能](messaging.html) をご確認ください ### datachannel DataChannel が利用可能になったときは `SoraDataChannelOpenEvent` が通知されます。 ```dart conn.events.listen((event) { if (event case SoraDataChannelOpenEvent(:final event)) { print('label: ${event.label}'); print('direction: ${event.direction}'); } }); ``` メッセージング機能を使う場合は、このイベントを送信可能の目安として扱えます。 > **注釈** > > 詳細は [リアルタイムメッセージング機能](messaging.html) と [DataChannel 経由のシグナリング](datachannel_signaling.html) をご確認ください ### signaling シグナリングメッセージの送受信を確認したいときは `SoraSignalingMessageEvent` を利用します。 ```dart conn.events.listen((event) { if (event case SoraSignalingMessageEvent(:final event)) { print('${event.transportType} ${event.direction}'); print(event.data); } }); ``` `transportType` は `websocket` または `datachannel` です。 `direction` は `sent` または `received` です。 `data` には、シグナリングメッセージの内容がそのまま入ります。 offer / answer / candidate などの内容を確認することができます。 ## デバッグイベント Stream `conn.debugEvents` はデバッグイベント専用 Stream です。構造化された `log` と `timeline` を受け取りたい場合に利用します。 ```dart conn.debugEvents.listen((event) { switch (event) { case SoraLogDebugEvent(:final event): print('${event.title}: ${event.message}'); case SoraTimelineDebugEvent(:final event): print('${event.logType.value} ${event.type}'); print(event.data); } }); ``` ### log SDK の構造化ログは `SoraLogDebugEvent` で受け取ることができます。 ```dart conn.debugEvents.listen((event) { if (event case SoraLogDebugEvent(:final event)) { print('${event.title}: ${event.message}'); } }); ``` > **注釈** > > このイベントはデバッグ時のトレースログ出力用途を想定しています ### timeline タイムラインの構造化ログは `SoraTimelineDebugEvent` で受け取ることができます。 ```dart conn.debugEvents.listen((event) { if (event case SoraTimelineDebugEvent(:final event)) { print('${event.logType.value} ${event.type}'); print(event.data); } }); ``` このイベントに含まれる `SoraTimelineEvent` では、主に次の情報を参照します。 - `type`: イベント種別 - `logType`: `websocket` / `datachannel` / `peerconnection` / `sora` - `data`: イベントの詳細データ - `dataChannelId`: DataChannel に紐づく場合の ID - `dataChannelLabel`: DataChannel に紐づく場合の label > **注釈** > > このイベントはデバッグ時の時系列調査用途を想定しています ## 接続確立後の識別子 offer を受信すると、次の getter がサーバーから割り当てられた値で更新されます。 - `connectionId` - `serverClientId` - `bundleId` - `sessionId` 接続前や offer 未到達のときは `null` です。RPC のパラメータ組み立てなどで利用できます。 ## イベント購読の終了 `conn.events` や `conn.debugEvents` を購読した場合は、 `conn.dispose()` の前に購読を解除してください。 ```dart late final StreamSubscription eventSubscription; late final StreamSubscription debugEventSubscription; void setup(SoraConnection conn) { eventSubscription = conn.events.listen((event) { // イベント処理 }); debugEventSubscription = conn.debugEvents.listen((event) { // debug event 処理 }); } Future dispose(SoraConnection conn) async { await eventSubscription.cancel(); await debugEventSubscription.cancel(); await conn.dispose(); } ``` # メディアデバイス ## 概要 `sora_sdk` でマイク・スピーカー・カメラを扱うための API と使い方を解説します。ここでは Sora が持つメディアデバイス関連 API のみを説明します。 ## audio / video 設定とデバイスの関係 Sora の場合、シグナリング時の `audio` / `video` の設定はあくまで「音声や映像を利用するかどうか」の設定であって、特定のデバイスを指定するといった仕組みとは異なります。 Sora Flutter SDK では `MediaDevices.getUserMedia()` でマイクやカメラを指定して `MediaStream` を作り、その stream を `conn.connect()` に渡すことで、任意のデバイスを使った音声・映像の送信ができるようになります。 ## API リファレンス ### MediaDevices.enumerateAudioInputDevices() 音声入力デバイス (マイク) の一覧を取得します。 ```dart static Future> enumerateAudioInputDevices(); ``` **引数**: なし **戻り値**: `AudioInputDevice` のリスト。各要素は `deviceId` と `label` を持ちます。 `type` は現状 Android でのみ取得できます #### 使い方 ```dart final devices = await MediaDevices.enumerateAudioInputDevices(); for (final device in devices) { print('${device.deviceId}: ${device.label}'); } ``` iOS / iPadOS では音声入力デバイス一覧の取得に `AVAudioSession.availableInputs` を利用します。 列挙結果は現在の `AVAudioSession` の設定や音声ルートの状態に依存し、接続状態や OS の音声ルートによって変わることがあります。 `deviceId` の意味はプラットフォームごとに異なります。 - Android: `AudioDeviceInfo.id` を文字列化したものです。接続し直すと変わることがあるため、長期保存して再利用する前提にはしないでください - iOS / iPadOS: `AVAudioSessionPortDescription.uid` です。 `availableInputs` に含まれる候補だけが列挙されます - macOS: CoreAudio の `kAudioDevicePropertyDeviceUID` です。入力ストリームを持つデバイスだけが列挙されます `label` も OS が返す値に依存します。特に Android では `AudioDeviceInfo.productName` を使うため、機種や接続先によっては分かりやすい製品名にならないことがあります。 ### MediaDevices.enumerateAudioOutputDevices() 音声出力デバイス (スピーカー) の一覧を取得します。現時点の SDK では、この API で取得した `deviceId` を使って再生先を切り替えることはできません。 ```dart static Future> enumerateAudioOutputDevices(); ``` **引数**: なし **戻り値**: `AudioOutputDevice` のリスト。各要素は `deviceId` と `label` を持ちます。 `type` は現状 Android でのみ取得できます #### 使い方 ```dart final devices = await MediaDevices.enumerateAudioOutputDevices(); for (final device in devices) { print('${device.deviceId}: ${device.label}'); } ``` 列挙結果や `deviceId` / `label` の意味はプラットフォームごとに異なります。 - Android: `deviceId` は `AudioDeviceInfo.id` を文字列化したものです。 `label` は `AudioDeviceInfo.productName` 、 `type` は `AudioDeviceInfo.type` です - iOS / iPadOS: `AVAudioSession.currentRoute.outputs` に含まれる現在の出力ポートだけが列挙されます。 `deviceId` は `AVAudioSessionPortDescription.uid` です - macOS: CoreAudio の `kAudioDevicePropertyDeviceUID` を `deviceId` として返します。出力ストリームを持つデバイスだけが列挙されます 再生先の扱いについては、この後の [音声出力デバイスの選択について](media_device.html#音声出力デバイスの選択について) を参照してください。 ### MediaDevices.enumerateVideoInputDevices() 映像入力デバイス (カメラ) の一覧を取得します。 ```dart static Future> enumerateVideoInputDevices(); ``` **引数**: なし **戻り値**: `VideoInputDevice` のリスト。各要素は `deviceId` と `label` を持ちます #### 使い方 ```dart final devices = await MediaDevices.enumerateVideoInputDevices(); for (final device in devices) { print('${device.deviceId}: ${device.label}'); } ``` 列挙結果や `deviceId` / `label` の意味はプラットフォームごとに異なります。 - Android: `deviceId` は Camera2 の camera ID です。 `label` は前面/背面で `Front Camera` / `Back Camera` 、それ以外は `Camera {id}` です。 `LENS_FACING` を取得できたカメラだけが列挙されます - iOS / iPadOS: `deviceId` は `AVCaptureDevice.uniqueID` 、 `label` は `AVCaptureDevice.localizedName` です。 `builtInWideAngleCamera` が列挙対象です - macOS: `deviceId` は `AVCaptureDevice.uniqueID` 、 `label` は `AVCaptureDevice.localizedName` です。内蔵カメラと外付けカメラが列挙されます iOS / iPadOS では、主に前面カメラと背面の広角カメラが列挙されます。望遠カメラや超広角カメラは個別には列挙されません。 USB カメラなど **外部の映像入力** を選ぶときの経路の整理は [外部カメラパッケージを利用する](external_camera_package.html) を参照してください。 ### VideoInputDevice.supportedFormats() カメラが対応している解像度・フレームレートの離散的な一覧を取得します。 `VideoInputDevice` のインスタンスメソッドです。 ```dart Future> supportedFormats(); ``` **引数**: なし **戻り値**: `VideoInputFormat` のリスト 各要素は次のフィールドを持ちます。 - `width` (`int`): 幅 - `height` (`int`): 高さ - `maxFrameRate` (`int`): その解像度で利用できる最大フレームレート `maxFrameRate` の求め方はプラットフォームごとに異なります。 - Android: `CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES` の上限値の最大を返します。取得できない場合は `30` にフォールバックします - iOS / iPadOS: `AVCaptureDeviceFormat.videoSupportedFrameRateRanges` の最大値を返します - macOS: `AVCaptureDeviceFormat.videoSupportedFrameRateRanges` の最大値を返します プラットフォーム側が `29.97` のような非整数を返す場合は、SDK が `round()` で整数に丸めます。 #### 使い方 ```dart final devices = await MediaDevices.enumerateVideoInputDevices(); final formats = await devices.first.supportedFormats(); for (final format in formats) { print('${format.width}x${format.height} @${format.maxFrameRate}fps'); } ``` 対応フォーマットは端末ごとに異なります。固定値を前提にせず、取得結果から選ぶようにしてください。 ### MediaDevices.getUserMedia() 送信用の `MediaStream` を生成します。マイク音声とカメラ映像をまとめて扱う、最も一般的な API です。 ```dart static Future getUserMedia(GetUserMediaOptions options); ``` **引数**: `GetUserMediaOptions` (音声・映像の有無、使用デバイス、映像フォーマット)。 **戻り値**: `LocalMediaStream` 。指定した `audio` / `video` に応じた track が入ります。 `MediaDevices.getUserMedia()` は接続ロールとは独立しています。 `GetUserMediaOptions.audio` / `GetUserMediaOptions.video` の指定に応じてローカルトラックを生成しますが、その時点では `Sora` へ送信しません。 `SoraRole.sendonly` または `SoraRole.sendrecv` で送信する場合にのみ、生成した `LocalMediaStream` を `SoraConnection.connect()` に渡します。 `SoraRole.recvonly` でもローカルトラックを生成できますが、 `SoraConnection.connect()` には渡しません。 #### GetUserMediaOptions のフィールド | フィールド | 型 | デフォルト | 説明 | | --- | --- | --- | --- | | `audio` | `bool` | `true` | 音声トラックを含めるか | | `video` | `bool` | `true` | 映像トラックを含めるか | | `audioDeviceId` | `String?` | `null` | 音声入力に使うデバイス ID。 `MediaDevices.enumerateAudioInputDevices()` で列挙した `deviceId` を指定します。省略時は前回の明示選択を解除して、プラットフォーム標準の入力選択に戻ります | | `videoDeviceId` | `String?` | `null` | 映像入力に使うカメラデバイス ID。 `MediaDevices.enumerateVideoInputDevices()` で列挙した `deviceId` を指定します。省略時は SDK / プラットフォームが選んだ既定のカメラを使います | | `videoWidth` | `int?` | `null` | 映像入力の希望幅 | | `videoHeight` | `int?` | `null` | 映像入力の希望高さ | | `videoFrameRate` | `int?` | `null` | 映像入力の希望フレームレート | `audio` と `video` の少なくとも片方は `true` にする必要があります。両方 `false` の場合は `StateError` が送出されます。 `videoWidth` / `videoHeight` / `videoFrameRate` を省略した場合のデフォルトは `640x480 @30fps` です。これはブラウザの `getUserMedia({ video: true })` と同じ挙動に合わせています。 #### 使い方 ```dart final audioDevices = await MediaDevices.enumerateAudioInputDevices(); final videoDevices = await MediaDevices.enumerateVideoInputDevices(); final stream = await MediaDevices.getUserMedia( GetUserMediaOptions( audio: true, video: true, audioDeviceId: audioDevices.first.deviceId, videoDeviceId: videoDevices.first.deviceId, videoWidth: 1280, videoHeight: 720, videoFrameRate: 30, ), ); ``` `videoWidth` / `videoHeight` / `videoFrameRate` は希望値です。特定の解像度やフレームレートを使いたい場合は、 `VideoInputDevice.supportedFormats()` でカメラの対応フォーマットを確認してから指定してください。 サイマルキャストで 3 本の映像を送信したい場合は、送信元の解像度も重要です。送信元の解像度が不足していると、期待したレイヤー構成で送れない場合があります。サイマルキャスト利用時の考え方は Sora ドキュメントの [サイマルキャスト機能](https://sora-doc.shiguredo.jp/SIMULCAST) と [サイマルキャスト機能の注意](https://sora-doc.shiguredo.jp/SIMULCAST#注意) をご確認ください。 #### 音声入力デバイス指定のプラットフォーム別の挙動 - Android では `JavaAudioDeviceModule` の preferred input device を利用します- Android の `BUILTIN_MIC` や `TYPE_USB_DEVICE` / `TYPE_USB_HEADSET` のように追加の通話用音声ルーティングを前提としない入力は、現時点では `setPreferredInputDevice(...)` による通常経路で扱います - Android の type ごとの個別検証は `BLUETOOTH_SCO` 以外はまだ限定的です。機種依存の音声ルーティングが必要な type は今後の対応対象です - iOS / iPadOS では `AVAudioSession` の入力切り替えを利用します - macOS では libwebrtc の `AudioDeviceModule` 経由で録音デバイスを切り替えます #### 音声デバイスの利用設定 接続前にメディア track を生成する場合は、 `SoraConnectionConfig.useAudioDevice` ではなく、 `MediaDevices.setUseAudioDevice()` を使用します。 ```dart MediaDevices.setUseAudioDevice(false); final stream = MediaDevices.createMediaStream(); ``` 共有 `PeerConnectionFactory` の生成後に設定値を変更することはできません。 既に `true` または `false` で生成されている場合に異なる値を指定すると `StateError` が送出されます。 複数の接続やメディア API を同じプロセスで利用する場合は、音声デバイス設定を統一してください。 #### 設定方法の使い分け `useAudioDevice` は、共有 `PeerConnectionFactory` が実音声デバイスを利用するかどうかを指定する設定です。 設定方法によって有効範囲が変わるのではなく、共有 factory が生成される前にどこから設定するかが異なります。 `MediaDevices` を使わずに接続する場合は、 `SoraConnectionConfig.useAudioDevice` を指定します。 `recvonly` や `messaging` のように、 `MediaDevices.createMediaStream()` などを呼び出さずに接続する場合にも音声デバイス設定を指定できます。 ```dart final conn = await Sora.createConnection( SoraConnectionConfig( signalingUrls: urls, channelId: 'example-channel', role: SoraRole.recvonly, audio: true, useAudioDevice: false, ), ); await conn.connect(); ``` `Sora.createConnection()` より先に `MediaDevices.createMediaStream()` などを呼び出す場合は、 `MediaDevices.setUseAudioDevice()` を先に呼び出します。 この場合も、後から作成する `SoraConnectionConfig.useAudioDevice` と同じ値を指定してください。 ```dart MediaDevices.setUseAudioDevice(false); final stream = MediaDevices.createMediaStream(); ``` 共有 factory が生成された後に異なる値を指定することはできません。 同じプロセス内で複数の接続やメディア API を利用する場合は、音声デバイス設定を統一してください。 #### 音声出力デバイスの選択について `MediaDevices.enumerateAudioOutputDevices()` は、現在 OS が認識している音声出力デバイスの一覧を取得する API です。 ただし、SDK の public API としては、列挙した `deviceId` を指定して再生先を統一的に切り替える API は現時点では提供していません。 音声の再生先は、基本的に各プラットフォームの標準ルーティングに従います。 - Android では Android / libwebrtc の音声ルーティングに従います。Bluetooth / 受話スピーカー / スピーカーの切り替えは通話用音声ルートの制約を受けるため、出力先だけを常に厳密指定できるとは限りません - iOS / iPadOS では `AVAudioSession` の音声ルートに従います。AirPlay や Bluetooth などの再生先をユーザーに選ばせる場合は、アプリケーション側で `AVRoutePickerView` を利用してください - macOS では OS / CoreAudio の既定音声出力へ再生されます。SDK から再生先を明示選択する public API は未提供です そのため、 `enumerateAudioOutputDevices()` は主に「現在利用可能な出力候補を把握する」用途の API として扱ってください。 #### 音声デバイス利用例 Android / iOS / macOS では、音声入力デバイスの選択コード自体は共通です。 `MediaDevices.enumerateAudioInputDevices()` で候補を列挙し、使いたい入力デバイスの `deviceId` を `GetUserMediaOptions.audioDeviceId` に渡します。 ```dart final inputDevices = await MediaDevices.enumerateAudioInputDevices(); final selectedInputDevice = inputDevices.first; final stream = await MediaDevices.getUserMedia( GetUserMediaOptions( audio: true, video: false, audioDeviceId: selectedInputDevice.deviceId, ), ); ``` プラットフォーム別の注意事項は次のとおりです。 - Android では `AudioDeviceInfo.id` は接続状態に依存して変わり得るため、保存した ID の長期利用は前提にしないでください - iOS / iPadOS の音声出力切り替えは `AVAudioSession` の音声ルートに従います。スピーカー / Bluetooth / AirPlay などの再生先をユーザーに選ばせる場合は、アプリケーション側で `AVRoutePickerView` を利用してください - macOS の音声入力は libwebrtc の `AudioDeviceModule` 経由で切り替わります。現時点では音声出力デバイスの切り替え public API は未提供です。 `MediaDevices.enumerateAudioOutputDevices()` で一覧取得はできますが、再生先の明示選択はまだサポートしていません ### MediaDevices.createMediaStream() 空の `LocalMediaStream` を生成します。通常のカメラ・マイク構成なら `getUserMedia()` で十分ですが、 `createExternalVideoTrack()` で作った外部映像トラックを自分で stream に追加したい場合はこの API を使います。 `getUserMedia()` は `audio: false, video: false` で呼べないため、 **音声なしで外部映像のみを送る** 構成では、まずこの API で空 stream を作る必要があります。 ```dart static LocalMediaStream createMediaStream(); ``` **引数**: なし **戻り値**: 空の `LocalMediaStream` 。 #### 使い方 ```dart // 音声なしの外部映像のみを送る stream final stream = MediaDevices.createMediaStream(); stream.addTrack(MediaDevices.createExternalVideoTrack()); ``` ### MediaDevices.createAudioTrack() 音声トラック ( `LocalAudioTrack` ) を 1 本生成します。マイク音声を送りたい場合に使います。 `getUserMedia()` は内部でこの API を利用して音声トラックを生成します。 ```dart static Future createAudioTrack({ String? audioDeviceId, }); ``` **引数**: `audioDeviceId` ( `String?` ): 音声入力に使うデバイス ID。 `MediaDevices.enumerateAudioInputDevices()` で列挙した `deviceId` を指定します。省略時は前回の明示選択を解除して、プラットフォーム標準の入力選択に戻ります **戻り値**: `LocalAudioTrack` 。 #### 使い方 外部映像入力とマイク音声を組み合わせて送る場合は、 `createMediaStream()` で作った空 stream に追加します。詳細は [外部映像入力を利用する](external_video_source.html) をご確認ください。 ```dart final stream = MediaDevices.createMediaStream(); stream.addTrack(await MediaDevices.createAudioTrack()); stream.addTrack(MediaDevices.createExternalVideoTrack()); ``` ### MediaDevices.createCameraVideoTrack() SDK 管理のカメラ入力を利用する `LocalVideoTrack` を 1 本生成します。 `getUserMedia()` は内部でこの API を利用して映像トラックを生成します。 ```dart static LocalVideoTrack createCameraVideoTrack({ String? videoDeviceId, int? videoWidth, int? videoHeight, int? videoFrameRate, }); ``` **引数**: `videoDeviceId` ( `String?` ): 映像入力に使うカメラデバイス ID。 `MediaDevices.enumerateVideoInputDevices()` で列挙した `deviceId` を指定します。省略時は SDK / プラットフォームが選んだ既定のカメラを使います。 `videoWidth` / `videoHeight` / `videoFrameRate` ( `int?` ): 映像入力の希望幅、希望高さ、希望フレームレート **戻り値**: `LocalVideoTrack` 。 #### 使い方 ```dart final device = (await MediaDevices.enumerateVideoInputDevices()).first; final stream = MediaDevices.createMediaStream(); stream.addTrack(MediaDevices.createCameraVideoTrack( videoDeviceId: device.deviceId, videoWidth: 1280, videoHeight: 720, videoFrameRate: 30, )); ``` `videoWidth` / `videoHeight` / `videoFrameRate` を省略した場合の値はプラットフォームとデバイスに依存します。 `getUserMedia()` 経由の場合は `640x480 @30fps` が使われます。詳細は [MediaDevices.getUserMedia()](media_device.html#mediadevices-getusermedia) をご確認ください。 ### MediaDevices.createExternalVideoTrack() 外部映像入力の `LocalVideoTrack` を 1 本生成します。カメラ以外の映像ソース (画面キャプチャ、動画ファイル、 `camera` パッケージからの取り込み、合成映像、テストパターンなど) を送りたい場合に使います。生成したトラックに `writeFrame(ExternalVideoFrame)` を呼び出すと、入力を検証した後、受け付けられたフレームがネイティブ映像ソースへ渡されます。 `writeFrame()` は同期的な `void` メソッドですが、Sora への配送、エンコード、ネットワーク送信の完了を示しません。 ```dart static LocalVideoTrack createExternalVideoTrack(); ``` **引数**: なし **戻り値**: `LocalVideoTrack` 。 `writeFrame()` で I420 フレームを投入できる外部映像入力用のトラックです。 #### 特性 - フレーム供給はアプリケーション側の責任: `writeFrame()` を呼ばなければフレームはネイティブ映像ソースへ渡りません。呼び出し間隔はアプリケーション側で制御しますが、実際の送信フレームレートは保証されません。例: Dart の `Timer.periodic` で 33ms (30 FPS 相当) 間隔に呼ぶ - 同期的なフレーム引き渡し: `writeFrame()` は `Future` を返しません。 `AdaptFrame` に受け付けられたフレームの Y / U / V plane は呼び出し中にネイティブ側の `I420 バッファー` へコピーされるため、戻り後に元の Dart 側のバッファーを再利用できます - フレームの適合: `AdaptFrame` が `0` を返した場合はフレームを破棄して正常に戻ります。受け付けられたフレームは、必要に応じて適合後の解像度へスケーリングされます - SDK 側のカメラキャプチャは動かない: `LocalVideoTrack.textureId` からのローカルプレビューは提供されません。ローカルで表示したい場合はアプリケーション側で Flutter の `CustomPaint` などにフレームを描画してください - I420 (YUV 4:2:0 planar) 固定: 他のフォーマットは事前にアプリケーション側で I420 に変換してください - カメラ入力との相互切り替え: `SoraConnection.replaceVideoTrack()` で差し替え可能です #### 使い方 単独生成 (音声なしの外部映像のみ送る構成。stream は `createMediaStream()` で作る): ```dart final stream = MediaDevices.createMediaStream(); final track = MediaDevices.createExternalVideoTrack(); stream.addTrack(track); await conn.connect(stream); // 接続後、任意のタイミングで I420 フレームを流し込む track.writeFrame(ExternalVideoFrame( width: 640, height: 480, yPlane: yBytes, uPlane: uBytes, vPlane: vBytes, yStride: 640, uStride: 320, vStride: 320, )); ``` マイク音声と組み合わせたいときは、 `getUserMedia()` で音声のみの stream を作り、外部映像トラックを追加します: ```dart final stream = await MediaDevices.getUserMedia( GetUserMediaOptions(audio: true, video: false), ); stream.addTrack(MediaDevices.createExternalVideoTrack()); ``` `ExternalVideoFrame` のフィールド (plane / stride / rotation / timestampUs など)、バリデーション規則、 `writeFrame()` の振る舞い (adapt によるスキップや scale、エラー条件)、カメラと外部入力の切り替え手順、一定フレームレートで書き込むサンプルなどの詳細は [外部映像入力を利用する](external_video_source.html) をご確認ください。 ## 利用例 ### 音声映像付きの標準構成 ```dart final audioDevices = await MediaDevices.enumerateAudioInputDevices(); final videoDevices = await MediaDevices.enumerateVideoInputDevices(); final stream = await MediaDevices.getUserMedia( GetUserMediaOptions( audio: true, video: true, audioDeviceId: audioDevices.first.deviceId, videoDeviceId: videoDevices.first.deviceId, ), ); final conn = await Sora.createConnection( SoraConnectionConfig( signalingUrls: ['wss://example.com/signaling'], channelId: 'example-channel', role: SoraRole.sendrecv, audio: true, video: true, ), ); await conn.connect(stream); ``` ## プラットフォームごとのデバイス権限 カメラやマイクを利用する際のプラットフォームごとの権限設定は以下をご確認ください。 - Android: [権限](android.html#権限) - iOS / iPadOS: [権限](ios_ipados.html#権限) - macOS: [権限](macos.html#権限) ## リソース解放 接続を終了して、その `LocalMediaStream` と track をもう使わないタイミングで解放してください。 `disconnect()` や `SoraConnection.dispose()` を呼んでも、アプリケーション側で作成した `LocalMediaStream` / `LocalAudioTrack` / `LocalVideoTrack` は自動では解放されません。 解放するときは、先に track を `dispose()` してから、最後に `LocalMediaStream` を `dispose()` します。 `LocalMediaStream.dispose()` は中の `LocalAudioTrack` / `LocalVideoTrack` を自動では解放しません。 ```dart await conn.disconnect(); for (final track in stream.getAudioTracks()) { await track.dispose(); } for (final track in stream.getVideoTracks()) { await track.dispose(); } await stream.dispose(); await conn.dispose(); ``` # Android での利用方法 ## 概要 Sora Flutter SDK で Android アプリケーションを実装する際のカメラやマイクの権限設定、注意点について説明します。 対応バージョンは Android 10 (API 29) 以降です。 Sora への接続やメディアデバイスの操作といったプラットフォーム共通の Dart API については、他ページをご確認ください。 ## 動作環境 - Android 10 (API 29) 以降 (arm64-v8a) - Flutter 3.44.0 以上 - Android SDK / Android NDK 28.2.13676358 - Sora 2025.2.0 以降 ## 依存関係 Android 向けビルドでは、Gradle の `fetchNativeDeps` タスクが `libwebrtc-c` と `webrtc` の配布物を自動取得します。 取得対象のバージョン、URL、SHA-256 は `scripts/native_deps.json` で管理しています。 ## ビルド アプリケーションのディレクトリで依存関係を取得し、APK をビルドします。 ```bash flutter pub get flutter build apk --debug flutter build apk --release ``` ## 権限 Android で音声や映像を配信する場合は、配信するメディアに応じてカメラまたはマイクの権限が必要になります。 音声のみの場合は `RECORD_AUDIO` 、映像のみの場合は `CAMERA` だけで十分です。 `CAMERA` と `RECORD_AUDIO` はランタイム権限で、マニフェストへの宣言だけでは付与されません。 また Sora への接続には `INTERNET` 権限が必要です。こちらはインストール時に自動で付与される通常の権限のため、マニフェストへの宣言のみで十分です。 SDK は権限リクエストを行わないため、アプリケーション側で `AndroidManifest.xml` の宣言とネイティブ側の権限リクエスト、および Dart 側からの呼び出しを実装してください。 ### AndroidManifest.xml の宣言 シグナリング接続用の `INTERNET` と、配信用の `CAMERA` / `RECORD_AUDIO` を宣言します。 ```xml ``` ### ランタイム権限のリクエスト ランタイム権限を取得するには、Dart 側から `MethodChannel` 経由でプラットフォームの権限 API を呼び出します。 #### Dart 側の実装例 Dart 側の `ensureMediaPermissions()` は `MethodChannel` に `camera` / `microphone` の要否を渡すラッパーです。 この呼び出しは `Android` / `iOS` / `macOS` で共通で利用することができます。 ```dart import 'dart:io'; import 'package:flutter/services.dart'; /// プラットフォームのメディア権限(カメラ・マイク)を要求します。 /// /// ``MethodChannel`` 経由で各プラットフォームのネイティブ権限 API を呼び出します。 /// この Dart コードは ``Android`` / ``iOS`` / ``macOS`` で共通です。 /// /// プラットフォームが ``Android`` / ``iOS`` / ``macOS`` 以外の場合は /// 常に ``true`` を返します。 /// /// ネイティブ側では同じチャンネル名 ``"example/permissions"`` (チャンネル名は例)で /// ``ensureMediaPermissions`` メソッドをハンドリングする必要があります。 /// /// - Android: ``ActivityCompat.requestPermissions`` で /// ``CAMERA`` / ``RECORD_AUDIO`` を要求 /// - iOS / macOS: ``AVCaptureDevice.requestAccess`` で /// ``.video`` / ``.audio`` を要求 /// /// [needsCamera]: カメラ権限が必要な場合に ``true`` /// [needsMicrophone]: マイク権限が必要な場合に ``true`` /// /// 戻り値: すべての要求された権限が許可された場合に ``true`` Future ensureMediaPermissions({ required bool needsCamera, required bool needsMicrophone, }) async { // Android / iOS / macOS 以外では権限要求の仕組みが異なるため // 常に許可扱いとします。 if (!Platform.isAndroid && !Platform.isIOS && !Platform.isMacOS) { return true; } // MethodChannel 経由でネイティブ側の権限 API を呼び出します。 // チャンネル名 "example/permissions" はネイティブ側と一致させる必要があります。 // (実際の実装時はアプリケーション名等に合わせて命名してください) const channel = MethodChannel('example/permissions'); // 戻り値が null の場合は false として扱います。 return await channel.invokeMethod( 'ensureMediaPermissions', { 'camera': needsCamera, 'microphone': needsMicrophone, }, ) ?? false; } ``` 接続前の呼び出し例: ```dart final granted = await ensureMediaPermissions( needsCamera: true, needsMicrophone: true, ); if (!granted) { return; } final stream = await MediaDevices.getUserMedia( const GetUserMediaOptions(audio: true, video: true), ); final conn = await Sora.createConnection( SoraConnectionConfig( signalingUrls: ['wss://sora.example.com/signaling'], channelId: 'example-channel', role: SoraRole.sendrecv, audio: true, video: true, ), ); await conn.connect(stream); ``` > **注釈** > > `permission_handler` パッケージを利用する方法もありますが、 Sora Flutter SDK の DevTools ではプラットフォーム側の細かい制御と依存の最小化のため、 > 独自の `MethodChannel` を採用しています。 #### Android ネイティブ側の実装例 `MainActivity.kt` で `MethodChannel` のハンドラを設定し、 `ActivityCompat.requestPermissions` でカメラ・マイクの許可を要求します。 ```kotlin import android.Manifest import android.content.pm.PackageManager import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel class MainActivity : FlutterActivity() { private var permissionChannel: MethodChannel? = null private var pendingPermissionResult: MethodChannel.Result? = null override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) permissionChannel = MethodChannel( flutterEngine.dartExecutor.binaryMessenger, "example/permissions", ).also { channel -> channel.setMethodCallHandler { call, result -> when (call.method) { "ensureMediaPermissions" -> { val args = call.arguments as? Map<*, *> ?: emptyMap() val needsCamera = (args["camera"] as? Boolean) ?: false val needsMicrophone = (args["microphone"] as? Boolean) ?: false ensureMediaPermissions( needsCamera = needsCamera, needsMicrophone = needsMicrophone, result = result, ) } else -> result.notImplemented() } } } } private fun ensureMediaPermissions( needsCamera: Boolean, needsMicrophone: Boolean, result: MethodChannel.Result, ) { val permissions = mutableListOf() if (needsCamera && ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED ) { permissions.add(Manifest.permission.CAMERA) } if (needsMicrophone && ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED ) { permissions.add(Manifest.permission.RECORD_AUDIO) } if (permissions.isEmpty()) { result.success(true) return } pendingPermissionResult = result ActivityCompat.requestPermissions( this, permissions.toTypedArray(), REQUEST_MEDIA_PERMISSIONS, ) } override fun onRequestPermissionsResult( requestCode: Int, permissions: Array, grantResults: IntArray, ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) if (requestCode != REQUEST_MEDIA_PERMISSIONS) { return } val result = pendingPermissionResult ?: return pendingPermissionResult = null result.success(grantResults.all { it == PackageManager.PERMISSION_GRANTED }) } companion object { private const val REQUEST_MEDIA_PERMISSIONS = 1001 } } ``` #### 実装の参照先 devtools の実装全体は以下を参照してください。 - Dart: `devtools/lib/src/devtools_connection_controller.dart` ( `ensureMediaPermissions` ) - Android ネイティブ: `devtools/android/app/src/main/kotlin/com/example/devtools/MainActivity.kt` > **注釈** > > 視聴のみ( `recvonly` ロール)で利用する場合はカメラ・マイクを使わないため、ランタイム権限の取得は不要です。 ## その他 - カメラ送信を含む動作確認には実機が必要です(エミュレーターではカメラデバイスを利用できません)。音声のみの送信はエミュレーターでも確認できます - リリースビルドでは縮小や難読化の影響を受けるため、 `release` でも必ず起動・動作確認をしてください - R8 や ProGuard の設定を独自に上書きする場合は、SDK が前提にしている keep 設定を壊さないように注意してください。JNI 初期化に必要なクラスが削除または難読化されると、起動直後にクラッシュする可能性があります。詳細は Android の [ネイティブ ライブラリの最適化](https://developer.android.com/topic/performance/app-optimization/library-optimization?hl=ja) をご確認ください - NDK や Gradle 周りを大きく変更する場合は、ネイティブライブラリの読み込み条件が変わらないか確認してください # iOS / iPadOS での利用方法 ## 概要 Sora Flutter SDK で iOS / iPadOS アプリケーションを実装する際のカメラやマイクの権限設定、注意点について説明します。 対応バージョンは iOS 16 以降、iPadOS 16 以降です。 Sora への接続やメディアデバイスの操作といったプラットフォーム共通の Dart API については、他ページをご確認ください。 ## 権限 iOS / iPadOS で音声や映像を配信する場合は、配信するメディアに応じてカメラまたはマイクの権限が必要になります。 音声のみの場合はマイク、映像のみの場合はカメラだけで十分です。 SDK は権限リクエストを行わないため、アプリケーション側で `Info.plist` の用途説明の設定とネイティブ側の権限リクエスト、および Dart 側からの呼び出しを実装してください。 ### Info.plist の用途説明 ```xml NSCameraUsageDescription カメラを利用する目的を記述します NSMicrophoneUsageDescription マイクを利用する目的を記述します ``` ### ランタイム権限のリクエスト ランタイム権限を取得するには、Dart 側から `MethodChannel` 経由でプラットフォームの権限 API を呼び出します。 #### Dart 側の実装例 Dart 側の `ensureMediaPermissions()` は `MethodChannel` に `camera` / `microphone` の要否を渡すラッパーです。 この呼び出しは `Android` / `iOS` / `macOS` で共通で利用することができます。 ```dart import 'dart:io'; import 'package:flutter/services.dart'; /// プラットフォームのメディア権限(カメラ・マイク)を要求します。 /// /// ``MethodChannel`` 経由で各プラットフォームのネイティブ権限 API を呼び出します。 /// この Dart コードは ``Android`` / ``iOS`` / ``macOS`` で共通です。 /// /// プラットフォームが ``Android`` / ``iOS`` / ``macOS`` 以外の場合は /// 常に ``true`` を返します。 /// /// ネイティブ側では同じチャンネル名 ``"example/permissions"`` (チャンネル名は例)で /// ``ensureMediaPermissions`` メソッドをハンドリングする必要があります。 /// /// - Android: ``ActivityCompat.requestPermissions`` で /// ``CAMERA`` / ``RECORD_AUDIO`` を要求 /// - iOS / macOS: ``AVCaptureDevice.requestAccess`` で /// ``.video`` / ``.audio`` を要求 /// /// [needsCamera]: カメラ権限が必要な場合に ``true`` /// [needsMicrophone]: マイク権限が必要な場合に ``true`` /// /// 戻り値: すべての要求された権限が許可された場合に ``true`` Future ensureMediaPermissions({ required bool needsCamera, required bool needsMicrophone, }) async { // Android / iOS / macOS 以外では権限要求の仕組みが異なるため // 常に許可扱いとします。 if (!Platform.isAndroid && !Platform.isIOS && !Platform.isMacOS) { return true; } // MethodChannel 経由でネイティブ側の権限 API を呼び出します。 // チャンネル名 "example/permissions" はネイティブ側と一致させる必要があります。 // (実際の実装時はアプリケーション名等に合わせて命名してください) const channel = MethodChannel('example/permissions'); // 戻り値が null の場合は false として扱います。 return await channel.invokeMethod( 'ensureMediaPermissions', { 'camera': needsCamera, 'microphone': needsMicrophone, }, ) ?? false; } ``` 接続前の呼び出し例: ```dart final granted = await ensureMediaPermissions( needsCamera: true, needsMicrophone: true, ); if (!granted) { return; } final stream = await MediaDevices.getUserMedia( const GetUserMediaOptions(audio: true, video: true), ); final conn = await Sora.createConnection( SoraConnectionConfig( signalingUrls: ['wss://sora.example.com/signaling'], channelId: 'example-channel', role: SoraRole.sendrecv, audio: true, video: true, ), ); await conn.connect(stream); ``` > **注釈** > > `permission_handler` パッケージを利用する方法もありますが、 Sora Flutter SDK の DevTools ではプラットフォーム側の細かい制御と依存の最小化のため、 > 独自の `MethodChannel` を採用しています。 #### iOS / iPadOS ネイティブ側の実装 `AppDelegate.swift` で `MethodChannel` のハンドラを設定し、 `AVCaptureDevice.requestAccess` でカメラ・マイクの許可を要求します。 ```swift import AVFoundation import Flutter import UIKit @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { private var permissionChannel: FlutterMethodChannel? func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) let channel = FlutterMethodChannel( name: "example/permissions", binaryMessenger: engineBridge.applicationRegistrar.messenger() ) channel.setMethodCallHandler { call, result in switch call.method { case "ensureMediaPermissions": let args = call.arguments as? [String: Any] ?? [:] let needsCamera = (args["camera"] as? Bool) ?? false let needsMicrophone = (args["microphone"] as? Bool) ?? false self.ensureMediaPermissions( needsCamera: needsCamera, needsMicrophone: needsMicrophone, result: result ) default: result(FlutterMethodNotImplemented) } } permissionChannel = channel } private func ensureMediaPermissions( needsCamera: Bool, needsMicrophone: Bool, result: @escaping FlutterResult ) { requestCameraIfNeeded(needsCamera) { cameraGranted in if !cameraGranted { result(false) return } self.requestMicrophoneIfNeeded(needsMicrophone) { microphoneGranted in result(microphoneGranted) } } } private func requestCameraIfNeeded( _ needsCamera: Bool, completion: @escaping (Bool) -> Void ) { guard needsCamera else { completion(true) return } let status = AVCaptureDevice.authorizationStatus(for: .video) switch status { case .authorized: completion(true) case .notDetermined: AVCaptureDevice.requestAccess(for: .video) { granted in DispatchQueue.main.async { completion(granted) } } case .denied, .restricted: completion(false) @unknown default: completion(false) } } private func requestMicrophoneIfNeeded( _ needsMicrophone: Bool, completion: @escaping (Bool) -> Void ) { guard needsMicrophone else { completion(true) return } let status = AVCaptureDevice.authorizationStatus(for: .audio) switch status { case .authorized: completion(true) case .notDetermined: AVCaptureDevice.requestAccess(for: .audio) { granted in DispatchQueue.main.async { completion(granted) } } case .denied, .restricted: completion(false) @unknown default: completion(false) } } } ``` #### 実装の参照先 devtools の実装全体は以下を参照してください。 - Dart: `devtools/lib/src/devtools_connection_controller.dart` ( `ensureMediaPermissions` ) - iOS ネイティブ: `devtools/ios/Runner/AppDelegate.swift` > **注釈** > > 視聴のみ( `recvonly` ロール)で利用する場合はカメラ・マイクを使わないため、ランタイム権限の取得は不要です。 ## その他 - カメラ送信を含む動作確認には実機が必要です(エミュレーターではカメラデバイスを利用できません)。音声のみの送信はエミュレーターでも確認できます - リリースビルドでは縮小や難読化の影響を受けるため、 `release` でも必ず起動・動作確認をしてください - カメラやマイクを使う場合は、ビルド前に `Info.plist` の用途説明を必ず設定してください - アプリケーション側で CocoaPods や Xcode 設定を独自に変更する場合は、SDK 側が前提にしているリンク設定を壊さないように注意してください # macOS での利用方法 ## 概要 Sora Flutter SDK で macOS アプリケーションを実装する際のカメラやマイクの権限設定、注意点について説明します。 対応バージョンは macOS 15 以降、Apple Silicon です。 Sora への接続やメディアデバイスの操作といったプラットフォーム共通の Dart API については、他ページをご確認ください。 ## 権限 macOS で音声や映像を配信する場合は、配信するメディアに応じてカメラまたはマイクの権限が必要になります。 音声のみの場合はマイク、映像のみの場合はカメラだけで十分です。 SDK は権限リクエストを行わないため、アプリケーション側で `Info.plist` の用途説明と Entitlements への設定、およびネイティブ側の権限リクエストと Dart 側からの呼び出しを実装してください。 ### Info.plist の用途説明 ```xml NSCameraUsageDescription カメラを利用する目的を記述します NSMicrophoneUsageDescription マイクを利用する目的を記述します ``` ### Entitlements の設定 App Sandbox を有効にしている場合は、Entitlement に配信するメディアに応じた設定をしてください。 カメラ送信には `com.apple.security.device.camera` 、マイク送信には `com.apple.security.device.audio-input` が必要です。 `DebugProfile.entitlements` と `Release.entitlements` の両方に設定する必要があります。 ```xml com.apple.security.device.camera com.apple.security.device.audio-input ``` また、 Sora への接続には `com.apple.security.network.client` が必要です。 ```xml com.apple.security.network.client ``` > **注釈** > > 視聴のみ( `recvonly` ロール)で利用する場合はカメラ・マイクを使わないため、デバイス関連の Entitlement は不要です。 ### ランタイム権限のリクエスト ランタイム権限の取得は、Dart 側から `MethodChannel` 経由でプラットフォームの権限 API を呼び出す構成が基本です。 #### Dart 側の実装例(全プラットフォーム共通) Dart 側の `ensureMediaPermissions()` は `MethodChannel` に `camera` / `microphone` の要否を渡すラッパーです。 この呼び出しは `Android` / `iOS` / `macOS` で共通で利用することができます。 ```dart import 'dart:io'; import 'package:flutter/services.dart'; /// プラットフォームのメディア権限(カメラ・マイク)を要求します。 /// /// ``MethodChannel`` 経由で各プラットフォームのネイティブ権限 API を呼び出します。 /// この Dart コードは ``Android`` / ``iOS`` / ``macOS`` で共通です。 /// /// プラットフォームが ``Android`` / ``iOS`` / ``macOS`` 以外の場合は /// 常に ``true`` を返します。 /// /// ネイティブ側では同じチャンネル名 ``"example/permissions"`` (チャンネル名は例)で /// ``ensureMediaPermissions`` メソッドをハンドリングする必要があります。 /// /// - Android: ``ActivityCompat.requestPermissions`` で /// ``CAMERA`` / ``RECORD_AUDIO`` を要求 /// - iOS / macOS: ``AVCaptureDevice.requestAccess`` で /// ``.video`` / ``.audio`` を要求 /// /// [needsCamera]: カメラ権限が必要な場合に ``true`` /// [needsMicrophone]: マイク権限が必要な場合に ``true`` /// /// 戻り値: すべての要求された権限が許可された場合に ``true`` Future ensureMediaPermissions({ required bool needsCamera, required bool needsMicrophone, }) async { // Android / iOS / macOS 以外では権限要求の仕組みが異なるため // 常に許可扱いとします。 if (!Platform.isAndroid && !Platform.isIOS && !Platform.isMacOS) { return true; } // MethodChannel 経由でネイティブ側の権限 API を呼び出します。 // チャンネル名 "example/permissions" はネイティブ側と一致させる必要があります。 // (実際の実装時はアプリケーション名等に合わせて命名してください) const channel = MethodChannel('example/permissions'); // 戻り値が null の場合は false として扱います。 return await channel.invokeMethod( 'ensureMediaPermissions', { 'camera': needsCamera, 'microphone': needsMicrophone, }, ) ?? false; } ``` 接続前の呼び出し例: ```dart final granted = await ensureMediaPermissions( needsCamera: true, needsMicrophone: true, ); if (!granted) { return; } final stream = await MediaDevices.getUserMedia( const GetUserMediaOptions(audio: true, video: true), ); final conn = await Sora.createConnection( SoraConnectionConfig( signalingUrls: ['wss://sora.example.com/signaling'], channelId: 'example-channel', role: SoraRole.sendrecv, audio: true, video: true, ), ); await conn.connect(stream); ``` > **注釈** > > `permission_handler` パッケージを利用する方法もありますが、 Sora Flutter SDK の DevTools ではプラットフォーム側の細かい制御と依存の最小化のため、 > 独自の `MethodChannel` を採用しています。 #### macOS ネイティブ側の実装 `MainFlutterWindow.swift` で `MethodChannel` のハンドラを設定し、 `AVCaptureDevice.requestAccess` でカメラ・マイクの許可を要求します。 macOS では `Info.plist` に用途説明が設定されていても、初回アクセス時に OS の権限ダイアログが表示されます。 ```swift import AVFoundation import Cocoa import FlutterMacOS private final class PermissionHandler: NSObject { private let channel: FlutterMethodChannel init(binaryMessenger: FlutterBinaryMessenger) { channel = FlutterMethodChannel( name: "example/permissions", binaryMessenger: binaryMessenger ) super.init() channel.setMethodCallHandler(handle) } private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { case "ensureMediaPermissions": let args = call.arguments as? [String: Any] ?? [:] let needsCamera = (args["camera"] as? Bool) ?? false let needsMicrophone = (args["microphone"] as? Bool) ?? false ensureMediaPermissions( needsCamera: needsCamera, needsMicrophone: needsMicrophone, result: result ) default: result(FlutterMethodNotImplemented) } } private func ensureMediaPermissions( needsCamera: Bool, needsMicrophone: Bool, result: @escaping FlutterResult ) { requestCameraIfNeeded(needsCamera) { cameraGranted in if !cameraGranted { result(false) return } self.requestMicrophoneIfNeeded(needsMicrophone) { microphoneGranted in result(microphoneGranted) } } } private func requestCameraIfNeeded( _ needsCamera: Bool, completion: @escaping (Bool) -> Void ) { guard needsCamera else { completion(true) return } let status = AVCaptureDevice.authorizationStatus(for: .video) switch status { case .authorized: completion(true) case .notDetermined: AVCaptureDevice.requestAccess(for: .video) { granted in DispatchQueue.main.async { completion(granted) } } case .denied, .restricted: completion(false) @unknown default: completion(false) } } private func requestMicrophoneIfNeeded( _ needsMicrophone: Bool, completion: @escaping (Bool) -> Void ) { guard needsMicrophone else { completion(true) return } let status = AVCaptureDevice.authorizationStatus(for: .audio) switch status { case .authorized: completion(true) case .notDetermined: AVCaptureDevice.requestAccess(for: .audio) { granted in DispatchQueue.main.async { completion(granted) } } case .denied, .restricted: completion(false) @unknown default: completion(false) } } } class MainFlutterWindow: NSWindow { private var permissionHandler: PermissionHandler? override func awakeFromNib() { let flutterViewController = FlutterViewController() let windowFrame = self.frame self.contentViewController = flutterViewController self.setFrame(windowFrame, display: true) RegisterGeneratedPlugins(registry: flutterViewController) permissionHandler = PermissionHandler( binaryMessenger: flutterViewController.engine.binaryMessenger ) super.awakeFromNib() } } ``` #### 実装の参照先 devtools の実装全体は以下を参照してください。 - Dart: `devtools/lib/src/devtools_connection_controller.dart` ( `ensureMediaPermissions` ) - macOS ネイティブ: `devtools/macos/Runner/MainFlutterWindow.swift` ## その他 - リリースビルドでは縮小や難読化の影響を受けるため、 `release` でも必ず起動・動作確認をしてください - カメラやマイクを使う場合は、ビルド前に `Info.plist` の用途説明と Entitlements への設定を必ず行ってください - アプリケーション側で CocoaPods や Xcode 設定を独自に変更する場合は、SDK 側が前提にしているリンク設定を壊さないように注意してください # Windows での利用方法 ## 概要 Sora Flutter SDK で Windows アプリケーションを実装する際の動作環境、ネイティブ依存、ビルド方法について説明します。 対応バージョンは Windows 10 20H2 以上 (x86_64) です。 Sora への接続やメディアデバイスの操作といったプラットフォーム共通の Dart API については、他ページをご確認ください。 ## 動作環境 - Windows 10 20H2 以上 (x86_64) - Flutter 3.44.0 以上 - Sora 2025.2.0 以降 - Visual Studio 2022 (MSVC v143) または Build Tools for Visual Studio 2022 - Windows SDK 10.0.26100 以上 Visual Studio 2022 または Build Tools for Visual Studio 2022 では、「C++ によるデスクトップ開発」ワークロードが必要です。 ## 依存関係 Sora Flutter SDK はネイティブ依存として次のライブラリを必要とします。 - `libwebrtc-c` (WebRTC の C API ラッパー) - `webrtc` (WebRTC ネイティブライブラリ) 依存取得の設定は `scripts/native_deps.json` で管理しています。 これらの依存は CMake ビルド時に自動でダウンロード・展開されるため、利用者が手動で用意する必要はありません。 CMake を経由せずに手動で取得する場合のみ、`sora-flutter-sdk` のルートで次を実行します。 ```bash dart pub get dart run scripts/fetch_native_deps.dart windows_x86_64 ``` ## ビルド アプリケーションのディレクトリで依存関係を取得し、アプリケーションをビルドします。 ```bash flutter pub get flutter build windows --debug flutter build windows --release ``` ## 備考 - 音声デバイスは WASAPI 経由で入出力を処理します - カメラキャプチャは Media Foundation 経由で動作します - リモート映像は libyuv で I420 から RGBA に変換し、ピクセルバッファ経由で Flutter Texture に描画します # Linux での利用方法 ## 概要 Sora Flutter SDK で Linux アプリケーションを実装する際の動作環境、ネイティブ依存、ビルド方法について説明します。 対応バージョンは Ubuntu 24.04 (x86_64) です。 Sora への接続やメディアデバイスの操作といったプラットフォーム共通の Dart API については、他ページをご確認ください。 ## 動作環境 - Ubuntu 24.04 (x86_64) - Flutter 3.44.0 以上 - Sora 2025.2.0 以降 - clang / cmake / ninja-build / pkg-config - GTK 3 開発パッケージ - PulseAudio 開発パッケージ - libjpeg-turbo 開発パッケージ ビルドに必要なシステムパッケージをインストールします。 ```bash sudo apt-get install -y clang cmake ninja-build pkg-config \ libgtk-3-dev liblzma-dev libpulse-dev libjpeg-dev ``` ## 依存関係 Sora Flutter SDK はネイティブ依存として次のライブラリを必要とします。 - `libwebrtc-c` (WebRTC の C API ラッパー) - `webrtc` (WebRTC ネイティブライブラリ) 依存取得の設定は `scripts/native_deps.json` で管理しています。 これらの依存は CMake の configure 時に自動でダウンロード・展開されるため、利用者が手動で用意する必要はありません。 CMake を経由せずに手動で取得する場合のみ、`sora-flutter-sdk` のルートで次を実行します。 ```bash dart pub get dart run scripts/fetch_native_deps.dart linux_ubuntu_24_04_x86_64 ``` ## ビルド アプリケーションのディレクトリで依存関係を取得し、アプリケーションをビルドします。 ```bash flutter pub get flutter build linux --debug flutter build linux --release ``` ## 備考 - 音声デバイスは PulseAudio 経由で入出力を処理します - カメラキャプチャは V4L2 経由で動作します - リモート映像は libyuv で I420 から RGBA に変換し、ピクセルバッファ経由で Flutter Texture に描画します