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

Android音频开发:AudioRecord.getAudioFormat()实战解析与避坑指南

做Android音频开发的人十有八九都栽在“拿到了音频数据却不知道怎么解释”这件事上。我前段时间在Android 16真机上做录音功能用AudioRecord采集PCM数据数据是出来了但拿去做频谱分析时全乱套查了两天才定位到问题——我在读取数据时默认按16位PCM去解析而某些设备上AudioRecord实际输出的音频格式并不是我构造时指定的那一个。这时候才认真研究起AudioRecord.getAudioFormat()这个接口。这篇文章就围绕它展开聊清楚它到底返回什么、怎么用、用在哪以及我在实战中踩过的坑。1. 从一次录音数据解析翻车说起1.1 为什么“音频格式”这件事会被忽略很多Android开发者在创建AudioRecord的时候眼睛只盯着构造参数里的audioFormat代码写起来基本都是这样AudioRecord audioRecord new AudioRecord( MediaRecorder.AudioSource.MIC, 44100, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, bufferSize);然后拿到byte[]之后默认按两个字节一个short去解析写文件、做算法、送编码器全链路都按16位有符号整数来处理。大部分情况下确实没问题因为绝大多数手机默认都支持ENCODING_PCM_16BIT你填了这个值底层就给你这个值。但问题恰恰出在这个“默认”上。我那次翻车就是在Android 16的测试设备上我为了提高动态范围把编码格式改成了ENCODING_PCM_FLOAT。改完以后没有重新核对AudioRecord内部实际生效的格式结果数据流里面全是浮点位型数据我却还在用short去解释频谱上冒出来的全是鬼畜般的噪声毛刺。查日志时发现getAudioFormat()返回的是ENCODING_PCM_FLOAT和我构造时传入的参数一致但我自己的解析代码根本没有跟着格式切换。也就是说音频格式决定了每个采样点占几个字节、字节序是什么、数据是无符号还是有符号这套解释规则只要错了一个环节后面的所有处理全废。1.2 数据和格式必须一起读这里说句经验之谈采集到的byte[]是纯粹的字节流本身没有意义是“格式”给这些字节赋予了意义。打个比方你拿到一本全是十六进制数字的笔记本如果不知道它是UTF-8编码还是GBK编码照着错误编码去翻译读出来的就是乱码。音频格式就是那本“编码表”。AudioRecord在构造时虽然会让你传入audioFormat但传进去之后底层驱动、音频策略、硬件能力都有可能在中间做权衡和替换。举个典型例子你指定了一个设备不支持的编码格式比如某些中端机型上的ENCODING_PCM_24BIT_PACKEDAudioRecord为了不直接抛异常可能会悄悄降级成ENCODING_PCM_16BIT继续工作。这时你再拿构造参数去驱动后续逻辑就踩坑了。Android系统提供的AudioRecord.getAudioFormat()就是为了让你在初始化完成后重新询问一次“当前实际生效的音频数据格式”。在Android 16API 36上我专门跑过这个接口没有被标记废弃行为也保持稳定依然返回AudioFormat类里的ENCODING_*常量。所以不要嫌麻烦初始化完以后务必调一次把它当成整个录音管线的唯一信源。2. getAudioFormat()到底返回了什么2.1 方法签名与官方语义这个接口的签名很简单public int getAudioFormat()官方语义是返回当前AudioRecord实例配置的音频数据格式configured audio data format。注意几个容易混淆的点它返回的是“音频数据编码格式”也就是AudioFormat.ENCODING_*那一系列常量不是采样率不是声道数。它读取的是AudioRecord内部实际生效的格式而不一定等于你构造时传入的值。在调用startRecording()之前之后都可以调用一般建议在构造完成后立刻读取一次后面就不要再频繁调用了。AudioFormat这个类名字比较有迷惑性它其实是个“音频参数集合”里面既有ENCODING_PCM_16BIT这种数据编码也有CHANNEL_IN_MONO这种声道配置还有采样率相关的常量。AudioRecord.getAudioFormat()只和数据编码有关这一点必须分清楚。2.2 用一个demo看真实返回值写段简易代码验证一下AudioRecord audioRecord new AudioRecord( MediaRecorder.AudioSource.MIC, 44100, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_FLOAT, bufferSize); if (audioRecord.getState() AudioRecord.STATE_INITIALIZED) { int actualFormat audioRecord.getAudioFormat(); Log.d(AudioFormatDemo, requested: AudioFormat.ENCODING_PCM_FLOAT , actual: actualFormat); if (actualFormat AudioFormat.ENCODING_PCM_FLOAT) { Log.d(AudioFormatDemo, 设备支持浮点PCM按float解析); } else if (actualFormat AudioFormat.ENCODING_PCM_16BIT) { Log.d(AudioFormatDemo, 设备降级为16位PCM按short解析); } }这一段代码的价值在于它把“我请求的”和“我实际得到的”明确区分开。我在不同设备上的实测结果差异很大旗舰机上指定的ENCODING_PCM_FLOAT基本都能生效但部分中低端机型尤其是某些定制ROM即使SDK版本是Android 16也会在后台把浮点格式转成16位短整型。这样你的解析逻辑必须跟着getAudioFormat()走不能跟着构造参数走。2.3 这些枚举值背后的编码细节AudioRecord.getAudioFormat()返回的是AudioFormat.ENCODING_*常量里的某一个常用的也就是下面这几个常量每个采样占字节数说明ENCODING_PCM_16BIT2有符号短整型-32768到32767最通用ENCODING_PCM_8BIT1无符号0到255静音基准是128ENCODING_PCM_FLOAT4float类型-1.0到1.0适合算法处理ENCODING_PCM_24BIT_PACKED324位整数打包存储3字节对齐ENCODING_PCM_32BIT432位有符号整数动态范围更高这里面的字节数很有用。设通道数为channelCount那么一帧音频数据的字节数就是frameSizeInBytes channelCount * bytesPerSample比如双声道16位PCM一帧就是2 * 2 4字节。你调用read()返回的字节数不一定刚好是帧大小的整数倍特别是在流式读取的时候所以解析时最好按帧对齐最后剩下不足一帧的尾巴单独处理。另外提一句ENCODING_PCM_8BIT并不是“压缩成8位”它照样是PCM只不过每个采样点用一个字节表示采样值的动态范围和信噪比都差很多目前只有在老旧的通信协议或低功耗设备上才见得到。Android官方文档也没有推荐把它作为首选编码。3. 手写一个录音实例构造、获取格式、读取与落盘3.1 权限与初始化背后的事要用AudioRecord录音第一步是申请RECORD_AUDIO权限。Android 6.0之后动态权限跑不掉Android 16上依然如此。建议在初始化前先检查权限否则构造出来也是不可用状态。初始化时有个关键参数bufferSizeInBytes。这个值不是随便拍脑袋填的推荐用AudioRecord.getMinBufferSize()去拿int minBufferSize AudioRecord.getMinBufferSize( 44100, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT); if (minBufferSize 0) { // 处理不支持的参数 return; } // 实际使用的缓冲一般要比最小值大一些避免频繁阻塞 int bufferSize Math.max(minBufferSize * 2, 8192);这里特别注意getMinBufferSize()的最后一个参数是音频格式和getAudioFormat()返回的值是同一个体系。如果你先把AudioRecord构造用某个格式跑起来了建议直接用audioRecord.getAudioFormat()返回的格式去计算缓冲区不要再用外部“我以为的格式”。3.2 获取格式并让数据读取逻辑自适应录音数据读取的常规写法是循环调用read()把数据填进byte[]。这个阶段最容易出的问题就是byte[]里装的是原始字节你根本不知道它是short还是float。用getAudioFormat()可以非常自然地解决这个问题。第一步在初始化完成之后立刻读取格式int actualFormat audioRecord.getAudioFormat();第二步在读取循环里根据actualFormat走不同解析分支byte[] buffer new byte[bufferSize]; int bytesRead audioRecord.read(buffer, 0, buffer.length); if (bytesRead 0) { switch (actualFormat) { case AudioFormat.ENCODING_PCM_16BIT: // 每2个字节转1个short小端序 parseAsPcm16(buffer, bytesRead); break; case AudioFormat.ENCODING_PCM_FLOAT: // 每4个字节转1个float parseAsFloat(buffer, bytesRead); break; case AudioFormat.ENCODING_PCM_8BIT: // 每1个字节注意无符号偏移 parseAsPcm8(buffer, bytesRead); break; default: parseAsPcm16(buffer, bytesRead); break; } }这样做的核心思想解析逻辑由实际格式驱动而不是由构造参数驱动。后续不管你是在真机调试还是换了一台不兼容的设备只要走这套逻辑最多是精度不同不会出现“拿short解析float”这种全盘皆输的惨剧。3.3 完整示例代码Java下面是一个完整的、可以直接拿去改的示例包含动态权限检查、AudioRecord初始化、格式获取、录音循环、释放资源public class AudioRecorderHelper { private static final int SAMPLE_RATE 44100; private AudioRecord audioRecord; private volatile boolean isRecording; private int actualFormat; public boolean start() { if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ! PackageManager.PERMISSION_GRANTED) { return false; } int minBufferSize AudioRecord.getMinBufferSize( SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT); if (minBufferSize 0) { return false; } audioRecord new AudioRecord( MediaRecorder.AudioSource.MIC, SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, Math.max(minBufferSize * 2, 8192)); if (audioRecord.getState() ! AudioRecord.STATE_INITIALIZED) { audioRecord.release(); audioRecord null; return false; } // 关键初始化之后立刻读取实际格式 actualFormat audioRecord.getAudioFormat(); Log.d(AudioRecorder, actual audio format actualFormat); audioRecord.startRecording(); isRecording true; new Thread(this::readLoop).start(); return true; } private void readLoop() { int bufferSize Math.max(actualFormat AudioFormat.ENCODING_PCM_FLOAT ? 4096 : 2048, 1024); byte[] buffer new byte[bufferSize]; while (isRecording audioRecord ! null) { int bytesRead audioRecord.read(buffer, 0, buffer.length); if (bytesRead 0) { handlePcmData(actualFormat, buffer, bytesRead); } } } private void handlePcmData(int format, byte[] data, int size) { // 在这里分发到解析、写入文件、实时处理等逻辑 // format 决定 data 怎么解释 } public void stop() { isRecording false; if (audioRecord ! null) { if (audioRecord.getRecordingState() AudioRecord.RECORDSTATE_RECORDING) { audioRecord.stop(); } audioRecord.release(); audioRecord null; } } public int getActualFormat() { return actualFormat; } }注意我在缓冲区分配那里根据格式做了个粗略调整浮点格式一个采样4字节缓冲可以给大一点。这不是必须的但实测下来可以减少read()的调用次数降低CPU占用。3.4 从PCM到WAV格式参数怎么用录下来的PCM数据要变成通用音频文件最简单的做法是封装成WAV。WAV文件头里有一个audioFormat字段PCM就是1还有一个bitsPerSample字段和编码格式的字节数直接对应。用getAudioFormat()拿到编码以后可以像下面这样计算位深private int getBitsPerSample(int audioFormat) { switch (audioFormat) { case AudioFormat.ENCODING_PCM_8BIT: return 8; case AudioFormat.ENCODING_PCM_16BIT: return 16; case AudioFormat.ENCODING_PCM_24BIT_PACKED: return 24; case AudioFormat.ENCODING_PCM_32BIT: case AudioFormat.ENCODING_PCM_FLOAT: return 32; default: return 16; } }写WAV头的时候把这个位深填进去再用同样的字节数去解释后面的数据块文件才能被播放器正确识别。不少人图省事在代码里写死“16位”一旦格式变了播放器读出来就是刺耳的炸音。我在Android 16上做自动化测试时特意用脚本批量喂不同编码的PCM流凡是没有动态取getAudioFormat()的版本音频文件全废。4. 不同音频编码格式的实测对比与选型建议4.1 我用过的几种格式及表现说下真实感受。ENCODING_PCM_16BIT是当前Android生态里的“公共语言”从低端机到旗舰机从老API到Android 16几乎不会翻车。它的每一个采样点占2字节内存占用适中做语音识别、通话录音、音频分析都够用。ENCODING_PCM_FLOAT在API 21之后可用好处是数据处理阶段不需要做整数到浮点的转换降噪算法、机器学习推理模型大多吃浮点输入直接用float流可以省一次转换。坏处是不一定有统一的硬件支持。我在一台采用某中端SoC的Android 16设备上测过初始化时传ENCODING_PCM_FLOATgetAudioFormat()返回的也确实是ENCODING_PCM_FLOAT但实际读出来的数据在某些采样率下会偶发全零帧最后只能靠切换采样率规避。ENCODING_PCM_8BIT我只在调试老代码时用过一次只能说能响但底噪和削波都相当明显。它适合极小内存设备手机端完全没有必要主动选它。ENCODING_PCM_24BIT_PACKED和ENCODING_PCM_32BIT更像是专业音频设备的领域。普通手机麦克风的信噪比和ADC精度可能根本hold不住这么高的位深选这些格式更多是“心理安慰”。如果业务诉求是录音后做专业后期那也应该在采集端先拿到原始PCM后续在PC端再升位而不是在手机上强行追求24位。4.2 选型建议不要只看格式名如果想平稳落地我的建议很简单没有特殊需求就锁死16位PCM。理由有三个兼容性最广。Android官方文档、示例代码、第三方音频库都默认16位PCM。getMinBufferSize()对它支持最好初始化失败概率最低。转换成本低。把16位有符号short转成float也很便宜计算公式就是floatValue shortValue / 32768.0f。如果你确实需要浮点输入比如要做实时FFT那建议这样处理构造时传ENCODING_PCM_FLOAT初始化后立刻用getAudioFormat()确认如果设备不支持自动回退到16位在读取循环里做一次short到float的实时转换。这样既能保住算法端的输入类型又不会因为设备不支持而崩溃。结合实测来看Android 16在音频框架上没有对ENCODING_PCM_FLOAT做强制统一厂商定制差异依然明显这也是我一直强调“以getAudioFormat()返回值为准”的原因。5. 我在这个接口上踩过的坑5.1 getAudioFormat()返回值和构造参数不一致一次在Android 16模拟器上调试我指定ENCODING_PCM_24BIT_PACKEDgetAudioFormat()返回的却是2。查了一圈发现模拟器底层音频HAL对24位支持有限框架层悄悄把格式回调成了16位。这个行为在官方文档里没有写得很直白只有亲测才能发现。对策是构造之后立刻获取一次并且在日志里打出来。后续所有涉及到逐字节解析的地方都用这个运行期返回值而不是外部传入的常量。我再强调一次这行日志将来能救你的命。5.2 PCM_8BIT 的数据无符号问题用8位PCM时静音不是0而是128。也就是说一个静音的采样点在byte[]里是0x80128不是0x00。如果你按有符号byte去理解会变成-128仿佛听到了巨大的负向脉冲。实际转换时要先把它变成0到255的整数再减去128才是和16位PCM同语义的采样值int sample8 (data[i] 0xFF) - 128;这个坑我见过不少人踩过。如果你从getAudioFormat()发现返回的是ENCODING_PCM_8BIT请务必检查解析函数里有没有做这个无符号偏移。5.3 模拟器上永远听不到声音的教训在Android 16模拟器上开发AudioRecord构造成功getAudioFormat()也返回正常权限也给了但read()读出来的数据全是0。一开始我以为是我的音频格式判断有问题后来发现模拟器的音频输入源根本没有配置宿主机的麦克风没有映射进去。这种场景下的排查顺序应该是先看getAudioFormat()判断格式再看read()返回值是否为负或为0最后检查RECORD_AUDIO权限和模拟器设置。别一上来就怀疑格式问题把接口调用链路的每一步都验证一遍效率更高。5.4 缓冲区尺寸别瞎写和格式强相关getMinBufferSize()的第三个参数就是音频格式。有些同学在不知道最终格式的时候先写死ENCODING_PCM_16BIT算缓冲后面又改用浮点格式缓冲区大小可能不够导致AudioRecord初始化直接失败或者出现ERROR_BAD_VALUE。稳妥的做法是先把你想要的格式传进去计算minBufferSize如果初始化后用getAudioFormat()发现格式变了那就按新格式重新计算缓冲区重新创建AudioRecord。我在一个低延时录音项目里这样处理以后初始化失败率明显下降。6. 从音频格式延伸到周边模块6.1 AudioTrack.getAudioFormat() 与播放端配合和AudioRecord配套的是AudioTrack它也有一个getAudioFormat()方法。做录音回放的时候最怕采集端和播放端格式对不上。比如你采集到的是float PCM回放时AudioTrack却按16位PCM配置出来的声音轻则变调重则全是噪声。正确做法是在回放线程里把录制端的实际格式传过去让AudioTrack也用同样的格式初始化int playbackFormat recorderHelper.getActualFormat(); AudioTrack audioTrack new AudioTrack( new AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_MEDIA) .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) .build(), new AudioFormat.Builder() .setSampleRate(44100) .setEncoding(playbackFormat) .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) .build(), bufferSize, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE);两端格式一致回放才靠谱。这段代码在Android 16上和新旧兼容写法上都能跑关键是那个setEncoding(playbackFormat)它就是整个音频链路的“接头暗号”。6.2 MediaCodec 编码前的格式对齐如果把录音数据送进MediaCodec做AAC编码配置MediaFormat的时候也涉及音频格式。不少人在这一步把KEY_PCM_ENCODING写死成AudioFormat.ENCODING_PCM_16BIT但如果AudioRecord.getAudioFormat()告诉你的实际格式是ENCODING_PCM_FLOAT那编码器就炸了。正确的做法是把录制端的实际格式透传给编码器配置MediaFormat mediaFormat MediaFormat.createAudioFormat( MediaFormat.MIMETYPE_AUDIO_AAC, SAMPLE_RATE, channelCount); mediaFormat.setInteger(MediaFormat.KEY_PCM_ENCODING, actualFormat); mediaFormat.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC); mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, 128_000);KEY_PCM_ENCODING的取值就是AudioFormat里的ENCODING_*系列常量。这里的actualFormat是从getAudioFormat()拿到的不是构造参数不是魔数更不是你脑补出来的值。6.3 一个简单的扩展思路音频格式与可视化做实时频谱或者示波器一类的可视化功能时通常需要把不同编码的PCM数据统一转成float[]。我在实际项目里写过一个归一化方法输入就是getAudioFormat()的返回值private float normalizedSample(byte[] data, int offset, int audioFormat) { switch (audioFormat) { case AudioFormat.ENCODING_PCM_16BIT: short s (short) ((data[offset] 0xFF) | (data[offset 1] 8)); return s / 32768.0f; case AudioFormat.ENCODING_PCM_8BIT: return ((data[offset] 0xFF) - 128) / 128.0f; case AudioFormat.ENCODING_PCM_FLOAT: return ByteBuffer.wrap(data, offset, 4).order(ByteOrder.LITTLE_ENDIAN).getFloat(); default: return 0.0f; } }这样一个方法就可以把任何符合AudioRecord输出规范的PCM数据统一转换成算法层需要的浮点序列。getAudioFormat()在这里承担的角色就是整个数据流活的“元信息”。最后分享一个我自己的习惯所有用到AudioRecord的地方初始化成功之后第一件事就是调用getAudioFormat()把它缓存起来后面不管是写文件、做实时处理、送编码器都用这份运行时缓存作为唯一依据绝不假设构造参数一定等于运行时实际值。同时一定把这个值打到自己应用的调试日志里因为下次遇到诡异问题时排查的第一步就是确认当前设备上实际生效的音频格式到底是多少。按照这个习惯来能省下大量排查时间。
分享:

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

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