news 2026/10/22 9:28:11

Android音视频开发:MediaCodec解码AAC为PCM全流程详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Android音视频开发:MediaCodec解码AAC为PCM全流程详解

做Android音视频开发的时候,我经常被同一个问题卡住:AAC音频明明放得好好的,但我想拿到声音本身的数字数据——比如做音频可视化画波形、做混音、做变声、分析响度——系统播放器根本不给你这个机会。翻来覆去之后,最直接的办法都是调用MediaCodec把AAC解码成PCM。解码出来的PCM是所有音频处理的起点,后续做什么都顺手。这篇文章就把我用MediaCodec解AAC的完整链路、关键代码和踩过的坑一次性说清楚,给同样在做音频处理、又不想被播放器API束缚的人一个可以直接复现的参考方案。

1. 解码AAC这件事,什么时候才需要自己动手

1.1 系统播放器解决不了的两个问题

很多朋友会问:我直接用MediaPlayer或ExoPlayer不就能放AAC吗?为什么非要绕一圈,自己把AAC解码成PCM?

答案很简单:播放器负责"出声",但不负责"给你数据"。你需要的是原始采样点,而不是听个响。举几个我实际遇到过的场景:

  • 音频可视化:要做频谱、波形、音量表,必须拿到每一帧PCM,播放器内部不会把这些数据暴露出来。
  • 混音:几段音频要叠加出同一个时间轴,光靠设置音量是做不到的,得先把各自的PCM解出来,按采样点相加。
  • 变声和音效:变调、均衡器、回声、混响,本质上都是对PCM做数学运算。
  • 降噪和回声消除:实时语音处理链路里,解码端拿不到干净PCM就根本无从下手。
  • 低延迟播放:某些互动场景对延迟敏感,系统播放器内部缓存太大,自己解码后直接喂AudioTrack反而更好控制。
  • 推流前处理:要重采样、声道转换、混音,都得在编码之前把源头变成PCM。

说白了,只要你需要对"声音的内容"做二次处理,那解码这一关就绕不开。播放器是黑盒,MediaCodec是你可以亲手操作的那扇门。

1.2 PCM到底是什么,为什么解码后要用它

PCM全称Pulse Code Modulation,脉冲编码调制。用生活化的话说:声音是连续振动的波,PCM就是按固定时间间隔对这条波"拍照"得到的一串数字。解码AAC的最终目的,就是把压缩编码里那些频域系数还原成这串最能代表原始波形的采样点。

AAC是压缩格式,文件里存的是经过心理声学模型处理后的编码数据;PCM是裸数据,没有任何压缩和容器结构,只有一个接一个的采样点。比如最常见的配置:每帧1024个采样点(AAC-LC标准帧)、16bit位深、双声道交错存储(左右声道轮流排列)。这意味着解码器每次吐出来的一块数据里,前两个字节是左声道,紧接着两个字节是右声道,依次排列。

有个数据量级的感受值得先说清楚:码率128kbps的AAC,解码成44.1kHz、16bit、双声道PCM后,数据速率变成44100×2×2=176400字节/秒,约172KB/s。也就是说,AAC的"瘦身"效果非常明显,一旦转成PCM,内存和I/O压力会立刻大起来,后面要做处理时心里要有这个数。

1.3 为什么优先选MediaCodec而不是FFmpeg

我也用过FFmpeg软解,在某些项目里它确实是更好的选择,但对"只想在Android上把AAC解成PCM"这个需求,MediaCodec往往更合适。

  • MediaCodec是Android系统自带的编解码接口,不需要引入任何第三方库,包体积和集成成本都低。
  • 绝大多数中端以上手机对AAC都有硬件解码器,解码速度快、功耗低,实时性完全够用。
  • 解码出来的数据格式稳定,最适合接到AudioTrack、可视化、混音等后续链路。

FFmpeg的优势是跨平台统一、支持格式多,但代价是巨大的库体积和纯CPU解码。如果你的项目本来就已经集成了FFmpeg,或者要在一套代码里同时解码几十种音频格式,那用它没毛病;如果只是单点解码AAC,为了它拖一整个FFmpeg进来,属于杀鸡用牛刀。

2. AAC的容器细节:ADTS头与MediaCodec的输入契约

