拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Android 13多路录音实战:AudioRecord六通道配置与避坑指南

1. 多路录音到底难在哪从AudioRecord的底层逻辑说起Android录音这件事表面上看就是拿个AudioRecord往缓冲区里读PCM数据简单得不能再简单。但一旦把需求改成“同时录6个麦克风”事情就完全不一样了。我在第一次接到这个需求的时候第一反应是“开6个AudioRecord实例不就行了”结果实测下来直接翻车——要么只能打开一两个要么打开之后数据全是零要么直接抛异常。后来花了不少时间啃AudioRecord的源码和AudioFlinger的混音策略才把这条路走通。先把结论放在前面Android 13上想同时录制6路麦克风核心不在于AudioRecord怎么写而在于设备本身有没有6个物理麦克风通道、HAL层有没有把多通道数据暴露出来、以及AudioRecord能不能用CHANNEL_IN_6这个通道掩码去打开它。三者缺一不可。很多人卡在第一步就以为是自己代码写错了其实是硬件根本不支持。这篇文章适合谁看如果你正在做车载语音、会议全向拾音、工业设备声源定位、或者多麦克风阵列的Android端采集那这篇内容基本就是给你写的。我会把从设备能力探测、通道掩码选择、AudioRecord参数配置、到实际读数据的完整链路拆开讲代码可以直接抄。如果你只是想做普通的单麦克风录音那这篇可能有点“杀鸡用牛刀”但里面关于AudioRecord参数选择的思路同样适用。先解释一个最容易被忽略的概念Android的录音通道数和物理麦克风数量不是一回事。手机上有3个麦克风不代表你能录到3通道数据。系统默认只会把多麦克风的数据在HAL层做波束成形、降噪之后混成1路或者2路立体声给上层。你想拿到原始的6路独立数据必须满足两个条件一是设备的audio_policy_configuration.xml里声明了对应的输入设备支持6通道二是这个输入设备没有被系统默认的混音策略吃掉。这也是为什么很多人用CHANNEL_IN_STEREO能录到2路但换成CHANNEL_IN_6就直接报错——不是AudioRecord不认这个常量是底层根本没有对应的输入profile。提示CHANNEL_IN_6这个常量在Android SDK里是存在的但它只是一个“请求”能不能兑现完全看设备。不要以为写了这个常量就一定能录到6路。我在实际项目里踩过的最大的坑就是拿一台普通手机去测6路录音折腾了两天以为是代码问题最后用AudioManager.getDevices()一查发现设备只暴露了一个TYPE_BUILTIN_MIC通道数最大就是2。换了一台带多路MEMS麦克风阵列的开发板之后同样的代码一次就跑通了。所以下面第一节我先讲怎么判断你的设备到底行不行这比写代码重要得多。2. 动手之前先探底设备到底支不支持6路输入2.1 用AudioManager把输入设备的能力翻个底朝天很多人写录音代码的习惯是直接new一个AudioRecord就开始读从来不查设备能力。单路录音这么干没问题多路录音这么干就是纯碰运气。正确的做法是先通过AudioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)拿到所有输入设备然后逐个看它的AudioDeviceInfo里声明的通道掩码和采样率。AudioManager audioManager (AudioManager) getSystemService(Context.AUDIO_SERVICE); AudioDeviceInfo[] devices audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS); for (AudioDeviceInfo device : devices) { int[] channelMasks device.getChannelMasks(); int[] sampleRates device.getSampleRates(); int[] encodings device.getEncodings(); Log.d(AudioProbe, 设备类型: device.getType()); Log.d(AudioProbe, 通道掩码: Arrays.toString(channelMasks)); Log.d(AudioProbe, 采样率: Arrays.toString(sampleRates)); Log.d(AudioProbe, 编码格式: Arrays.toString(encodings)); }这段代码跑出来的结果就是你判断设备能力的唯一依据。如果channelMasks里出现了AudioFormat.CHANNEL_IN_6对应的值也就是0x3F二进制6个1那说明设备在框架层声明了支持6通道输入。如果只有CHANNEL_IN_STEREO0xC或者CHANNEL_IN_MONO0x10那你就别指望用标准API录到6路了得走别的路子。这里有个细节要注意getChannelMasks()返回的是设备支持的通道掩码集合不是当前可用的通道数。有些设备会声明支持6通道但实际打开的时候因为资源被占用或者HAL限制只能给你2通道。所以探测到支持只是第一步真正能不能打开还得看AudioRecord.getState()。2.2 从audio_policy_configuration.xml反推硬件能力如果你有设备的root权限或者能拿到系统镜像直接看/vendor/etc/audio_policy_configuration.xml是最准的。这个文件里会定义每个输入设备的profile包括channelMasks和samplingRates。我一般会重点看这几个字段devicePort tagNameBuilt-In Mic typeAUDIO_DEVICE_IN_BUILTIN_MIC rolesource profile name formatAUDIO_FORMAT_PCM_16_BIT samplingRates48000 channelMasksAUDIO_CHANNEL_IN_6/ /devicePort如果这里写的是AUDIO_CHANNEL_IN_6那恭喜你硬件和HAL层是支持的。如果写的是AUDIO_CHANNEL_IN_STEREO那上层再怎么折腾也拿不到6路。这个文件是只读的改不了所以它其实就是设备能力的“判决书”。注意有些厂商会在HAL层做手脚xml里声明支持6通道但实际驱动只接了2个麦克风剩下的通道填的是静音数据。这种情况你用AudioRecord能打开也能读到数据但读到的6路里可能有4路全是0。判断方法是录一段有声音的音频然后看每一路的RMS值如果某几路一直是0那就是硬件没接。2.3 一个快速验证设备是否真的能出6路数据的方法与其纠结配置文件不如直接写个最小验证程序用CHANNEL_IN_6打开AudioRecord录3秒钟然后把6个通道的数据分别算一下能量。如果6个通道都有明显的能量变化说明硬件是真的6路如果只有前2路有数据后面4路是0那就是假的6路。int minBufferSize AudioRecord.getMinBufferSize( 48000, AudioFormat.CHANNEL_IN_6, AudioFormat.ENCODING_PCM_16BIT); AudioRecord recorder new AudioRecord( MediaRecorder.AudioSource.MIC, 48000, AudioFormat.CHANNEL_IN_6, AudioFormat.ENCODING_PCM_16BIT, minBufferSize * 2); if (recorder.getState() ! AudioRecord.STATE_INITIALIZED) { Log.e(AudioProbe, 6通道初始化失败设备不支持); return; }getMinBufferSize这一步很关键。如果设备不支持CHANNEL_IN_6这个函数会返回ERROR_BAD_VALUE你就能提前知道走不通不用等到startRecording才报错。我一般会把这个检查放在最前面省得后面白忙活。3. AudioRecord六通道参数怎么配才不翻车3.1 通道掩码、采样率、缓冲区三者的取舍逻辑确认设备支持6通道之后接下来就是配参数。AudioRecord的构造函数有5个关键参数audioSource、sampleRateInHz、channelConfig、audioFormat、bufferSizeInBytes。这5个参数里任何一个配错都会导致初始化失败或者录出来是噪音。先说audioSource。多路录音场景下我一般用MediaRecorder.AudioSource.MIC或者MediaRecorder.AudioSource.UNPROCESSED。这两个的区别在于MIC会经过系统的降噪、AGC等处理UNPROCESSED则是尽量拿原始数据。如果你做的是声源定位、波束成形这类对相位敏感的应用必须用UNPROCESSED因为系统的降噪处理会改变各通道之间的相位关系导致算法失效。但UNPROCESSED不是所有设备都支持得先用AudioManager.getProperty查一下。String unprocessedSupported audioManager.getProperty( AudioManager.PROPERTY_SUPPORT_AUDIO_SOURCE_UNPROCESSED); if (true.equals(unprocessedSupported)) { audioSource MediaRecorder.AudioSource.UNPROCESSED; } else { audioSource MediaRecorder.AudioSource.MIC; }再说采样率。6通道录音对带宽的要求是单通道的6倍。48kHz、16bit、6通道一秒钟的数据量是48000 × 2 × 6 576KB。这个带宽对大多数设备来说不算大但有些低端设备的I2S总线可能扛不住会丢帧。如果遇到丢帧可以降到16kHz试试。我实测下来48kHz在大多数支持6通道的设备上都能跑但如果是车载场景16kHz其实够用了因为语音的主要能量集中在4kHz以下。缓冲区大小的计算有个经验公式bufferSize minBufferSize × 2。getMinBufferSize返回的是能维持录音不丢帧的最小值但实际用的时候建议翻倍给系统留点余量。如果缓冲区太小read会频繁返回0或者负数如果太大延迟会变高。6通道场景下我一般会再乘一个通道系数int minBufferSize AudioRecord.getMinBufferSize(48000, AudioFormat.CHANNEL_IN_6, AudioFormat.ENCODING_PCM_16BIT); int bufferSize minBufferSize * 2;3.2 为什么CHANNEL_IN_6不是随便写的AudioFormat.CHANNEL_IN_6这个常量的值是0x3F也就是二进制的0011 1111低6位全是1。Android用位掩码来表示通道布局每一位代表一个通道。CHANNEL_IN_STEREO是0xC1100CHANNEL_IN_MONO是0x101 0000。这些掩码不是随便定的它们对应的是audio_channel_mask_t这个底层枚举。当你传CHANNEL_IN_6给AudioRecord的时候框架会拿这个掩码去匹配设备的输入profile。如果设备的profile里声明的channelMasks包含0x3F匹配成功录音通道打开如果不包含getMinBufferSize就会返回错误值。这就是为什么我一直强调“先探测再写代码”——掩码对不对不是你能决定的是设备决定的。还有一个容易踩的坑有些设备支持6通道但只支持CHANNEL_IN_6不支持CHANNEL_IN_5或者CHANNEL_IN_4。你如果想录4路不能直接写CHANNEL_IN_4得先查设备支持哪些掩码。我遇到过一台设备只声明了CHANNEL_IN_6和CHANNEL_IN_STEREO想录4路的话只能录6路然后丢掉2路。这种硬件设计上的“任性”只能靠探测来规避。3.3 数据读取6通道PCM在内存里是怎么排的6通道录音读出来的数据是交错排列的也就是L1 R1 L2 R2 L3 R3 ...这种形式。每一帧包含6个采样点每个采样点2字节16bit。假设你一次读1024字节那就是1024 / (2 × 6) 85帧每帧6个采样点。short[] buffer new short[frameCount * 6]; int read recorder.read(buffer, 0, buffer.length); // 分离6个通道 for (int i 0; i read / 6; i) { short ch1 buffer[i * 6]; short ch2 buffer[i * 6 1]; short ch3 buffer[i * 6 2]; short ch4 buffer[i * 6 3]; short ch5 buffer[i * 6 4]; short ch6 buffer[i * 6 5]; // 分别处理 }这里有个性能上的注意点不要在录音线程里做复杂的通道分离和算法处理。录音线程的唯一任务就是把数据从AudioRecord读到内存里然后丢给一个阻塞队列让工作线程去处理。如果你在录音线程里做FFT或者波束成形很容易导致读数据不及时进而丢帧。我一般会用一个ArrayBlockingQueue做缓冲录音线程只管往里塞工作线程只管从里取。提示read返回的是实际读到的short数量不是帧数。计算帧数的时候要除以通道数。如果read返回负数说明录音出错了常见的是ERROR_INVALID_OPERATION和ERROR_BAD_VALUE。4. 完整代码实现从初始化到落盘的每一步4.1 初始化阶段的完整代码与参数注释下面这段代码是我在实际项目里用的初始化逻辑包含了设备探测、参数选择、异常处理。你可以直接复制到项目里改改就能用。public class MultiChannelRecorder { private static final String TAG MultiChannelRecorder; private static final int SAMPLE_RATE 48000; private static final int CHANNEL_COUNT 6; private static final int CHANNEL_MASK AudioFormat.CHANNEL_IN_6; private static final int ENCODING AudioFormat.ENCODING_PCM_16BIT; private AudioRecord audioRecord; private Thread recordThread; private volatile boolean isRecording false; private ArrayBlockingQueueshort[] dataQueue; public boolean init(Context context) { AudioManager am (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); // 第一步探测设备是否支持6通道 AudioDeviceInfo[] devices am.getDevices(AudioManager.GET_DEVICES_INPUTS); boolean support6Ch false; for (AudioDeviceInfo device : devices) { for (int mask : device.getChannelMasks()) { if (mask CHANNEL_MASK) { support6Ch true; break; } } } if (!support6Ch) { Log.e(TAG, 设备不支持6通道输入); return false; } // 第二步计算最小缓冲区 int minBufferSize AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_MASK, ENCODING); if (minBufferSize AudioRecord.ERROR_BAD_VALUE || minBufferSize AudioRecord.ERROR) { Log.e(TAG, 缓冲区计算失败: minBufferSize); return false; } // 第三步选择音频源 int audioSource MediaRecorder.AudioSource.MIC; String unprocessed am.getProperty( AudioManager.PROPERTY_SUPPORT_AUDIO_SOURCE_UNPROCESSED); if (true.equals(unprocessed)) { audioSource MediaRecorder.AudioSource.UNPROCESSED; } // 第四步创建AudioRecord int bufferSize minBufferSize * 2; audioRecord new AudioRecord(audioSource, SAMPLE_RATE, CHANNEL_MASK, ENCODING, bufferSize); if (audioRecord.getState() ! AudioRecord.STATE_INITIALIZED) { Log.e(TAG, AudioRecord初始化失败); audioRecord.release(); audioRecord null; return false; } dataQueue new ArrayBlockingQueue(64); Log.i(TAG, 初始化成功, bufferSize bufferSize); return true; } }这段代码里有几个地方值得展开说。第一getMinBufferSize的返回值一定要检查它返回负数的时候说明参数组合不被支持这时候不要硬着头皮往下走。第二UNPROCESSED的检查用getProperty这个属性在Android 7.0之后才有低版本会返回null所以判断的时候用true.equals()而不是直接比较。第三bufferSize我用了minBufferSize * 2这是经验值如果你发现录音有杂音或者丢帧可以再往上加。4.2 录音线程与数据分发录音线程的设计原则是“只读不处理”。下面这个recordThread只做一件事从AudioRecord读数据然后塞进队列。public void startRecording() { if (audioRecord null || isRecording) return; isRecording true; audioRecord.startRecording(); recordThread new Thread(() - { short[] buffer new short[1024 * CHANNEL_COUNT]; while (isRecording) { int read audioRecord.read(buffer, 0, buffer.length); if (read 0) { short[] data new short[read]; System.arraycopy(buffer, 0, data, 0, read); try { dataQueue.put(data); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } else { Log.w(TAG, read返回异常: read); } } }, AudioRecordThread); recordThread.start(); }这里buffer的大小是1024 * 6也就是一次读1024帧。这个值不是固定的你可以根据实际情况调整。读到的数据我做了个拷贝再入队因为buffer是复用的不拷贝的话下一轮读会覆盖掉。这个拷贝有内存开销但6通道场景下数据量不大可以接受。如果你追求极致性能可以用双缓冲或者直接操作ByteBuffer但代码复杂度会上升不少。工作线程从队列里取数据做通道分离和后续处理public void startProcessing() { new Thread(() - { while (isRecording || !dataQueue.isEmpty()) { try { short[] data dataQueue.poll(100, TimeUnit.MILLISECONDS); if (data null) continue; processMultiChannelData(data); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } }, ProcessThread).start(); } private void processMultiChannelData(short[] data) { int frameCount data.length / CHANNEL_COUNT; short[][] channels new short[CHANNEL_COUNT][frameCount]; for (int i 0; i frameCount; i) { for (int ch 0; ch CHANNEL_COUNT; ch) { channels[ch][i] data[i * CHANNEL_COUNT ch]; } } // 到这里channels[0]到channels[5]就是6路独立数据 // 可以送去做波束成形、声源定位、或者直接写文件 }4.3 落盘把6路数据写成WAV文件调试阶段最实用的功能就是把6路数据分别写成WAV文件用Audacity或者Adobe Audition打开看波形。WAV头是44字节写起来不复杂但有几个字段容易写错。public static void writeWavHeader(OutputStream out, int sampleRate, int channelCount, int totalDataLen) throws IOException { byte[] header new byte[44]; int byteRate sampleRate * channelCount * 2; // RIFF header[0] R; header[1] I; header[2] F; header[3] F; writeIntLE(header, 4, 36 totalDataLen); header[8] W; header[9] A; header[10] V; header[11] E; // fmt header[12] f; header[13] m; header[14] t; header[15] ; writeIntLE(header, 16, 16); // fmt chunk size writeShortLE(header, 20, (short) 1); // PCM writeShortLE(header, 22, (short) channelCount); writeIntLE(header, 24, sampleRate); writeIntLE(header, 28, byteRate); writeShortLE(header, 32, (short) (channelCount * 2)); // block align writeShortLE(header, 34, (short) 16); // bits per sample // data header[36] d; header[37] a; header[38] t; header[39] a; writeIntLE(header, 40, totalDataLen); out.write(header); }写WAV的时候有个坑totalDataLen是数据字节数不是帧数。6通道16bit的话每帧12字节。如果你录了1000帧totalDataLen就是12000。这个值写错了播放器打开文件会显示时长不对或者直接报错。我一般会先把数据写到临时文件最后再回填头部这样就不用提前知道总长度。注意如果你要把6路数据写成6个独立的单通道WAV那每个文件的channelCount都写1byteRate也要相应改成sampleRate * 2。不要直接把6通道的数据塞进单通道WAV头里那样播放出来是噪音。5. 踩坑实录那些文档里不会写的坑5.1 权限、后台限制与录音中断Android 13对录音权限管得很严。RECORD_AUDIO是危险权限必须动态申请。但很多人不知道的是Android 9之后后台应用默认不能录音。如果你的应用切到后台还想继续录必须在Foreground Service里录并且声明foregroundServiceTypemicrophone。service android:name.RecordService android:foregroundServiceTypemicrophone android:exportedfalse/没有这个声明切后台之后AudioRecord.read会返回0或者直接抛异常。我遇到过最诡异的情况是前台录得好好的锁屏之后数据就断了查了半天才发现是后台限制。另外Android 13还引入了“录音时状态栏显示绿点”的机制这个不影响功能但用户能看到做产品的时候要考虑这个UI提示。还有一个容易忽略的点电话打进来的时候录音会被系统强制中断。这时候AudioRecord会进入STATE_UNINITIALIZED你需要监听AudioManager.ACTION_AUDIO_BECOMING_NOISY广播在中断后重新初始化。这个在车载场景里特别重要因为车载系统经常有电话接入。5.2 多应用同时录音的冲突问题Android 9之前多个应用同时录音是允许的谁先打开谁先录。Android 9之后系统引入了“录音焦点”机制默认情况下同一时间只有一个应用能录音。如果你的应用在录音另一个应用也想录后者会录到静音数据。这个机制对多路录音的影响在于如果你的应用被别的应用抢了录音焦点你的6路数据会全部变成0。解决办法是在AndroidManifest.xml里声明android:allowAudioPlaybackCapture这个是针对播放捕获的录音焦点没有直接的豁免声明或者引导用户关闭其他录音应用。更稳妥的做法是监听AudioManager.AudioRecordingCallback在录音被抢占的时候及时保存数据并提示用户。AudioManager.AudioRecordingCallback callback new AudioManager.AudioRecordingCallback() { Override public void onRecordingConfigChanged(ListAudioRecordingConfiguration configs) { super.onRecordingConfigChanged(configs); for (AudioRecordingConfiguration config : configs) { if (config.getClientAudioSource() MediaRecorder.AudioSource.MIC) { // 有其他应用在录音 Log.w(TAG, 录音焦点被抢占); } } } }; audioManager.registerAudioRecordingCallback(callback, null);5.3 常见问题速查表问题现象可能原因排查方法解决方案getMinBufferSize返回ERROR_BAD_VALUE设备不支持CHANNEL_IN_6用getDevices查通道掩码换设备或降级到2通道初始化成功但读到的数据全是0录音焦点被抢占或硬件通道未接检查AudioRecordingCallback关闭其他录音应用或换设备6路里只有前2路有数据HAL层只接了2个麦克风分别计算6路RMS确认硬件规格录音有杂音或断断续续缓冲区太小或采样率过高增大bufferSize或降低采样率48kHz降到16kHz试试切后台后录音停止没有用Foreground Service检查Service声明加foregroundServiceType录出来的WAV时长不对WAV头totalDataLen写错检查头部第40字节用临时文件回填头部read返回ERROR_INVALID_OPERATIONAudioRecord状态异常检查getState()重新初始化6路数据相位不一致用了MIC而不是UNPROCESSED检查audioSource改用UNPROCESSED这张表里的每一条都是我实际踩过的。特别是“6路里只有前2路有数据”这一条我一开始以为是代码问题后来用示波器量了麦克风的I2S信号才发现硬件上只焊了2个麦克风另外4个通道是悬空的。所以做多路录音之前一定要先确认硬件规格书别像我一样白折腾。5.4 几个提升稳定性的实操心得第一个心得录音线程的优先级要调高。Android默认的线程优先级是THREAD_PRIORITY_DEFAULT录音线程建议调到THREAD_PRIORITY_URGENT_AUDIO。这个在AudioRecord内部其实已经做了但如果你自己起了线程去读最好手动设一下。Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO);第二个心得不要在录音过程中频繁调用getState()。这个函数会加锁频繁调用会影响录音线程的实时性。我一般只在初始化之后检查一次运行过程中靠read的返回值来判断状态。第三个心得6通道数据的内存拷贝要小心。48kHz、16bit、6通道一分钟的数据是34MB左右。如果你在录音线程里做深拷贝GC压力会很大。我一般用short[]池化复用或者直接用ByteBuffer的direct模式减少堆内存分配。第四个心得测试的时候先用AudioSource.DEFAULT跑通再换UNPROCESSED。有些设备对UNPROCESSED的支持不完整用DEFAULT能录到数据换UNPROCESSED就全是0。先用DEFAULT确认链路通了再逐步替换参数这样排查问题的时候变量少。第五个心得如果设备支持6通道但只给你2通道数据试试AudioRecord.Builder。Android 6.0之后推荐用Builder模式创建AudioRecord它比构造函数更灵活可以设置setAudioFormat和setBufferSizeInBytes。有些设备用构造函数打不开6通道用Builder反而可以。AudioRecord recorder new AudioRecord.Builder() .setAudioSource(MediaRecorder.AudioSource.UNPROCESSED) .setAudioFormat(new AudioFormat.Builder() .setEncoding(AudioFormat.ENCODING_PCM_16BIT) .setSampleRate(48000) .setChannelMask(AudioFormat.CHANNEL_IN_6) .build()) .setBufferSizeInBytes(bufferSize) .build();这个Builder模式我在几个不同厂商的设备上都试过兼容性确实比直接调构造函数好一些。特别是那些Android 13的定制ROM用Builder能绕过一些厂商自己加的检查逻辑。最后说一个关于CHANNEL_IN_6的冷知识这个常量在Android的官方文档里其实没有详细说明很多人以为它只能用于特定场景。但实际上只要设备的HAL层声明了6通道输入CHANNEL_IN_6就是通用的。我甚至在Android 13的模拟器上试过模拟器默认不支持6通道但如果你在config.ini里把hw.audio.input.channels改成6重启之后就能用CHANNEL_IN_6录到6路数据虽然模拟器的6路数据都是同一个麦克风复制出来的。这个技巧在开发阶段用来验证代码逻辑很有用不用等硬件到位就能先把代码跑通。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门