1. 多路录音到底难在哪:从AudioRecord的底层逻辑说起
Android录音这件事,看起来简单——调个AudioRecord,传个AudioFormat,startRecording()就完事了。但一旦你需要的不是"一路混音后的立体声",而是"同时拿到6个物理麦克风的独立PCM数据流",事情就完全不一样了。我在第一次接到这个需求的时候,脑子里第一反应是"多开几个AudioRecord实例不就行了",结果实测下来,第二个实例直接初始化失败,返回的state是STATE_UNINITIALIZED。
这个坑的根源在于Android音频框架的设计哲学。AudioRecord在Java层只是一个壳,真正干活的是native层的AudioFlinger和AudioPolicyService。当你创建一个AudioRecord时,系统会根据你传入的AudioSource(比如MediaRecorder.AudioSource.MIC)去匹配一个输入设备。默认情况下,MIC这个source只会匹配到"主麦克风"这一个逻辑设备,你开多少个实例,它们抢的都是同一个物理通道。这就是为什么很多人尝试多实例录音,最后拿到的6路数据其实是一模一样的。
那Android 13到底给了我们什么新东西?核心在于AudioFormat.CHANNEL_IN_6这个通道掩码,以及AudioRecord.Builder配合setAudioSource和setAudioFormat时对多通道输入的支持。CHANNEL_IN_6表示6通道输入,对应的通道布局通常是CHANNEL_IN_FRONT_LEFT、CHANNEL_IN_FRONT_RIGHT、CHANNEL_IN_CENTER、CHANNEL_IN_LOW_FREQUENCY、CHANNEL_IN_BACK_LEFT、CHANNEL_IN_BACK_RIGHT这一套。但注意,这只是"逻辑通道",能不能真的拿到6路独立数据,取决于硬件HAL层是否暴露了6个独立的输入通道。
我实测过几台设备,结论很直接:大部分手机的主MIC和副MIC(降噪MIC)在HAL层是被绑定成"立体声对"的,你强行要6通道,系统要么给你返回2通道的重复数据,要么直接初始化失败。真正能跑通6路独立录音的,通常是那些面向车载、会议、工业场景的定制设备,它们的音频HAL明确声明了AUDIO_DEVICE_IN_BACK_MIC、AUDIO_DEVICE_IN_EXT_MIC等多个独立输入设备。
所以这篇文章要解决的核心问题是:在Android 13上,如何正确地用AudioRecord去请求多通道输入,如何判断设备是否真的支持6路独立麦克风,以及拿到数据后怎么拆分和验证每一路。适合谁看?做车载语音、会议全向拾音、工业设备声学监测的Android开发,以及任何被"多路录音"需求折磨过的同行。如果你只是想做普通录音,这篇文章可能有点"杀鸡用牛刀",但如果你想搞清楚Android音频输入的底层逻辑,往下看绝对不亏。
2. 方案选型:为什么是AudioRecord而不是MediaRecorder
2.1 MediaRecorder的先天局限
MediaRecorder是Android给"录一段音频存成文件"这个场景准备的高层API。它的优点是简单,几行代码就能录出AAC、AMR、MP3等格式的文件。但它的缺点同样致命:它只输出编码后的单路音频流,不给你碰原始PCM的机会,更不给你按通道拆分数据的能力。
我试过用MediaRecorder设置setAudioChannels(6),结果在大部分设备上直接抛IllegalArgumentException,少数设备虽然不报错,但录出来的文件依然是立体声,多出来的通道被系统静默丢弃了。原因很简单,MediaRecorder的编码器(比如AAC)本身对多通道PCM的支持就有限,而且它的设计目标从来不是"多路独立采集",而是"把麦克风听到的东西压成一个文件"。
所以只要你的需求里出现了"每一路麦克风的数据要单独处理"——比如做波束成形、声源定位、多通道降噪——MediaRecorder就可以直接排除了。
2.2 AudioRecord的核心优势
AudioRecord是Android最低层的音频采集API(再往下就是native的AAudio和OpenSL ES了)。它的工作模式很纯粹:你给它一个AudioFormat,它给你一个ByteBuffer或short[],里面是按帧排列的原始PCM。多通道数据在PCM里是交错排列的,比如6通道16bit,每一帧就是12个字节,顺序是ch0_low, ch0_high, ch1_low, ch1_high, ..., ch5_low, ch5_high。你拿到这个buffer之后,想怎么拆就怎么拆,想怎么处理就怎么处理。
这就是我们选AudioRecord的根本原因:它把"采集"和"处理"解耦了。采集层只负责把6路麦克风的原始数据搬过来,处理层(降噪、AEC、波束成形)完全由我们自己控制。对于多路录音这种需求,这种解耦是必须的。
2.3 Android 13在音频输入上的变化
Android 13(API 33)在音频方面有几个值得注意的改动。首先是AudioRecord.Builder对setAudioFormat的校验更严格了,如果你声明的通道数和设备实际支持的通道数不匹配,build()会直接抛异常,而不是像老版本那样"静默降级"。这其实是好事,至少让你在初始化阶段就知道设备行不行,而不是录了半天发现数据是错的。
其次是AudioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)返回的设备列表更准确了。在Android 13上,你可以通过遍历输入设备,检查每个设备的AudioDeviceInfo.getChannelCounts()来确认它到底支持几个通道。如果某个设备返回的channelCounts里包含6,那它才有可能给你6路独立数据。
还有一个细节是AudioRecord.getRoutedDevice()这个API,它能在录音过程中告诉你当前数据实际来自哪个设备。多路录音时,如果系统偷偷把路由切到了主MIC,你通过这个API就能发现。
2.4 方案对比表
| 方案 | 多通道支持 | 原始PCM | 通道拆分 | 适用场景 |
|---|---|---|---|---|
| MediaRecorder | 基本不支持 | 否 | 否 | 单路录音存文件 |
| AudioRecord | 支持(依赖HAL) | 是 | 是 | 多路采集、声学处理 |
| AAudio (native) | 支持 | 是 | 是 | 超低延迟专业场景 |
| OpenSL ES | 支持 | 是 | 是 | 老设备兼容 |
对于绝大多数Android应用层开发,AudioRecord是性价比最高的选择。AAudio虽然延迟更低,但需要写JNI,调试成本高;OpenSL ES已经是过时方案,新项目不建议碰。
3. 核心细节解析:CHANNEL_IN_6与设备能力探测
3.1 CHANNEL_IN_6到底是什么
AudioFormat.CHANNEL_IN_6是Android定义的一个通道掩码常量,值为0x000000fc。它表示"6个输入通道"。与之对应的还有CHANNEL_IN_MONO(1通道)、CHANNEL_IN_STEREO(2通道)。注意,这些常量只是"掩码",不是"布局"。也就是说,你告诉系统"我要6个通道",但系统怎么把这6个通道映射到物理麦克风上,是由HAL层决定的。
在标准的通道布局里,6通道通常对应5.1环绕声的布局:前左、前右、中置、低频、后左、后右。但在手机或车载设备上,这个映射往往是厂商自定义的。我见过一台车载设备,它的6个麦克风分别对应"驾驶员位、副驾位、后排左、后排右、车顶、后备箱",但HAL层上报的通道顺序和物理位置完全对不上,最后是靠逐个拍打麦克风、观察哪一路数据有峰值才把映射关系理清楚的。
注意:不要假设
CHANNEL_IN_6的通道顺序是固定的。拿到数据后,第一件事应该是做通道映射验证。
3.2 用AudioManager探测设备能力
在创建AudioRecord之前,必须先确认设备到底支不支持6通道输入。这一步很多人会跳过,结果就是build()抛异常,然后一脸懵。正确的做法是:
AudioManager audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); AudioDeviceInfo[] devices = audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS); for (AudioDeviceInfo device : devices) { int[] channelCounts = device.getChannelCounts(); int[] sampleRates = device.getSampleRates(); int[] encodings = device.getEncodings(); Log.d(TAG, "Device: " + device.getProductName()); Log.d(TAG, " Type: " + device.getType()); Log.d(TAG, " ChannelCounts: " + Arrays.toString(channelCounts)); Log.d(TAG, " SampleRates: " + Arrays.toString(sampleRates)); Log.d(TAG, " Encodings: " + Arrays.toString(encodings)); }这段代码会打印出所有输入设备的能力。如果某个设备的channelCounts里包含6,那它才有可能支持6通道。如果所有设备最多只报2通道,那这台设备就是物理上不支持,再怎么折腾代码也没用。
我实测过的一台设备,getChannelCounts()返回的是[1, 2],但它的HAL层其实有6个麦克风。这种情况下,你需要用AudioRecord.Builder配合setAudioSource指定一个特殊的source(比如厂商自定义的AudioSource常量),才能激活多通道模式。这种"隐藏能力"在消费级手机上很少见,但在定制设备上很常见。
3.3 采样率与位深的选择
多通道录音对采样率和位深的选择有额外约束。通道数越多,单位时间的数据量越大,对I/O和内存的压力也越大。计算公式很简单:
数据率 = 采样率 × 通道数 × 位深 / 8比如48000Hz、6通道、16bit,数据率就是48000 × 6 × 2 = 576000 字节/秒,也就是约562KB/s。如果录10分钟,就是约337MB。这个量级对手机存储来说不算小,所以位深和采样率要按需选择。
我的建议是:
- 语音场景:16000Hz、16bit足够,6通道数据率约187KB/s
- 音乐/声学分析:44100Hz或48000Hz、16bit
- 高精度声源定位:48000Hz、32bit float(但AudioRecord对float的支持要看设备)
采样率的选择还要看设备的getSampleRates()返回值。如果设备只支持44100Hz,你硬要48000Hz,build()会失败。Android 13上,AudioRecord.getMinBufferSize()也会根据通道数返回不同的值,这个值必须作为buffer大小的下限。
3.4 Buffer大小的计算
AudioRecord的buffer大小直接决定了录音的稳定性和延迟。太小会丢帧,太大会增加延迟。标准做法是:
int minBufferSize = AudioRecord.getMinBufferSize( SAMPLE_RATE, AudioFormat.CHANNEL_IN_6, AudioFormat.ENCODING_PCM_16BIT ); int bufferSize = minBufferSize * 2; // 留一倍余量getMinBufferSize()返回的是"能稳定录音的最小buffer字节数"。乘以2是为了应对系统调度抖动。但注意,这个值在多通道场景下可能会很大,比如6通道48000Hz下,minBufferSize可能是几十KB。如果你对延迟敏感,可以尝试用AudioRecord.Builder.setBufferSizeInBytes()手动指定一个更小的值,但要承担丢帧的风险。
实操心得:我一般会把bufferSize设成
minBufferSize * 4,然后在读取线程里用read()的阻塞模式。这样即使系统偶尔卡顿,也不会丢数据。代价是延迟会增加几十毫秒,对录音场景来说完全可以接受。
4. 完整实操:从初始化到6路数据拆分
4.1 权限与前台服务
Android 13上录音权限有了新变化。RECORD_AUDIO依然是必须的,但如果你要在后台录音,必须启动一个前台服务,并且声明FOREGROUND_SERVICE_MICROPHONE权限(API 34开始强制,API 33上建议提前适配)。
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />运行时权限申请这块,Android 13把READ_MEDIA_AUDIO从READ_EXTERNAL_STORAGE里拆出来了,但录音本身只需要RECORD_AUDIO。我踩过的一个坑是:在Android 13上,如果用户拒绝了RECORD_AUDIO,AudioRecord的build()不会抛异常,而是返回一个STATE_UNINITIALIZED的实例,然后startRecording()静默失败。所以一定要在初始化后检查getState()。
4.2 AudioRecord初始化代码
下面是完整的初始化代码,我加了详细注释:
public class MultiChannelRecorder { private static final String TAG = "MultiChRecorder"; private static final int SAMPLE_RATE = 48000; private static final int CHANNEL_COUNT = 6; private static final int ENCODING = AudioFormat.ENCODING_PCM_16BIT; private AudioRecord audioRecord; private int bufferSize; private boolean isRecording = false; public boolean init() { // 1. 计算最小buffer int minBuffer = AudioRecord.getMinBufferSize( SAMPLE_RATE, AudioFormat.CHANNEL_IN_6, ENCODING ); if (minBuffer == AudioRecord.ERROR_BAD_VALUE) { Log.e(TAG, "设备不支持6通道48000Hz"); return false; } bufferSize = minBuffer * 4; // 2. 用Builder创建实例 AudioFormat format = new AudioFormat.Builder() .setEncoding(ENCODING) .setSampleRate(SAMPLE_RATE) .setChannelMask(AudioFormat.CHANNEL_IN_6) .build(); audioRecord = new AudioRecord.Builder() .setAudioSource(MediaRecorder.AudioSource.MIC) .setAudioFormat(format) .setBufferSizeInBytes(bufferSize) .build(); // 3. 检查状态 if (audioRecord.getState() != AudioRecord.STATE_INITIALIZED) { Log.e(TAG, "AudioRecord初始化失败, state=" + audioRecord.getState()); audioRecord.release(); audioRecord = null; return false; } Log.i(TAG, "初始化成功, bufferSize=" + bufferSize); return true; } }这段代码里最关键的是第3步的状态检查。我见过太多人写完build()就直接startRecording(),结果什么都没录到,还找不到原因。
4.3 录音线程与数据读取
AudioRecord.read()是阻塞式的,必须放在独立线程里。读取的时候要注意,返回的字节数不一定是bufferSize,可能是任意值,所以要用返回值来控制处理范围。
private Thread recordThread; public void start() { if (audioRecord == null) return; isRecording = true; audioRecord.startRecording(); recordThread = new Thread(() -> { byte[] buffer = new byte[bufferSize]; while (isRecording) { int bytesRead = audioRecord.read(buffer, 0, buffer.length); if (bytesRead > 0) { processMultiChannelData(buffer, bytesRead); } else if (bytesRead < 0) { Log.e(TAG, "读取错误: " + bytesRead); break; } } }, "AudioRecordThread"); recordThread.start(); }这里有个细节:read()返回负数表示错误,返回0表示没有数据(非阻塞模式下),返回正数才是实际读到的字节数。不要假设每次都能读满buffer。
4.4 6路数据拆分
拿到交错的PCM数据后,拆分的逻辑很直接。16bit PCM,每帧12字节(6通道×2字节),第i个通道的数据在每帧的第i*2和i*2+1字节。
private short[][] channelBuffers = new short[CHANNEL_COUNT][]; private void processMultiChannelData(byte[] data, int length) { int frameCount = length / (CHANNEL_COUNT * 2); // 确保每个通道的buffer够大 for (int ch = 0; ch < CHANNEL_COUNT; ch++) { if (channelBuffers[ch] == null || channelBuffers[ch].length < frameCount) { channelBuffers[ch] = new short[frameCount]; } } // 拆分 for (int frame = 0; frame < frameCount; frame++) { for (int ch = 0; ch < CHANNEL_COUNT; ch++) { int byteIndex = (frame * CHANNEL_COUNT + ch) * 2; // 小端序:低字节在前 short sample = (short) ((data[byteIndex] & 0xFF) | (data[byteIndex + 1] << 8)); channelBuffers[ch][frame] = sample; } } // 此时channelBuffers[0]到[5]就是6路独立数据 // 可以分别做降噪、VAD、声源定位等处理 }这段代码里有个容易出错的地方:字节序。Android的PCM数据是小端序(little-endian),低字节在前。如果你按大端序解析,得到的数据会全是噪声。我当初就在这上面浪费了半天时间,听回放的时候以为是麦克风坏了,其实是解析错了。
4.5 通道映射验证方法
前面说过,CHANNEL_IN_6的通道顺序不一定是物理顺序。验证方法很简单:逐个拍打麦克风,观察哪一路数据出现峰值。
具体操作:
- 启动录音,实时计算每个通道的短时能量(比如每100ms的RMS)
- 用手指轻轻拍打第1个麦克风,观察哪个通道的能量飙升
- 记录映射关系,重复6次
我一般会写一个简单的能量显示界面,6个进度条实时刷新。这样拍一遍就能把映射关系理清楚。实测下来,大部分设备的通道顺序和物理位置是对应的,但车载设备经常是乱的。
注意:拍打的时候力度要轻,别把MEMS麦克风拍坏了。MEMS麦克风虽然皮实,但过大的声压级会导致削波,影响判断。
5. 常见问题与排查技巧实录
5.1 初始化失败:STATE_UNINITIALIZED
这是最常见的问题,原因通常有三个:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| build()后state=0 | 设备不支持6通道 | 检查getChannelCounts() |
| build()后state=0 | 采样率不支持 | 检查getSampleRates() |
| build()后state=0 | 权限被拒 | 检查checkSelfPermission |
| build()抛异常 | 参数非法 | 检查CHANNEL_IN_6拼写 |
我遇到过一次特别诡异的情况:设备明明支持6通道,但build()就是失败。后来发现是AudioSource选错了。默认的MIC在某些设备上只暴露2通道,换成MediaRecorder.AudioSource.UNPROCESSED(未处理音频)之后,6通道就出来了。原因是MIC这个source会经过系统的降噪、AGC等处理链路,而处理链路通常只支持2通道;UNPROCESSED绕过这些处理,直接拿原始数据,多通道就通了。
实操心得:如果
MIC不行,试试UNPROCESSED、VOICE_RECOGNITION、CAMCORDER这几个source。不同厂商的HAL实现不一样,多试几个往往能碰对。
5.2 录到的6路数据完全一样
这个问题我遇到过两次。第一次是因为设备HAL层把6个麦克风的数据复制成了6份,本质上还是单路。第二次是因为AudioSource选的是MIC,系统做了混音后复制到6个通道。
判断方法:计算6路数据的两两相关系数。如果相关系数接近1,说明数据是复制的;如果接近0,说明是独立的。正常情况下,同一房间里的6个麦克风,相关系数应该在0.3到0.8之间(取决于麦克风间距和声源位置)。
5.3 录音过程中数据断流
多通道录音的数据量是单通道的6倍,如果读取线程处理不及时,buffer会溢出,导致丢帧。表现是录音文件里有"咔咔"的爆音,或者数据长度对不上。
解决方法:
- 增大bufferSize(但会增加延迟)
- 把数据处理逻辑从读取线程里剥离,用生产者-消费者模式
- 降低采样率或位深
我一般会用ArrayBlockingQueue把原始数据快速转移到处理线程,读取线程只负责read()和入队,保证不阻塞。
5.4 Android 13上的权限静默失败
Android 13有个坑:如果用户之前拒绝过RECORD_AUDIO,再次申请时系统可能直接返回拒绝,不弹窗。这时候AudioRecord会静默失败。解决方法是在申请权限前先检查shouldShowRequestPermissionRationale(),如果返回false且权限未授予,说明用户选了"不再询问",需要引导用户去设置页手动开启。
5.5 常见问题速查表
| 问题 | 排查方向 | 解决手段 |
|---|---|---|
| 初始化失败 | 设备能力、权限、source | 换source、检查channelCounts |
| 数据全一样 | HAL复制、source混音 | 换UNPROCESSED、验证相关性 |
| 数据断流 | buffer太小、线程阻塞 | 增大buffer、异步处理 |
| 全是噪声 | 字节序错误 | 改小端序解析 |
| 通道顺序乱 | HAL自定义映射 | 拍打验证、建立映射表 |
| 录音无声 | 权限静默拒绝 | 检查权限、引导设置页 |
6. 性能优化与进阶方向
6.1 内存与CPU优化
6通道48000Hz 16bit的数据率是576KB/s,一分钟就是34.5MB。如果直接存内存,几分钟就OOM了。我的做法是:读取线程只做拆分,拆分后的数据立刻写入环形缓冲区或直接落盘,不在内存里堆积。
CPU方面,拆分操作本身很轻量(就是位运算),真正的开销在后续处理(降噪、AEC)。如果6路都要做实时降噪,建议用native层实现,Java层的性能撑不住。
6.2 写入WAV文件的正确姿势
多通道PCM要存成WAV,需要在文件头里声明通道数。标准的WAV头是44字节,其中偏移22处是通道数(2字节小端),偏移24处是采样率(4字节小端),偏移28处是字节率(采样率×通道数×位深/8),偏移32处是块对齐(通道数×位深/8)。
我见过有人直接把6通道数据写成单通道WAV,结果播放器只播第一路,还以为是录音坏了。WAV头必须和实际数据匹配。
6.3 进阶:波束成形与声源定位
拿到6路独立数据后,能做的事情就多了。最典型的是波束成形:通过对6路信号施加不同的延迟和权重,可以"聚焦"到某个方向的声音,抑制其他方向的噪声。这在会议全向麦克风、车载语音里非常常用。
另一个方向是声源定位:利用6个麦克风之间的到达时间差(TDOA),可以计算出说话人的方位。6个麦克风可以覆盖360度,精度取决于麦克风间距和采样率。48000Hz下,相邻采样点对应的时间分辨率约20微秒,对应声程约7毫米,这个精度做粗略定位足够了。
6.4 设备选型建议
如果你正在选型做多路录音的产品,我的建议是:
- 消费级手机:基本别指望6路独立,最多2路(主MIC+副MIC),而且副MIC的数据质量通常很差
- 车载设备:部分车型的音频HAL支持4到6路,但需要厂商提供文档或自己逆向
- USB麦克风阵列:这是最靠谱的方案,比如ReSpeaker、Matrix Creator等,通过USB接入Android,HAL层会把它识别为多通道输入设备
- 定制Android板:直接找方案商要支持多路I2S输入的板子,HAL层可以定制
我个人在实际操作中的体会是,多路录音这件事,软件层面的代码其实不复杂,复杂的是设备能力的探测和通道映射的验证。很多时候你以为是代码问题,其实是硬件或HAL不支持。所以动手写代码之前,先用AudioManager.getDevices()把设备能力摸清楚,能省掉大量无效调试。
最后再分享一个小技巧:如果你手头没有6麦克风的设备,可以用USB音频接口接多个麦克风来模拟。Android 13对USB音频设备的支持比老版本好很多,getDevices()能正确枚举出每个USB音频输入设备。虽然它们可能被识别成多个独立的2通道设备而不是一个6通道设备,但用来验证拆分逻辑和通道映射是够用的。