使用投影內容存取智慧音訊眼鏡和智慧螢幕眼鏡的硬體

適用的 XR 裝置
本指南可協助您為這類 XR 裝置打造體驗。
音訊和
顯示型眼鏡

要求並取得必要權限後,應用程式就能存取語音眼鏡或顯示型眼鏡的硬體。如要存取眼鏡的硬體 (而非手機的硬體),關鍵在於使用投影情境。

取得投影內容的主要方式有兩種,取決於程式碼的執行位置:

如果程式碼在投影活動中執行,則取得投影內容

如果應用程式的程式碼是從投影活動中執行,其活動內容本身就已是投影內容。在這種情況下,該活動中的呼叫已可存取眼鏡的硬體。

取得在「電話」應用程式元件中執行的程式碼投影內容

如果應用程式中投影活動以外的部分 (例如手機活動或服務) 需要存取眼鏡的硬體,則必須明確取得投影結構定義。方法是使用 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...")
                }
            }
    }
}

檢查有效性

使用 ProjectedContext.isProjectedDeviceConnected 包裝 createProjectedDeviceContext 呼叫。雖然這個方法會傳回 true,但投影情境對連結裝置仍有效,且手機應用程式活動或服務 (例如 CameraManager) 可以存取眼鏡硬體。

中斷連線時清除

投影情境會繫結至連結裝置的生命週期,因此會在裝置中斷連線時刪除。裝置中斷連線時,ProjectedContext.isProjectedDeviceConnected 會傳回 false。應用程式應監聽這項變更,並清除應用程式使用該投影內容建立的任何系統服務 (例如 CameraManager) 或資源。

重新連線時重新初始化

眼鏡重新連線後,應用程式可以使用 createProjectedDeviceContext 取得另一個投影情境例項,然後使用新的投影情境重新初始化任何系統服務或資源。

使用眼鏡的麥克風錄音

你可以使用藍牙免持設定檔 (HFP),透過眼鏡錄製音訊。 在此模式下,眼鏡會使用標準耳機和進階音訊傳輸設定檔 (A2DP) 設定檔連線至手機,運作方式與一般藍牙周邊裝置類似。如果應用程式已設計為標準藍牙錄音,您可以使用這個方法錄製眼鏡的音訊,不需額外開發。

使用藍牙 HFP 錄音

如要使用藍牙 HFP 錄製音訊,請先要求必要的執行階段權限,然後使用 AudioManager API 錄製音訊,如以下各節所述。

要求權限

與任何標準藍牙音訊裝置一樣,RECORD_AUDIO、BLUETOOTH_CONNECT 和其他相關權限是由手機控制,而非連結裝置 (例如語音眼鏡或顯示型眼鏡)。

請按照下列步驟要求權限:

  1. 在應用程式的資訊清單檔案中宣告下列權限:

  2. 在執行階段,使用標準 Android 權限流程要求 RECORD_AUDIO 和 BLUETOOTH_CONNECT 權限。

使用 AudioManager 轉送音訊

使用者授予應用程式必要的執行階段權限後,請使用 AudioManager API 將通訊裝置設為 TYPE_BLUETOOTH_SCO,透過藍牙 HFP 轉送音訊。這會指示系統從藍牙周邊裝置擷取音訊。

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),確認裝置上是否有選取的相機,然後再繼續操作。
  • 搭配使用 Camera2 Interop 和 Camera2CameraInfo 讀取基礎 CameraCharacteristics#SCALER_STREAM_CONFIGURATION_MAP,這有助於對支援的解析度進行進階檢查。
  • 自訂 ResolutionSelector 旨在精確控制 ImageCapture 的輸出圖片解析度。
  • 建立 ImageCapture 用途,並設定自訂 ResolutionSelector。
  • 將 ImageCapture 用途繫結至活動的生命週期。這會根據活動狀態自動管理相機的開啟和關閉 (例如在活動暫停時停止相機)。
  • 設定單一串流硬體:眼鏡相機管道一次只能有一個有效的串流。如果單獨繫結 ImageCapture,CameraX 預設會附加內部重複串流 (MeteringRepeating) 進行對焦和測光,導致單一串流管道停滯。如要單獨繫結 ImageCapture,而不使用預覽串流,請在 CameraXConfig.Builder 上設定 setRepeatingStreamForced(false),停用強制重複串流 (例如在 Application 類別中實作 CameraXConfig.Provider,或在初始化前設定 ProcessCameraProvider)。

設定好眼鏡的相機後,您可以使用 CameraX 的 ImageCapture 類別擷取圖片。請參閱 CameraX 說明文件,瞭解如何使用 takePicture 擷取圖片。

使用眼鏡的攝影機擷取串流

如要擷取串流而非圖片,請將擷取圖片範例中的 ImageCapture 元件替換為 VideoCapture 或 ImageAnalysis 元件,並變更擷取邏輯。

如要進一步瞭解這些 API 及其使用方式,請參閱 CameraX 影片擷取和 CameraX 圖片分析說明文件。

選擇解析度和影格率

您應查詢裝置,找出支援的解析度和對應的最大影格率。下表列出您可能會看到的值範例,具體取決於應用程式的用途。為節省電池電力並降低眼鏡溫度,請根據您的用途,選擇最低的解析度和影格率。

用途 解析度 影格速率
文字辨識 (OCR) 或環境感知 1920 x 1080 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

程式碼重點

設定影格率

如要根據 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
    }
}

在混合式應用程式 (同時包含行動裝置和眼鏡體驗的應用程式) 中,存取主機裝置 (手機) 專屬的硬體或資源時,必須明確選取正確的結構定義,確保應用程式能存取正確的硬體: