尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Android 13 六路麦克风并发录音实战:AudioRecord 配置与避坑指南

Android 13 六路麦克风并发录音实战:AudioRecord 配置与避坑指南 1. 六路麦克风同时录音Android 13 到底放开了什么第一次接到同时录 6 个麦克风的需求时我的直觉是这事儿在 Android 上根本做不了。原因很简单绝大多数手机厂商在系统层面对录音通道做了硬性限制普通应用能拿到 1 路、顶多 2 路音频输入就已经算给面子了。但 Android 13 确实在这个方向上松了一道口子配合正确的设备选型和 API 调用方式多路并发录音是可以跑通的。这篇文章要讲清楚三件事Android 13 在多路录音上到底提供了什么能力、AudioRecord 怎么配置才能同时打开 6 路输入、以及实际跑起来之后你会遇到哪些文档里不会写的坑。适合已经熟悉 Android 音频基础、正在做多麦克风阵列采集、声源定位或者波束成形相关开发的工程师参考。如果你只是想做普通录音这篇文章的内容对你来说属于过度设计可以跳过。先说结论Android 13 本身并没有提供一个一键开 6 路的 API它做的是放宽了 AudioRecord 对多实例并发的限制同时要求硬件层HAL和驱动层真正暴露出多个可独立寻址的输入设备。换句话说系统给了你可能性但能不能跑通取决于你手上的设备是不是真的把 6 个麦克风都接出来了。我实测用的设备是一台基于 Android 13 的定制开发板板载 6 颗 MEMS 数字麦克风通过 I2S/TDM 接口挂到同一路音频控制器上。这个硬件前提非常关键——如果你拿一台普通手机哪怕系统是 Android 13大概率也只能看到 1 到 2 个输入设备。所以下面的内容我会先讲清楚设备枚举这一关再讲 AudioRecord 的并发配置。提示多路录音的可行性 系统版本 HAL 实现 硬件通道数三者缺一不可。不要假设Android 13 就一定能录 6 路。2. 先搞清楚设备到底暴露了几路输入2.1 用 AudioManager 和 AudioDeviceInfo 做设备枚举动手写 AudioRecord 之前第一步永远是枚举当前系统能看到的输入设备。Android 提供了AudioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)这个接口返回一个AudioDeviceInfo数组。每一路独立的输入通道理论上会对应一个AudioDeviceInfo条目。AudioManager audioManager (AudioManager) getSystemService(Context.AUDIO_SERVICE); AudioDeviceInfo[] inputDevices audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS); for (AudioDeviceInfo device : inputDevices) { Log.d(MultiMic, id device.getId() type device.getType() name device.getProductName() channels device.getChannelCounts().length sampleRates Arrays.toString(device.getSampleRates())); }这段代码跑出来之后你会看到类似这样的输出不同硬件差异很大设备 ID类型产品名通道数采样率1TYPE_BUILTIN_MICbuiltin-mic-01480002TYPE_BUILTIN_MICbuiltin-mic-11480003TYPE_BUILTIN_MICbuiltin-mic-2148000...............这里有个非常容易踩的坑很多设备只会返回一个 TYPE_BUILTIN_MIC但它的 channelCounts 里写着 6。这种情况下6 个麦克风是被打包成一路 TDM 多通道流暴露的你不能用 6 个 AudioRecord 实例去分别打开而是要用一个 AudioRecord 打开 6 通道然后在数据回调里做通道分离de-interleave。这两种硬件形态对应完全不同的代码路径所以枚举这一步必须做扎实。判断方法很简单数一数getDevices返回的输入设备数量再看每个设备的getChannelCounts()。2.2 两种硬件形态独立设备 vs TDM 多通道我把这两种形态的差异整理成一张表方便你对照自己的硬件对比项独立多设备形态TDM 多通道形态设备枚举结果6 个 AudioDeviceInfo1 个 AudioDeviceInfochannelCount6AudioRecord 实例数6 个1 个数据获取方式每个实例独立回调单流交错数据需手动分离通道同步性依赖 HAL可能有微小偏移硬件级同步天然对齐适用场景分布式麦克风麦克风阵列、波束成形从工程实践角度看做声源定位、波束成形这类对通道间相位一致性要求高的应用优先选 TDM 多通道形态。因为 6 个独立 AudioRecord 实例各自有独立的缓冲和时钟采样点之间可能存在几个到几十个采样周期的偏移这个偏移会直接破坏相位信息让波束成形算法失效。而如果你的 6 个麦克风物理上分散在不同位置、只做简单的能量采集或语音活动检测独立设备形态反而更灵活因为你可以单独控制每一路的增益和开关。2.3 用 adb 和 tinycap 验证底层通道在写应用层代码之前我强烈建议先用底层工具确认硬件通道是真的通的。Android 自带tinycap这个命令行工具可以直接抓取 PCM 数据adb shell tinycap /sdcard/test.wav -D 0 -d 0 -c 6 -r 48000 -b 16参数含义-D 0指定声卡-d 0指定设备-c 6指定 6 通道-r 48000采样率-b 16位深。如果这条命令能正常录出文件并且用 Audacity 打开后能看到 6 条独立的波形说明 HAL 和驱动层是通的问题只可能在应用层。如果 tinycap 报错或者只录到 1 路那就要回头查 HAL 配置了应用层再怎么调也没用。这一步能帮你快速定位问题边界省下大量瞎调试的时间。3. AudioRecord 并发配置的核心参数怎么定3.1 采样率、位深与通道掩码的取舍配置 AudioRecord 时最关键的四个参数是音频源、采样率、通道掩码、位深。这四个参数必须和硬件能力严格匹配否则AudioRecord构造会失败或者初始化后getState()返回STATE_UNINITIALIZED。音频源AudioSource多路录音场景下MediaRecorder.AudioSource.MIC是最通用的选择。有些定制硬件会提供UNPROCESSED源它能绕过系统的自动增益、降噪、回声消除等处理链路拿到最原始的麦克风数据。做阵列算法一定要用UNPROCESSED否则系统的前处理会破坏通道间的幅度和相位关系。int audioSource MediaRecorder.AudioSource.UNPROCESSED; // 如果设备不支持 UNPROCESSED回退到 MIC if (!isSourceSupported(audioSource)) { audioSource MediaRecorder.AudioSource.MIC; }采样率优先用设备原生支持的采样率通常是 48000 Hz。不要想当然地用 44100因为很多 MEMS 麦克风的时钟就是按 48k 系列设计的用 44.1k 会触发重采样既增加 CPU 开销又可能引入通道间的不一致。用AudioDeviceInfo.getSampleRates()拿到支持列表从中选一个。通道掩码这是多路录音的核心。TDM 形态下用AudioFormat.CHANNEL_IN_6如果系统定义了或者直接用数值掩码。Android 的通道掩码是按位定义的标准定义只到CHANNEL_IN_5左右6 通道以上往往需要厂商扩展。如果标准常量不够用可以直接传数值// 标准常量不够用时用位掩码组合 int channelMask AudioFormat.CHANNEL_IN_LEFT | AudioFormat.CHANNEL_IN_RIGHT | AudioFormat.CHANNEL_IN_FRONT | AudioFormat.CHANNEL_IN_BACK | AudioFormat.CHANNEL_IN_LEFT_BACK | AudioFormat.CHANNEL_IN_RIGHT_BACK;位深ENCODING_PCM_16BIT是最稳妥的选择兼容性最好。如果硬件支持 24 位或 32 位浮点且你的算法需要更高动态范围可以用ENCODING_PCM_FLOAT但要确认 HAL 真的支持否则会静默降级或报错。3.2 缓冲区大小的计算与实测调整缓冲区大小直接决定了录音的稳定性和延迟。太小会丢帧read()返回负值或 0太大则增加延迟。Android 提供了AudioRecord.getMinBufferSize()作为参考下限int minBufferSize AudioRecord.getMinBufferSize( sampleRate, channelMask, AudioFormat.ENCODING_PCM_16BIT ); // 实际缓冲区取最小值的 2 到 4 倍给系统留余量 int bufferSize minBufferSize * 4;这里有个经验值6 通道 48kHz 16bit 的情况下每秒数据量是 48000 × 6 × 2 576000 字节。如果缓冲区只给 minBufferSize在高负载场景下极容易 underrun。我实测下来取 minBufferSize 的 4 倍比较稳再大就会明显感觉到延迟。对于 TDM 单流形态缓冲区还要考虑通道分离的处理时间。如果你在回调线程里做 de-interleave缓冲区太小会导致回调过于频繁CPU 被频繁唤醒。建议把缓冲区设到能容纳 20 到 40 毫秒的数据量。3.3 权限与前台服务别让系统在后台掐掉你的录音Android 13 对后台录音的管控更严了。RECORD_AUDIO权限是基础但如果你在后台录音必须启动一个前台服务并且声明FOREGROUND_SERVICE_MICROPHONE权限Android 14 起强制13 上建议提前适配。uses-permission android:nameandroid.permission.RECORD_AUDIO / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE_MICROPHONE /前台服务的类型要声明为microphoneservice android:name.MultiMicRecordService android:foregroundServiceTypemicrophone /我踩过的一个坑在 Android 13 上如果应用退到后台但没有前台服务系统会在几秒内静默停止录音AudioRecord.read()开始返回 0但不会抛异常。你以为是硬件问题其实是系统把录音掐了。所以多路录音一定要跑在前台服务里这是硬性要求。4. 六路录音的完整代码实现4.1 TDM 单流形态一个 AudioRecord 搞定 6 通道先讲 TDM 形态因为这是阵列场景最常用的。核心思路是用一个 AudioRecord 打开 6 通道然后在读取回调里把交错数据分离成 6 个独立通道。public class TdmMultiChannelRecorder { private static final int SAMPLE_RATE 48000; private static final int CHANNEL_COUNT 6; private static final int BYTES_PER_SAMPLE 2; // 16bit private AudioRecord audioRecord; private Thread recordThread; private volatile boolean isRecording false; public boolean init() { int channelMask AudioFormat.CHANNEL_IN_6; // 厂商扩展常量 int minBuf AudioRecord.getMinBufferSize( SAMPLE_RATE, channelMask, AudioFormat.ENCODING_PCM_16BIT); if (minBuf 0) { Log.e(MultiMic, getMinBufferSize failed: minBuf); return false; } int bufferSize minBuf * 4; audioRecord new AudioRecord( MediaRecorder.AudioSource.UNPROCESSED, SAMPLE_RATE, channelMask, AudioFormat.ENCODING_PCM_16BIT, bufferSize ); return audioRecord.getState() AudioRecord.STATE_INITIALIZED; } public void start() { if (audioRecord null || audioRecord.getState() ! AudioRecord.STATE_INITIALIZED) { return; } isRecording true; audioRecord.startRecording(); recordThread new Thread(this::recordLoop, tdm-record); recordThread.start(); } private void recordLoop() { // 每次读 1024 帧每帧 6 通道 short[] interleaved new short[1024 * CHANNEL_COUNT]; short[][] channels new short[CHANNEL_COUNT][1024]; while (isRecording) { int read audioRecord.read(interleaved, 0, interleaved.length); if (read 0) { Log.w(MultiMic, read returned read); continue; } int frames read / CHANNEL_COUNT; deinterleave(interleaved, channels, frames); // 把 channels 交给后续算法处理 onAudioFrame(channels, frames); } } private void deinterleave(short[] src, short[][] dst, int frames) { for (int ch 0; ch CHANNEL_COUNT; ch) { for (int i 0; i frames; i) { dst[ch][i] src[i * CHANNEL_COUNT ch]; } } } private void onAudioFrame(short[][] channels, int frames) { // 这里接入你的波束成形 / 声源定位算法 } public void stop() { isRecording false; if (recordThread ! null) { try { recordThread.join(500); } catch (InterruptedException ignored) {} } if (audioRecord ! null) { audioRecord.stop(); audioRecord.release(); audioRecord null; } } }这段代码的关键点在于deinterleave的效率。6 通道 48kHz 意味着每秒要处理 28.8 万个采样点如果 de-interleave 写得低效CPU 会很快吃满。上面用的是最朴素的嵌套循环实测在中等性能的 SoC 上单核占用约 15%。如果不够可以用 NEON 指令做向量化或者干脆把 de-interleave 推迟到算法内部按需读取。4.2 独立多设备形态6 个 AudioRecord 实例并发如果你的硬件是 6 个独立输入设备那就需要开 6 个 AudioRecord 实例。这里最大的挑战是启动时序和线程管理。public class MultiDeviceRecorder { private static final int SAMPLE_RATE 48000; private final ListAudioRecord recorders new ArrayList(); private final ListThread threads new ArrayList(); private volatile boolean isRecording false; public boolean init(ListAudioDeviceInfo devices) { for (AudioDeviceInfo device : devices) { int channelMask AudioFormat.CHANNEL_IN_MONO; int minBuf AudioRecord.getMinBufferSize( SAMPLE_RATE, channelMask, AudioFormat.ENCODING_PCM_16BIT); AudioRecord record new AudioRecord.Builder() .setAudioSource(MediaRecorder.AudioSource.UNPROCESSED) .setAudioFormat(new AudioFormat.Builder() .setEncoding(AudioFormat.ENCODING_PCM_16BIT) .setSampleRate(SAMPLE_RATE) .setChannelMask(channelMask) .build()) .setBufferSizeInBytes(minBuf * 4) .setPreferredDevice(device) // 关键绑定到指定设备 .build(); if (record.getState() ! AudioRecord.STATE_INITIALIZED) { Log.e(MultiMic, init failed for device device.getId()); return false; } recorders.add(record); } return true; } public void start() { isRecording true; // 先全部 startRecording再统一开线程读减少启动偏移 for (AudioRecord r : recorders) { r.startRecording(); } for (int i 0; i recorders.size(); i) { final int idx i; Thread t new Thread(() - readLoop(idx), mic- idx); threads.add(t); t.start(); } } private void readLoop(int idx) { AudioRecord record recorders.get(idx); short[] buffer new short[1024]; while (isRecording) { int read record.read(buffer, 0, buffer.length); if (read 0) { onChannelData(idx, buffer, read); } } } private void onChannelData(int channelIndex, short[] data, int length) { // 按通道索引处理数据 } public void stop() { isRecording false; for (Thread t : threads) { try { t.join(500); } catch (InterruptedException ignored) {} } for (AudioRecord r : recorders) { r.stop(); r.release(); } recorders.clear(); threads.clear(); } }setPreferredDevice(device)是绑定特定输入设备的关键 API。如果不设置系统会默认路由到主麦克风6 个实例可能全部录到同一路信号。4.3 通道对齐独立实例形态绕不开的同步问题独立实例形态最头疼的就是通道对齐。6 个 AudioRecord 各自有独立的时钟和缓冲即使你在同一时刻调用startRecording()实际开始采集的时刻也会有差异。我实测下来这个偏移在 5 到 30 毫秒之间波动换算成采样点是 240 到 1440 个。对于只做能量检测的应用这个偏移无所谓。但如果你要做通道间的时间差定位TDOA这个偏移是致命的。解决办法有两个方案一用已知信号做校准。在正式录音前播放一个所有麦克风都能同时收到的脉冲信号比如拍手然后通过互相关找到各通道的峰值位置算出偏移量后续处理时补偿掉。这个方法简单有效但每次设备重启或环境变化都要重新校准。方案二改用 TDM 形态。如果硬件允许直接换成单流多通道从根上消除同步问题。这也是我在实际项目中最终采用的方案——与其在应用层费劲对齐不如在硬件层就保证同步。注意不要指望用System.nanoTime()打时间戳来对齐因为 AudioRecord 的缓冲机制会让数据到达时间和采集时间之间产生不确定的延迟时间戳对不上。5. 实测中那些文档不会告诉你的坑5.1 采样率不匹配导致的静默失败我遇到过一个非常隐蔽的问题AudioRecord 初始化成功getState()返回STATE_INITIALIZEDstartRecording()也不报错但read()一直返回 0。排查了很久才发现硬件实际支持的是 48000 Hz但我传了 44100系统没有报错而是静默地不产生数据。教训初始化后一定要做一次实际的 read 测试确认能拿到非零数据再进入正式流程。不要只看getState()。// 初始化后做一次自检 short[] probe new short[1024]; int read audioRecord.read(probe, 0, probe.length); if (read 0) { Log.e(MultiMic, probe read failed, check sample rate / channel mask); }5.2 通道掩码写错数据全乱通道掩码和实际通道数不匹配时数据会错位。比如硬件是 6 通道你传了CHANNEL_IN_STEREO2 通道系统会按 2 通道解析 6 通道的数据流结果就是通道 0 和 1 交替出现完全乱套。判断方法录一段已知的声音比如只在第 1 个麦克风前说话然后看 6 个通道的数据正常情况应该只有通道 0 有信号其他通道接近静音。如果多个通道都有信号说明掩码或 de-interleave 逻辑有问题。5.3 高负载下的 underrun 与数据丢失6 通道 48kHz 的数据量不小如果系统同时跑着其他重负载任务很容易出现 underrun。表现是read()偶尔返回比预期少的帧数或者日志里出现AudioRecord: obtainBuffer timed out。应对措施提高录音线程优先级Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO)增大缓冲区到 minBufferSize 的 4 到 8 倍避免在录音线程里做重计算把数据快速拷贝到环形缓冲区交给独立的工作线程处理// 在录音线程开头设置优先级 Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO);5.4 厂商 HAL 的兼容性差异不同厂商对多通道录音的 HAL 实现差异极大。有的厂商在getDevices里正确暴露 6 个设备有的只暴露 1 个但支持 6 通道掩码还有的干脆把多通道录音禁掉了。我遇到过一台设备getMinBufferSize对 6 通道返回正常值但AudioRecord构造直接抛IllegalArgumentException。建议在项目初期就做一轮设备兼容性摸底把目标设备的多通道能力测一遍形成一张兼容性表。不要等到开发到一半才发现某台设备根本不支持。设备型号系统版本枚举设备数支持通道数备注开发板 AAndroid 1316TDM 形态需 UNPROCESSED开发板 BAndroid 1361独立设备需手动对齐手机 CAndroid 1312不支持 6 通道6. 数据落盘与后续处理链路的衔接6.1 原始 PCM 的存储格式选择录到的 6 通道数据落盘时有两种选择存成交错的原始 PCM或者存成 6 个独立文件。交错 PCM 的好处是保留了通道间的原始时序关系后续处理时自己 de-interleave 即可独立文件的好处是方便单独查看每一路。我一般存成交错 PCM因为这样不会在存储环节引入额外的通道对齐假设。文件头可以自己写一个简单的 WAV 头标明 6 通道这样用 Audacity 直接就能打开看波形。// 写 WAV 头简化版 private void writeWavHeader(FileOutputStream fos, int sampleRate, int channels, int bitsPerSample, int dataLen) throws IOException { ByteBuffer header ByteBuffer.allocate(44).order(ByteOrder.LITTLE_ENDIAN); header.put(RIFF.getBytes()); header.putInt(36 dataLen); header.put(WAVE.getBytes()); header.put(fmt .getBytes()); header.putInt(16); header.putShort((short) 1); // PCM header.putShort((short) channels); header.putInt(sampleRate); header.putInt(sampleRate * channels * bitsPerSample / 8); header.putShort((short) (channels * bitsPerSample / 8)); header.putShort((short) bitsPerSample); header.put(data.getBytes()); header.putInt(dataLen); fos.write(header.array()); }6.2 从录音到算法环形缓冲区的设计录音线程和算法线程之间一定要用环形缓冲区解耦。录音线程只管往缓冲区里写算法线程只管从缓冲区里读两者通过读写指针同步。这样即使算法偶尔卡顿也不会导致录音丢数据。环形缓冲区的大小建议至少能容纳 1 秒的数据也就是 576000 字节6 通道 48kHz 16bit。用AtomicInteger管理读写指针避免加锁带来的开销。public class RingBuffer { private final short[] buffer; private final AtomicInteger writePos new AtomicInteger(0); private final AtomicInteger readPos new AtomicInteger(0); public RingBuffer(int capacityFrames, int channels) { this.buffer new short[capacityFrames * channels]; } public synchronized void write(short[] data, int offset, int length) { // 写入逻辑处理环绕 } public synchronized int read(short[] out, int length) { // 读取逻辑返回实际读取长度 return 0; } }6.3 多路数据的实时可视化验证开发阶段强烈建议做一个简单的实时波形显示把 6 个通道的波形画出来。这样一眼就能看出哪一路没数据、哪一路有串扰、通道间是否有明显延迟。用SurfaceView或者Custom View画 6 条波形每帧从环形缓冲区取最新数据刷新即可。这个可视化工具帮我省了大量排查时间。有一次发现通道 3 和通道 4 的波形完全一样一查才发现是 HAL 配置里把这两个通道映射到了同一个物理麦克风。这种问题光看代码是发现不了的必须靠波形对比。7. 性能与功耗的平衡取舍6 路 48kHz 录音对 CPU 和功耗的压力不小。我实测下来纯录音不做算法的情况下单核占用约 10% 到 15%整机功耗增加约 300 到 500 毫瓦。如果加上波束成形等算法CPU 占用会显著上升。几个优化方向降低采样率。如果应用场景不需要 48kHz 的带宽比如只做人声检测16kHz 足够把采样率降到 16kHz数据量直接降到三分之一CPU 和功耗都会明显下降。但要注意降采样率前确认硬件支持且不会影响通道同步。按需开启通道。如果某些场景只需要 2 到 3 路就不要开满 6 路。AudioRecord 的通道数是初始化时确定的但你可以根据场景动态创建不同通道数的实例。用 DMA 和硬件缓冲。这个属于 HAL 层优化应用层能做的有限。但如果你的硬件支持大缓冲 DMA尽量让 HAL 配置大一点的硬件缓冲减少中断频率。算法下采样处理。如果算法对实时性要求不高可以先把数据攒够一批再处理减少线程唤醒次数。比如每 100 毫秒处理一次而不是每 20 毫秒。我在实际项目里的做法是录音线程只做最轻量的 de-interleave 和环形缓冲写入所有算法处理放到独立的工作线程并且工作线程的优先级低于录音线程。这样即使算法偶尔超时也不会影响录音的连续性。最后分享一个调试技巧如果怀疑是性能问题导致丢数据可以在录音线程里统计每秒实际读到的帧数和理论值对比。理论值是 48000 帧/秒如果实测明显低于这个数就说明有丢帧需要往性能方向排查。这个统计我一般会做成一个实时显示的计数器跑起来一眼就能看出问题。
返回列表