实战)
1. 为什么音频开发绕不开“帧大小”这个概念做音频采集、播放或者实时处理的时候早晚会遇到一个绕不开的基础指标音频帧大小。很多刚接触音频开发的同学第一反应是“帧不是视频里那一张张画面吗”确实容易混淆但在音频领域帧的定义完全不同——它指的是一次采样周期内所有声道合在一起所产生的数据量。举个例子就明白了。假设采样率是44100Hz意思是每秒钟采集44100次声音信号。每一次采集左右声道各取一个采样点如果每个采样点用16位2字节表示那么这一个采样周期内两个声道合起来就是4字节。这4字节就是这一个音频帧的帧大小。那知道这个4字节有什么意义我做音频开发这几年几乎每个环节都要跟它打交道分配读取缓冲区时缓冲区大小最好能对齐帧大小否则可能出现读取到半个帧的情况计算实时延迟时帧大小乘以缓冲区帧数才能估算出端到端的感受延迟做音频格式转换、混音、采样率重采样时数据定位必须按帧来算否则左右声道数据就乱了。所以帧大小不是理论课本里的名词它直接决定你手里的字节流应该怎么切、怎么算、怎么塞给系统。Android从API 21开始提供了一个便捷方法叫AudioFormat.getFrameSizeInBytes()可以不用自己手算直接拿到格式对应的帧大小字节数。这篇是系列里第一篇专门讲这个API的用法后面提到的很多代码和思路在Android 16上实测都能跑通。整体我会从概念讲到API细节再给一个完整的采集实例最后补充几个实际项目中很容易踩的坑。2. getFrameSizeInBytes() 的API真相它到底返回什么2.1 方法签名与引入版本这个方法在Android 5.0API 21就加入了属于AudioFormat类的静态方法。签名很简单public static int getFrameSizeInBytes(int audioFormat, int channelMask)两个参数第一个是编码格式第二个是声道布局掩码。调用方式是这样int frameSize AudioFormat.getFrameSizeInBytes( AudioFormat.ENCODING_PCM_16BIT, AudioFormat.CHANNEL_IN_STEREO ); // frameSize 4我在多个项目里实测下来16位双声道返回416位单声道返回224位双声道返回6这些都是预期内的结果。方法内部就是拿每个采样点的字节数乘以声道数逻辑并不复杂但官方封装的意义在于你不用再去记每个编码格式的位深也不用处理声道掩码到声道的换算一个方法全部搞定。2.2 返回值在不同音频格式下的具体表现要真正用好这个方法得先弄明白它背后的计算依赖。我整理了一张常用格式的帧大小对照表这张表对后面写采集代码很有帮助编码格式位深字节/采样点声道布局声道数帧大小字节ENCODING_PCM_8BIT1CHANNEL_IN_MONO11ENCODING_PCM_8BIT1CHANNEL_IN_STEREO22ENCODING_PCM_16BIT2CHANNEL_IN_MONO12ENCODING_PCM_16BIT2CHANNEL_IN_STEREO24ENCODING_PCM_24BIT3CHANNEL_IN_STEREO26ENCODING_PCM_32BIT4CHANNEL_IN_STEREO28ENCODING_PCM_FLOAT4CHANNEL_IN_STEREO28不做任何编码直接返回最朴素的字节数。这里有个容易忽略的核心事实**帧大小只由编码格式和声道数决定跟采样率没有任何关系。**采样率影响的是“一秒钟有多少帧”而不是“一个帧有多少字节”。44100Hz和48000Hz下同样16位双声道帧大小都是4字节区别只是每秒数据量分别是176.4KB和192KB。2.3 什么时候不该信这个方法这个方法不是万能的有几种情况返回值没有参考价值。第一压缩格式。假设编码格式是ENCODING_MP3或ENCODING_AAC方法返回的数字可能只是一个基础估计值。为什么因为压缩音频帧的长度不固定MP3还分CBR固定码率和VBR可变码率VBR下每个帧承载的数据量差异很大拿一个固定字节数去切流必然出错。所以拿到压缩格式时不要拿这个返回值去做数据流的帧边界定位。第二编码格式传了ENCODING_INVALID。如果编码格式无效返回值是-1。我在某个需求里踩过这个坑以为传了非法格式会抛异常结果返回-1后续用这个值算缓冲区直接出错。所以调用前最好做一次合法性判断或者对返回负数的情况单独处理。第三DTS/AC3这类多声道压缩格式。声道布局看起来是5.1或者7.1实际编码后的帧结构跟PCM完全不同直接乘以声道数的方式并不准确。提示PCM线性编码是这个方法最忠实的使用场景。如果你处理的是PCM数据流可以放心大胆地用如果处理的是压缩码流建议改用码流解析库来获取真实帧长度。3. 一个完整的AudioRecord采集实例从帧大小开始搭架子3.1 最小可行代码与参数选型先给一个最朴素的采集实现把帧大小用起来。这个例子适合刚上手AudioRecord的开发者我尽量少写业务包装聚焦在数据通道本身。private static final int SAMPLE_RATE 48000; private static final int CHANNEL_MASK AudioFormat.CHANNEL_IN_STEREO; private static final int ENCODING AudioFormat.ENCODING_PCM_16BIT; // 帧大小2字节 * 2声道 4字节 int frameSize AudioFormat.getFrameSizeInBytes(ENCODING, CHANNEL_MASK); // 缓冲区大小按200ms时长估算具体见下文 int bufferSizeInBytes AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_MASK, ENCODING); if (bufferSizeInBytes frameSize * 10) { bufferSizeInBytes frameSize * 480; // 兜底策略 } AudioRecord recorder new AudioRecord( MediaRecorder.AudioSource.MIC, SAMPLE_RATE, CHANNEL_MASK, ENCODING, bufferSizeInBytes );这里面有几个细节需要注意。getMinBufferSize()返回的是系统建议的最小时长对应的字节数它不一定恰好是帧大小的整数倍。如果直接用这个值做缓冲区后续按帧读取时可能出现尾部余数。我在实际项目中处理过这类问题明明只差两三个字节但数据切分就乱了所以强烈建议在拿到最小值后做一次对齐。对齐逻辑很简单int aligned (bufferSizeInBytes / frameSize) * frameSize; if (aligned bufferSizeInBytes) { aligned frameSize; } bufferSizeInBytes aligned;这样缓冲区永远是帧大小的整数倍读取过程中头尾对齐心里踏实。3.2 缓冲区尺寸的计算逻辑为什么不能拍脑袋很多初学者喜欢直接把缓冲区设成一个固定值比如8192字节觉得“够用就行”。但缓冲区过大延迟会肉眼可见缓冲区过小系统底层可能频繁唤醒出现丢帧或者爆音。科学做法是根据目标延迟来反推。延迟与缓冲区的关系可以这样理解缓冲区能装多少帧决定了底层能缓冲多久的音频数据。比如48000Hz采样率下每帧时长约为20.8微秒如果缓冲区有4800帧那么播放延迟大概就是100毫秒。公式是缓冲区时长(毫秒) 缓冲区字节数 / (帧大小 * 采样率) * 1000反过来推假设你的应用能接受80毫秒采集延迟int targetDelayMs 80; int framesPerBuffer (int) (SAMPLE_RATE * targetDelayMs / 1000.0); int targetBufferSize framesPerBuffer * frameSize;4800帧乘以4字节结果19200字节。用这个值去建AudioRecord比随便写个固定值靠谱得多。提示低延迟场景下不要无限调小缓冲区。实际工程里我遇到过缓冲区小于系统最低要求导致IllegalArgumentException的情况。正确姿态是先调getMinBufferSize()再根据延迟目标在上浮。3.3 轮询读取与数据消耗的配套写法缓冲区建好后采集循环需要配套的读取消费逻辑。这里有一个我反复跟人强调的要点每一轮读取前先算最大可读帧数避免无谓的字节浪费。byte[] data new byte[2048 * frameSize]; int expectedFrames data.length / frameSize; while (isRecording) { int framesRead readFrames(recorder, data, expectedFrames); if (framesRead 0) { int bytesRead framesRead * frameSize; // 将本次读到的数据交给你自己的处理链路 handlePcmData(data, bytesRead, frameSize); } }readFrames的实现需要逐帧校验这块代码放这里容易被误以为“多此一举”。但实际做混音或者第三方音频库对接时只有按帧消费才能保证声道数据不错位。你们看下面这个读取片段就会明白为什么我这么执着于帧对齐private int readFrames(AudioRecord recorder, byte[] out, int maxFrames) { int frames 0; while (frames maxFrames) { int offset frames * frameSize; int read recorder.read(out, offset, frameSize); if (read 0) { if (recorder.getRecordingState() AudioRecord.RECORDSTATE_STOPPED) { break; } continue; } if (read % frameSize ! 0) { // 理论情况不会发生但达到时说明底层缓冲区边界异常 break; } frames read / frameSize; } return frames; }每次只读一帧效率上肯定不如一批读完但胜在边界绝对可控。如果你的性能敏感度没那么高我更推荐这个写法来排查问题。实际调优时可以在确认稳定后改成批量读取、再按帧切分。4. 实践中容易踩的坑压缩格式、声道布局、采样率三座大山4.1 压缩格式下帧大小会失效这一条在2.3里提过但值得单独拿出来展开说。有一位找我交流的开发者曾经拿getFrameSizeInBytes(ENCODING_MP3, CHANNEL_IN_STEREO)的结果去切MP3数据流结果播放出来的声音完全没法听断断续续还伴随噪声。原因很简单MP3每个帧的字节长度不固定跟码率、采样率、填充字节都有关系用固定返回值切帧根本切不对。正确的处理方式是用音频解析器去解析帧头信息或者直接用媒体框架的MediaExtractor按数据块读取而不是自己手动切帧。原则就是这个方法只适合PCM这类每个帧长度恒定的格式。4.2 声道布局不统一导致的计算偏差Android的声道掩码分两套一套是输入用的CHANNEL_IN_MONO、CHANNEL_IN_STEREO另一套是输出用的CHANNEL_OUT_MONO、CHANNEL_OUT_STEREO。还有一个容易混的CHANNEL_IN_STEREO的掩码值跟CHANNEL_OUT_STEREO不一样不能互换。我早期写过一段代码把CHANNEL_IN_MONO传给了AudioTrack的构造器帧大小算出来倒是2字节但实际播放时数据一直被当作单声道处理声音信息丢失严重。后来排查发现输入输出的声道掩码体系在Android里并不完全等价尤其是5.1声道这类多通道配置掩码值差异更大。正确做法是采集侧用CHANNEL_IN_*系列播放侧用CHANNEL_OUT_*系列。getFrameSizeInBytes本身不区分输入输出但你传入的掩码必须和实际使用的通道配置一致。4.3 采样率对帧大小无影响但影响帧时长前面讲了帧大小跟采样率无关这个反直觉的点值得再啰嗦一遍。因为很多人计算数据量时会写int bytesPerSecond frameSize * sampleRate;这个公式没问题。但理解错误会导致调试方向错了——曾经有位同事始终认为采样率翻倍帧大小也应该翻倍结果他手动算的缓冲区和实际分配的对不上查了半天才发现是概念搞混了。理解正确的做法是帧大小按编码和声道数定采样率变帧时长变每帧时长 1 / 采样率每秒数据量 帧大小 * 采样率。假设采样率从48000降到44100帧大小还是4字节但每秒数据量从192KB降到176.4KB。这块在计算实时性时尤其重要因为帧时长直接关系到延迟指标。5. 一些不太常见但很有用的衍生用法5.1 用帧大小计算低延迟场景的帧时长做实时通话或K歌类项目时帧时长比帧大小更常被拿来评估延迟。帧大小配合采样率就能精确算出来int frameSize AudioFormat.getFrameSizeInBytes(ENCODING, CHANNEL_MASK); long bytesPerSecond (long) frameSize * SAMPLE_RATE; double frameDurationMs 1000.0 / SAMPLE_RATE;以48kHz、16位双声道为例帧大小4字节帧时长约0.0208毫秒。假设底层需要处理128帧的数据包那一包的总时长就是2.67毫秒左右。这个数很关键设计音频处理链时能直接估算每级处理带来的延迟。5.2 用于数据拷贝与格式转换的定位换算帧大小还有一个实用场景字节数组和采样点数组之间互相转换时的索引换算。比如你用short[]读写16位PCM数据时帧内可能有多个声道交织。如果要知道第n帧的起始位置int shortIndex n * frameSize / 2;如果frameSize是4第3帧n3的起始short索引就是6对应数据里第6、7两个short位置。这种换算在做重采样、增益控制时会频繁用到。再比如把16位PCM数据从单声道复制到双声道源帧大小是2目标帧大小是4循环里每抄完一个源帧就要腾出4字节目标空间。这类逻辑用帧大小去驱动比直接写计数器更清晰也不容易出错。我自己在实际调试中还会用帧大小来验证数据流是否连续。做法是检查每次回调或读取的数据字节数能否被帧大小整除不能整除基本就意味着丢帧或越界了。这个排查技巧花不了几行代码但能省下好几个小时的抓狂时间。说到底getFrameSizeInBytes()只是一个两参数的小方法但它背后连接的是对音频数据组织方式的理解。把这个基础概念吃透后面的采集、播放、混音、格式转换都会顺很多。至少在这个系列接下来的几篇里帧大小这个术语会频繁出场先把它记牢后面的代码看起来就不会犯迷糊了。