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"
}
}
几个参数值得单独说明:
VOICE_COMMUNICATION更符合蓝牙通话链路语义,通常也更容易让系统选择 HFP/SCO 输入。是否启用回声消除、自动增益等预处理由设备和系统实现决定。- 蓝牙通话链路本身可能只提供 8 kHz 或 16 kHz 单声道。把
AudioRecord请求改成 48 kHz 双声道,不会凭空提高眼镜麦克风质量,反而可能增加重采样和兼容问题。 setPreferredDevice()是路由偏好,不是绝对命令。开始录音后应检查record.routedDevice,确认实际输入设备类型。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
)
}
停止顺序建议为:
- 先把采集标记设为
false,阻止继续分发 PCM。 - 调用
AudioRecord.stop(),让阻塞中的read()尽快返回。 - 等待读取协程结束,再调用
release()。 - Android 12+ 调用
clearCommunicationDevice();旧系统停止 SCO。 - 恢复原来的
mode和扬声器状态。 - 注销通信设备监听、广播接收器和
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_SCO 或 TYPE_BLE_HEADSET 输入,继续调整 AudioRecord 参数通常没有意义。此时应确认厂商是否提供以下能力:
- 开始和停止采集眼镜麦克风的命令;
- 通过 BLE、经典蓝牙、Wi-Fi Direct 或自定义通道回传 PCM/Opus/AAC;
- 音频格式、采样率、声道数和帧长说明;
- 丢包、重传、时钟漂移和断线重连机制;
- 麦克风权限提示、录音指示灯和隐私合规要求。
厂商 SDK 回传音频时,系统 AudioRecord 可能完全不参与。应用只需要接收 SDK 音频帧,再做解码、重采样、存储或上传。这与本文前面的标准蓝牙耳机路由是两套不同的技术方案,不应混在一起排查。
九、常见问题
1. 已连接蓝牙,但录到的仍是手机麦克风
最常见原因是只连接了 A2DP,没有建立 HFP/SCO 或 LE Audio 通信输入;也可能是 AudioRecord 在路由完成前就启动。检查 availableCommunicationDevices、communicationDevice 和 audioRecord.routedDevice,不要只看蓝牙是否显示"已连接"。
2. 一切换到眼镜麦克风,播放音质就明显下降
这是经典蓝牙通话链路的正常现象。蓝牙设备进入 HFP/SCO 后,媒体播放可能从高质量 A2DP 切换到低带宽通话通道。停止录音并恢复路由后,音质通常会恢复。
3. 某些手机可以录,某些手机不行
蓝牙协议栈、系统音频策略、耳机固件和厂商定制都会影响结果。建议记录系统版本、设备类型、路由请求结果、实际 routedDevice、AudioRecord 状态和 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 眼镜录音就会从一个难以复现的兼容性问题,变成一条可观察、可降级、可维护的工程链路。