智能眼镜开发:眼镜侧收集音频

Android 如何采集AI眼镜麦克风:蓝牙音频路由与 AudioRecord 实战

AI 眼镜通常同时具备扬声器、麦克风和蓝牙连接能力。从 Android 应用的视角看,如果眼镜向系统注册为一个标准蓝牙耳机,那么"从眼镜侧录音"的本质,就是让 Android 把录音输入路由切换到蓝牙耳机麦克风,再通过 AudioRecord 持续读取 PCM 数据。

这件事看起来只是"指定一个麦克风",实际却涉及蓝牙 Profile、通信音频路由、系统版本兼容、权限、异步切换和资源恢复。

本文所说的"眼镜侧录音",是指 Android App 采集由眼镜麦克风传回手机的音频。它不等于让眼镜在自己的存储空间里生成录音文件。后者通常只能通过眼镜厂商提供的 SDK 或私有协议实现。

一、先理解蓝牙音频链路

蓝牙耳机常见的音频链路主要有两类:

  • A2DP:面向高质量媒体播放,主要负责手机向耳机输出音乐,通常不提供耳机麦克风输入。
  • HFP/HSP(SCO):面向通话,支持耳机扬声器和麦克风双向传输,但通常是单声道、窄带或宽带语音。
  • LE Audio :新一代低功耗音频方案,在较新的 Android 和硬件上可能表现为 TYPE_BLE_HEADSET

因此,眼镜即使已经可以播放手机音乐,也不代表 App 一定能采集它的麦克风。只有当设备提供了系统可用的通话输入能力,Android 才能通过标准音频 API 读取眼镜麦克风。

一个完整的录音流程应当是:

text 复制代码
眼镜完成蓝牙配对与连接
        ↓
检查录音和蓝牙权限
        ↓
进入 MODE_IN_COMMUNICATION
        ↓
选择蓝牙通信设备(HFP/SCO 或 BLE Headset)
        ↓
等待系统确认路由切换完成
        ↓
创建 AudioRecord,并设置首选输入设备
        ↓
持续读取 PCM,送给编码器、识别服务或文件写入器
        ↓
停止录音、释放 AudioRecord、恢复原音频路由

这里最重要的一点是:音频路由切换是异步过程setCommunicationDevice()startBluetoothSco() 返回,并不一定意味着蓝牙麦克风已经可以立即读取。

二、权限与 Manifest 配置

最小权限配置如下:

xml 复制代码
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />

<!-- Android 11 及以下的蓝牙权限 -->
<uses-permission
    android:name="android.permission.BLUETOOTH"
    android:maxSdkVersion="30" />
<uses-permission
    android:name="android.permission.BLUETOOTH_ADMIN"
    android:maxSdkVersion="30" />

<!-- Android 12 及以上,访问已配对蓝牙设备和音频设备信息 -->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

运行时至少需要检查:

kotlin 复制代码
fun requiredPermissions(): Array<String> = buildList {
    add(Manifest.permission.RECORD_AUDIO)
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        add(Manifest.permission.BLUETOOTH_CONNECT)
    }
}.toTypedArray()

MODIFY_AUDIO_SETTINGS 是普通权限,不需要运行时申请;RECORD_AUDIO 和 Android 12 以上的 BLUETOOTH_CONNECT 需要在运行时获得用户授权。

如果需要在页面退到后台后继续录音,还要使用麦克风类型的前台服务。面向 Android 14 及以上时需要补充:

xml 复制代码
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />

<service
    android:name=".AudioCaptureService"
    android:exported="false"
    android:foregroundServiceType="microphone" />

后台启动前台服务还会受到"使用中权限"和启动时机限制。最稳妥的做法是由用户在可见页面主动开始录音,再进入前台服务持续采集。

三、Android 12+:使用通信设备 API

Android 12(API 31)开始,推荐使用 AudioManager.setCommunicationDevice() 选择通话设备。不要再把 startBluetoothSco() 作为新系统上的首选方案。

先进入通信模式,再从系统提供的通信设备列表中选择蓝牙耳机:

kotlin 复制代码
@RequiresApi(Build.VERSION_CODES.S)
@SuppressLint("MissingPermission")
fun selectBluetoothCommunicationDevice(audioManager: AudioManager): AudioDeviceInfo? {
    audioManager.mode = AudioManager.MODE_IN_COMMUNICATION

    val target = audioManager.availableCommunicationDevices.firstOrNull { device ->
        device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO ||
            device.type == AudioDeviceInfo.TYPE_BLE_HEADSET
    } ?: return null

    return if (audioManager.setCommunicationDevice(target)) target else null
}