2.1 ADTS和ADIF,为什么你多半会碰见ADTS

AAC裸流有两种常见的容器组织方式。一种是ADIF,整个文件前面一个大头部,后面连续跟数据,不支持随机定位,流式播放很吃亏,实际应用里很少见。另一种就是ADTS,每隔一帧都在前面带一个7字节的帧头,帧与帧之间可以独立解析和定位,网络流、本地裸AAC文件基本都用它。

MediaCodec在解码AAC时,会根据输入提供的信息决定怎么初始化内部解码器。AAC是一个带状态的压缩格式,解码器必须知道采样率、声道数、profile才能正确设置解码管线。如果输入是ADTS格式,好消息是这些信息每一帧头部都带着,MediaCodec可以自己解析;如果输入是纯AAC数据(比如从MP4容器里抽取出来的Access Unit),那这些信息就必须通过MediaFormat里的csd-0字段提前告诉解码器。

2.2 两种输入方式的配置差异

第一种方式,输入直接给ADTS流。在配置MediaFormat时,除了MIME类型、采样率、声道数之外,还要显式设置一个键:

val format = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, sampleRate, channelCount) format.setInteger(MediaFormat.KEY_IS_ADTS, 1)

KEY_IS_ADTS设为1,等于告诉解码器:喂进来的数据是带ADTS头的,不需要我再猜。这是最省事的路径,本地文件解码基本都走这条路。

第二种方式,输入是去掉ADTS头的raw AAC。这时KEY_IS_ADTS不能设,但必须把AudioSpecificConfig通过csd-0传进去。从MP4文件里用MediaExtractor取音频轨道时,轨道格式里通常已经带好了csd-0,直接拿出来塞进MediaFormat就行:

val csdBuffer = extractor.getTrackFormat(trackIndex).getByteBuffer("csd-0") val format = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, sampleRate, channelCount) format.setByteBuffer("csd-0", csdBuffer)

这两种方式的本质区别就在信息传递渠道:一种靠每帧自带头部,一种靠解码前一次性告知。搞错的话,解码器要么报错,要么输出一堆无法理解的噪声。

2.3 自己解析ADTS头,比你想的更有用

虽然设置了KEY_IS_ADTS之后,MediaCodec能自己处理ADTS流,但实际开发中我仍然强烈建议写一个简单的ADTS头解析函数。原因有两个:第一,dequeueOutputBuffer返回的BufferInfo里没有采样率和声道信息,而这些信息后面接AudioTrack或写WAV头时都必须用到;第二,从文件流中切割AAC帧时需要知道frame_length,这是ADTS头里最关键的字段。

ADTS头固定7字节(有CRC时是9字节,但实际少见),解析逻辑如下:

val ADTS_SAMPLE_RATES = intArrayOf( 96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350 ) data class AdtsHeader( val profile: Int, // ADTS profile字段:Main=0, LC=1, SSR=2, LTP=3 val sampleRateIndex: Int, val sampleRate: Int, val channelConfig: Int, val frameLength: Int // 包含ADTS头在内的整个帧字节数 ) fun parseAdtsHeader(data: ByteArray, offset: Int): AdtsHeader? { if (data.size - offset < 7) return null val b0 = data[offset].toInt() and 0xFF val b1 = data[offset + 1].toInt() and 0xFF if (b0 != 0xFF || (b1 shr 4) != 0x0F) return null // syncword必须是0xFFF val b2 = data[offset + 2].toInt() and 0xFF val b3 = data[offset + 3].toInt() and 0xFF val b4 = data[offset + 4].toInt() and 0xFF val b5 = data[offset + 5].toInt() and 0xFF val profile = (b2 shr 6) and 0x03 val sampleRateIndex = (b2 shr 2) and 0x0F val channelConfig = ((b2 and 0x01) shl 2) or ((b3 shr 6) and 0x03) val frameLength = ((b3 and 0x03) shl 11) or (b4 shl 3) or ((b5 shr 5) and 0x07) return AdtsHeader( profile = profile, sampleRateIndex = sampleRateIndex, sampleRate = ADTS_SAMPLE_RATES.getOrNull(sampleRateIndex) ?: 0, channelConfig = channelConfig, frameLength = frameLength ) }

采样率索引和实际采样率的对照关系很常用,我在这里整理一张表,建议收藏:

sampling_frequency_index采样率(Hz)
096000
188200
264000
348000
444100
532000
624000
722050
816000
912000
1011025
118000
127350

此外,ADTS头里的profile字段对应MPEG-4 Audio Object Type减1:比如常见的AAC-LC,Object Type是2,在ADTS头里profile字段就是1。如果想要在csd-0里手动构造AudioSpecificConfig,要用的是Object Type值,这一点经常有人搞反。

3. 同步模式完整实现:从文件到PCM的逐段代码

3.1 创建解码器与MediaFormat配置

同步模式是MediaCodec最直观的用法,我自己在实际项目中,凡是离线把AAC文件转成PCM文件,基本都是这套代码骨架。

第一步,创建解码器和MediaFormat。假设我们已经从ADTS头解析出了sampleRate和channelCount:

val codec = MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_AUDIO_AAC) val format = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, sampleRate, channelCount) format.setInteger(MediaFormat.KEY_IS_ADTS, 1) codec.configure(format, null, null, 0) codec.start()

