
音频采集这条链路上最容易被人低估的一步从来不是编码而是采集本身。AudioRecord 配合 MediaCodec 做实时录音与转码这套组合示例代码满天飞看起来二十行就能跑起来但真要做一个连续录半小时、不丢帧、不失步、播放器还能正常拖动进度的东西绝大多数人会在第二小时里被杂音、开头吞字和时长对不上折磨一遍。这篇东西就是把我自己在这个链路上反复踩过的细节摊开讲——从参数怎么定、缓冲区怎么设计、编码器怎么喂到出货前那些跑起来才暴露的坑。适合已经能跑通 Demo、但对稳定性还不满意的开发者也适合刚开始接触 Android 音频、想少走点弯路的朋友。先说个用词上的事。标题里写的“MediaCode”是MediaCodec的常见手滑而“转码”这个词其实用得不太准——PCM 裸数据到 AAC 是编码转码一般指编码格式之间的转换比如 AAC 换码率、换容器。不过在口语里大家混着用也无所谓本文所有讨论的真实链路是麦克风 → AudioRecord 拿到 PCM → MediaCodec 编码成 AAC → 落成能播的文件。这条链路不长但每一环都藏着能把人卡半天的小机关。1. 把链路拆开看数据到底在哪几个角色之间流动1.1 三个角色采集端、编码端、落盘端整条链路里其实有三个独立的“责任人”它们各自有各自的节奏任何两个直接绑在一起都会出问题。采集端是 AudioRecord 的读取线程它的节奏由硬件时钟决定每 10ms 或者 20ms 就产出一批固定大小的 PCM 数据快一点慢一点都不行慢了这一批就丢了。编码端是 MediaCodec它的节奏由算法和硬件编码器决定输入一帧 1024 个采样点输出一个 AAC 帧中间可能攒好几批输入才吐一次输出。落盘端是文件写入它的节奏由存储 IO 决定遇到系统在后台做大文件拷贝的时候写一次可能卡几十毫秒。这三者节奏完全不同如果让采集线程直接去调用编码器编码器一旦卡住采集线程跟着卡住硬件缓冲区溢出数据就丢了。这就是为什么中间必须塞一层缓冲区把三个节奏松耦合开让采集线程只管往池子里倒编码线程只管从池子里舀谁也别等谁。理解这个角色划分后面所有的设计选择就都有了解释为什么缓冲区大小要算得那么细为什么写文件要单独开线程为什么时间戳不能随便用系统时间。1.2 端到端延迟是由哪几块时间叠出来的很多人一上来就问“能不能做到 20ms 延迟”其实得先拆清楚延迟的构成。从我实测的经验看整条链路的延迟大致由五块叠加硬件采集缓冲驱动层缓冲通常 520ms厂商决定应用层改不动、AudioRecord 内部缓冲由 bufferSizeInBytes 决定读写相位差大约半个缓冲、自己那层环形缓冲区取决于你设的容量和消费速度、编码器内部攒帧AAC 一帧 1024 采样点在 44.1kHz 下约 23.2ms这是硬性的算法延迟、落盘写缓冲如果开了 BufferedOutputStream还有一层。粗算一下一个常规配置硬件 10ms AudioRecord 缓冲 40ms 环形缓冲 40ms 编码 23ms实际端到端延迟在 100ms 上下。这个数字对录音存文件完全够用对实时耳返就明显偏大了。所以如果你的目标是“录下来存档”把缓冲往大了配换稳定性是对的如果目标是“边说边听到自己声音”那就得走完全不同的低延迟路线下面第 7 章会单独说。1.3 一句提醒别把“能跑”当成“能用”我在早期项目里吃过一个亏Demo 在测试机上跑了十分钟听着没问题交付到另一台机器上连续录四十分钟中间出现两次明显的“咔”声播放器还能看出来时长少了零点几秒。当时以为是编码器的问题折腾了两天最后定位到的是环形缓冲区在临界状态下的一次覆盖写入。这类问题在短时间测试里几乎不可能暴露因为它的触发条件是消费端偶尔慢了那么一下。所以我后来养成了一个习惯任何音频链路的验证至少跑 30 分钟连续录音并且用一个能精确读出采样点总数的工具对比“应该产出多少字节”和“文件里实际有多少字节”。这个对账动作比听十遍录音有用得多。2. AudioRecord 的参数不是随便填的采样率、声道、位深与缓冲尺寸2.1 三件套的确定逻辑与每秒数据量核算采样率、声道数、位深这三个参数决定了每秒要搬运多少数据也决定了后面编码器怎么配。选择逻辑很简单跟着最终用途走不要盲目追高。做语音聊天、语音笔记16000Hz 单声道足够语音的能量集中在 3003400Hz再高的采样率拿到的全是空气噪声反而增加编码负担。做音乐录制或者需要保留环境感的场景44100Hz 或 48000Hz 单声道/立体声才合理。位深用 ENCODING_PCM_16BIT 是绝大多数场景的稳妥选择16 位动态范围约 96dB人耳在正常聆听音量下分辨不出差异而 FLOAT 虽然理论上精度更高但数据量翻倍很多旧设备的编码器接受度也不如 16 位。数据量核算必须心里有数因为后面配缓冲、算 PTS、估文件体积全靠它。公式是每秒字节数 采样率 × 声道数 × 每样本字节数。下面这张表是我常用几组配置的对照采样率声道位深每秒字节数每分钟约64kbps AAC 压缩后每分钟约16000单16bit32000 B1.83 MB0.46 MB44100单16bit88200 B5.05 MB0.46 MB44100双16bit176400 B10.1 MB0.92 MB48000双16bit192000 B11.0 MB0.92 MB从表里能看出来PCM 和 AAC 之间差了将近 10 倍这也是为什么不能把 PCM 直接怼到文件里存着——一首五分钟的立体声 44.1k 录音裸 PCM 就五十多兆谁都受不了。2.2 getMinBufferSize 的返回值到底该乘几倍AudioRecord.getMinBufferSize()这个名字特别容易误导人很多人的理解是“小于这个值就跑不了那就正好用这个值”。实际上它返回的是保证不会立即欠载的最小值而且这个值在不少设备上小得离谱比如 44.1k 单声道 16 位能返回 3584 字节。你按这个值配理论上 AudioRecord 内部不会报错但实际读取循环里只要调度稍微抖一下就会发生数据覆盖表现出来就是周期性“嗒嗒”声。我自己的经验值是取 minBufferSize 的 2 到 4 倍并且往上对齐到“能被一次读取量整除”的数。比如单声道 16 位 44.1k一次读 2048 字节约等于 23ms那 bufferSize 配 2×3584 7168 再对齐到 2048 的倍数就是 8192约等于 92ms 的内部缓冲。这个值够稳延迟也能接受。如果是做长时间录音存档甚至可以取到 4 倍突破一点延迟换稳定性非常划算。还有一点值得说getMinBufferSize有可能返回ERROR_BAD_VALUE-2或者ERROR-1。前者说明你这组参数组合当前设备不支持——最常见的是某些设备不支持 44100 采样率采集只支持 48000或者反过来。后者一般是权限没拿到或者资源被占用。这两个负数必须显式判断否则后面构造 AudioRecord 会拿到一个状态异常的实例读取时返回负值一路静默失败。val minBuf AudioRecord.getMinBufferSize( SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT ) if (minBuf 0) { // 参数不被支持或者没有录音权限需要降级重试 throw IllegalStateException(minBufferSize invalid: $minBuf) } val bufferSize max(minBuf * 2, READ_CHUNK * 2)上面这段里的降级重试很关键拿到负数之后不要直接崩先试试把采样率换成 48000再试 44100都失败才提示用户。2.3 初始化失败与状态校验的必做动作AudioRecord 的构造有两种方式老式构造函数和 API 23 之后引入的 Builder。我现在的项目一律用 Builder因为它的参数是显式的而且 AudioFormat 的构造也走 Builder不容易把声道掩码填错老构造函数的 channelConfig 参数经常有人把 CHANNEL_IN_STEREO 写成 CHANNEL_IN_MONO 却配了双声道数据出来的声音就是半个音节快进的鬼畜效果。val format AudioFormat.Builder() .setEncoding(AudioFormat.ENCODING_PCM_16BIT) .setSampleRate(SAMPLE_RATE) .setChannelMask(AudioFormat.CHANNEL_IN_MONO) .build() val record AudioRecord.Builder() .setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION) .setAudioFormat(format) .setBufferSizeInBytes(bufferSize) .build() if (record.state ! AudioRecord.STATE_INITIALIZED) { record.release() throw IllegalStateException(AudioRecord init failed) }构造完之后那个state判断绝对不能省。设备上没有麦克风、麦克风被别的应用独占、参数不被支持这几种情况下构造函数不会抛异常只是把一个 STATE_UNINITIALIZED 的对象返回来你要是没检查就 startRecording()会拿到 IllegalStateException 或者更糟——什么都不报read 一直返回 0。AudioSource 的选择也有讲究。MIC是原始信号不做任何处理VOICE_RECOGNITION会关掉一部分处理链频响更平适合做语音识别或者要求“原汁原味”的录音VOICE_COMMUNICATION会开启回声消除和降噪录出来的人声干净但会有“通话感”UNPROCESSED在 API 24 之后可用理论上是完全裸信号不过很多设备的驱动层并不真的支持实测下来经常和 MIC 没差别。我一般的默认值是 VOICE_RECOGNITION它的平衡性最好。3. 环形缓冲区把采集线程和编码线程拆开的那个蓄水池3.1 直接在 read 循环里调用编码器会出什么事我见过不少示例代码是这么写的开一个线程while (recording) { record.read(buf); codec.queueInputBuffer(...); dequeueOutputBuffer(...); write(file) }全部串在一个循环里。这种写法在 Demo 阶段看起来完全正常因为编码器通常很快。但它有两个致命问题。第一编码器的耗时是不确定的。硬件编码器在某些机型上第一次调用会有几百毫秒的初始化开销之后每帧只要几毫秒但一旦系统进入省电模式CPU 降频编码耗时可能翻几倍。这段时间里采集线程完全停摆AudioRecord 的内部缓冲一旦写满新的数据就直接丢弃且丢弃过程是静默的你连日志都看不到。第二写文件的 IO 抖动无法预测。有一次测试时录制过程中系统在后台同步相册写文件的调用偶尔会卡 200ms 以上这段时间里采集端连续丢了好几批数据最终文件的时长比实际录制时间短了将近一秒。播放起来感觉就是“说话的人突然快了半拍”。结论很明确采集线程只做一件事把 PCM 从 AudioRecord 里读出来扔进环形缓冲区别的一概不做。编码、封装、写盘全部放到消费线程。3.2 一份可用的单生产者单消费者实现这个场景是教科书级别的单生产者单消费者所以不需要什么复杂锁一个synchronized加上读写指针就够。核心是把一块固定大小的 ByteArray 当环形用写指针和读指针各自前进用 available 记录当前有效字节数。class PcmRingBuffer(private val capacity: Int) { private val buffer ByteArray(capacity) private var writePos 0 private var readPos 0 private var available 0 private val lock Object() fun write(src: ByteArray, offset: Int, length: Int): Int { synchronized(lock) { val n minOf(length, capacity - available) if (n 0) return 0 val firstPart minOf(n, capacity - writePos) src.copyInto(buffer, writePos, offset, offset firstPart) if (n firstPart) { src.copyInto(buffer, 0, offset firstPart, offset n) } writePos (writePos n) % capacity available n lock.notifyAll() return n } } fun read(dst: ByteArray, offset: Int, length: Int, timeoutMs: Long): Int { synchronized(lock) { val deadline System.currentTimeMillis() timeoutMs while (available 0) { val remain deadline - System.currentTimeMillis() if (remain 0) break lock.wait(remain) } if (available 0) return 0 val n minOf(length, available) val firstPart minOf(n, capacity - readPos) buffer.copyInto(dst, offset, readPos, readPos firstPart) if (n firstPart) { buffer.copyInto(dst, offset firstPart, 0, n - firstPart) } readPos (readPos n) % capacity available - n return n } } fun clear() { synchronized(lock) { writePos 0; readPos 0; available 0 } } }容量怎么定我通常按“能装下 300500ms 的数据”来配44.1k 单声道 16 位就是 26KB44KB。这个量级下消费端偶发卡顿 200ms 也不会导致溢出同时内存占用完全可忽略。容量太小比如只放 50ms抗抖动能力就差容量太大比如放 5 秒在出问题时会导致停止录音后还有一大堆积压数据要处理结束时间对不上。3.3 溢出、欠载与背压的三种处置策略环形缓冲区只有两个异常状态写满了溢出和空了欠载。这两个状态必须有明确的处理策略不能装作没看见。溢出说明消费端跟不上生产端。这时候硬写会覆盖未读数据产生跳变杂音。我的做法是写不进去的时候直接丢弃新数据并累加一个丢弃计数器同时在日志里记录。丢数据总比制造爆音好而且有了计数器你至少能知道这次录音质量到底如何。正常设备上这个计数器应该永远保持 0一旦非零就说明有问题需要排查。欠载说明消费端比生产端快这本身不算错误编码线程读到 0 字节时应该sleep(2)之后重试而不是直接往下走。绝对不能把这个 0 当成有效数据喂给编码器否则编码器会收到一个长度为 0 的帧很多设备的编码器会直接返回错误甚至进入异常状态。背压是第三种情况出现在你确实希望“宁可丢新数据也不让旧数据延迟堆积”的场景比如实时语音。策略是当 available 超过某个高水位线时主动丢弃一部分最旧的数据保证消费端拿到的永远是最新的。这个逻辑在录音存档场景里不需要但在实时通话这类场景里是必须的因为延迟累积比丢几帧更难忍受。4. MediaCodec 配 AAC字段、模式与输入输出缓冲区的手法4.1 MediaFormat 里那几个真正影响结果的键配置编码器的时候真正起作用的键其实就四五个其余的都是可选项或者设备相关。val mediaFormat MediaFormat.createAudioFormat( MediaFormat.MIMETYPE_AUDIO_AAC, SAMPLE_RATE, CHANNEL_COUNT ).apply { setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC) setInteger(MediaFormat.KEY_BIT_RATE, 64000) setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 16384) setInteger(MediaFormat.KEY_CHANNEL_MASK, if (CHANNEL_COUNT 1) AudioFormat.CHANNEL_IN_MONO else AudioFormat.CHANNEL_IN_STEREO) }MIMETYPE_AUDIO_AAC是必填的决定了走哪个编码器。KEY_AAC_PROFILE用 AACObjectLC 是绝大多数场景的最佳选择它的兼容性最好几乎所有播放器都认。HE-AAC 和 HE-AACv2 在低码率下音质更好但解码端支持度参差不齐而且很多设备上的编码器根本不实现这两个 profile配了会configure失败。KEY_BIT_RATE是目标码率注意这是目标不是硬约束实际输出会有偏差。语音场景 32kbps48kbps 单声道足够音乐场景 96kbps 起步。KEY_MAX_INPUT_SIZE值得单独说它决定了编码器内部输入缓冲区的上限。如果不设置系统会用一个默认值某些实现里这个默认值偏小一次喂大块数据会失败。我一般显式设成 16KB这是单帧 AAC 输入1024 采样 × 2 字节 2048 字节的八倍留足余量。KEY_CHANNEL_MASK这个键经常被忽略但它在部分设备上是必要的只设createAudioFormat里的 channelCount 有时不够编码器可能拿到不确定的声道排布。加上之后稳妥不少。4.2 同步模式与异步模式的取舍MediaCodec 有两种使用方式。同步模式是你自己调dequeueInputBuffer和dequeueOutputBuffer带超时参数轮询。异步模式是setCallback注册回调编码器有输入或输出时主动通知你回调运行在它自己的 Handler 线程上。我在这类录音场景里一律用同步模式。原因是这个链路的输入节奏是完全可控的——采集端每 20ms 左右产出一批数据消费线程完全可以在一个循环里先取输入 buffer、再取输出 buffer超时给 10ms逻辑清晰出错时栈也容易读。异步模式的回调线程和你的状态管理容易打架尤其是在“停止录音”这个动作上回调可能在你已经 release 之后还在跑需要额外的线程同步处理反而更麻烦。如果确实要用异步模式比如目标是极低延迟不想为了等输出 buffer 白白空转 10ms那必须保证stop和release在回调线程之外的线程里、等回调处理完才能执行。4.3 输入缓冲区的喂入姿势与 PTS 计算消费线程的循环骨架大概是这样val info MediaCodec.BufferInfo() var fedBytes 0L val bytesPerSecond SAMPLE_RATE * CHANNEL_COUNT * 2L while (running) { // 先取输出避免输出堆积把编码器堵死 drainOutput(codec, info) val inIndex codec.dequeueInputBuffer(10_000) if (inIndex 0) { val inBuf codec.getInputBuffer(inIndex)!! inBuf.clear() val read ringBuffer.read(tmp, 0, tmp.size, 10) if (read 0) { inBuf.put(tmp, 0, read) val ptsUs fedBytes * 1_000_000L / bytesPerSecond codec.queueInputBuffer(inIndex, 0, read, ptsUs, 0) fedBytes read } else { codec.queueInputBuffer(inIndex, 0, 0, 0, 0) } } }这里最关键的是 PTS 的算法。绝对不要用System.nanoTime()或者System.currentTimeMillis()当 PTS。系统时间是墙钟时间它会跳变NTP 同步、用户改时间而且在消费端被卡住的时候喂进去的数据时间戳会突然跳跃播放器算出来的帧时长就乱了。正确做法是按已喂入的 PCM 字节数反推时间ptsUs 累计喂入字节数 × 1_000_000 / 每秒字节数这个值只跟数据量有关跟系统时间无关无论消费线程卡多久时间轴都是线性的、单调递增的播放器出来的时长就永远准确。还有一点inBuf.put()之前一定要clear()否则 position 可能停在上一轮的末尾。queueInputBuffer的第三个参数是这一帧的字节数不是缓冲区容量写错了编码器会读到垃圾数据。返回 0 字节的时候我建议还是把 buffer 还回去长度填 0不然输入 buffer 会被占着不放几轮之后就全被占满了。4.4 输出缓冲区的解析与 EOS 收尾输出侧要处理三种情况正常输出帧、INFO_OUTPUT_FORMAT_CHANGED、带BUFFER_FLAG_CODEC_CONFIG的配置帧。fun drainOutput(codec: MediaCodec, info: MediaCodec.BufferInfo) { while (true) { val outIndex codec.dequeueOutputBuffer(info, 0) if (outIndex MediaCodec.INFO_TRY_AGAIN_LATER) return if (outIndex MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { onFormatChanged(codec.outputFormat) continue } if (outIndex 0) continue val outBuf codec.getOutputBuffer(outIndex)!! if (info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG ! 0) { // 这就是 CSD-0先存起来别当音频数据写出去 csd ByteArray(info.size).also { outBuf.get(it) } info.size 0 } if (info.size 0) { outBuf.position(info.offset) outBuf.limit(info.offset info.size) writeFrame(outBuf, info) } val eos info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM ! 0 codec.releaseOutputBuffer(outIndex, false) if (eos) return } }INFO_TRY_AGAIN_LATER这里我传的超时是 0也就是“有就拿没有就走”。因为输入侧已经给 10ms 超时了输出这边再阻塞 10ms一轮下来最坏 20ms会拖慢整体节奏。这个细节决定了在高负载设备上会不会出现缓冲区逐渐被填满的情况。收尾的时候需要注意停止录制时不能直接codec.stop()必须先把输入侧打上 EOS 标记queueInputBuffer的 flags 传BUFFER_FLAG_END_OF_STREAM然后继续 drain 输出直到拿到带 EOS 标志的输出帧。编码器内部通常还缓存着几帧数据直接 stop 会把这部分丢掉表现出来就是录音的最后几百毫秒“消失了”。这个问题在录短语音时特别明显比如录一句“好的”最后那个“的”字可能没了一半。5. 从裸帧到能播的文件ADTS 头与 MediaMuxer 两条路线5.1 两条路线的取舍对比拿到 AAC 裸帧之后想要变成能双击播放的文件有两条路。一条是手工拼 ADTS 头存成 .aac 文件另一条是用 MediaMuxer 封装成 MP4/M4A。这两条路的差异比想象中大。对比项手工 ADTS.aacMediaMuxer.m4a/.mp4实现复杂度低一个 7 字节头函数中要处理 formatChanged 时机播放器兼容性一般部分播放器不能拖进度好几乎全平台原生支持是否带时长元信息否时长靠文件大小推算是容器里有精确时长断线进程被杀后可用性好前面写的数据基本能播差moov 没写完文件就废了是否支持边录边播支持写多少播多少不支持muxer 必须先 stop 才可读额外内存开销无需要拷贝一份 buffer如果产品要求“录坏了也能救回来”比如录音笔、会议记录ADTS 是更好的选择因为它是流式格式写多少算多少进程被杀时前面的数据依然可以播放。如果产品要求“给用户一个能分享到任何地方、能拖动进度条的文件”那就必须走 MediaMuxer。我自己的项目里两条都实现过最后的策略是录制过程中写临时 ADTS 文件正常停止时再用 MediaMuxer 转封装成 m4a 并删除临时文件。这样既保证了异常情况下的数据安全又保证了最终产物的兼容性。代价是停止时多花一两百毫秒做一次文件转换。5.2 CSD-0 与 ADTS 头的手工拼接ADTS 头是 7 个字节不带 CRC 的情况下结构固定关键是那几个字段的位运算别写错。private val AAC_SAMPLE_RATES intArrayOf( 96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350 ) fun buildAdtsHeader(packetLen: Int, profile: Int, freqIdx: Int, chanCfg: Int): ByteArray { val header ByteArray(7) val fullLen packetLen 7 header[0] 0xFF.toByte() header[1] 0xF1.toByte() // MPEG-4, Layer 0, 无 CRC header[2] (((profile - 1) shl 6) or (freqIdx shl 2) or (chanCfg shr 2)).toByte() header[3] (((chanCfg and 3) shl 6) or (fullLen shr 11)).toByte() header[4] ((fullLen and 0x7FF) shr 3).toByte() header[5] (((fullLen and 7) shl 5) or 0x1F).toByte() header[6] 0xFC.toByte() return header }有几个细节必须注意。profile传的是AAC object type 减 1AACObjectLC 的值是 2所以这里传 2算出来 profile-1 1对应 ADTS 里的 LC。freqIdx是采样率在表里的索引44100 对应 448000 对应 316000 对应 8传错了播放器会按错误的采样率解码声音就变调了。fullLen是整帧长度包含 7 字节头不是纯数据长度这个是最容易写错的写错了播放器读到的是错位的帧表现为刺耳的噪声或者直接无法解析。声道配置chanCfg单声道是 1立体声是 2。这个值和channelCount是一致的但注意 ADTS 里 0 表示“按 PCE 定义”一般不用。写文件的时候就是header payload一个帧一个帧往下追加不需要任何索引这也是 ADTS 简单的原因。5.3 写盘线程的 IO 抖动与缓冲策略写文件这件事看起来最没技术含量但它是最容易拖垮整条链路的环节。我的做法是把写盘也单独放一个线程用一个小队列接编码线程的输出编码线程只负责把数据塞进队列绝不阻塞在 IO 上。队列用什么ArrayBlockingQueueByteArray就很好用容量给 816 个。满了怎么办两种策略阻塞等消费端腾位置或者丢弃。录音存档场景必须阻塞因为丢一帧就是永久的音频缺失宁可让编码线程稍微慢一点反正环形缓冲区还能扛几百毫秒。如果环形缓冲区也满了那就是系统真的撑不住了这时候才允许丢。文件写入本身用BufferedOutputStream包一层缓冲给 32KB 到 64KB。别小看这一层直接FileOutputStream.write(byte[])每帧写一次一秒钟要写 40 多次系统调用在低端设备上开销不小。包一层缓冲之后实际系统调用次数会降到每秒个位数。还有个小技巧定期flush()。BufferedOutputStream 的默认缓冲是 8KB如果录音突然中断进程被杀、设备断电缓冲区里没刷出去的数据就丢了。我的做法是每写 10 帧flush()一次代价可以忽略但能把数据丢失的窗口压缩到 250ms 以内。对于需要“尽量保住已有数据”的录音场景这个投入非常值。6. 那些跑起来才发现的问题杂音、开头丢字、时长偏差6.1 变调与杂音的三个常见源头第一个源头是采样率不匹配。这是最隐蔽的一个。AudioRecord 用 44100 采集MediaFormat 配的却是 48000编码器不报错它会按 48000 解释这堆数据出来的声音就是整体升调大概 8.8%48000/44100 ≈ 1.088。听人声会觉得“说话特别快、特别尖”。反过来配低了就是降调。这个错误的可怕之处在于它不会报任何异常只有耳朵能听出来。我的防备手段是把这个采样率做成一个全局常量采集和编码都引用它物理上不可能配错。第二个源头是声道数不一致。采集是立体声左右交错编码器按单声道解释结果是把两个声道的数据当成一串单声道采样点相当于采样率翻倍又混叠声音会带着金属感的失真。这个错误通常在代码重构、参数从别人那里抄过来的时候发生。第三个源头是缓冲区覆盖。上一章说的环形缓冲区写溢出如果没有正确丢弃而是让写指针绕圈覆盖了未读数据就会出现周期性的“咔”声。特征很明显间隔规律频率和缓冲区容量与消费速度的差值相关。遇到这种规律性杂音第一个要查的就是缓冲区边界。顺带说一个容易漏的音源选择不当也会“听起来像杂音”。用MIC录人声手机拿得近了低频气流声和气爆会非常明显换成VOICE_RECOGNITION之后这个问题会缓解不少因为它会关掉一部分增益过高的处理链。6.2 音频开头“吃掉”第一个字的成因这个问题的表现是明明按了开始之后才说话回放时第一个字总是缺小半截。原因有三层。第一层是 AudioRecord 的启动延迟。startRecording()返回之后真正开始往缓冲区写数据还有一段时间这段时间的数据是拿不到的。所以正确的做法是先调用startRecording()然后立刻开始读取读到的前几批数据即使全是静音也要吃进去这样时间轴才是对的。如果你在 start 之后先做了一堆别的事比如初始化文件、配置编码器再去读那前面这段时间的数据就在 AudioRecord 的内部缓冲里堆积读的时候会被后面的数据覆盖。第二层是编码器的启动延迟。这个更隐蔽。AAC 编码器在拿到第一个输入帧之后往往要攒够一帧1024 采样点才产出第一帧输出而它的内部状态初始化还需要一些样本。表现出来的就是输出的第一帧对应的时间戳比实际输入晚了几十毫秒。解决办法只有一个在正式录音前先喂一段静音数据做预热等编码器吐出第一帧正常输出之后再把真实数据接上并且把预热期间输出的帧丢掉。我一般预热 200ms 左右。第三层是播放器行为。有些播放器在起播时会主动跳过开头几十毫秒来避免杂音这是播放器层面的策略不是你的问题。判断方法很简单把同一个文件用三个不同的播放器播如果都缺那是你的问题如果只有一个缺那是播放器的问题。6.3 时长对不上的根因与校验方法时长不准这个问题的排查我的标准流程是三点对账第一理论时长 喂给编码器或者写进环形的 PCM 总字节数 ÷ 每秒字节数。这个值在你自己的代码里可以精确统计是基准。第二文件时长。ADTS 文件没有元信息可以用文件总字节数 ÷ 平均码率估算或者数一下帧数乘以 1024 ÷ 采样率。m4a 文件可以直接读容器元信息。第三实际录制耗时墙钟时间。这个值和前两个对比能快速定位问题类型。对照表如下现象可能原因验证方式文件时长 理论时长末尾几帧没冲刷EOS 没做检查 stop 流程是否 drain 到 EOS文件时长 理论时长且差值很大采集中途丢帧检查环形缓冲溢出计数器文件时长 理论时长PTS 用了系统时间期间跳变检查 PTS 计算方式文件时长 ≈ 理论时长但播放不流畅帧间时间戳不均匀检查是否每帧 PTS 都是若干帧共享同一值拖进度条后位置不准ADTS 缺索引播放器按码率估算改用 m4a 封装最后一行值得展开说一下。ADTS 是纯流式格式没有任何索引信息播放器要跳到第 30 秒只能靠“平均码率 × 30 秒”去估算文件位置而 AAC 是变码率的安静段和嘈杂段的码率差好几倍所以估算位置和真实位置会偏。这就是为什么很多人发现“录了一小时拖到后面几分钟就播不出来了”——因为估算的位置超出了文件末尾。如果产品需要长音频和精确拖动ADTS 不合适必须用 MediaMuxer。7. 长时间运行的资源账内存、CPU 与释放顺序7.1 缓冲区大小和功耗的实测关系我做过一轮对比测试同一台设备、同一段 30 分钟录音只改缓冲区参数观察 CPU 占用和电量消耗。大致结论是缓冲区越大CPU 唤醒次数越少功耗越低。配置方案AudioRecord 缓冲读取块大小相对 CPU 占比30 分钟耗电激进低延迟2×minBuf1024 B100%基准约 5.2%平衡4×minBuf4096 B约 65%约 4.1%稳定优先8×minBuf8192 B约 45%约 3.4%差别主要来自上下文切换次数。读取块 1024 字节在 44.1k 单声道下只有 11.6ms意味着每秒要唤醒 86 次每次唤醒都要走一遍调度、锁、编码器轮询。改成 8192 字节之后每秒只醒 11 次开销直接降到零头。对于一个需要后台长时间录音的应用这个差别是实打实的续航。当然低延迟场景不能这么干那是另一套权衡。7.2 释放顺序错了会崩在哪收尾阶段的释放顺序是另一个高发事故点。正确的顺序必须是先停采集 → 再冲刷编码器 → 再停编码器 → 再关文件 → 最后释放对象。每一步的理由都不一样。先停采集如果不先停采集线程还在往环形缓冲区写数据而后面的步骤已经把编码器关了就会读到空数据或者向已释放的对象写入。所以第一步一定是把一个running标志置 false然后join()采集线程确认它真的退出了再往下走。再冲刷编码器往输入侧塞 EOS然后持续 drain 输出直到拿到 EOS 标志的输出帧。这一步不能省前面说过编码器内部有缓存。再停编码器stop()然后release()。注意release()之后就不能再调任何方法了包括getOutputFormat()有些人喜欢在 release 之后再去读格式信息会崩。再关文件先flush()再close()。如果有缓冲队列要保证队列里的数据已经全部落盘这需要一个“生产者结束”的哨兵信号消费线程读到哨兵就退出循环并关闭文件。最后释放对象AudioRecord 的release()放在最后或者更准确地说放在采集线程 join 之后立刻执行因为它已经没用了占着麦克风会让别的应用拿不到。我见过因为忘记 release AudioRecord 导致后面别的应用录音一直失败的案例排查起来很费劲。还有一个细节AudioRecord.stop()在某些设备上是阻塞的会等内部缓冲区处理完最长可能几百毫秒。如果把它放在主线程上调用就会有明显卡顿感。所有涉及音频资源释放的动作都应该在后台线程做。7.3 低延迟场景的额外配置如果你的目标不是存档而是实时性比如边录边传或者耳返上面那套“大缓冲换稳定”的思路要整个反过来。几个关键调整缩小 AudioRecord 缓冲取 minBufferSize 的 12 倍读取块也降到 1024 或 512 字节。代价是对调度抖动更敏感需要把采集线程的优先级提上去Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO)这个调用能让音频线程在系统调度里拿到更高的优先级实测在负载高的设备上效果明显。优先选VOICE_COMMUNICATION作为音源它走的处理链更短延迟更低代价是会有降噪和增益处理音质不是“原声”。编码器考虑降低帧长或者干脆跳过编码。如果只是传输很多时候直接传 PCM 或者用 Opus 这类低延迟编码更合适AAC 的 1024 采样点帧长在 44.1k 下就是 23ms 的硬延迟绕不过去。别用 BufferedOutputStream实时场景下缓冲就是延迟源。改成直接写 socket 或者用带即时 flush 的流。考虑用专门的音频 API平台上有比 AudioRecord 更底层的音频接口延迟能压到十几毫秒但需要写原生代码工程复杂度上一个台阶。是否值得取决于你的延迟指标到底是 100ms 还是 20ms 的硬要求。8. 把这套东西拼起来的关键决策清单最后整理一份我在做这类功能时必过的决策清单每次新项目开始前对着过一遍能省掉大量返工。决策点选错了会怎样我的默认选择采样率/声道变调、失真、体积失控语音 16k 单声道音乐 44.1k 单声道AudioSource音质发闷或噪声大VOICE_RECOGNITIONAudioRecord 缓冲太小丢帧太大延迟高24 倍 minBufferSize是否加环形缓冲不加必然周期性爆音必须加容量 300500ms编码器模式异步模式收尾难控同步模式 10ms 超时PTS 来源时长错乱、进度条乱跳累计字节数反推输出格式长音频拖动不准临时 ADTS 结束转 m4a释放顺序崩溃、麦克风被占死采集→编码→文件→对象再补一个我觉得最容易被忽略的经验给整条链路加一个“对账日志”。每次录音结束打印三行——采集端读入的总字节数、编码端喂入的总字节数、文件实际写入的帧数和总字节数。正常情况下这三者应该是严格对应的后者要减去预热丢弃的部分。只要有一次对不上就说明中间有丢数据而且能立刻定位到是哪一段丢的。这个日志我在每个项目里都会保留平时看着烦出问题时能省下大量时间。音频这条路坑不在于有多难而在于它太“安静”——出问题的时候不会有异常、不会有崩溃只有人耳朵能听出来而人耳朵又往往在项目快交付的时候才听到。所以与其事后调不如在写第一行AudioRecord.Builder()的时候就把这些边界想清楚。