setCommunicationDevice() 返回 true 只代表系统接受了这次请求。生产代码还应监听 OnCommunicationDeviceChangedListener,等 communicationDevice 真正变成目标设备后再创建或启动 AudioRecord

kotlin 复制代码
@RequiresApi(Build.VERSION_CODES.S)
@MainThread
@SuppressLint("MissingPermission")
fun routeToBluetoothHeadset(
    context: Context,
    audioManager: AudioManager,
    timeoutMillis: Long = 1_500,
    onResult: (AudioDeviceInfo?) -> Unit
) {
    val target = audioManager.availableCommunicationDevices.firstOrNull {
        it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO ||
            it.type == AudioDeviceInfo.TYPE_BLE_HEADSET
    }
    if (target == null) {
        onResult(null)
        return
    }

    val mainHandler = Handler(Looper.getMainLooper())
    var finished = false
    lateinit var listener: AudioManager.OnCommunicationDeviceChangedListener

    fun finish(result: AudioDeviceInfo?) {
        if (finished) return
        finished = true
        mainHandler.removeCallbacksAndMessages(listener)
        audioManager.removeOnCommunicationDeviceChangedListener(listener)
        onResult(result)
    }

    listener = AudioManager.OnCommunicationDeviceChangedListener { current ->
        if (current?.id == target.id) {
            finish(current)
        }
    }

    audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
    audioManager.addOnCommunicationDeviceChangedListener(
        ContextCompat.getMainExecutor(context),
        listener
    )

    if (!audioManager.setCommunicationDevice(target)) {
        finish(null)
        return
    }

    // 某些设备在注册监听前就已经处于目标路由,因此主动检查一次当前值。
    if (audioManager.communicationDevice?.id == target.id) {
        finish(target)
        return
    }

    mainHandler.postAtTime(
        { finish(null) },
        listener,
        SystemClock.uptimeMillis() + timeoutMillis
    )
}

超时不能简单理解为"眼镜断开"。也可能是设备没有提供 HFP/LE Audio 输入、蓝牙通话能力未连接、麦克风正被其他应用占用,或者厂商系统没有完成路由切换。

四、Android 11 及以下:兼容 SCO

旧系统通常通过 startBluetoothSco() 打开通话音频链路:

kotlin 复制代码
@Suppress("DEPRECATION")
fun startLegacySco(context: Context, audioManager: AudioManager) {
    val receiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context, intent: Intent) {
            if (intent.action != AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED) return

            when (intent.getIntExtra(
                AudioManager.EXTRA_SCO_AUDIO_STATE,
                AudioManager.SCO_AUDIO_STATE_ERROR
            )) {
                AudioManager.SCO_AUDIO_STATE_CONNECTED -> {
                    // 此时再启动 AudioRecord。
                }

                AudioManager.SCO_AUDIO_STATE_DISCONNECTED,
                AudioManager.SCO_AUDIO_STATE_ERROR -> {
                    // 连接失败或已断开,结束本次录音流程。
                }
            }
        }
    }

    context.registerReceiver(
        receiver,
        IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED)
    )

    audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
    audioManager.startBluetoothSco()
    audioManager.isBluetoothScoOn = true
}

实际封装时要保存 receiver,并在成功、失败、超时或页面销毁时调用 unregisterReceiver()。不要只写一个固定的 delay(500) 就认定 SCO 已经连接;不同手机、耳机和系统负载下,建立通话链路所需的时间差异很大。

停止旧版 SCO:

kotlin 复制代码
@Suppress("DEPRECATION")
fun stopLegacySco(audioManager: AudioManager) {
    audioManager.stopBluetoothSco()
    audioManager.isBluetoothScoOn = false
}

五、使用 AudioRecord 读取眼镜麦克风 PCM

路由就绪后,就可以创建 AudioRecord。语音识别、实时翻译、语音助手等场景通常使用 16 kHz、单声道、PCM 16 bit;最终参数仍应以服务端或音频算法的输入要求为准。

