moq-kit について
moq-kitは、Media over QUICのライブ配信アプリ向けにSwiftとKotlinのネイティブSDKを提供します。moq-liteを基盤とし、iOSやAndroidでの再生、配信、データトラックに対応します。
主な機能
- 01SwiftとKotlinのネイティブSDK
- 02ブロードキャストの購読とトラック再生
- 03カメラ、マイク、画面のメディア配信
- 04生データトラックの交換
- 05再生と配信状態の監視
活用シーン
- 01ライブ配信を視聴するiOSアプリの開発
- 02Androidからカメラやマイクのストリームを配信
- 03データトラックでライブ状態やチャットを送信
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ software-mansion-labs/ skills --skill moq-kitインストール後、Claude Code で/moq-kitを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
references/data-tracks-android.mddata-tracks-ios.mdplayback-android.mdplayback-ios.mdpublishing-android.mdpublishing-ios.mdscreen-capture-android.mdscreen-capture-ios.md詳細
moq-kit
Media over QUIC ライブストリーミング向けのネイティブ Swift および Kotlin SDK です。リレーに接続し、ブロードキャストを検出し、カタログで記述されたストリームを 1 秒未満の遅延で再生し、カメラ/マイク/画面のトラックを公開し、生データトラックをやり取りできます。moq-ffi(Luke Curley の moq-dev/moq)から生成された UniFFI バインディングをラップしています。対象は moq-lite プロトコルであり、IETF の moq-transport ドラフトではありません。
要件: iOS 16+ / macOS 13+(ビルドには Xcode 16+)/ Android minSdk 29、compileSdk 35。接続先となる moq-lite リレー。リファレンスは iOS と Android を対象としています。macOS は Swift パッケージで宣言されていますが、ここではテストされていません。
moq-kit はプレビュー段階で積極的に開発中です。公開 API、パッケージング、コーデックの対応状況はリリース間で変更される可能性があります。バージョンを正確に固定し、API の形状を想定するのではなく、インストール済みの成果物でシグネチャを確認してください。
インストール — iOS(SPM):
.package(url: "https://github.com/software-mansion-labs/moq-kit", exact: "<latest release>")
// product: .product(name: "MoQKit", package: "moq-kit")
インストール — Android(Maven Central):
dependencies { implementation("com.swmansion.moqkit:moqkit:<latest release>") }
https://github.com/software-mansion-labs/moq-kit/releases から <latest release> を入力してください。どちらも意図的にバージョンを正確に固定しています。プレビューリリースが公開されるたびに追従する場合に限り、SPM の指定を緩めてください(.upToNextMinor(from:))。
プラットフォームの判定
リファレンスファイルはプラットフォームごとに分かれています。対象プロジェクトに該当するものだけを参照してください。
Package.swift、*.xcodeproj、またはPodfileがある場合 →*-ios.mdのリファレンス(Swift)。com.swmansion.moqkitを含むbuild.gradle/build.gradle.ktsがある場合 →*-android.mdのリファレンス(Kotlin)。- クロスプラットフォームの作業(機能の同等性、両方の SDK 上に構築するバインディング)→ 両方を参照してください。API は意図的に同等に保たれています。
基本的な考え方
Sessionはリレーへの QUIC 接続を 1 つ所有し、購読と公開の両方を担います。connect()は 1 回だけ呼び出します。セッションは一度限りです。close()の後、または接続エラーの後は、新しいSessionを作成してください。Swift ではactorで、connect() async throwsを使います。Kotlin では必要に応じてparentScopeを指定できます(デフォルトは内部の IO スコープです。キャンセル時にセッションを終了させるには、viewModelScope/lifecycleScopeを渡してください)。サスペンド関数はconnect()のみです。- 購読:
session.subscribe(prefix:)→Broadcastのストリーム →broadcast.catalogs()→ 再生可能なトラックを選択 →Player(catalog:videoTrackName:audioTrackName:targetBuffering:)→play()。レンダリング方法はプラットフォームごとに異なります。iOS ではplayer.videoLayer(AVSampleBufferDisplayLayer)をビュー階層に追加し、Android では所有するSurfaceをplayer.setSurface(surface)に渡します。 - 公開: キャプチャソース(
CameraCapture、MicrophoneCapture、MultiCameraCapture、ScreenCapture)を作成し、自分で開始します →Publisher()とaddVideoTrack/addAudioTrack/addDataTrack→session.publish(path, publisher)→publisher.start()。Publisher は 1 回しか使用できません。start()の前にすべてのトラックを追加してください。 - データトラック(
DataTrackEmitter→addDataTrack、受信にはbroadcast.subscribeTrack(name:)を使用)はメディアカタログを介しません。Publisher と Subscriber は、帯域外でトラック名を合意しておきます。 - すべての状態を監視できます。
session.state、publisher.state/events、プレイヤーのイベント/統計情報などです。Kotlin ではStateFlow/SharedFlow/coldFlowを使用します。Swift ではセッション/Publisher の状態と診断情報にAsyncStreamを使用し、プレイヤーのイベント/統計情報にはコールバック登録(subscribeEvents/subscribeStats)を使用します。
クイックスタート — 視聴
iOS:
let session = Session(url: "http://localhost:4443/anon")
try await session.connect()
let subscription = try await session.subscribe(prefix: "live")
for await broadcast in subscription.broadcasts {
for await catalog in broadcast.catalogs() {
let video = catalog.playableVideoTracks.first?.name
let audio = catalog.playableAudioTracks.first?.name
guard video != nil || audio != nil else { continue }
let player = try await MainActor.run {
try Player(catalog: catalog, videoTrackName: video, audioTrackName: audio)
}
try await player.play() // render via player.videoLayer — see playback-ios.md
}
}
Android:
scope.launch {
val session = Session(url = "http://localhost:4443/anon", parentScope = scope)
session.connect()
session.subscribe(prefix = "live").broadcasts.collect { broadcast ->
broadcast.catalogs().collect { catalog ->
val video = catalog.playableVideoTracks.firstOrNull()?.name
val audio = catalog.playableAudioTracks.firstOrNull()?.name
if (video == null && audio == null) return@collect
val player = Player(catalog, video, audio, parentScope = scope)
player.setSurface(surfaceView.holder.surface)
player.play()
}
}
}
意図的に最小限の例にしています。ネストしたループは一度に 1 つのブロードキャストを処理します。実際のコードではブロードキャストごとに Task/launch を起動してください。また、カタログが更新されたら、新しいプレイヤーを作成する前に以前のプレイヤーを必ず終了してください(プレイヤーはネイティブハンドルを保持します)。
クイックスタート — ライブ配信
両プラットフォームで手順は同じ 4 つですが、順序が重要です(ルール 3)。
iOS:
try await camera.start() // 1. captures — publisher.start() won't do this
let publisher = try Publisher() // 2. every track before start()
publisher.addVideoTrack(name: "camera", source: camera, config: VideoEncoderConfig())
try await session.publish(path: "live/ios", publisher: publisher) // 3. register
try await publisher.start() // 4. go live
Android:
camera.start(context, lifecycleOwner)
val publisher = Publisher()
publisher.addVideoTrack(name = "camera", source = camera, config = VideoEncoderConfig())
session.publish(path = "live/android", publisher = publisher)
publisher.start()
キャプチャ、プレビュー、エンコーダー設定、権限については、公開のリファレンスを参照してください。
共通概念(両プラットフォーム)
- カタログ:
broadcast.catalogs()はカタログの更新をストリームで配信します。各要素は直前の要素を置き換えます。ストリームが終了したら、ブロードキャストが消失したことを意味するため、オフラインとして扱います。正常に完了するのではなく、例外をスローして終了することがよくあります。消失したプロデューサーはcollectの外側にMoqException$Mux: json: remote error: code=…として現れるため、例外と正常な完了は同じように処理してください(両方のデモアプリと react-native-moq がこの処理を行います)。生のリストではなく、必ずplayableVideoTracks/playableAudioTracksから選択してください(AndroidとiOSのオーディオはデバイスでフィルタリングされます。iOSのビデオはコーデックファミリーを認識するだけで再生可能と判定されます)。除外理由はトラックごとのisPlayable/unsupportedReasonで確認できます。これらは基底のTrackInfoではなく、具体的なVideoTrackInfo/AudioTrackInfoにあります。ビデオのコーデック文字列は"avc1"、"hev1"、"av01"、オーディオは"mp4a.40.2"/"aac"(Androidが公開するトラックは"aac"を通知します)、"opus"です。AV1は再生専用です。どちらのプラットフォームもAV1を公開しません。また、Apple側でのデコードにはiPhone 15 Proクラスのハードウェアが必要で、一般的な機能ではありません。 - ブロードキャストのパスはプレフィックスを基準にします: プレフィックス
"live"でサブスクライブすると、ブロードキャストlive/gameはpath: "game"として通知されます。完全なパスを確認するには""でサブスクライブしてください。 Playerの3つの監視チャネル — 混同しないでください。イベント(ライフサイクル上の節目 —player.init、playback.start、track.ready、track.switch、track.stall.start、rebuffer.end、decode.error— ワイヤー値。KotlinではPlayerEventNameenum に対応します。再送されません)、統計情報(約1秒間隔のPlaybackStats— レイテンシー、fps、ビットレート、StallStats、timeToFirst、バッファー深度、ドロップ、切り替え)、診断情報(型付きのフレーム単位のPipelineEvent—DropStage/DropReasonを含むframeDropped、stallStarted、latencySampleなど — 最大256件で、古いものから破棄されます — Androidではすべての呼び出し元に同じ共有フローが渡されます — 再生をバックプレッシャーで妨げることはありません。ログ記録やテレメトリー専用です)。- パブリッシャーの状態:
Idle / Publishing / Stopped / Error(message)— ただし、Androidは実際にはErrorにならず、セッションの終了にも反応しません。Androidのパブリッシャーはセッションが消失した後もPublishingのままになるため、ブロードキャストが停止したことを示す唯一のシグナルはセッション自体の状態です。失敗を示すUIはイベントに連動させてください:TrackStarted/TrackStopped/ トラックごとのエラー(KotlinではTrackError(name, message)、Swiftでは.error(name, message))。トラックごとのPublishedTrack.state(Idle / Starting / Active / Stopped)とPublishedTrack.stop()を使うと、個別のトラックを終了できます。session.unpublish(path)はpublisher.stop()を呼び出します。 - 生のトラックサブスクリプションで使う
TrackDelivery:monotonic(デフォルト)は遅れて届いたグループをスキップします。最新値を優先するライブ状態に適しています。arrivalはすべてのグループを順番に配信します。チャットなど、データの欠落が許されない用途に適しています。
重要なルール
- セッション、パブリッシャー、プレーヤーは使い捨てです。
Sessionは一度だけ接続できます(再利用するとalreadyConnected/IllegalStateExceptionが発生します)。close()の後は新しいものを作成してください。停止したPublisherは再起動できません。どちらのプラットフォームでも、破棄済みのPlayerを再起動しないでください。新しいものを作成してください。 - メディアトラック名はローカルのラベルであり、カタログ名はマルチプレクサーによって生成されます。
addVideoTrack/addAudioTrackに渡すname:(デフォルトは"video"/"audio")は、PublishedTrackとパブリッシャーのイベントにラベルを付けるだけです。サブスクライバーはCatalog.videoTracks/audioTracksから実際のトラック名を取得する必要があります。例外:データトラック —addDataTrack(name:)(デフォルトは"data")の名前は、サブスクライバーがsubscribeTrack(name:)に渡す名前になります。 publisher.start()はキャプチャを開始しません。CameraCapture/MicrophoneCaptureなどは自分で、次の順序で開始してください:キャプチャを開始 → トラックを追加 →session.publish(path, publisher)→publisher.start()。- 使用前にコーデックを確認してください。 パブリッシュ側では
VideoEncoderConfig.supportedCodecs()/AudioEncoderConfig.supportedCodecs()/.isSupported、再生側ではcatalog.playableVideoTracks/playableAudioTracks/トラックごとのisPlayableを使用してください。サポートされていない設定では例外(SessionError.unsupportedCodec/UnsupportedCodecException)が発生します。最もよくテストされているのは H.264 と AAC/Opus の組み合わせです。デフォルトでは H.264 を使用してください。Opus では 48 kHz が必要です。Android ではMicrophoneCapture(sampleRate = …)をエンコーダー設定と一致させてください。iOS にはマイクのサンプルレートを指定する設定がなく、内部でリサンプリングされます。 - プレーヤーの起動イベントは再送されません。
play()の前に購読してください。iOS ではplayer.subscribeEvents { ... }を使用します(返されたサブスクリプションは必ず保持してください。破棄時にキャンセルされます)。Android ではCoroutineStart.UNDISPATCHEDを指定してplayer.eventsを収集します。どちらのプラットフォームでも、player.initは構築中に発生するため、観測できません。このルールに従えば、track.ready/playback.startを確実に受け取れます。 - 権限とオーディオセッションはアプリ側で管理します。 iOS:
NSCameraUsageDescription、NSMicrophoneUsageDescription、NSLocalNetworkUsageDescription(ローカルリレー用)を設定し、AVAudioSession(配信には.playAndRecord、視聴には.playback)も自分で設定してください。MicrophoneCaptureは設定しません。Android:CAMERA/RECORD_AUDIOを宣言してリクエストしてください(権限がない場合、MicrophoneCapture.start()は何もせずに終了します)。また、INTERNETと画面キャプチャ用の MediaProjection フォアグラウンドサービス設定も必要です。 - 参照を保持し、後片付けをしてください。 iOS:
Session、BroadcastSubscription、Publisher、DataTrackEmitter、Player、およびすべてのイベント/統計サブスクリプションへの強参照を保持してください。for awaitループを実行するTaskハンドルも保持してください(AsyncStreamも単一コンシューマーです)。Android:ほぼすべてがAutoCloseableで、参照カウントされたネイティブハンドルを保持します。各Broadcast、BroadcastSubscription、TrackSubscription、MediaTrack、AudioDataStream、Playerをclose()してください(1つでもリークするとブロードキャストが存続します)。broadcasts、objects、frames、audioの各フローは、単一のコレクターでのみ使用できます。 - iOS の
Playerは@MainActorです — メインアクター上で構築し、操作してください。iOS のPublisher.stop()はエンコーダーのフラッシュを同期的に行います — メインスレッド以外から呼び出してください(Task.detachedを使用します。PublisherはSendableではないため、Swift 6 の厳密な並行性チェックではnonisolated(unsafe)を使用するか、Swift 5 モードのターゲットが必要です)。 - 1つのセッションが同じプレフィックスをパブリッシュしてサブスクライブすると、自分自身のブロードキャストも受信します — 報告されるパスはプレフィックス相対であることに注意して除外してください。サブスクライブのプレフィックスを取り除いたパスを、パブリッシュのパスと比較します。1つのプロセス内で2つの
Session(パブリッシュ用とサブスクライブ用)を使うこともできます。セッションは一度しか使えないため、別々にしておくと、パブリッシュ側のトランスポート障害や破棄が再生に影響するのを防げます。 - レイテンシは実行中に調整できます:
targetBufferingのデフォルトは 100 ms です。player.updateTargetLatency(_:)を使って再生中に調整できます。同じメディアトラックの2つ目のコンシューマー(別のPlayerやAudioDataStream)は、最初のサブスクライバーの上流バッファリングを再利用するため、個別にバッファリングを減らすことはできません。 - カスタムソースはパブリッシャーと同じクロックドメインを共有する必要があります。
Publisherは最初のトラックのフレームを基準にした1つのエポックを使って、すべてのトラックにタイムスタンプを付けます。そのため、独自のゼロ始まりのタイムラインを出力するソースはマイクと同期がずれたり、動画がはるか未来にスケジュールされたりします。iOS:ホストクロック上の PTS(CMClockGetTime(CMClockGetHostTimeClock()))を使用します。これは AVFoundation のキャプチャがすでに使用しているドメインです。Android:PCM にはSystemClock.elapsedRealtimeNanos() / 1_000(MicrophoneCaptureが使用する値)を使用し、エンコーダーサーフェスのプレゼンテーションタイムスタンプにも同じクロックを使用してください。
症状 → 原因
| 症状 | 考えられる原因 |
|---|---|
| ブロードキャストがまったく届かない | リレー URL に名前空間パス(ローカルでは /anon)がありません。リレーが NotFound(code=13)を返すため、何も表示されません。または、セッションが接続状態になる前に subscribe() が実行されています |
| パブリッシュは「成功」するが、サブスクライバーにメディアが届かない | キャプチャが開始されていません。publisher.start() はキャプチャを開始しません(ルール 3) |
| マイクの音声が聞こえない | iOS:AVAudioSession が録音可能なカテゴリに設定されていません。Android:RECORD_AUDIO がなく、MicrophoneCapture.start() はエラーを出さずに何もせず終了します |
| Android:音声は再生されるが、映像が真っ黒 | サーフェスが設定されていないか、surfaceDestroyed 後の古いサーフェスが残っています。そこで null にし、isValid のサーフェスだけを再適用してください |
| iOS:音声は再生されるが、映像が真っ黒 | player.videoLayer がレイヤー階層に追加されていないか、サイズが設定されていません。階層に追加されて play() が実行されるまで描画されません |
track.ready / playback.start が一度も発生しない | play() の後にサブスクライブしたか、(iOS)返された PlayerEventSubscription を保持していません |
| データトラックの送信が消える | publisher.start() の前、または stop() の後に送信しています。どちらのプラットフォームでも何も通知されずに破棄されるため、パブリッシャーの状態に応じて送信してください |
| ネットワークが一時的に途切れた後、何も復旧しない | セッションは一度きりです。Android では、要求していない Closed が失敗を示します。新しい Session を作成してください。ただし、Error(message) が表示されたら記録してください。問題の説明が記載されるのはこれだけです |
リモートエラーに code=N だけが含まれる | コードは moq_net::Error の判別値です。13 は NotFound、24 は Dropped(「プロデューサーが完了せずに破棄された」)、25 は Closed、26 は Lagged です。全一覧は moq-net の error.rs(moq-kit のチェックアウトでは vendor/moq/rs/moq-net/src/error.rs)にあります |
| iOS:配信を停止すると UI が引っかかる | Publisher.stop() はエンコーダーを同期的にフラッシュします。メインスレッド以外で呼び出してください |
| パブリッシャー自身のブロードキャストがサブスクライバー一覧に表示される | 同じセッションで同じプレフィックスをパブリッシュおよびサブスクライブしています(ルール 9)。プレフィックスからの相対パスでフィルタリングしてください |
| カスタムソース:音声と映像がずれる、または音声の再生中に映像がフリーズする | フレームのタイムスタンプが、パブリッシャーの他のトラックと同じクロックドメインにありません(ルール 11) |
Android:play() が IllegalArgumentException をスローする | デコードできない選択済みトラックに対する UnsupportedCodecException(サブクラス)です。先にこちらを捕捉してください |
| Android:画面キャプチャが始まった後、停止する | mediaProjection フォアグラウンドサービスがまだ起動していなかったか、同意結果のコールバック以外で起動されました(Android 14 以降では拒否されます) |
| デバイスやエミュレーターからローカルリレーに接続できない | localhost はデバイス自体を指します。Android エミュレーターでは 10.0.2.2 を、物理デバイスではホストの LAN IP を使用してください |
デバッグ
リレー、サブスクリプション、パブリッシュの失敗は、通常、状態に反映される前にネイティブトランスポートのログに現れます。他の API を操作する前に、ログレベルを上げてください。
KitLogger.setNativeLogLevel("debug") // "error"/"warn"/"info"/"debug"/"trace"; only the first call takes effect
NativeLogging.setLogLevel("debug") // same levels; invalid values are ignored with a warning
Android のネイティブログは MoQNative タグの logcat に出力されますが、ネイティブライブラリが android-logcat cargo 機能(moq-ffi → moq-native → tracing-android)を有効にしてビルドされている場合に限ります。有効でない場合、tracing サブスクライバーは stderr に書き込みますが、Android はこれを破棄します。setLogLevel の呼び出しが成功した後も MoQNative の行が出ない場合、使用中のアーティファクトではこの機能が無効になっています。その場合は、セッション/パブリッシャーの状態とプレーヤーイベントを確認してください。
iOS では、サブシステム com.swmansion.MoQKit(カテゴリは session、transport、catalog、media、player、publish)の os_log 経由でもログが出力されます。Console.app でこのサブシステムをフィルタリングしてください。フレーム単位のドロップ/停止の詳細は、これらのログではなくプレーヤーの診断チャンネルに出力されます。
リレーに対するテスト
moq-kit のチェックアウトで(Rust と mise が必要です):mise run relay:run でローカルの moq-lite リレー http://localhost:4443/anon が起動します。mise run media:to-fmp4 で動画を CMAF fMP4 に変換し、mise run stream:file --input file.mp4 でそれをループ再生してリレーにブロードキャストします。実機からは localhost の代わりにマシンの LAN IP を使用し、Android エミュレーターからは 10.0.2.2 を使用してください(これはホストのループバックを指すエミュレーター固有のエイリアスです。エミュレーター自身の localhost はエミュレーター自体を指します。iOS シミュレーターはホストを共有します)。リレー URL には名前空間パス(ローカルでは /anon)を含める必要があります。パスが間違っているか抜けていると、ブロードキャストは表示されません(リレーの NotFound、code=13)。デモアプリ(examples/ios/demo/MoQDemo、examples/android/demo/MoQDemo)が標準的な統合例です。
リファレンス
| タスク | iOS(Swift) | Android(Kotlin) |
|---|---|---|
| 再生:セッション、検出、Player、レンダリング、イベント/統計の連携 | references/playback-ios.md | references/playback-android.md |
| パブリッシング:カメラ、マルチカメラ、マイク、プレビュー、エンコーダー設定、権限 | references/publishing-ios.md | references/publishing-android.md |
| 画面共有 | references/screen-capture-ios.md | references/screen-capture-android.md |
| データトラック、圧縮メディアの生データ(MediaTrack)、デコード済み PCM(AudioDataStream) | references/data-tracks-ios.md | references/data-tracks-android.md |