configure的四个参数里,第二个是Surface,音频解码用不到,传null;第三个是Crypto,加密媒体流才用,也传null;第四个是flags,通常0。这里有一个值得说的点:如果输入不是ADTS而是raw AAC,那KEY_IS_ADTS就不要设置,并且必须提供csd-0,否则解码器会因为没有初始化参数而抛异常。

3.2 按ADTS帧切割文件,保证frame对齐

解码器对输入数据有一个硬性要求:每一个输入buffer必须在AAC帧边界上对齐。也就是一次queueInputBuffer进去的数据,要么恰好是一个完整帧,要么是若干个完整帧,绝对不能把一个ADTS帧切成两半放进两个buffer,也不能把两个不完整的帧拼在一起。

我见过不少早期代码图省事,直接按固定大小从文件里读一段就塞给解码器,结果各种花音、爆音、解码中断。正确的做法是先读取7字节头,解析出frameLength,再按frameLength读完整帧。

private fun readNextAdtsFrame(stream: InputStream): ByteArray? { val header = ByteArray(7) var read = 0 while (read < 7) { val n = stream.read(header, read, 7 - read) if (n < 0) { if (read == 0) return null throw IOException("ADTS header truncated at EOF") } read += n } val parsed = parseAdtsHeader(header, 0) ?: throw IOException("Invalid ADTS header") val frameLength = parsed.frameLength val frame = ByteArray(frameLength) header.copyInto(frame, 0, 0, 7) var offset = 7 while (offset < frameLength) { val n = stream.read(frame, offset, frameLength - offset) if (n < 0) throw IOException("ADTS frame truncated at EOF") offset += n } return frame }

如果AAC文件本身不大(几MB以内),也可以干脆一次性读进内存,然后用解析函数逐帧切割。文件特别大时再用流式方法,避免OutOfMemory。

3.3 输入输出主循环,这是一个经典的生产者-消费者模型

解码循环的核心代码是这样的:

