ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Android 13多路录音实战:AudioRecord 6通道PCM采集与拆分

Android 13多路录音实战:AudioRecord 6通道PCM采集与拆分 1. 多路录音到底难在哪从AudioRecord的底层逻辑说起Android录音这件事看起来简单——调个AudioRecord传个AudioFormatstartRecording()就完事了。但一旦你需要的不是一路混音后的立体声而是同时拿到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而不是MediaRecorder2.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 13API 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_MONO1通道、CHANNEL_IN_STEREO2通道。注意这些常量只是掩码不是布局。也就是说你告诉系统我要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你硬要48000Hzbuild()会失败。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:nameandroid.permission.RECORD_AUDIO / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE_MICROPHONE /运行时权限申请这块Android 13把READ_MEDIA_AUDIO从READ_EXTERNAL_STORAGE里拆出来了但录音本身只需要RECORD_AUDIO。我踩过的一个坑是在Android 13上如果用户拒绝了RECORD_AUDIOAudioRecord的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*21字节。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()后state0设备不支持6通道检查getChannelCounts()build()后state0采样率不支持检查getSampleRates()build()后state0权限被拒检查checkSelfPermissionbuild()抛异常参数非法检查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接入AndroidHAL层会把它识别为多通道输入设备定制Android板直接找方案商要支持多路I2S输入的板子HAL层可以定制我个人在实际操作中的体会是多路录音这件事软件层面的代码其实不复杂复杂的是设备能力的探测和通道映射的验证。很多时候你以为是代码问题其实是硬件或HAL不支持。所以动手写代码之前先用AudioManager.getDevices()把设备能力摸清楚能省掉大量无效调试。最后再分享一个小技巧如果你手头没有6麦克风的设备可以用USB音频接口接多个麦克风来模拟。Android 13对USB音频设备的支持比老版本好很多getDevices()能正确枚举出每个USB音频输入设备。虽然它们可能被识别成多个独立的2通道设备而不是一个6通道设备但用来验证拆分逻辑和通道映射是够用的。
返回列表