投影されたコンテキストを使用して、オーディオ グラスとディスプレイ グラスのハードウェアにアクセスする

対象となる XR デバイス
このガイダンスは、次のようなタイプの XR デバイス向けのエクスペリエンスを構築する際に役立ちます。
オーディオ&
ディスプレイ グラス

必要な権限をリクエストして付与されると、アプリはオーディオ グラスまたはディスプレイ グラスのハードウェアにアクセスできるようになります。(スマートフォンのハードウェアではなく)グラスのハードウェアにアクセスするための鍵は、投影されたコンテキストを使用することです。

投影されたコンテキストを取得するには、コードの実行場所に応じて主に 2 つの方法があります。

コードが投影されたアクティビティで実行されている場合は、投影されたコンテキストを取得します。

アプリのコードが投影されたアクティビティ内から実行されている場合、そのアクティビティ コンテキストはすでに投影されたコンテキストになっています。このシナリオでは、そのアクティビティ内で行われた呼び出しは、すでにグラスのハードウェアにアクセスできます。

電話アプリ コンポーネントで実行されているコードの投影コンテキストを取得する

投影されたアクティビティの外部にあるアプリの一部(電話アクティビティやサービスなど)がグラスのハードウェアにアクセスする必要がある場合は、投影されたコンテキストを明示的に取得する必要があります。これを行うには、createProjectedDeviceContext メソッドを使用します。

@RequiresApi(Build.VERSION_CODES.BAKLAVA)
@OptIn(ExperimentalProjectedApi::class, ExperimentalCoroutinesApi::class)
private fun monitorProjectedConnectivity(activity: ComponentActivity) {
    activity.lifecycleScope.launch {
        // Before creating a projected context, check to see if the projected device is connected.
        // While this method returns true, the projected context remains valid.
        ProjectedContext.isProjectedDeviceConnected(activity, coroutineContext)
            .collectLatest { isConnected ->
                if (isConnected) {
                    // From a phone Activity or Service, get a context for the audio and display glasses.
                    // Re-initialize on reconnect: Obtain another context instance.
                    val projectedContext = try {
                        ProjectedContext.createProjectedDeviceContext(activity)
                    } catch (e: IllegalStateException) {
                        Log.e(TAG, "Failed to create projected context", e)
                        return@collectLatest
                    }

                    // Use the projectedContext to initialize system services (e.g., CameraManager).
                    Log.i(TAG, "Projected device connected. Initializing hardware...")
                } else {
                    // The projected context is destroyed when the device disconnects.
                    // Clean up on disconnect: Listen for 'false' and release resources.
                    Log.i(TAG, "Projected device disconnected. Cleaning up hardware resources...")
                }
            }
    }
}

有効性を確認する

createProjectedDeviceContext 呼び出しを ProjectedContext.isProjectedDeviceConnected 内にラップします。このメソッドが true を返している間、投影されたコンテキストは接続されたデバイスに対して有効なままであり、スマートフォン アプリのアクティビティまたはサービス(CameraManager など)はグラスのハードウェアにアクセスできます。

接続解除時にクリーンアップする

投影されたコンテキストは、コネクテッド デバイスのライフサイクルに関連付けられているため、デバイスが切断されると破棄されます。デバイスが接続解除されると、ProjectedContext.isProjectedDeviceConnected は false を返します。アプリはこの変更をリッスンし、その投影されたコンテキストを使用してアプリが作成したシステム サービス(CameraManager など)やリソースをクリーンアップする必要があります。

再接続時に再初期化する

グラスが再接続すると、アプリは createProjectedDeviceContext を使用して別の投影コンテキスト インスタンスを取得し、新しい投影コンテキストを使用してシステム サービスまたはリソースを再初期化できます。

グラスのマイクで音声を録音する

