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

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_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 眼镜录音就会从一个难以复现的兼容性问题,变成一条可观察、可降级、可维护的工程链路。

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

参考资料

相关推荐
千里马学框架3 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台3 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone3 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc3 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo3 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077003 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼3 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone3 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen3 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone3 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui