統計情報¶
Sora iOS SDK では、Sora を利用した WebRTC セッション中に iOS クライアントが送信している音声・映像・データパケットの状態を確認できます。 ビットレートや RTT、パケットロスといった統計情報を、W3C WebRTC Stats 仕様の getStats に準拠した形式で取得します(https://www.w3.org/TR/webrtc-stats/)。
統計情報の取得¶
Sora iOS SDK では、Sora からの送信要求に応じて統計情報を定期的に自動で送信していますが、
MediaChannel.getStats() メソッドを利用することでクライアントの任意のタイミングで統計情報を取得することができます。
取得した統計情報は通信状況の確認・可視化等に利用することができます。
統計情報を取得して統計要素を抽出する実装サンプル¶
// 画面中のボタンをタップすることにより統計情報を取得するサンプル実装です
// 統計情報取得に必要な処理以外は省略しています
import Sora
import UIKit
class ViewController: UIViewController {
// 接続完了後に設定される MediaChannelです
// 統計情報の取得には Sora へ接続済みである必要があります
var mediaChannel: MediaChannel?
// 統計情報取得アクションです。ボタン等にアクションとして紐付けます
@IBAction func getStats(_ sender: AnyObject) {
// 接続していない状態で実行した場合は処理を抜けます
guard let mediaChannel else {
return
}
// getStats 実行により統計情報を取得します。
// getStats は libwebrtc の統計情報取得メソッド(クロージャ)に、
// 接続状況のチェック等を追加したラッパー関数のためクロージャ形式となっています
mediaChannel.getStats { [weak self] result in
// ViewController が解放済みの場合は処理を抜けます
guard let self else { return }
// getStats 結果の評価
switch result {
case .success(let stats):
// stats は entries プロパティで StatisticsEntry のリストを持っています
// ここでは例として、entries の中から type=outbound-rtp の要素を抽出します
let outboundEntries = stats.entries.filter { $0.type == "outbound-rtp" }
// StatisticsEntry は id, type(統計種別), timestamp(測定時刻[μs]), values を要素として持ちます
// values は type ごとに異なる統計要素です
for entry in outboundEntries {
print("id: \(entry.id), timestamp: \(entry.timestamp), values: \(entry.values)")
}
case .failure(let error):
// MediaChannel が切断済み、または WebRTC 側で統計取得に失敗した場合
print("Stats Error:", error)
}
}
}
}
統計情報の構造¶
統計情報は RTCStatsReport 型で取得されます。 [{ "type": "...", ... }] のように type ごとのレコードが並ぶイメージで、
ID をキーにしたレコードの集合となっています。
統計情報を取得して内容を表示する
統計情報サンプル¶
実際に取得される統計情報のサンプルを掲載します。
[
{
"ssrc" : 1853431412,
"id" : "RIV1853431412",
"localId" : "OTaudio_OhfC2B1V1853431412",
"codecId" : "COTaudio_OhfC2B1_120_profile-id=0;x-google-start-bitrate=500",
"transportId" : "Taudio_OhfC2B1",
"kind" : "video",
"jitter" : 0.0078770000000000003,
"totalRoundTripTime" : 0.034118999999999997,
"roundTripTime" : 0.034118999999999997,
"fractionLost" : 0,
"type" : "remote-inbound-rtp",
"roundTripTimeMeasurements" : 1,
"timestamp" : 1762916916047921,
"packetsLost" : 0
},
{
"id" : "P",
"type" : "peer-connection",
"dataChannelsClosed" : 0,
"dataChannelsOpened" : 0,
"timestamp" : 1762916916592191
},
{
"type" : "media-source",
"totalAudioEnergy" : 5.311656835890106e-06,
"timestamp" : 1762916916592191,
"kind" : "audio",
"audioLevel" : 0.00048829615161595508,
"trackIdentifier" : "mainAudio",
"totalSamplesDuration" : 2.1699999999999977,
"id" : "SA3"
},
{
"payloadType" : 109,
"clockRate" : 48000,
"sdpFmtpLine" : "maxplaybackrate=48000;minptime=10;sprop-stereo=1;stereo=1;usedtx=0;useinbandfec=1",
"id" : "COTaudio_OhfC2B1_109_maxplaybackrate=48000;minptime=10;sprop-stereo=1;stereo=1;usedtx=0;useinbandfec=1",
"type" : "codec",
"timestamp" : 1762916916592191,
"transportId" : "Taudio_OhfC2B1",
"mimeType" : "audio\/opus",
"channels" : 2
},
{
"mediaSourceId" : "SA3",
"id" : "OTaudio_OhfC2B1A3036164645",
"type" : "outbound-rtp",
"kind" : "audio",
"active" : true,
"retransmittedPacketsSent" : 0,
"targetBitrate" : 64000,
"retransmittedBytesSent" : 0,
"transportId" : "Taudio_OhfC2B1",
"packetsSent" : 108,
"mid" : "audio_OhfC2B",
"totalPacketSendDelay" : 0,
"codecId" : "COTaudio_OhfC2B1_109_maxplaybackrate=48000;minptime=10;sprop-stereo=1;stereo=1;usedtx=0;useinbandfec=1",
"timestamp" : 1762916916592191,
"headerBytesSent" : 2592,
"nackCount" : 0,
"ssrc" : 3036164645,
"bytesSent" : 17388
},
{
"...": "省略"
}
]
Swift 6 言語モードに対応する Sendable な統計情報を取得する¶
MediaChannel.getStats() は可変 (mutable) な Statistics を返すため、結果を actor 境界や Task の @Sendable クロージャへ渡せません。
MediaChannel.getStatsSnapshot(handler:) と MediaChannel.getStatsSnapshot() は、 MediaChannel.getStats() と同じ統計情報を、不変 (immutable) で Sendable な StatisticsSnapshot として取得します。
統計情報を actor 境界や Task の @Sendable クロージャへ渡す場合は、こちらを利用してください。
StatisticsSnapshot と StatisticsEntrySnapshot は Statistics と StatisticsEntry の主要なプロパティを同じ名前で持ち、統計値の型だけが [String: JSONValue] になります。
StatisticsSnapshot は timestamp (収集時刻 [μs]) と entries ( StatisticsEntrySnapshot の一覧) を持ちます。
StatisticsEntrySnapshot は id (エントリー ID)、 type (統計種別)、 timestamp (測定時刻 [μs])、 values (統計値) を持ちます。
Statistics.jsonObject に相当するプロパティは持たないため、 JSON へ変換する場合は JSONValue を利用してください。
クロージャで統計情報を取得する¶
MediaChannel.getStatsSnapshot(handler:) は結果を StatisticsSnapshot としてクロージャへ渡します。
クロージャの実行スレッドは保証されません。接続状態の判定で失敗する場合は呼び出し元のスレッドから同期的に呼ばれ、統計情報の取得まで進んだ場合は libwebrtc 側のスレッドから呼ばれます。
// mediaChannel は Sora へ接続済みの MediaChannel です
mediaChannel.getStatsSnapshot { result in
switch result {
case .success(let snapshot):
// snapshot.entries は StatisticsEntrySnapshot の一覧です
// ここでは type が outbound-rtp の要素を抽出します
for entry in snapshot.entries where entry.type == "outbound-rtp" {
print("id: \(entry.id), timestamp: \(entry.timestamp), values: \(entry.values)")
}
case .failure(let error):
// MediaChannel が切断済み、または統計値を JSONValue へ変換できない場合
print("Stats Error:", error)
}
}
async で統計情報を取得する¶
MediaChannel.getStatsSnapshot() は MediaChannel.getStatsSnapshot(handler:) の async 版で、 StatisticsSnapshot を返します。
タスクをキャンセルした場合は CancellationError を投げます。
libwebrtc には統計取得をキャンセルする API が無いため、タスクをキャンセルしても RTCPeerConnection.statistics は完了まで実行されます。
その場合、取得した統計情報は返しません。
do {
let snapshot = try await mediaChannel.getStatsSnapshot()
let outboundEntries = snapshot.entries.filter { $0.type == "outbound-rtp" }
_ = outboundEntries
} catch is CancellationError {
// タスクをキャンセルした場合
} catch {
// 予期しないエラー
_ = error
}
StatisticsSnapshot を actor へ渡す¶
StatisticsSnapshot と StatisticsEntrySnapshot は Sendable のため、統計情報を actor 境界や Task の @Sendable クロージャへそのまま渡せます。
actor StatsStore {
private var latest: StatisticsSnapshot?
func store(_ snapshot: StatisticsSnapshot) {
latest = snapshot
}
}
統計値を読む¶
StatisticsEntrySnapshot.values は [String: JSONValue] のため、 StatisticsEntry.values のように NSObject を取り出すことはできません。
JSONValue の case ごとに値を取り出してください。
- 数値 (整数を含む) は
JSONValue.decimal、真偽値はJSONValue.bool、文字列はJSONValue.string、配列はJSONValue.array、オブジェクトはJSONValue.objectです Decimalが表現できる範囲を超える指数の値 (1e300など) だけがJSONValue.doubleになりますJSONSerializationを経由するため、NSNumberの小数はDecimalの値として入ります (例:0.1は0.10000000000000001)JSONValueはEncodableのため、JSONEncoderで JSON データへ変換できます
// type が outbound-rtp の統計値を読む例です
let outboundEntry = snapshot.entries.first { $0.type == "outbound-rtp" }
// 数値は JSONValue.decimal として入ります
if case .decimal(let bytesSent)? = outboundEntry?.values["bytesSent"] {
print("bytesSent: \(bytesSent)")
}
// 整数の統計値も JSONValue.decimal として入るため、整数として読む場合は Decimal から変換します
// ( JSONValue に整数の case はありません)
if case .decimal(let packetsSent)? = outboundEntry?.values["packetsSent"] {
print("packetsSent: \(NSDecimalNumber(decimal: packetsSent).int64Value)")
}
// 文字列は JSONValue.string として入ります
if case .string(let kind)? = outboundEntry?.values["kind"] {
print("kind: \(kind)")
}
エラーを扱う¶
MediaChannel.getStatsSnapshot(handler:) / MediaChannel.getStatsSnapshot() は次のエラーを返します。
SoraError.peerChannelError: Sora へ接続していない、nativeChannelが取得できない、接続状態が遷移した、MediaChannelの解放が始まったSoraError.mediaChannelError: 統計値をJSONValueへ変換できないCancellationError: async 版でタスクをキャンセルした
do {
let snapshot = try await mediaChannel.getStatsSnapshot()
_ = snapshot
} catch SoraError.peerChannelError(let reason) {
// 接続状態または nativeChannel の問題
_ = reason
} catch SoraError.mediaChannelError(let reason) {
// 統計値を JSONValue へ変換できない
_ = reason
} catch {
// 予期しないエラー
_ = error
}
Sendable な統計情報のサンプルコード¶
import Foundation
import Sora
// MARK: - StatisticsSnapshot を actor へ渡す
/// 統計情報を受け取る actor です。
///
/// `StatisticsSnapshot` / `StatisticsEntrySnapshot` は `Sendable` のため、 actor 境界を越えて
/// そのまま渡せます。
actor StatsStore {
private var latest: StatisticsSnapshot?
func store(_ snapshot: StatisticsSnapshot) {
latest = snapshot
}
/// 保持している snapshot から `outbound-rtp` の `bytesSent` を読み出します。
///
/// `StatisticsEntrySnapshot.values` は `[String: JSONValue]` のため、 `StatisticsEntry.values`
/// のように `NSObject` を取り出すことはできません。 `JSONValue` の case で取り出します。
func latestOutboundBytesSent() -> Decimal? {
guard
let entry = latest?.entries.first(where: { $0.type == "outbound-rtp" }),
case .decimal(let bytesSent)? = entry.values["bytesSent"]
else {
return nil
}
return bytesSent
}
}
// MARK: - async 版で統計情報を取得する
/// 統計情報を取得するサンプル実装です。
///
/// `MediaChannel` 自体は `Sendable` ではないため、 チャネルを actor 境界や `Task` の
/// `@Sendable` クロージャへ渡す場合は `nonisolated(unsafe)` などの隔離が必要です。
/// これは `getStats` / `getStatsSnapshot` のどちらを使う場合でも同じです。
final class SendableGetStatsExample {
nonisolated(unsafe) let mediaChannel: MediaChannel
let store = StatsStore()
init(mediaChannel: MediaChannel) {
self.mediaChannel = mediaChannel
}
/// `getStatsSnapshot()` で統計情報を取得し、 snapshot を actor へ渡します。
func fetchSnapshot() async throws {
do {
// getStatsSnapshot() は不変で Sendable な StatisticsSnapshot を返します
let snapshot = try await mediaChannel.getStatsSnapshot()
// StatisticsSnapshot は Sendable のため、 actor 境界へそのまま渡せます
await store.store(snapshot)
} catch is CancellationError {
// タスクをキャンセルした場合
} catch SoraError.peerChannelError(let reason) {
// 接続状態または nativeChannel の問題で失敗した場合
_ = reason
} catch SoraError.mediaChannelError(let reason) {
// 統計値を JSONValue へ変換できない場合
_ = reason
} catch {
// 予期しないエラー
_ = error
}
}
/// actor に保持した統計情報から `bytesSent` を読み出します。
func printOutboundBytesSent() async {
guard let bytesSent = await store.latestOutboundBytesSent() else {
return
}
print("bytesSent: \(bytesSent)")
}
}
// MARK: - クロージャ版で統計情報を取得する
/// `getStatsSnapshot(handler:)` で統計情報を取得し、 snapshot を actor へ渡します。
///
/// クロージャの実行スレッドは保証されません。 MainActor 隔離の文脈から渡したクロージャは
/// MainActor 隔離を継承するため、 クロージャの中ではクロージャを渡す API を呼ばず (`first(where:)` など)、
/// `@Sendable` なクロージャへ値を詰め替えてから main actor / main queue へ渡します。
func fetchSnapshotWithHandler(mediaChannel: MediaChannel, store: StatsStore) {
mediaChannel.getStatsSnapshot { result in
switch result {
case .success(let snapshot):
// snapshot は Sendable のため、 @Sendable クロージャへそのまま渡せます
Task { @Sendable in
await store.store(snapshot)
}
case .failure(let error):
// MediaChannel が切断済み、または統計値を JSONValue へ変換できない場合
print("Stats Error:", error)
}
}
}
// MARK: - 統計値を JSON へ変換する
/// 統計値を JSON データへ変換します。
///
/// `StatisticsSnapshot` は `Statistics.jsonObject` に相当するプロパティを持たないため、
/// `Encodable` な `JSONValue` を `JSONEncoder` で変換します。
func encodeStatsValues(of entry: StatisticsEntrySnapshot) throws -> Data {
try JSONEncoder().encode(entry.values)
}