Bluetooth ハンズフリー プロファイル(HFP)を使用して、グラスから音声を録音できます。このモードでは、グラスは標準のヘッドセット プロファイルと Advanced Audio Distribution Profile(A2DP)プロファイルを使用してスマートフォンに接続し、一般的な Bluetooth 周辺機器のように機能します。アプリがすでに標準の Bluetooth 録音用に設計されている場合は、この方法を使用することで、追加の開発なしでグラスから音声を録音できます。

Bluetooth HFP を使用して音声を録音する

Bluetooth HFP を使用して音声を録音するには、まず必要な実行時の権限をリクエストし、次に AudioManager API を使用して音声を録音します。詳しくは、以下のセクションをご覧ください。

権限をリクエスト

標準の Bluetooth オーディオ デバイスと同様に、RECORD_AUDIO、BLUETOOTH_CONNECT、その他の関連する権限は、接続されたデバイス(オーディオ グラスやディスプレイ グラスなど)ではなく、スマートフォンによって制御されます。

権限をリクエストする手順は次のとおりです。

  1. アプリのマニフェスト ファイルで次の権限を宣言します。

  2. 標準の Android 権限フローを使用して、実行時に RECORD_AUDIO 権限と BLUETOOTH_CONNECT 権限の両方をリクエストします。

AudioManager を使用して音声をルーティングする

ユーザーがアプリに必要な実行時の権限を付与したら、AudioManager API を使用して、通信デバイスを TYPE_BLUETOOTH_SCO に設定し、Bluetooth HFP 経由で音声をルーティングします。これにより、Bluetooth ペリフェラルから音声を取得するようシステムに指示します。

val audioManager = context.getSystemService(AudioManager::class.java) ?: return
val hfpDevice = audioManager.availableCommunicationDevices
    .firstOrNull { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO } ?: return

// HFP input is limited to 16 kHz, with mono or stereo channel configurations.
val sampleRate = 16_000
val channelMask = AudioFormat.CHANNEL_IN_MONO
val encoding = AudioFormat.ENCODING_PCM_16BIT

// Use a small multiple of the minimum buffer size to keep chunks short for low latency.
val minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelMask, encoding)
if (minBufferSize <= 0) return

val audioRecord = AudioRecord.Builder()
    .setAudioSource(MediaRecorder.AudioSource.VOICE_COMMUNICATION)
    .setAudioFormat(
        AudioFormat.Builder()
            .setSampleRate(sampleRate)
            .setChannelMask(channelMask)
            .setEncoding(encoding)
            .build()
    )
    .setBufferSizeInBytes(minBufferSize * 2)
    .build()

try {
    // Route voice communication audio through the Bluetooth device.
    if (!audioManager.setCommunicationDevice(hfpDevice)) return
    audioRecord.startRecording()
    // Read audio with audioRecord.read() on a background thread.
} finally {
    // Stop and release when done.
    if (audioRecord.recordingState == AudioRecord.RECORDSTATE_RECORDING) audioRecord.stop()
    audioRecord.release()
    audioManager.clearCommunicationDevice()
}

グラスのカメラで画像を撮影する

グラスのカメラで画像を撮影するには、アプリに適したコンテキストを使用して、CameraX の ImageCapture ユースケースをセットアップし、グラスのカメラにバインドします。