kotlin 复制代码
class BluetoothPcmRecorder(
    private val context: Context,
    private val scope: CoroutineScope
) {
    private val audioManager =
        context.getSystemService(Context.AUDIO_SERVICE) as AudioManager

    private var audioRecord: AudioRecord? = null
    private var recordingJob: Job? = null

    @Volatile
    private var recording = false

    @SuppressLint("MissingPermission")
    fun start(onPcmFrame: (ByteArray) -> Unit) {
        if (recording) return

        val sampleRate = 16_000
        val channelMask = AudioFormat.CHANNEL_IN_MONO
        val encoding = AudioFormat.ENCODING_PCM_16BIT
        val minBufferSize = AudioRecord.getMinBufferSize(
            sampleRate,
            channelMask,
            encoding
        )
        require(minBufferSize > 0) { "Unsupported audio format: $minBufferSize" }

        // 内部缓冲区取系统下限和约 200 ms PCM 中的较大值。
        val bytesPerSecond = sampleRate * 1 * 2
        val internalBufferSize = maxOf(minBufferSize, bytesPerSecond / 5)

        val record = AudioRecord.Builder()
            .setAudioSource(MediaRecorder.AudioSource.VOICE_COMMUNICATION)
            .setAudioFormat(
                AudioFormat.Builder()
                    .setSampleRate(sampleRate)
                    .setChannelMask(channelMask)
                    .setEncoding(encoding)
                    .build()
            )
            .setBufferSizeInBytes(internalBufferSize)
            .build()

        check(record.state == AudioRecord.STATE_INITIALIZED) {
            "AudioRecord initialization failed"
        }

        // setPreferredDevice 只是偏好,不是强制保证;最终要检查 routedDevice。
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            findBluetoothInputDevice()?.let(record::setPreferredDevice)
        }

        record.startRecording()
        check(record.recordingState == AudioRecord.RECORDSTATE_RECORDING) {
            record.release()
            "AudioRecord did not enter RECORDING state"
        }

        audioRecord = record
        recording = true

        recordingJob = scope.launch(Dispatchers.IO) {
            // 20 ms 一帧:16000 × 2 bytes × 20 / 1000 = 640 bytes。
            val frameBuffer = ByteArray(640)

            while (isActive && recording) {
                val size = record.read(
                    frameBuffer,
                    0,
                    frameBuffer.size,
                    AudioRecord.READ_BLOCKING
                )

                when {
                    size > 0 -> onPcmFrame(frameBuffer.copyOf(size))
                    size == AudioRecord.ERROR_DEAD_OBJECT -> break
                    size < 0 -> Log.w(TAG, "AudioRecord read failed: $size")
                }
            }
        }
    }

    @SuppressLint("MissingPermission")
    private fun findBluetoothInputDevice(): AudioDeviceInfo? {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return null

        return audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)
            .firstOrNull { device ->
                device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO ||
                    (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
                        device.type == AudioDeviceInfo.TYPE_BLE_HEADSET)
            }
    }

    suspend fun stop() {
        recording = false

        runCatching { audioRecord?.stop() }
        recordingJob?.cancelAndJoin()
        recordingJob = null

        audioRecord?.release()
        audioRecord = null
    }

    companion object {
        private const val TAG = "BluetoothPcmRecorder"
    }
}

几个参数值得单独说明:

  1. VOICE_COMMUNICATION 更符合蓝牙通话链路语义,通常也更容易让系统选择 HFP/SCO 输入。是否启用回声消除、自动增益等预处理由设备和系统实现决定。
  2. 蓝牙通话链路本身可能只提供 8 kHz 或 16 kHz 单声道。把 AudioRecord 请求改成 48 kHz 双声道,不会凭空提高眼镜麦克风质量,反而可能增加重采样和兼容问题。
  3. setPreferredDevice() 是路由偏好,不是绝对命令。开始录音后应检查 record.routedDevice,确认实际输入设备类型。
  4. getMinBufferSize() 返回的是创建录音器所需的最低缓冲区,不是业务帧大小。上传给 ASR 或实时翻译服务时,可以再按 10 ms、20 ms 或服务端协议要求切帧。

六、如何确认声音真的来自眼镜

"录音成功"只能说明拿到了音频数据,不能证明数据来自蓝牙麦克风。建议至少做三层验证。

第一层是检查实际路由:

kotlin 复制代码
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
    val routed = audioRecord.routedDevice
    Log.d(
        "BluetoothMic",
        "routedDevice=${routed?.productName}, type=${routed?.type}"
    )
}

第二层是物理验证:把手机放远或遮住手机麦克风,只在眼镜麦克风附近说话,对比波形和音量。

第三层是断开验证:录音过程中关闭眼镜蓝牙或断开通话音频,观察路由变化和错误处理是否符合预期。生产代码可以注册 AudioDeviceCallback,在输入设备移除时及时停止录音或降级到手机麦克风。