val bufferInfo = MediaCodec.BufferInfo() var inputEof = false var outputEof = false var ptsUs = 0L while (!outputEof) { // 输入侧:向解码器喂数据 if (!inputEof) { val inIndex = codec.dequeueInputBuffer(10_000) if (inIndex >= 0) { val inputBuffer = codec.getInputBuffer(inIndex)!! inputBuffer.clear() val frame = readNextAdtsFrame(inputStream) if (frame != null) { inputBuffer.put(frame) codec.queueInputBuffer(inIndex, 0, frame.size, ptsUs, 0) ptsUs += 1024L * 1_000_000L / sampleRate } else { codec.queueInputBuffer(inIndex, 0, 0, ptsUs, MediaCodec.BUFFER_FLAG_END_OF_STREAM) inputEof = true } } } // 输出侧:取出解码后的PCM val outIndex = codec.dequeueOutputBuffer(bufferInfo, 10_000) if (outIndex >= 0) { val outputBuffer = codec.getOutputBuffer(outIndex)!! outputBuffer.position(bufferInfo.offset) outputBuffer.limit(bufferInfo.offset + bufferInfo.size) val pcm = ByteArray(bufferInfo.size) outputBuffer.get(pcm) outputStream.write(pcm) codec.releaseOutputBuffer(outIndex, false) if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { outputEof = true } } else if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { // 解码器格式可能变化,后面单独讲 } }

这段代码里有几个细节,每一个都曾经坑过我,值得单独说明。

首先是inputBuffer.clear()。MediaCodec的buffer是带position和limit状态的,如果不clear,上次写入的数据会让这次put的起始位置错乱。我第一次写的时候漏了这行,结果解码出来的音频每隔一会就出现一小段杂音,排查了很久。

其次是时间戳。AAC-LC每一个ADTS帧固定包含1024个采样点,所以每喂一帧,播放时间戳就增加1024 * 1000000 / sampleRate微秒。比如44.1kHz下约23219微秒,也就是23.2毫秒。如果你不关心播放同步,随便给0也行;但后续如果要做音视频同步、拼接、倍速播放,这个时间戳就是生命线。

然后是输出侧的处理。bufferInfo.offset和bufferInfo.size分别表示有效数据的起始位置和长度。请注意不能直接用outputBuffer.array()去读,因为ByteBuffer的position不一定在offset上,必须要先position(offset)和limit(offset + size)。这里我见过很多人踩坑,读出来的全是错位数据,听起来就像严重失真。

最后是releaseOutputBuffer(outIndex, false)。第二个参数render,只有视频渲染到Surface时才用true,音频解码必须传false。

解码结束后一定要按顺序收尾:

codec.stop() codec.release() inputStream.close() outputStream.close()

3.4 解码后的PCM要写到哪里

拿到PCM字节数组后,根据不同用途有几种落地方式。

如果只是保存成PCM裸流,直接按顺序写入文件即可,不需要任何头。很多音频处理工具可以直接打开无头PCM。

如果需要用Audacity、CoolEdit之类工具快速检查解码结果,最好转成WAV格式。WAV就是在PCM前面加一个44字节的RIFF头,把采样率、声道数、位深、数据长度写进去就行。核心写法:

fun writeWavHeader(out: OutputStream, sampleRate: Int, channelCount: Int, pcmSize: Int) { val byteRate = sampleRate * channelCount * 2 val blockAlign = channelCount * 2 val header = ByteArray(44) header[0] = 'R'.code.toByte(); header[1] = 'I'.code.toByte() header[2] = 'F'.code.toByte(); header[3] = 'F'.code.toByte() writeIntLE(header, 4, 36 + pcmSize) header[8] = 'W'.code.toByte(); header[9] = 'A'.code.toByte() header[10] = 'V'.code.toByte(); header[11] = 'E'.code.toByte() header[12] = 'f'.code.toByte(); header[13] = 'm'.code.toByte() header[14] = 't'.code.toByte(); header[15] = ' '.code.toByte() writeIntLE(header, 16, 16) // PCM chunk大小 writeShortLE(header, 20, 1) // 音频格式,1表示PCM writeShortLE(header, 22, channelCount) writeIntLE(header, 24, sampleRate) writeIntLE(header, 28, byteRate) writeShortLE(header, 32, blockAlign) writeShortLE(header, 34, 16) // 位深 header[36] = 'd'.code.toByte(); header[37] = 'a'.code.toByte() header[38] = 't'.code.toByte(); header[39] = 'a'.code.toByte() writeIntLE(header, 40, pcmSize) out.write(header, 0, 44) }

这个WAV头是我每次调试解码器都会用的工具,先写WAV再看波形,比直接盯着PCM十六进制高效得多。

如果解码出来要实时播放,那就用AudioTrack。初始化时sampleRate、channelCount、编码格式都要和PCM数据一致,然后在一个独立线程里不断write(pcm)。

4. 异步回调改造:边解码边播放的正确姿势

4.1 setCallback模式的基本写法

同步模式在离线转换场景下完全够用,但如果要做实时播放、直播、语音通话这类链路,同步循环会占用一个线程,而且每次dequeue的阻塞时间不容易控制。这时候MediaCodec的异步回调模式更合适。

用异步模式,不需要主动dequeue,而是给解码器设置Callback,MediaCodec在内部线程里准备好输入buffer或产生输出buffer时回调你:

codec.setCallback(object : MediaCodec.Callback() { override fun onInputBufferAvailable(codec: MediaCodec, index: Int) { // 在这个回调里取出一帧AAC,queue进去 } override fun onOutputBufferAvailable(codec: MediaCodec, index: Int, info: MediaCodec.BufferInfo) { // 在这个回调里读PCM、写AudioTrack或文件 } override fun onError(codec: MediaCodec, error: MediaCodec.CodecException) { // 处理解码错误 } override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) { // 解码器输出格式变化 } }) codec.configure(format, null, null, 0) codec.start()

一个必须时刻记住的点:这些回调跑在MediaCodec自己的内部线程上,不是主线程,也不由你控制。回调里绝不能做耗时操作,比如从网络读文件、大批量写磁盘、更新UI。最佳实践是把回调里拿到的PCM塞进一个有界队列,由专门的播放线程或处理线程消费;AAC输入则尽量预读一些帧到内存队列,免得读文件慢拖住解码器。

4.2 同步和异步到底怎么选

我自己的选择标准归纳成一张表:

维度同步模式异步模式
代码结构一个循环里手动推进回调驱动
适用场景离线转换、批量文件处理实时播放、低延迟链路
Buffer获取dequeueInputBuffer/dequeueOutputBufferonInputBufferAvailable/onOutputBufferAvailable
线程模型当前线程MediaCodec内部线程
调试难度逻辑直观,容易定位回调时序要小心
潜在风险循环写不好会阻塞回调阻塞会拖垮解码

如果你只是为了把一个AAC文件转成PCM文件保存,同步模式就够了,代码简单,出错也容易排查。如果你在做一个实时音频采集、处理、播放的完整链路,或者在直播推流前要解出PCM,异步模式更合理。

4.3 onOutputFormatChanged是最容易被忽略的一环

这个回调我单独拿出来说,因为它在AAC解码里真的会出现,而且一旦忽略,结果就是"前半段正常,后半段声音不对劲"。

典型场景是HE-AAC(AAC+)。这种格式用了SBR频带复制技术,解码器在内部会把采样率翻倍输出。输入ADTS头里写的是24000Hz,解码器输出的PCM可能变成48000Hz。如果不监听onOutputFormatChanged,你的AudioTrack还是按24000Hz去播放,声音就会偏快、偏高。

正确处理方式是在这个回调里取出新的采样率和声道数,动态更新AudioTrack配置,或者在离线转换时用新参数重写WAV头。我这个坑当年排查了一整个下午,最后打日志才发现输出采样率在某个时间戳之后悄悄变了。

5. 高频踩坑实录:变调、卡顿、尾部缺失的排查链路

5.1 解码后声音明显偏快或偏慢,先查ADTS头

有一阵子我拿到一个抓下来的AAC文件,播放速度像开了1.2倍速。第一反应是怀疑MediaCodec配置,查来查去都没问题。后来把自写的parseAdtsHeader跑了一遍,才发现文件实际采样率是24000Hz,而我之前根据文件命名、码率等信息想当然地按44100Hz去配置了AudioTrack。

这个教训很重要:ADTS头里的采样率和声道数,才是唯一可信的来源。文件名、URL、网络协议里的元数据全都可能骗人,但每一帧ADTS头里的参数是真实解码必需的,不会有假。

遇到声音变调、速度不对,排查链路是这样的:

  1. 打印parseAdtsHeader解析出的sampleRate和channelConfig。
  2. 检查AudioTrack初始化参数是否和解析结果一致。
  3. 检查写WAV头时是否用了同样的参数。

基本上走到第三步就能定位问题。另外一个隐藏很深的点是HE-AAC的SBR倍频,输出采样率可能不是ADTS头里的采样率,这时要结合onOutputFormatChanged动态处理。

5.2 声音发闷、像在水里说话,多半是声道数错了

AAC的channel_configuration字段,1代表单声道,2代表双声道。如果文件实际是双声道,但配置MediaFormat或AudioTrack时写成了单声道,解码器可能不会直接报错,而是把交错数据按单声道读取出来,听感就是发闷、模糊、层次混乱。

我之前遇到过一个项目,解码某些AAC文件声音正常,某些文件声音发闷,后来发现这些文件根本不是标准双声道,而是channel_configuration为0的特殊配置,声道布局定义在AudioSpecificConfig里。MediaCodec对这类流的处理依赖csd-0里的完整信息,如果信息缺失,它只能按默认参数解,声音自然不对。

处理方式很直接:不要硬编码声道数,每次解码前先解析ADTS头,用解析出的channelConfig初始化MediaFormat和后续播放器。

5.3 解码循环卡死或者只出一部分PCM,问题在输入输出不平衡

MediaCodec内部是一个有限buffer池。如果你在一个大循环里拼命往输入侧塞帧,塞到所有input buffer都被占用,dequeueInputBuffer就会一直等,看起来像程序卡死。反过来,如果只注意输入不注意输出,输出buffer也会占满。

同步模式的正确姿势,就是我在第3节里写的那种结构:先处理一次输入,再处理一次输出,如此交替。输入和输出各自dequeue时设置合理超时(比如10毫秒),并正确处理INFO_TRY_AGAIN_LATER这类返回值,不要在一个方向上一口气堵到底。

我还见过另一种"假卡死":文件读取线程和解码线程共用同一个InputStream,读取位置互相干扰,导致读出来的字节流错乱。解决办法很简单,解码前的数据读取和解码本身要职责清晰:要么一次性把帧切好放到队列,要么在同一个线程里严格串行读。

5.4 解码结果末尾缺一段,或者多出一段噪声

尾部问题几乎都出在END_OF_STREAM的处理上。正确做法是:

  • 输入侧:读取到文件末尾时,用一个size为0但带BUFFER_FLAG_END_OF_STREAM标记的queueInputBuffer通知解码器。
  • 输出侧:循环结束的唯一条件是output buffer里检测到同样的BUFFER_FLAG_END_OF_STREAM标记,而不是"文件读完了就停止"。

为什么不能文件读完了就直接break?因为解码器内部可能还残留着最后几帧数据,如果你在输入结束时立刻退出循环,这些尾部PCM就丢了。我做过对比,丢失的时长通常就是几个帧的时间,大约几十毫秒,对一般听感不明显,但在音频剪辑、拼接场景里会明显感觉"尾音被截断"。

反过来,如果输出侧没检测到END_OF_STREAM就停,然后额外多等了几秒,又会把解码器内部残留的数据读出来,导致尾部多出一小段无意义的填充数据。所以我的经验是:只信任输出侧的那个flag。

5.5 某些AAC文件一解码就直接报错,可能是规格支持问题

Android设备的MediaCodec对AAC的支持不是完全一致的。市面上绝大多数设备支持AAC-LC,但HE-AAC、HE-AACv2、AAC-LTP这些规格,部分系统或芯片可能不支持,或者支持得磕磕绊绊。

排查时先用设备能力查询:

val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS) val codecInfo = codecList.findDecoderForFormat(format) // codecInfo为null或空字符串说明当前设备没有对应解码器

如果查询发现不支持,我的兜底方案是交给FFmpeg软解,然后用同样的PCM数据结构对接后续处理。这个方案很笨,但在产品上线前做兼容性测试时是必要的。

6. 解码效率优化与不同AAC规格的适配

6.1 先算清楚PCM数据量,别让内存崩了

AAC解码成PCM之后,数据量会膨胀10倍以上。按照常见规格,一秒钟的PCM数据量如下:

采样率声道数位深PCM速率
44100216bit176.4KB/s
48000216bit192KB/s
24000116bit48KB/s
16000116bit32KB/s
8000116bit16KB/s

做实时链路时,如果要把PCM缓存到内存做可视化或混音,一定要有队长度上限。比如一次缓存2秒的44.1kHz双声道PCM,就是350KB左右,看起来不大,但如果每帧都新建数组不回收,一分钟后依然能吃掉二十几MB。用有界队列加上复用ByteArray,是最实用的优化之一。

6.2 离线批量转换的提速技巧

如果是批量把几百个AAC文件转成PCM,有几个优化点值得做:

  1. 复用MediaCodec实例:创建解码器、configure、start这套流程在部分设备上耗时几十毫秒,批量转换时没必要每次都重来。同一个解码器可以stop再start接着解下一个文件,但要保证上一个流完全处理完并清空buffer,否则状态残留会串味。
  2. 输出用BufferedOutputStream:每块PCM直接写FileOutputStream会频繁触发系统调用,包一层8KB到32KB的缓冲能明显加快速度。
  3. 避免多余拷贝:从getOutputBuffer得到的ByteBuffer读进ByteArray本身就有一份拷贝,没必要为了后续处理再做System.arraycopy。读取时直接让position对准offset、limit对准末尾,一次get完事。
  4. 预读ADTS帧到内存队列:只要文件大小可控,先把帧全部切好放到ArrayDeque,解码循环只管从队列取,能省掉很多I/O等待的时间。

这些优化在单个文件上感知不明显,但我在批量处理项目里实际对比过,处理时间能减少30%到50%,主要是减少了无效等待和重复创建。

6.3 AAC规格适配速查:LC、HE-AAC、HE-AACv2

最后整理一份规格速查,方便写兼容逻辑时对照。

规格ADTS profile字段Audio Object Type特性适配注意点
AAC Main01很少见基本只存在于老编码器
AAC-LC12最常用,兼容性最好移动端主力格式
AAC-SSR23少见不建议依赖
AAC-LTP34少见不建议依赖
HE-AAC依赖编码配置5(SBR)用SBR扩展高频输出采样率可能翻倍
HE-AACv2依赖编码配置29(PS)在SBR上加参数立体声部分设备不支持,需兜底

实际项目中,我默认以AAC-LC为主力,遇到HE-AAC就走onOutputFormatChanged动态适配,遇到设备不支持的规格就回退FFmpeg。这个策略在兼容性和开发成本之间比较平衡,不用为了极小概率的冷门格式把整个架构搞复杂。


最后分享一个我自己的排查习惯:无论解码后的PCM是要播放、转码还是做算法处理,我都会先把它写成一个临时WAV文件,用音频编辑工具打开看一眼波形。波形正常,再继续接后面的处理;波形不对,第一件事就是回到ADTS头解析,核对采样率和声道数。这个习惯帮我解决了不少"看起来代码没问题但声音就是不对"的疑难杂症,也推荐你试试。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 10:15:10

嵌入式Flash时钟:RTC时间掉电保持的工程实现方案

简介&#xff1a;本资源是一份基于Adobe Flash平台开发的桌面时钟实现方案&#xff0c;面向ActionScript初学者与Flash动画爱好者&#xff0c;解决动态时钟组件开发与桌面集成的实际问题。压缩包共3个文件&#xff08;562KB&#xff09;&#xff0c;含核心源码文件时钟的制作.f…

作者头像 李华
网站建设 2026/10/22 9:35:56

Modbus测试包实战:协议要点、主从站模拟与自动化排查

简介&#xff1a;Modbus通信测试工程压缩包面向STM32-F0/F1/F2系列单片机开发者设计&#xff0c;内含完整源码、编译中间文件与调试辅助文件&#xff0c;适合刚接触MODBUS协议栈移植或需要做工业设备通信联调的嵌入式工程师与学习者。压缩包共含70个文件&#xff0c;以C源文件&…

作者头像 李华
网站建设 2026/10/19 12:33:51

Qt视频播放器跨平台实战:解决黑屏、同步与版本兼容问题

1. 项目概述&#xff1a;为什么一个“分分钟学会”的Qt视频播放器值得深挖 “让你分分钟学会Qt视频播放器”——这个标题乍看像极了短视频平台里常见的流量钩子&#xff0c;但作为在桌面应用开发一线摸爬滚打十多年的从业者&#xff0c;我反而被它戳中了痛点。不是因为它夸张&a…

作者头像 李华
网站建设 2026/10/22 18:05:03

PMSM 5次7次谐波注入补偿:Simulink模型搭建与参数整定

简介&#xff1a;这份资源面向从事电机控制、电力电子方向的研究人员与工程技术人员&#xff0c;聚焦永磁同步电机中5次、7次电流谐波引发的转矩脉动、效率下降与发热问题&#xff0c;提供一套基于Simulink的谐波注入补偿仿真方案。压缩包内仅含1个PDF文件&#xff0c;约115KB&…

作者头像 李华