private fun setUpGlassesCamera(activity: ComponentActivity) {
    activity.lifecycleScope.launch {
        // Before creating a projected context, check to see if the projected device is connected.
        ProjectedContext.isProjectedDeviceConnected(activity, coroutineContext)
            .collectLatest { isConnected ->
                if (isConnected) {
                    // 1. Get the CameraProvider using the projected context.
                    // When using the projected context, DEFAULT_BACK_CAMERA maps to the audio and display glasses' camera.
                    val projectedContext = try {
                        ProjectedContext.createProjectedDeviceContext(activity)
                    } catch (e: IllegalStateException) {
                        Log.e(TAG, "Projected context could not be created", e)
                        return@collectLatest
                    }

                    val cameraProviderFuture = ProcessCameraProvider.getInstance(projectedContext)

                    cameraProviderFuture.addListener({
                        val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
                        val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA

                        // 2. Check for the presence of a camera.
                        if (!cameraProvider.hasCamera(cameraSelector)) {
                            Log.w(TAG, "The selected camera is not available.")
                            return@addListener
                        }

                        // 3. Query supported streaming resolutions dynamically using Camera2 Interop.
                        val cameraInfo = cameraProvider.getCameraInfo(cameraSelector)
                        val map = Camera2CameraInfo.from(cameraInfo).getCameraCharacteristic(
                            CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP
                        )
                        val supportedResolutions = map?.getOutputSizes(ImageFormat.JPEG)?.toList() ?: emptyList()
                        Log.i(TAG, "Supported resolutions for JPEG: ${supportedResolutions.joinToString()}")

                        // 4. Dynamically select the best target resolution from the queried list.
                        val preferredResolution = Size(1920, 1080)
                        val dynamicTargetResolution = when {
                            // Case A: The preferred resolution is explicitly supported by the glasses.
                            supportedResolutions.contains(preferredResolution) -> preferredResolution

                            // Case B: Find the highest supported 16:9 resolution as a fallback.
                            else -> {
                                val targetAspectRatio = 16.0 / 9.0
                                val tolerance = 0.01
                                supportedResolutions
                                    .filter { abs((it.width.toDouble() / it.height) - targetAspectRatio) < tolerance }
                                    .maxByOrNull { it.width * it.height }
                                    // Case C: Absolute fallback if no 16:9 resolutions are found.
                                    ?: supportedResolutions.firstOrNull()
                                    ?: preferredResolution
                            }
                        }
                        Log.i(TAG, "Selected target resolution: $dynamicTargetResolution")

                        val resolutionStrategy = ResolutionStrategy(
                            dynamicTargetResolution,
                            ResolutionStrategy.FALLBACK_RULE_CLOSEST_LOWER
                        )
                        val resolutionSelector = ResolutionSelector.Builder()
                            .setResolutionStrategy(resolutionStrategy)
                            .build()

                        // 5. Initialize the ImageCapture use case with options.
                        val imageCapture = ImageCapture.Builder()
                            // Optional: Configure resolution, format, etc.
                            .setResolutionSelector(resolutionSelector)
                            .build()

                        try {
                            // Unbind use cases before rebinding.
                            cameraProvider.unbindAll()

                            // Bind use cases to camera using the Activity as the LifecycleOwner.
                            cameraProvider.bindToLifecycle(
                                activity,
                                cameraSelector,
                                imageCapture
                            )
                        } catch (exc: Exception) {
                            Log.e(TAG, "Use case binding failed", exc)
                        }
                    }, ContextCompat.getMainExecutor(activity))
                }
            }
    }
}

コードに関する主なポイント

  • 投影されたデバイス コンテキストを使用して、ProcessCameraProvider のインスタンスを取得します。
  • 投影されたコンテキストのスコープ内で、カメラを選択すると、グラスのメインの外向きカメラが DEFAULT_BACK_CAMERA にマッピングされます。
  • バインド前のチェックでは、cameraProvider.hasCamera(cameraSelector) を使用して、選択したカメラがデバイスで利用可能であることを確認してから、処理を続行します。
  • Camera2CameraInfo で Camera2 Interop を使用して、基盤となる CameraCharacteristics#SCALER_STREAM_CONFIGURATION_MAP を読み取ります。これは、サポートされている解像度の高度なチェックに役立ちます。
  • カスタム ResolutionSelector は、ImageCapture の出力画像解像度を正確に制御するために構築されています。
  • カスタム ResolutionSelector で構成された ImageCapture ユースケースを作成します。
  • ImageCapture ユースケースをアクティビティのライフサイクルにバインドします。これにより、アクティビティの状態に基づいてカメラの開閉が自動的に管理されます(たとえば、アクティビティが一時停止されたときにカメラが停止されます)。
  • シングルストリーム ハードウェアの構成: メガネカメラのパイプラインは、一度に 1 つのアクティブなストリームに制限されます。ImageCapture が単独でバインドされている場合、CameraX はデフォルトでフォーカスと測光用の内部繰り返しストリーム(MeteringRepeating)をアタッチします。これにより、単一ストリーム パイプラインが停止します。プレビュー ストリームなしで ImageCapture 自体をバインドするには、CameraXConfig.Builder で setRepeatingStreamForced(false) を設定して、強制的な繰り返しストリームを無効にします(たとえば、Application クラスに CameraXConfig.Provider を実装するか、初期化前に ProcessCameraProvider を構成します)。

