# メディアデバイス

## 概要

`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<List<AudioInputDevice>> 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<List<AudioOutputDevice>> 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<List<VideoInputDevice>> 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<List<VideoInputFormat>> 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<LocalMediaStream> 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<LocalAudioTrack> 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: <String>['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();
```