kotlin 复制代码
val callback = object : AudioDeviceCallback() {
    override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) {
        val bluetoothInputRemoved = removedDevices.any {
            it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO ||
                (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
                    it.type == AudioDeviceInfo.TYPE_BLE_HEADSET)
        }
        if (bluetoothInputRemoved) {
            // 停止录音,或明确提示用户已切换到手机麦克风。
        }
    }
}

audioManager.registerAudioDeviceCallback(callback, Handler(Looper.getMainLooper()))

不要悄悄降级输入源。对于会议、翻译和证据留存类功能,用户以为正在采集眼镜麦克风,实际却录到了手机麦克风,往往比直接报错更危险。

七、停止录音时必须恢复系统状态

通信路由是系统级状态。若页面销毁时只释放 AudioRecord,却没有恢复 AudioManager,可能出现后续音乐音质变差、声音仍走蓝牙通话通道、扬声器选择异常等问题。

开始前建议保存完整快照,而不只是保存当前 mode。如果 App 启动录音前已经有其他通信设备被选中,结束时直接 clearCommunicationDevice() 并不等于恢复现场。

kotlin 复制代码
data class AudioRouteSnapshot(
    val mode: Int,
    val speakerphoneOn: Boolean,
    val communicationDevice: AudioDeviceInfo?,
    val bluetoothScoOn: Boolean
)

@Suppress("DEPRECATION")
fun captureAudioRoute(audioManager: AudioManager): AudioRouteSnapshot {
    return AudioRouteSnapshot(
        mode = audioManager.mode,
        speakerphoneOn = audioManager.isSpeakerphoneOn,
        communicationDevice = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            audioManager.communicationDevice
        } else {
            null
        },
        bluetoothScoOn = audioManager.isBluetoothScoOn
    )
}

停止顺序建议为:

  1. 先把采集标记设为 false,阻止继续分发 PCM。
  2. 调用 AudioRecord.stop(),让阻塞中的 read() 尽快返回。
  3. 等待读取协程结束,再调用 release()
  4. Android 12+ 调用 clearCommunicationDevice();旧系统停止 SCO。
  5. 恢复原来的 mode 和扬声器状态。
  6. 注销通信设备监听、广播接收器和 AudioDeviceCallback
kotlin 复制代码
fun restoreAudioRoute(
    audioManager: AudioManager,
    snapshot: AudioRouteSnapshot
) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        val previousDevice = snapshot.communicationDevice
        if (previousDevice != null &&
            audioManager.availableCommunicationDevices.any { it.id == previousDevice.id }
        ) {
            audioManager.setCommunicationDevice(previousDevice)
        } else {
            audioManager.clearCommunicationDevice()
        }
    } else {
        if (!snapshot.bluetoothScoOn) {
            @Suppress("DEPRECATION")
            audioManager.stopBluetoothSco()
        }
        @Suppress("DEPRECATION")
        run { audioManager.isBluetoothScoOn = snapshot.bluetoothScoOn }
    }

    audioManager.mode = snapshot.mode
    audioManager.isSpeakerphoneOn = snapshot.speakerphoneOn
}

这段恢复逻辑应放在统一的 finally 或资源管理层中,确保权限失败、路由超时、AudioRecord 初始化失败、用户主动停止和页面销毁都能执行。

如果设备正在进行系统通话,或另一个 VoIP 功能正在占用通信模式,应用不应贸然覆盖它的路由。录音模块需要和应用内其他通话、播放、语音助手能力做互斥或统一仲裁。

八、标准蓝牙方案并不适用于所有 AI 眼镜

有些 AI 眼镜虽然能播放音乐、接听电话,却不会把麦克风作为可供第三方 App 选择的系统输入设备;还有一些设备只允许自家 SDK 获取原始音频。

开发前可以先检查系统枚举结果:

kotlin 复制代码
val inputs = audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)
inputs.forEach {
    Log.d("AudioInput", "name=${it.productName}, type=${it.type}, id=${it.id}")
}

如果系统始终没有出现 TYPE_BLUETOOTH_SCOTYPE_BLE_HEADSET 输入,继续调整 AudioRecord 参数通常没有意义。此时应确认厂商是否提供以下能力:

  • 开始和停止采集眼镜麦克风的命令;
  • 通过 BLE、经典蓝牙、Wi-Fi Direct 或自定义通道回传 PCM/Opus/AAC;
  • 音频格式、采样率、声道数和帧长说明;
  • 丢包、重传、时钟漂移和断线重连机制;
  • 麦克风权限提示、录音指示灯和隐私合规要求。

