一、音频数据流程:
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)
}
关键点:
dequeueInputBuffer(0):超时时间 0,非阻塞。如果编码器忙,返回 -1,本次循环跳过。audioRecord.read():从麦克风读取原始 PCM 数据,直接写入 InputBuffer。- 静音处理 :
isMuted为 true 时,把 InputBuffer 里的数据全部置 0。这是软件静音,比关闭 AudioRecord 再重启更流畅(避免开关麦克风的爆破音)。 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)
}
关键点:
dequeueOutputBuffer可能一次返回多帧,所以用while循环。audioInfo.size > 2:过滤空帧或异常帧。outputBuffer.position(audioInfo.offset):MediaCodec 的输出 Buffer 可能包含偏移,必须复位到正确位置。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软件置零,实现无缝静音。