グラスのカメラをセットアップしたら、CameraX の ImageCapture クラスを使用して画像をキャプチャできます。takePicture を使用して画像をキャプチャする方法については、CameraX のドキュメントをご覧ください。

グラスのカメラでストリームをキャプチャする

画像の代わりにストリームをキャプチャするには、画像のキャプチャの例の ImageCapture コンポーネントを VideoCapture コンポーネントまたは ImageAnalysis コンポーネントに置き換え、キャプチャ ロジックを変更します。

これらの API とその使用方法について詳しくは、CameraX の動画キャプチャと CameraX の画像分析のドキュメントをご覧ください。

解像度とフレームレートを選択する

デバイスにクエリを実行して、サポートされている解像度と、それに対応する最大フレームレートを確認する必要があります。次の表に、アプリのユースケースに応じて表示される値の例を示します。バッテリー電力を節約し、グラスの熱を抑えるには、ユースケースに適した最も低い解像度とフレームレートを選択します。

ユースケース 解決策 フレームレート
テキスト認識(OCR)または世界認識 1,920 x 1,080 1 FPS
AI 動画ストリーミング(マルチモーダル アシスタント) 1024 x 768 1 FPS
ローカライズとビジュアル ポジショニング(VPS) 640 x 360 または 640 x 480 10 FPS
ビデオ通話 640 x 360 または 640 x 480 24 fps

各解像度の最大フレームレートをクエリする

CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES は、デバイスの全体的なフレームレートの範囲を報告します。これは、特定の解像度がサポートするストリーミング レートよりも高くなる可能性があります。オーディオ グラスとディスプレイ グラスの各解像度の実際の最大値を取得するには、最小フレーム時間を読み取り、StreamConfigurationMap.getOutputMinFrameDuration(format, size) を使用して FPS に変換します。

@androidx.annotation.OptIn(ExperimentalCamera2Interop::class)
fun getMaxFpsBySize(cameraInfo: CameraInfo, format: Int): Map<Size, Int> {
    val map = Camera2CameraInfo.from(cameraInfo).getCameraCharacteristic(
        CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP
    ) ?: return emptyMap()

    return map.getOutputSizes(format).associateWith { size ->
        val minFrameDurationNs = map.getOutputMinFrameDuration(format, size)
        if (minFrameDurationNs > 0) (1_000_000_000L / minFrameDurationNs).toInt() else 0
    }
}

次に、その値を使用して、ターゲット フレームレート範囲の minFps と maxFps の境界を計算します。下限(minFps)を、getOutputMinFrameDuration から計算された解像度の最大ストリーミング フレームレートに制限し、上限(maxFps)をキャッチアップ ウィンドウとして高く設定します。

val maxFpsBySize = getMaxFpsBySize(cameraInfo, ImageFormat.YUV_420_888)
val maxSupportedFps = maxFpsBySize[Size(640, 480)] ?: return ImageAnalysis.Builder().build()

// minFps is the target streaming frame rate and should be at most maxSupportedFps.
val minFps = minOf(desiredFps, maxSupportedFps)
// Set the upper bound to 2x minFps so the stream can catch up if frames are delayed.
val maxFps = minFps * 2

