做Android音频采集,核心就两件事:把PCM音频数据从麦克风里读出来,再把它送到扬声器去播放。听着顺理成章,但一旦麦克风变成USB摄像头自带的MIC,事情就开始不简单了——内置MIC录得清清楚楚,插上USB摄像头反而静音;要么能录,一播放音调全不对。最近我在折腾一个音频回环的Demo:外接USB摄像头,用它的麦克风采集PCM音频数据,同时通过扬声器实时播放出来,顺便把整套AudioRecord、AudioTrack和USB音频路由链路走了一遍。这篇就把方案、代码、参数选择和踩过的坑完整记录下来。
如果你是刚接触Android音频开发,想搞懂PCM怎么采、怎么播,或者正在做外接USB声卡、USB摄像头这类设备的兼容适配,这篇基本可以当一次完整实战复盘来参考。我也会把“为什么这么写”讲透,而不是只丢一段能跑的代码。
1. 先把需求拆明白:采集、播放和USB麦克风,真的不是三件事
1.1 标题拆解与真实使用场景
“Android采集PCM音频数据并播放”,字面上是一个音频回环需求:一端从输入设备读取原始PCM数据,一端把数据写入输出设备进行播放,中间不做编码、不做文件落地。这种形态的功能非常多——实时音质检测、变声预览、音量表监控、声学回声测试、直播场景的耳返,甚至教育硬件里的“老师声音放大”功能,底层都是这么一套闭环。
为什么这个需求里要特别强调“支持USB摄像头MIC”?因为内置麦克风的采集路径太顺了:新建一个AudioRecord,指定MIC音源,read数据就行。但外接USB摄像头的麦克风不同,它在系统里属于USB音频输入设备,Android的音频策略不会自动把录音路由到它身上,否则整个系统就可能出现在视频通话时“摄像头对着人,对方却听不到声音”的尴尬情况。所以这个标题的难点根本不在于PCM本身,而在于要不要处理USB设备识别、路由切换、采样率协商、热插拔恢复这些系统级问题。
我建议把这类项目拆成四个子任务来理解:
- PCM采集:用AudioRecord从某个输入设备读PCM裸数据。
- PCM播放:用AudioTrack把PCM裸数据写进输出设备。
- 设备路由:明确当前使用的输入设备到底是内置MIC还是USB摄像头MIC。
- 动态适配:设备插拔、采样率变化时,能自动恢复可用链路。
这四个子任务单独看都不复杂,组合在一起就会碰到很多“单看文档根本发现不了”的坑。
1.2 方案选型:为什么用AudioRecord + AudioTrack,而不是MediaRecorder + MediaPlayer
早期初学者最容易犯的错误,是看到“录音”就想到MediaRecorder,看到“播放”就想到MediaPlayer。但这两个类根本不是为实时PCM流准备的。MediaRecorder会把音频编码成AAC、AMR等格式,它输出的不是裸PCM,你的实时处理逻辑根本无从下手;MediaPlayer是用来播放文件的,它接收的是文件路径或媒体流,而不是一帧一帧的PCM字节。
AudioRecord和AudioTrack则完全相反,它们天生就是处理PCM短块的:AudioRecord负责从输入设备“读”字节,AudioTrack负责往输出设备“写”字节。你拿到的每一块数据都是最原始的声音采样点,想做音量检测、实时滤波、变声、混音,都在这一层做。而且它不经过文件系统和编解码器,链路短,延迟低,非常适合实时回环。
如果你追求更低延迟,也可以选AAudio或OpenSL ES。但AAudio对USB音频设备的策略其实还在迭代,OpenSL ES用起来又太底层,代码量翻倍。做USB摄像头MIC兼容,最稳的方式还是先把AudioRecord + AudioTrack这套原生接口吃透。它也是Android音频生态里兼容性最广的方案,几乎从老机型到新机型都能跑通。
2. 动手之前必须搞清楚的三个基础问题
2.1 PCM音频数据的基本格式
PCM(脉冲编码调制)本质上就是声音的“裸编码”。理解它只需要抓住三个参数:采样率、声道数、位深。
生活化类比一下。采样率等于每秒给声音拍多少张照片,拍得越勤,能还原的声音频率越高。电话那样的8kHz只能还原到4kHz以内的声音,所以听起来闷;普通视频的48kHz就能覆盖人耳能听到的20kHz上限,音质就立体很多。声道数就是同时有几路话筒,单声道是一路,双声道(立体声)是左右两路。位深则是每张照片里记录声音细节的位数,常见的16bit意味着每个采样点用两个字节表示,数值范围大概是-32768到32767。
三者相乘就是每秒的数据量。比如48kHz采样率、16bit位深、单声道:
48000 × 1 × 2字节 = 96000 字节/秒,约93.75 KB/s如果是双声道,直接翻倍变成187.5 KB/s。做实时回环的时候,这个数值直接决定了你的缓冲buffer大小和线程读取频率,所以一定要会算。
还有一个必须记住的特点:PCM是裸数据,它没有文件头,不包含采样率、声道数这些“元信息”。这意味着你把一块PCM数据丢给AudioTrack播放时,必须自己保证播放端参数和采集端完全一致。采集用的48kHz单声道,播放端配成了44.1kHz单声道,就会明显出现音调偏高或偏低。很多同学遇到的问题不是“没声音”,而是“声音速度不对”,基本就是这个原因。
2.2 设备路由:录音到底走哪个麦克风
Android的音频系统里,输入设备不只有“麦克风”这一个概念。内置麦克风、耳机麦克风、蓝牙麦克风、USB声卡输入,在底层都会映射成一个个AudioDeviceInfo,系统有一个路由策略来决定当前录音用哪个设备。默认情况下,普通App调用AudioRecord指定MIC音源,系统会按“主麦克风优先”的路由逻辑去选,大概率选到内置麦克风,而不是你刚插上的USB摄像头。
所以要让USB摄像头MIC真正工作,必须做两件事:第一步,从系统设备列表里找出对应USB音频输入设备;第二步,把AudioRecord的PreferredDevice设置成这个设备,显式强制路由。这一步只能靠代码完成,没有任何UI能帮你默认切换。
设备枚举可以通过AudioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)拿到,然后逐个判断type是否属于USB类型。常见的几个类型是:
| 类型 | 含义 |
|---|---|
| TYPE_USB_DEVICE | USB声卡、USB摄像头自带的音频输入 |
| TYPE_USB_HEADSET | USB耳机 |
| TYPE_USB_ACCESSORY | USB配件,通常也会被当音频设备暴露 |
用device.isSource可以进一步确认它是不是输入设备。这一层过滤做对了,后面路由才有意义。
2.3 权限和系统广播准备
采集PCM需要RECORD_AUDIO权限,这属于危险权限,Android 6.0以后必须动态申请。这一步漏掉,AudioRecord初始化时直接就是STATE_UNINITIALIZED,根本不会有数据。我见过很多同学写完代码安装后抛异常,第一反应找原生代码问题,其实只是没弹权限框。
播放端AudioTrack不需要权限,因为扬声器输出不受保护。但如果你的App同时在后台播放音频,建议申请AudioFocus,避免出现音量突变或被其他应用打断时路由被抢走。对回环Demo来说,不申请也能响,但做一个音频焦点处理更文明,也能减少设备切换时的异常情况。
USB热插拔广播也建议在这个阶段就注册好。监听ACTION_USB_DEVICE_ATTACHED和ACTION_USB_DEVICE_DETACHED,能让你在摄像头插拔时及时重建音频链路。这里有个小细节:动态注册比静态注册可靠得多,很多定制ROM对静态广播做了限制,你写在Manifest里可能压根收不到。
3. 一步步实现:PCM采集与播放闭环
3.1 采集端:AudioRecord的初始化和参数选择
AudioRecord的初始化看起来简单,但参数选择非常关键。我推荐直接用Builder方式,代码更清晰,也方便后续设置PreferredDevice。以下是一个面向USB摄像头MIC场景的初始化片段:
val sampleRate = 48000 val channelIn = AudioFormat.CHANNEL_IN_MONO val encoding = AudioFormat.ENCODING_PCM_16BIT val minBufferSize = AudioRecord.getMinBufferSize( sampleRate, channelIn, encoding ) var audioRecord: AudioRecord? = null try { audioRecord = AudioRecord.Builder() .setAudioSource(MediaRecorder.AudioSource.CAMCORDER) .setAudioFormat( AudioFormat.Builder() .setSampleRate(sampleRate) .setEncoding(encoding) .setChannelMask(channelIn) .build() ) .setBufferSizeInBytes(minBufferSize * 2) .build() } catch (e: UnsupportedOperationException) { // 参数组合不被设备支持时,会在这里抛异常 } if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { // 初始化失败,通常需要降级采样率或检查权限 }这里有几个关键点要展开讲。
采样率为什么首选48000Hz?因为USB Audio Class协议里,48kHz是绝大多数声学设备的基础支持采样率。很多USB摄像头、USB声卡的输入通道只认48kHz和16kHz,你如果按默认的44.1kHz去创建AudioRecord,要么返回初始化失败,要么初始化成功但数据全静音。44.1kHz是音频文件领域常见采样率,但在USB音频硬件上,反而是48kHz更“通用”。
getMinBufferSize返回的是系统认为该配置下不会掉数据的最小缓冲字节数。实际使用时我习惯乘2,因为回环场景里采集和播放是两个独立节奏,缓冲区太小,某次调度延迟就会导致read返回0或者write写入不及时,表现为声音断续。注意:如果这个函数返回负数,比如ERROR_BAD_VALUE,说明当前采样率和声道组合不被设备支持,需要在代码里先降级再试。
MediaRecorder.AudioSource.CAMCORDER是我这里选用的音源。这个选择有讲究:CAMCORDER在大多数设备上会走相机/视频录制相关的音频通道,对USB摄像头的路由优先级会比MIC更高,同时底层增益策略也更适合外接麦克风。后面我会再对比VOICE_RECOGNITION和UNPROCESSED的差异。
3.2 播放端:AudioTrack的参数和模式
AudioTrack的初始化看起来比AudioRecord还简单,但模式选错就会出现“数据全部写进去但一点声音没有”的情况。先看代码:
val audioTrack = AudioTrack.Builder() .setAudioFormat( AudioFormat.Builder() .setSampleRate(sampleRate) .setEncoding(encoding) .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) .build() ) .setBufferSizeInBytes(minBufferSize * 4) .setTransferMode(AudioTrack.MODE_STREAM) .build()AudioTrack有两种传输模式:MODE_STREAM和MODE_STATIC。
- MODE_STREAM:数据通过write方法不断“流”进来,适用于连续音频流,比如实时回环、在线播放。这是我们的首选。
- MODE_STATIC:一次性把全部音频数据交给AudioTrack,适用于短音效、提示音,比如按键声。它需要你在一开始就把所有数据填进去,不适合持续采集。
播放端的采样率和声道数必须和采集端一致。如果采集是48kHz单声道,播放端就写48kHz单声道,不能写44.1kHz,也不能写双声道。这里不仅影响音调,还会影响声音内容本身——采集是单声道数据,播放端却用立体声解释,数据会被当成左右声道交错排列,放出来要么音量小一半,要么声音错乱。
缓冲区大小我习惯取minBufferSize * 4。原因在于播放端比采集端更容易出现“缓冲区下溢”:写数据的速度如果跟不上播放速度,AudioTrack就会进入停滞状态,然后恢复时产生爆音。缓冲区翻倍能显著降低这个概率。如果设备内存紧张,最低也应该等于minBufferSize。
performanceMode我建议保持默认。虽然可以指定PERFORMANCE_MODE_LOW_LATENCY来获得更低延迟,但在USB音频设备上,低延迟模式有时会与帧大小协商冲突,导致初始化失败。默认兼容模式是回环Demo最稳妥的选择。
3.3 实时数据搬运线程
采集和播放都必须放在后台线程,不能阻塞主线程。最简单的回环方案是起一个Thread,循环read再write:
private var isLooping = false private var workThread: Thread? = null private fun startLoop(audioRecord: AudioRecord, audioTrack: AudioTrack) { if (audioRecord.state != AudioRecord.STATE_INITIALIZED || audioTrack.state != AudioTrack.STATE_INITIALIZED) { return } isLooping = true workThread = Thread { val buffer = ByteArray(minBufferSize * 2) audioRecord.startRecording() audioTrack.play() while (isLooping && !Thread.currentThread().isInterrupted) { val read = audioRecord.read(buffer, 0, buffer.size) if (read > 0) { val written = audioTrack.write(buffer, 0, read) if (written < read) { // 播放缓冲区可能已满,可以考虑短暂sleep或继续读下一块 } } else if (read == AudioRecord.ERROR_DEAD_OBJECT) { // 底层音频服务异常,需要退出并重建 break } } audioTrack.stop() audioRecord.stop() } workThread?.start() } private fun stopLoop() { isLooping = false workThread?.join(1000) workThread = null }这里的阅读顺序很有意思:每次从AudioRecord读出一块,立刻写进AudioTrack。只要缓冲区大小匹配,逻辑上就是无缝的。但有几个细节不处理就会出问题。
启动顺序上,startRecording()和play()要在循环开始前先调用,而不是先read第一块再去play。否则第一块数据已经进入播放缓冲区,但AudioTrack还没进入播放状态,导致开头丢失一小截。
read的返回值是个容易被忽略的坑。它不是每次都会返回buffer.size,有可能返回更小的值,比如只读了几百字节。如果你按buffer.size去write,就会把上次残留的旧数据也算进去,产生噪音。一定要用read的返回值作为写入长度,这是我调试时被坑最多的地方之一。
线程优先级也值得调整。默认线程优先级在音频场景下可能不够,容易被其他任务抢占,导致偶尔卡顿。可以设置Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO),能减少一部分爆音。
注意:实时回环有个天然问题——扬声器播出来的声音很可能被麦克风重新采进去,形成回声甚至啸叫。所以测试时尽量戴着耳机,或者把播放音量调低。这个问题不是代码bug,是物理特性。
4. USB摄像头MIC兼容实战:从识别到重配
4.1 插上USB摄像头为何录不到声
整个项目最痛苦的环节就在这一节。你插上一个USB摄像头,打开系统相机APP,画面有了,麦克风也是好的,可你自己的App一录音就是静音。常见原因有这么几类:
第一类是系统层面根本没把摄像头识别为音频输入设备。某些定制ROM的音频HAL层对USB Audio支持不完整,或者摄像头本身的固件只曝露了视频功能,没有注册USB声卡。这种情况纯靠代码救不了,至少要有“识别不到设备”的提示逻辑。
第二类是识别到了,但默认路由没有切过去。前面说过,Android默认主麦克风优先,USB输入设备插上不等于录音就会走它。代码里没有显式设置preferredDevice,自然录静音。
第三类是采样率或声道数不匹配。USB声卡驱动为了简化设计,经常只支持固定采样率组合。例如某个摄像头的输入设备只支持48kHz和16kHz双声道,你却用44.1kHz单声道创建AudioRecord,这时候HAL层可能在初始化时给你一个能用的句柄,但底层完全没有数据流。
排查顺序建议固定为:设备是否被系统识别 → 采样率是否协商成功 → 路由是否指定到USB设备 → 最后才怀疑代码逻辑。
4.2 枚举USB输入设备并强制路由
要做路由,先得能拿到目标设备。下面这段代码可以枚举所有输入设备并筛选出USB类型:
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager val inputDevices = audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS) var usbMic: AudioDeviceInfo? = null for (device in inputDevices) { if (!device.isSource) continue Log.d("USB-MIC", "type=${device.type}, name=${device.productName}") if (device.type == AudioDeviceInfo.TYPE_USB_DEVICE || device.type == AudioDeviceInfo.TYPE_USB_HEADSET || device.type == AudioDeviceInfo.TYPE_USB_ACCESSORY) { usbMic = device break } }拿到AudioDeviceInfo之后,在Builder阶段把它设进去:
val builder = AudioRecord.Builder() .setAudioSource(MediaRecorder.AudioSource.CAMCORDER) .setAudioFormat(format) .setBufferSizeInBytes(bufferSize) if (usbMic != null) { builder.setPreferredDevice(usbMic) } audioRecord = builder.build()setPreferredDevice是API 23之后才有的方法,但它处理的是“首选”逻辑,不是“必须”。系统在无法使用这个设备时,仍然会fallback到默认设备。所以光设PreferredDevice不算完,还得在初始化后检查audioRecord.routedDevice,确认实际路由到的设备确实是我们想要的那个:
val actualDevice = audioRecord?.routedDevice if (actualDevice?.type != AudioDeviceInfo.TYPE_USB_DEVICE) { Log.w("USB-MIC", "路由没有生效,实际设备 type=${actualDevice?.type}") }这里再提一下audioSource的选择。除了CAMCORDER,我还测过VOICE_RECOGNITION和UNPROCESSED。VOICE_RECOGNITION会启动语音识别链路,部分品牌ROM会强制添加回声消除和降噪算法,坏处是这些处理也会把原始PCM“改造”掉,增益路径不一定适合USB MIC。UNPROCESSED理论上最原始,但很多设备不实现该策略,会悄悄fallback。CAMCORDER是我试下来对USB摄像头兼容性最均衡的选项:容易路由到外部设备,又没有明显的算法侵入。
4.3 采样率探测与动态重配
识别到了设备但数据仍然静音,下一步就该怀疑采样率。写一个探测函数,把常用采样率挨个试一遍:
private fun findSupportedSampleRate( audioSource: Int, encoding: Int, channelIn: Int ): Int { val candidates = intArrayOf(48000, 44100, 32000, 16000, 8000) for (rate in candidates) { val bufferSize = AudioRecord.getMinBufferSize(rate, channelIn, encoding) if (bufferSize <= 0) { Log.d("USB-MIC", "$rate 不被getMinBufferSize支持") continue } val record = try { AudioRecord.Builder() .setAudioSource(audioSource) .setAudioFormat( AudioFormat.Builder() .setSampleRate(rate) .setEncoding(encoding) .setChannelMask(channelIn) .build() ) .setBufferSizeInBytes(bufferSize * 2) .build() } catch (e: Exception) { Log.d("USB-MIC", "$rate Builder构建失败") continue } if (record.state == AudioRecord.STATE_INITIALIZED) { // 真实采几帧,确认不是“假初始化” record.startRecording() val probeBuffer = ByteArray(bufferSize) val readCount = record.read(probeBuffer, 0, probeBuffer.size) record.stop() record.release() if (readCount > 0) { Log.d("USB-MIC", "实际可用采样率: $rate") return rate } } else { record.release() } } return 0 }这个探测比只看getMinBufferSize可靠得多。因为部分驱动对不支持的采样率不会直接返回错误,而是给你一个初始化成功的AudioRecord,然后read永远返回0。只有真实读一帧才能验证链路是通的。
动态重配的意思是:当设备热插拔后,原来协商好的采样率未必还能用,需要重新探测、重新创建AudioRecord和AudioTrack。不能抱着“之前能用就永远能用”的心态。
4.4 热插拔监听与资源恢复
热插拔是USB外设的自带属性,不处理就等于等着崩溃。我用的组合拳是AudioDeviceCallback加USB广播两种情况都监听。
AudioDeviceCallback是音频设备变化的回调,覆盖面广,但不保证所有ROM都可靠:
val callback = object : AudioManager.AudioDeviceCallback() { override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) { addedDevices?.firstOrNull { it.isSource && it.type in usbTypes } ?.let { reconnectToUsbMic() } } override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) { removedDevices?.firstOrNull { it.isSource && it.type in usbTypes } ?.let { stopLoop() } } } audioManager.registerAudioDeviceCallback(callback, Handler(Looper.getMainLooper()))USB广播则补足设备插入/拔出的最原始信号:
val filter = IntentFilter().apply { addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED) addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) } registerReceiver(usbReceiver, filter)真正的坑在“去抖”。USB摄像头通常是复合设备,插上时系统会依次枚举视频和音频功能,拔出时也有一连串事件。如果你每收到一次回调就重建AudioRecord,可能刚好撞上设备还没完全ready,导致重建失败。我会在回调里加一个500ms到1秒的延迟任务,等到事件稳定了再初始化:
private fun reconnectToUsbMic() { reconnectHandler.removeCallbacksAndMessages(null) reconnectHandler.postDelayed({ destroyRecordAndTrack() val usbDevice = findUsbInputDevice() if (usbDevice != null) { val rate = findSupportedSampleRate(...) if (rate > 0) { createRecordAndTrack(usbDevice, rate) } } }, 800) }设备拔出时也要把正在跑的循环停下来。否则AudioRecord.read会开始返回ERROR_DEAD_OBJECT,AudioTrack.write可能直接抛异常,导致线程崩溃。停止顺序是先停线程,再release AudioRecord和AudioTrack。重复release或release已经被系统回收的对象,也会崩,所以每次release前要把引用置空并判断。
5. 高频问题与排错速查
5.1 问题现象速查表
把实际调试涉及的典型问题汇总成下表,排查时可以直接对照:
| 现象 | 可能原因 | 排查/解决思路 |
|---|---|---|
| AudioRecord初始化失败,state非INITIALIZED | 权限未授予;采样率/声道组合不被支持 | 动态申请RECORD_AUDIO;用48kHz/16bit/MONO优先尝试 |
| 能录音但所有数据全静音 | 路由没有切到USB设备;audioSource不合适 | 枚举设备后setPreferredDevice;改用CAMCORDER |
| 播放声音音调偏高/偏低 | 播放端采样率与采集端参数不一致 | 统一采样率,检查AudioTrack的sampleRate |
| 播放声音像“语速加快” | 播放端按双声道解析单声道数据,或位深不匹配 | 统一声道数和encoding |
| 声音断续、爆音 | buffer太小;线程优先级低;write追不上 | bufferSize至少getMinBufferSize的两倍;设置URGENT_AUDIO优先级 |
| 拔插USB设备后应用无响应 | 没有监听设备移除;read/write在等IO | 监听回调中先stopLoop,再release资源 |
| read返回ERROR_DEAD_OBJECT | 底层音频系统/设备异常 | 释放旧对象,重新初始化链路 |
| 有明显回声或啸叫 | 播放声音被麦克风再次采集 | 戴耳机测试;降低播放音量;后续可接AEC算法 |
5.2 现场排查实录与操作心得
我在调试时遇到最诡异的一次,是某个USB摄像头能枚举出输入设备,也能成功初始化AudioRecord,数据也能读到几百字节,但播放出去的音频又闷又小,几乎像隔了一堵墙。一开始怀疑AudioTrack参数不对,反复改采样率,没有改善。最后用耳机插到摄像头自己的耳机口才发现,是摄像头固件内部的自动增益压得太狠,环境安静时输入电平被压到很低。后来我把播放端音量拉高、并在测试环境里放一段近距离人声,波形才恢复正常。这个案例的教训是:USB摄像头MIC的“硬件特性”千差万别,同一套代码在不同摄像头上的音量、噪底可能差好几倍,不能只看参数,要实际去听。
另一个经常出问题的点,是很多设备上AudioRecord.getMinBufferSize返回的值不是固定不变的,它受当前audioSource、sampleRate影响。我在某个模拟器上看到函数的返回值只有320字节,如果真按320字节去开循环,声音必然一卡一卡。实际设备上这个值通常在几千字节。所以代码里不能写死常量,必须每次用系统函数动态获取,并留足余量。
还有一个小技巧:调试回环链路时,可以先用AudioRecord抓两秒数据写到本地文件,再用AudioTrack播放这个文件。这个做法能把问题快速隔离开——如果能录能存、播放也正常,说明链路整体没问题,剩下的都是路由和实时调度问题;如果文件里都是静音,那问题一定在采集端,不用反复改播放端参数。这个思路帮我节省了大量时间。
6. 这套闭环后续还能怎么扩展
6.1 把PCM保存成WAV文件
实时回环接通之后,最自然的扩展就是“录下来”。PCM本身没有头信息,想把它变成能被播放器打开的WAV文件,需要手动补44字节的文件头。WAV头里的采样率、声道、位深直接填你采集时的参数,数据区直接写PCM字节。注意一点:写文件时allocate一个足够大的ByteArray缓冲,避免每次write触发频繁IO,回环线程要尽量减少文件写入造成的阻塞,否则声音会卡。
6.2 实时音频处理
拿到PCM原始帧之后,你可以做的处理非常多:音量表(计算RMS)、实时降噪滤波、简单的变声算法、混音增益等。原理都是拿到PCM字节后转成short数组,做数学运算再转回去。十六位PCM数据按小端存储,每次取两个字节合成一个短整型,这是所有处理的基础。需要注意,这类处理如果放在回环线程里,会增加延迟,所以要把“采集→处理→播放”的阶段延迟控制在一个合理范围,必要时把处理逻辑拆到另一个线程,用环形缓冲衔接。
6.3 回声消除与通话链路优化
如果未来要做双向通话,回声消除(AEC)几乎是必选项。Android原生的AcousticEchoCanceler可以创建,但很多USB设备上不一定有效,因为它依赖底层算法实现。业界常见做法是用WebRTC的音频处理模块,引入成本偏高,也会增加App体积,但效果确实比原生可靠。这一层建议等项目核心链路稳定后再接入,先把采集、路由、播放三个环节打稳,再去想算法。
整套做下来,我最深的体会是:PCM采集播放的代码门槛不高,真正决定项目成败的是对设备路由和动态重配的把控。千万不要想当然认为系统会在你插上USB摄像头后自动把麦克风切到它身上。代码里要主动去枚举、主动去指定PreferredDevice、主动在热插拔后重建链路,这种“显式管理”才是稳定性的来源。最后再分享一个小习惯:测试时先把播放音量调到最低,用耳机听,确认数据链路是通的之后,再慢慢调高音量处理回声问题。这样你踩的坑会少一半。