要求並取得必要權限後,應用程式就能存取語音眼鏡或顯示型眼鏡的硬體。如要存取眼鏡的硬體 (而非手機的硬體),關鍵在於使用投影情境。
取得投影內容的主要方式有兩種,取決於程式碼的執行位置:
如果程式碼在投影活動中執行,則取得投影內容
如果應用程式的程式碼是從投影活動中執行,其活動內容本身就已是投影內容。在這種情況下,該活動中的呼叫已可存取眼鏡的硬體。
取得在「電話」應用程式元件中執行的程式碼投影內容
如果應用程式中投影活動以外的部分 (例如手機活動或服務) 需要存取眼鏡的硬體,則必須明確取得投影結構定義。方法是使用 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 和其他相關權限是由手機控制,而非連結裝置 (例如語音眼鏡或顯示型眼鏡)。
請按照下列步驟要求權限:
在應用程式的資訊清單檔案中宣告下列權限:
在執行階段,使用標準 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
程式碼重點
- 請根據用途選擇合適的格式。
ImageAnalysis則為YUV_420_888。 - 如果是
VideoCapture,請改用Class超載:getOutputSizes(MediaRecorder::class.java)和getOutputMinFrameDuration(MediaRecorder::class.java, size)。 - 請指定
maxFps大於minFps的範圍 (例如minFps*2),而非將兩個界限設為相同的值 (例如Range(minFps, minFps))。下限 (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,因為如果投影活動是最近啟動的元件,應用程式內容可能會錯誤傳回眼鏡的內容。