コードに関する主なポイント

  • ユースケースに合った形式を使用してください。ImageAnalysis の場合は YUV_420_888 です。
  • VideoCapture の場合は、代わりに Class オーバーロード(getOutputSizes(MediaRecorder::class.java) と getOutputMinFrameDuration(MediaRecorder::class.java, size))を使用します。
  • 両方の境界を Range(minFps, minFps) のように同じ値に設定するのではなく、maxFps が minFps より大きい範囲(minFps*2 など)を指定します。下限(minFps)は、ターゲット ストリーミング フレームレートを設定し、getOutputMinFrameDuration から計算された値以下にする必要があります。一方、上限(maxFps)は、遅延フレームのキャッチアップ ウィンドウとして機能し、永続的なレイテンシを防ぎます。たとえば、1 FPS ストリームの場合は [1,2] または [1,3]、24 FPS ストリームの場合は [24,30] または [24,48] です。

フレームレートを構成する

CameraX のユースケースに基づいてフレームレート範囲を構成するには:

// Configure Resolution Strategy for the stream
val resolutionStrategy = ResolutionStrategy(
    size,
    ResolutionStrategy.FALLBACK_RULE_CLOSEST_LOWER
)
val resolutionSelector = ResolutionSelector.Builder()
    .setResolutionStrategy(resolutionStrategy)
    .build()

// Create and return the ImageAnalysis builder with proper frame rate ranges
// ImageAnalysis: OCR, AI streaming, and VPS
val analysisBuilder = ImageAnalysis.Builder()
    .setResolutionSelector(resolutionSelector)

Camera2Interop.Extender(analysisBuilder)
    .setCaptureRequestOption(
        CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE,
        Range(minFps, maxFps)
    )
return analysisBuilder.build()

この例では、VideoCapture と ImageAnalysis の両方の構成を示していますが、アプリがサポートするユースケースを実装できます。

ストリームの動作を理解する

キャプチャ ストリームを操作する際は、次のシステム動作に注意してください。

  • サポートされていない解像度は拒否される: リストにないサイズをリクエストすると、Camera2 はストリームが開く前に構成を拒否します。
  • 最大値を超えるフレームレートをリクエストする: 解像度でサポートされている最大値を超える最小フレームレートをリクエストした場合、セッションは開きますが、グラスからのターゲット フレームレートはそのサポートされている最大値に制限され、上限は引き続きキャッチアップ レート制限として機能します。たとえば、640 x 480 で [30,60] をリクエストすると、ターゲット フレームレートは 24 FPS に固定されます([24,60] のように動作します)。
  • サーマル スロットリング: グラスが熱くなると、フレームレートは低下しますが、解像度は変わりません。フレームレートが低い場合や不均一な場合に対応できるようにアプリを構築します。たとえば、固定レートを想定するのではなく、フレームのタイムスタンプを確認します。

投影されたアクティビティからスマートフォンのハードウェアにアクセスする

投影されたアクティビティは、createHostDeviceContext(context) を使用してホストデバイス(スマートフォン)のコンテキストを取得することで、スマートフォンのハードウェア(カメラやマイクなど)にアクセスすることもできます。

@OptIn(ExperimentalProjectedApi::class)
private fun getPhoneContext(activity: ComponentActivity): Context? {
    return try {
        // From a projected Activity, get a context for the phone.
        ProjectedContext.createHostDeviceContext(activity)
    } catch (e: IllegalStateException) {
        Log.e(TAG, "Failed to create host device context", e)
        null
    }
}

ハイブリッド アプリ(モバイルとグラスの両方のエクスペリエンスを含むアプリ)でホストデバイス(スマートフォン)に固有のハードウェアまたはリソースにアクセスする場合は、アプリが正しいハードウェアにアクセスできるように、正しいコンテキストを明示的に選択する必要があります。

  • スマートフォン Activity の Activity コンテキスト、または ProjectedContext.createHostDeviceContext を使用して、スマートフォンのコンテキストを取得します。
  • getApplicationContext は使用しないでください。投影されたアクティビティが最後に起動されたコンポーネントであった場合、アプリケーション コンテキストがグラスのコンテキストを誤って返す可能性があるためです。