做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_PACKED,AudioRecord为了不直接抛异常,可能会悄悄降级成ENCODING_PCM_16BIT继续工作。这时你再拿构造参数去驱动后续逻辑,就踩坑了。
Android系统提供的AudioRecord.getAudioFormat(),就是为了让你在初始化完成后,重新询问一次“当前实际生效的音频数据格式”。在Android 16(API 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_16BIT | 2 | 有符号短整型,-32768到32767,最通用 |
| ENCODING_PCM_8BIT | 1 | 无符号,0到255,静音基准是128 |
| ENCODING_PCM_FLOAT | 4 | float类型,-1.0到1.0,适合算法处理 |
| ENCODING_PCM_24BIT_PACKED | 3 | 24位整数打包存储,3字节对齐 |
| ENCODING_PCM_32BIT | 4 | 32位有符号整数,动态范围更高 |
这里面的字节数很有用。设通道数为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_FLOAT,getAudioFormat()返回的也确实是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_PACKED,getAudioFormat()返回的却是2。查了一圈发现模拟器底层音频HAL对24位支持有限,框架层悄悄把格式回调成了16位。这个行为在官方文档里没有写得很直白,只有亲测才能发现。
对策是:构造之后立刻获取一次,并且在日志里打出来。后续所有涉及到逐字节解析的地方,都用这个运行期返回值,而不是外部传入的常量。我再强调一次,这行日志将来能救你的命。
5.2 PCM_8BIT 的数据无符号问题
用8位PCM时,静音不是0,而是128。也就是说,一个静音的采样点在byte[]里是0x80(128),不是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(),把它缓存起来,后面不管是写文件、做实时处理、送编码器,都用这份运行时缓存作为唯一依据,绝不假设构造参数一定等于运行时实际值。同时,一定把这个值打到自己应用的调试日志里,因为下次遇到诡异问题时,排查的第一步就是确认当前设备上实际生效的音频格式到底是多少。按照这个习惯来,能省下大量排查时间。