Android 音视频通话核心二 —— 音频编码详解记录

一、音频数据流程:

scss 复制代码
[麦克风硬件] 
→ AudioRecord (PCM 16bit Mono 16kHz) 
→ [AEC/NS/AGC 音频预处理] 
→ MediaCodec InputBuffer (原始 PCM) 
→ MediaCodec 硬编码 (AAC LC)
→ MediaCodec OutputBuffer (AAC raw) 
→ ADTS 封包 (7字节头 + AAC raw) 
→ 网络发送 (RTP/RTMP/WebSocket)

二、录音参数设置

ini 复制代码
**关键参数**:
- 采样率:16kHz(语音通话场景,平衡音质与带宽)
- 声道:Mono 单声道(通话不需要立体声)
- 编码格式:AAC-LC(profile=2)
- 码率:48kbps(语音通话足够)
- 封包:ADTS(每帧带头,方便接收端直接解码)

---

## AudioRecord 配置:MIC vs VOICE_COMMUNICATION 的选择

###  代码配置
open fun initMicParam(): MicParam {
    return MicParam().apply {
        audioFormat = AudioFormat.ENCODING_PCM_16BIT
        channelConfig = AudioFormat.CHANNEL_IN_MONO
        sampleRateInHz = 16000
        audioSource = MediaRecorder.AudioSource.MIC  
    }
}

为什么选 16kHz + Mono?

  • 16kHz:语音通话的奈奎斯特频率是 8kHz,16kHz 采样足够还原人声(300Hz~3400Hz),且数据量减半。
  • Mono:双声道对通话无意义,且编码后数据量翻倍。
  • PCM_16BIT:16bit 位深是 MediaCodec AAC 编码器的标准输入,不要选 8bit。

audioSource 选 MIC 还是 VOICE_COMMUNICATION?

MediaRecorder.AudioSource.MIC是常用的,具体用哪个得根据具体机器硬件能力选择,看实际效果决定。

参数 MIC VOICE_COMMUNICATION
回声消除 无内置 系统强制开启 AEC
降噪 无内置 系统强制开启 NS
适用场景 录音、语音识别 语音通话、VoIP
延迟 较低 经过系统音频处理,略高

踩坑记录

项目初期使用 MIC,结果小程序端听到严重回音。排查后发现设备端扬声器播放的声音被麦克风再次采集,形成声学回路。虽然代码里手动调用了 AcousticEchoCanceler,但低端设备硬件 AEC 往往不支持或效果极差。

audioSource 改成 VOICE_COMMUNICATION,让系统在驱动层就做好回声消除,而不是依赖硬件 AEC API。

三、音频预处理:AEC、NS、AGC

scss 复制代码
mAudioRecord = AudioRecord(...).apply {
    initAEC(audioSessionId)      // 回声消除
    initNoiseSuppressor(audioSessionId)  // 噪声抑制
    initAGC(audioSessionId)      // 自动增益控制
}

3.1 AcousticEchoCanceler(回声消除)

kotlin 复制代码
private fun initAEC(audioSessionId: Int) {
    if (AcousticEchoCanceler.isAvailable()) {
        mAcousticEchoCanceler = AcousticEchoCanceler.create(audioSessionId)
        mAcousticEchoCanceler?.enabled = true
    }
}
  • isAvailable() 返回 true,不代表效果好。
  • 教育硬件为了压成本,用的都是最低档音频芯片,AEC 往往只是"有"但"不能用"。
  • "小程序端听到回音",根源就在这里:硬件 AEC 形同虚设,软件层又没有做 Speex/WebRTC AEC

"硬件 AEC 是'薛定谔的可用'。在项目中,我们测试了 5 款设备,只有 1 款 AEC 有效。最终方案是:audioSource 改为 VOICE_COMMUNICATION + 驱动层回声消除,辅以 Speex 软件 AEC 兜底。"

3.2 NoiseSuppressor(噪声抑制)

与 AEC 类似,低端设备支持率不高。但在户外/家庭环境,背景噪声(电视声、说话声)会严重影响通话质量。

3.3 AutomaticGainControl(自动增益控制)

解决"离麦克风远就听不见,离近了就爆音"的问题。AGC 会自动调整输入音量,保持响度一致。


四、MediaCodec AAC 硬编码配置

4.1 编码器初始化

csharp 复制代码
mAudioCodec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
mAudioCodec?.configure(
    initAudioFormat(mAudioEncodeParam.audioMimeType, 1, mMicSampleRateInHz),
    null, null, MediaCodec.CONFIGURE_FLAG_ENCODE
)