厂商 SDK 回传音频时,系统 AudioRecord 可能完全不参与。应用只需要接收 SDK 音频帧,再做解码、重采样、存储或上传。这与本文前面的标准蓝牙耳机路由是两套不同的技术方案,不应混在一起排查。

九、常见问题

1. 已连接蓝牙,但录到的仍是手机麦克风

最常见原因是只连接了 A2DP,没有建立 HFP/SCO 或 LE Audio 通信输入;也可能是 AudioRecord 在路由完成前就启动。检查 availableCommunicationDevicescommunicationDeviceaudioRecord.routedDevice,不要只看蓝牙是否显示"已连接"。

2. 一切换到眼镜麦克风,播放音质就明显下降

这是经典蓝牙通话链路的正常现象。蓝牙设备进入 HFP/SCO 后,媒体播放可能从高质量 A2DP 切换到低带宽通话通道。停止录音并恢复路由后,音质通常会恢复。

3. 某些手机可以录,某些手机不行

蓝牙协议栈、系统音频策略、耳机固件和厂商定制都会影响结果。建议记录系统版本、设备类型、路由请求结果、实际 routedDeviceAudioRecord 状态和 read 错误码,并准备手机麦克风降级方案。

4. 为什么不直接使用 MediaRecorder

如果目标只是保存一个普通音频文件,MediaRecorder 更简单;如果需要把实时音频送给语音识别、翻译、通话或自定义编码器,AudioRecord 能直接提供 PCM,更容易控制帧长、缓冲和延迟。

5. 能否同时使用 A2DP 高音质播放和眼镜麦克风录音

经典蓝牙设备通常会在使用麦克风时进入 HFP/SCO,难以同时保持 A2DP 的高质量播放。部分支持 LE Audio 的设备可能有更好的双向音频能力,但最终仍取决于手机系统、眼镜硬件和固件实现。

十、工程化建议

实际项目中,建议把能力拆成三个职责清晰的组件:

  • BluetoothAudioRouter:选择通信设备、等待路由、监听断开、恢复现场;
  • PcmRecorder:创建和释放 AudioRecord、读取 PCM、处理错误;
  • AudioConsumer:负责写 WAV、编码 Opus/AAC、上传 ASR 或送入实时翻译 SDK。

页面或 ViewModel 只编排状态,不直接操作 SCO、线程和底层缓冲区。录音状态也最好建模为 Idle → Routing → Recording → Stopping → Failed,避免用户连续点击造成多个 AudioRecord 实例争抢麦克风。

此外,录音日志应记录设备类型、路由状态和错误码,但不要记录原始音频、用户内容或其他敏感信息。产品层面还应提供清晰的录音提示、停止入口和隐私说明。

总结

Android 采集 AI 眼镜麦克风的关键不在 AudioRecord 本身,而在于先建立正确的蓝牙通信输入链路:

  • A2DP 主要负责高质量播放,眼镜麦克风通常需要 HFP/SCO 或 LE Audio;
  • Android 12+ 使用 setCommunicationDevice(),旧系统使用 startBluetoothSco()
  • 路由切换是异步的,确认就绪后再启动 AudioRecord
  • setPreferredDevice() 只是偏好,必须检查实际 routedDevice
  • 停止时要完整释放录音资源并恢复系统音频路由;
  • 系统没有暴露蓝牙输入设备时,应转向眼镜厂商 SDK,而不是反复调整采样参数。

只要把"设备能力确认、通信路由、PCM 采集、生命周期恢复"这四层拆开,AI 眼镜录音就会从一个难以复现的兼容性问题,变成一条可观察、可降级、可维护的工程链路。

注:部分内容由AI根据项目代码总结生成。

参考资料

相关推荐
又见情义1 小时前
Android 系统设置从平板版迁移至TV版实践
android
杉氧4 小时前
打破边界(一):实战编写 Android/iOS 原生模块 (Native Modules)
android·react native·前端框架
baidu_247438615 小时前
Android 35适配
android
mmsx5 小时前
osmdroid 踩坑清单:大级别崩溃/路径消失/低内存卡顿/范围缩放不准
android·源码·地图·osmdroid
gf13211116 小时前
【python_回复邮件】
android·java·python
段一凡-华北理工大学6 小时前
高炉智能布料技术与炉料分布优化~专栏简介与目录
android·人工智能·高炉智能化·高炉布料·高炉布料分布·高炉布料参数优化·高炉布料矩阵
hunterandroid7 小时前
Android 多渠道打包与 Gradle 构建优化实战
android·前端·kotlin
小白羊丨7 小时前
异步任务创建接口如何幂等?
android·数据库