4.2 MediaFormat 参数详解

kotlin 复制代码
private fun initAudioFormat(audioMimeType: String, channelCount: Int, sampleRateInHz: Int): MediaFormat {
    return MediaFormat.createAudioFormat(audioMimeType, sampleRateInHz, channelCount).apply {
        setInteger(MediaFormat.KEY_BIT_RATE, 48000)           // 码率 48kbps
        setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)  // AAC-LC
        setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, bufferSizeInBytes)  // 输入缓冲区大小
    }
}

参数解释

参数 含义
KEY_BIT_RATE 48000 48kbps,语音通话黄金码率
KEY_AAC_PROFILE AACObjectLC Low Complexity,低复杂度,兼容性好
KEY_MAX_INPUT_SIZE bufferSizeInBytes 单帧最大输入字节数,避免缓冲区溢出
KEY_SAMPLE_RATE 16000 采样率,必须与 AudioRecord 一致
KEY_CHANNEL_COUNT 1 单声道

如果 sampleRateInHz 和 AudioRecord 不一致会怎样?

编码器会按 16kHz 的采样率去编码,但输入数据是 16kHz 的,所以没问题。但如果 AudioRecord 是 44.1kHz,编码器是 16kHz,编码器内部会重采样,但延迟和音质都会劣化。最佳实践是两端保持一致


五、编码循环:InputBuffer / OutputBuffer 详解

这是 MediaCodec 的核心,也是最容易出 Bug 的地方。

5.1 输入端:PCM → InputBuffer

scss 复制代码
val audioInputBufferId = it.dequeueInputBuffer(0)  // 非阻塞
if (audioInputBufferId >= 0) {
    val inputBuffer = it.getInputBuffer(audioInputBufferId)
    var readSize = audioRecord.read(inputBuffer, bufferSizeInBytes)
    
    // 静音处理:把 PCM 数据全部置零
    if (isMuted && readSize > 0) {
        for (i in 0 until readSize) {
            inputBuffer.put(i, 0.toByte())
        }
    }
    
    it.queueInputBuffer(audioInputBufferId, 0, readSize, System.nanoTime() / 1000, 0)
}

关键点

  1. dequeueInputBuffer(0):超时时间 0,非阻塞。如果编码器忙,返回 -1,本次循环跳过。
  2. audioRecord.read():从麦克风读取原始 PCM 数据,直接写入 InputBuffer。
  3. 静音处理isMuted 为 true 时,把 InputBuffer 里的数据全部置 0。这是软件静音,比关闭 AudioRecord 再重启更流畅(避免开关麦克风的爆破音)。
  4. presentationTimeUs:用 System.nanoTime() / 1000,单位微秒。这个时间戳很重要,接收端同步音视频靠它。

5.2 输出端:OutputBuffer → AAC raw

scss 复制代码
var audioOutputBufferIndex = it.dequeueOutputBuffer(audioInfo, 0)
while (audioOutputBufferIndex >= 0) {
    val outputBuffer = it.getOutputBuffer(audioOutputBufferIndex)
    if (audioInfo.size > 2) {
        outputBuffer?.position(audioInfo.offset)
        outputBuffer?.limit(audioInfo.offset + audioInfo.size)
        addADTStoPacket(outputBuffer)  // ← ADTS 封包
    }
    it.releaseOutputBuffer(audioOutputBufferIndex, false)
    audioOutputBufferIndex = it.dequeueOutputBuffer(audioInfo, 0)
}

关键点

  1. dequeueOutputBuffer 可能一次返回多帧,所以用 while 循环。
  2. audioInfo.size > 2:过滤空帧或异常帧。
  3. outputBuffer.position(audioInfo.offset):MediaCodec 的输出 Buffer 可能包含偏移,必须复位到正确位置。
  4. releaseOutputBuffer:必须释放,否则编码器缓冲区耗尽,后续编码阻塞。

六、ADTS 封包

scss 复制代码
private fun addADTStoPacket(packet: ByteArray, packetLen: Int) {
    val profile = 2        // AAC LC
    val chanCfg = 1        // Mono
    val freqIdx = samplingFrequencyIndexMap[mMicSampleRateInHz]!!  // 16kHz → 8
    
    packet[0] = 0xFF.toByte()   // syncword
    packet[1] = 0xF9.toByte()   // MPEG-2 + CRC absent
    packet[2] = ((profile - 1 shl 6) + (freqIdx shl 2) + (chanCfg shr 2)).toByte()
    packet[3] = ((chanCfg and 3 shl 6) + (packetLen shr 11)).toByte()
    packet[4] = (packetLen and 0x7FF shr 3).toByte()
    packet[5] = ((packetLen and 7 shl 5) + 0x1F).toByte()
    packet[6] = 0xFC.toByte()
}

6.1 为什么需要 ADTS?

AAC 数据本身没有头信息,接收端不知道采样率、声道数、码率。ADTS(Audio Data Transport Stream)在每帧 AAC 数据前加 7 字节头,让接收端能自解释解码。

6.2 ADTS 头结构

字段 说明
Syncword 12 0xFFF 同步字
ID 1 0 MPEG-4
Layer 2 0
Protection Absent 1 1 无 CRC
Profile 2 1 AAC-LC (profile=2, 所以存的是 1)
Sampling Frequency Index 4 8 16kHz
Channel Configuration 3 1 Mono
Frame Length 13 packetLen 整帧长度(含头)
Buffer Fullness 11 0x7FF VBR
Number of Raw Blocks 2 0 1 个 AAC raw block

6.3 采样率对照表

kotlin 复制代码
val samplingFrequencyIndexMap = HashMap<Int, Int>().apply {
    this[96000] = 0
    this[88200] = 1
    this[64000] = 2
    this[48000] = 3
    this[44100] = 4
    this[32000] = 5
    this[24000] = 6
    this[22050] = 7
    this[16000] = 8   // ← 本文用的
    this[12000] = 9
    this[11025] = 10
    this[8000] = 11
}

七、资源释放:顺序很重要

kotlin 复制代码
private fun release() {
    stopEncode = true
    mAudioExecutor?.shutdown()      // 1. 先停线程,防止新任务提交
    mAudioRecord?.stop()            // 2. 停采集
    mAudioRecord?.release()         // 3. 释放麦克风资源
    mAudioCodec?.stop()             // 4. 停编码器
    mAudioCodec?.release()          // 5. 释放编码器
    mAcousticEchoCanceler?.release() // 6. 释放音频处理器
}

释放顺序原则 :先停上游(采集),再停下游(编码),最后释放系统资源。如果先释放 AudioRecord 但编码线程还在跑,read() 会抛异常。


八、踩坑记录

坑 1:硬件 AEC 不可用,导致小程序端严重回音

  • 现象:设备端扬声器开声音,小程序端说话后 1 秒听到自己回音。
  • 排查AcousticEchoCanceler.isAvailable() 返回 true,但 enabled = true 后无效。
  • 根因:设备音频 HAL 层没有实现 AEC 算法,只是暴露了 API。
  • 解决 :audioSource 改为 VOICE_COMMUNICATION + 后续集成 Speex 软件 AEC。

坑 2:编码线程阻塞,导致通话卡顿

  • 现象:通话 30 秒后,声音开始卡顿,最后完全无声。
  • 排查 :发现 dequeueOutputBuffer 一直返回 -1,编码器缓冲区满。
  • 根因 :某次 releaseOutputBuffer 没调用(异常分支遗漏)。
  • 解决 :在 while 循环和异常处理里确保 releaseOutputBuffer 一定执行。

坑 3:静音后恢复,出现爆破音

  • 现象:关闭麦克风再打开,对方听到"噗"的一声。
  • 根因 :直接 stop() / start() AudioRecord,麦克风电源开关产生电涌。
  • 解决 :不用 stop/start,而是用 isMuted 软件置零,实现无缝静音。

相关推荐
QING6184 小时前
如何使用Compose 绘制提升性能 —— 新手指南
kotlin·android jetpack·canvas
Refrain_zc5 小时前
Android 音视频通话核心 —— MediaCodec H.264 硬编码,SPS/PPS 合并与动态码率,视频编码全解析
kotlin
plainGeekDev5 小时前
Fragment 手动跳转 → Navigation 组件
android·java·kotlin
plainGeekDev5 小时前
XML 主题 → Compose Material3 主题
android·java·kotlin
Kapaseker6 小时前
Rust 是如何干掉空指针的
rust·kotlin
消失的旧时光-19437 小时前
Kotlin 协程设计思想(四):launch、async、withContext 到底有什么区别?
java·kotlin·async·launch·withcontext·deferred
修行者对6667 小时前
Kotlin学习笔记(1)
kotlin
Refrain_zc21 小时前
Android 音视频通话核心 —— 音频解码(AAC → PCM → 播放)完整解析
kotlin
Refrain_zc21 小时前
Android 音视频通话核心 —— Camera 采集 + 音视频编码调度
kotlin