一、数据流程:
scss
[网络接收 AAC-ADTS]
→ decoderAAC(data)
→ MediaCodec InputBuffer (AAC raw)
→ MediaCodec 硬解码 (AAC → PCM 16bit)
→ MediaCodec OutputBuffer (PCM)
→ AudioTrack.write()
→ [扬声器播放]
二、AudioSessionId 共享:根治回音的核心设计
yaml
**与编码侧的关键差异**:
- 编码侧:AudioRecord → PCM → MediaCodec → AAC → 网络
- 解码侧:网络 → AAC → MediaCodec → PCM → AudioTrack
---
## 二、AudioSessionId 共享:根治回音的核心设计
### 2.1 为什么必须共享?
传统做法:
- 编码器(AudioRecord)创建一个 SessionId
- 解码器(AudioTrack)再创建一个 SessionId
- **问题**:系统把输入和输出当成两个独立音频流,AEC(回声消除)无法关联"扬声器输出"与"麦克风输入",回音消除形同虚设。
方案:
// Encoder 侧
val sessionId = mAudioRecord.audioSessionId
// Decoder 侧:接收 Encoder 传过来的 sessionId
startAudio(type, mode, width, sampleRate, sharedSessionId = sessionId)
原理 :AudioRecord 和 AudioTrack 使用同一个 AudioSessionId,系统音频框架把它们绑定到同一会话,AEC 在 HAL 层就能识别"这个麦克风的回声来自那个扬声器",从而有效消除。
代码体现
kotlin
open fun startAudio(type: Int, mode: Int, width: Int, sampleRate: Int, sharedSessionId: Int) {
isStopped = false
initAudioDecoderParam(mode, width, sampleRate)
initAudioTrack(gain = 0.8f, sharedSessionId = sharedSessionId) // ← 关键
initAudio(type, mode.plus(1))
}
kotlin
private fun initAudioTrack(gain: Float = 1.0f, sharedSessionId: Int = AudioManager.AUDIO_SESSION_ID_GENERATE): AudioTrack? {
mAudioTrack = AudioTrack(
mAudioDecoderParam.streamType,
mAudioDecoderParam.sampleRateInHz,
mAudioDecoderParam.channelConfig,
mAudioDecoderParam.audioFormat,
minBufSize,
mAudioDecoderParam.audioMode,
sharedSessionId // ← 使用 Encoder 传过来的 SessionId
).apply {
// 删除 initAEC(audioSessionId) ------ 共享 SessionId 后,Encoder 侧已创建
setVolume(gain)
play()
}
}
注意:Decoder 侧不再单独创建 AEC/NS,因为共享 SessionId 后,系统级 AEC 在 Encoder 侧(AudioRecord)已经绑定到整个会话,Decoder 侧重复创建反而可能冲突。
三、解码器配置:ADTS 与 MediaFormat
3.1 为什么解码也需要 ADTS?
编码侧是给每帧 AAC raw 加 7 字节 ADTS 头。解码侧则相反:需要把 ADTS 头里的采样率、声道信息提取出来,配置给 MediaCodec 。一种更简洁的方式------直接构造 csd-0(Codec Specific Data):
scss
private fun initAudioFormat(audioMimeType: String, channelCount: Int): MediaFormat? {
mAudioFormat = MediaFormat.createAudioFormat(audioMimeType, mAudioDecoderParam.sampleRateInHz, channelCount).apply {
setInteger(MediaFormat.KEY_PCM_ENCODING, mAudioDecoderParam.audioFormat)
setInteger(MediaFormat.KEY_IS_ADTS, 1) // ← 标记输入带 ADTS 头
setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 256 * 1024)
setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
setByteBuffer("csd-0", createAdtsData()) // ← 传入解码器配置
}
}
关键点:
KEY_IS_ADTS = 1:告诉 MediaCodec,输入的 AAC 数据是带 ADTS 头的,解码器会自动跳过 7 字节头解析真实 AAC 帧。csd-0:解码器初始化时需要知道 AAC 的 profile、采样率、声道配置。createAdtsData()构造了 2 字节的 ASC(AudioSpecificConfig),相当于把 ADTS 头里的核心信息提取出来给解码器。
3.2 createAdtsData 解析
scss
private fun createAdtsData(): ByteBuffer {
val adtsData = ByteArray(2)
val sampleRateIdx = when (mAudioDecoderParam.sampleRateInHz) {
16000 -> 0x08
8000 -> 0x0B
44100 -> 0x04
48000 -> 0x03
else -> 0
}
// profile (5bit) + sampleRateIdx (4bit) + channelConfig (4bit) 的压缩表示
adtsData[0] = ((MediaCodecInfo.CodecProfileLevel.AACObjectLC shl 3) or (sampleRateIdx shr 1)).toByte()
adtsData[1] = ((sampleRateIdx shl 7) and 0x80 or (mAudioDecoderParam.channelConfig shl 3)).toByte()
return ByteBuffer.wrap(adtsData)
}
为什么解码器需要 csd-0,而编码器不需要?
解码器在
configure阶段就必须知道音频流的格式参数(采样率、声道、profile),否则无法初始化。编码器是自己生成数据,参数在代码里写死了,所以不需要。
四、解码循环:InputBuffer / OutputBuffer
4.1 入口函数 decoderAAC
kotlin
open fun decoderAAC(data: ByteArray, len: Int, pts: Long): Int {
if (isStopped || mAudioExecutor?.isShutdown == true) return -1
if (mAudioTrack == null) return -2
mAudioExecutor?.submit {
try {
// 如果解码器未初始化,直接播放原始数据(兜底)
if (isStopped || mAudioCodec == null) {
mAudioTrack?.write(data, 0, len)
return@submit
}
// 1. 输入 AAC 数据
val inputBufferIndex = mAudioCodec?.dequeueInputBuffer(0) ?: -1
if (inputBufferIndex >= 0) {
mAudioCodec?.getInputBuffer(inputBufferIndex)?.apply {
clear()
put(data)
mAudioCodec?.queueInputBuffer(inputBufferIndex, 0, len, pts * 1000, 0)
}
}
// 2. 输出 PCM 数据并播放
val bufferInfo = MediaCodec.BufferInfo()
var outputBufferIndex = mAudioCodec?.dequeueOutputBuffer(bufferInfo, 0) ?: -1
while (outputBufferIndex >= 0) {
mAudioCodec?.getOutputBuffer(outputBufferIndex)?.let {
val outData = ByteArray(bufferInfo.size)
it.get(outData)
if (mAudioTrack?.playState == AudioTrack.PLAYSTATE_PLAYING) {
mAudioTrack?.write(outData, 0, outData.size)
}
mAudioCodec?.releaseOutputBuffer(outputBufferIndex, false)
}
outputBufferIndex = mAudioCodec?.dequeueOutputBuffer(bufferInfo, 0) ?: -1
}
} catch (e: Exception) {
e.printStackTrace()
}
}
return 0
}
关键点:
- 线程安全 :
decoderAAC是从网络线程/SDK 回调里调用的,解码和播放放在mAudioExecutor单线程池中,避免多线程同时操作 MediaCodec。 - pts 单位转换 :
pts * 1000,网络层通常传毫秒,MediaCodec 需要微秒。 - 解码器未就绪的兜底 :
if (mAudioCodec == null) { mAudioTrack?.write(data...) }------ 如果解码器初始化失败,直接把原始数据塞给 AudioTrack。虽然可能是噪音,但保证通话不中断。 - AudioTrack 状态检查 :
playState == PLAYSTATE_PLAYING时才write,防止在stopAudio()过程中写数据抛异常。
4.2 为什么用单线程池?
csharp
private var mAudioExecutor = Executors.newSingleThreadExecutor()
- MediaCodec 不是线程安全的,同一个实例不能并发 dequeue/queue。
- 音频帧到达频率高(30-50ms 一帧),单线程顺序处理足够,且避免锁竞争。
isStopped标志 +executor.isShutdown双重检查,防止关闭后还有任务提交。
五、AudioTrack 配置与音量控制
5.1 播放参数
kotlin
private fun initAudioDecoderParam(mode: Int, width: Int, sampleRate: Int): AudioDecoderParam {
mAudioDecoderParam.apply {
channelConfig = if (mode == 1) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO
audioFormat = if (width == 1) AudioFormat.ENCODING_PCM_16BIT else AudioFormat.ENCODING_PCM_8BIT
sampleRateInHz = when (sampleRate) {
in 7999 until 16001 -> 16000
in 16000 until 44101 -> 44100
in 44100 until 48001 -> 48000
else -> sampleRate
}
}
}
mode对应声道:1=立体声,其他=单声道。语音通话通常传 0 或 2,即单声道。width对应位深:1=16bit,其他=8bit。现代设备都是 16bit。- 采样率兜底:网络层可能传 8000/16000/44100/48000,代码做了区间映射,确保落到标准值。
5.2 音量增益(Gain)
scss
initAudioTrack(gain = 0.8f, sharedSessionId = sharedSessionId)
// AudioTrack 创建后
setVolume(gain)
gain = 0.8f:设备端扬声器功率通常较大,默认 1.0f 可能刺耳,适当降低保护儿童听力(教育硬件场景)。- 范围 0.0f ~ 1.0f,超过 1.0f 可能失真。
5.3 静音控制
kotlin
open fun setMute(enableMute: Boolean) {
if (enableMute) {
mAudioTrack?.setVolume(0f)
} else {
mAudioTrack?.setVolume(1f)
}
}
- 不用
pause()/play(),因为恢复时有延迟和爆破音。 - 直接
setVolume(0f)实现软件静音,无感切换。
六、资源释放:优雅关闭
kotlin
override fun stopAudio() {
if (isStopped) return
isStopped = true
mAudioExecutor?.let {
it.shutdown()
try {
if (!it.awaitTermination(200, TimeUnit.MILLISECONDS)) {
it.shutdownNow()
}
} catch (e: InterruptedException) {
it.shutdownNow()
}
mAudioExecutor = null
}
mAudioTrack?.let {
if (it.playState == AudioTrack.PLAYSTATE_PLAYING) it.stop()
it.release()
mAudioTrack = null
}
mAudioCodec?.let {
try { it.stop() } catch (e: Exception) { }
try { it.release() } catch (e: Exception) { }
mAudioCodec = null
}
}
- 先立 flag :
isStopped = true,让decoderAAC新数据直接返回。 - 等线程结束 :
awaitTermination(200ms),给正在解码的一帧留时间,超时才强制shutdownNow。 - 先停播放,再释放解码器 :如果先释放
mAudioCodec,但mAudioTrack还在播放缓冲区的数据,可能导致末尾爆音。 - 异常吞掉 :
stop()/release()在非法状态下可能抛异常,全部try-catch,保证释放流程不中断。
七、踩坑记录
坑 1:不共享 AudioSessionId,导致小程序端回音严重
- 现象:设备端扬声器播放小程序端声音,小程序端又能听到自己的回声。
- 根因:AudioRecord 和 AudioTrack 各自独立的 SessionId,系统 AEC 无法关联输入输出。
- 解决 :Encoder 创建 AudioRecord 后,把
audioSessionId传给 Decoder 的 AudioTrack,共用同一会话。
坑 2:解码器未就绪时直接 write 原始 AAC 数据,扬声器发出噪音
- 现象:通话建立初期,扬声器发出"哒哒哒"的爆音。
- 根因 :
decoderAAC里mAudioCodec == null时直接mAudioTrack.write(data),但 data 是 AAC 不是 PCM。 - 解决:增加格式判断,非 PCM 数据不直接播放;或等解码器初始化完成后再开始收包。
坑 3:频繁 start/stop AudioTrack 导致崩溃
- 现象:通话中切换扬声器/听筒,应用崩溃。
- 根因 :
AudioTrack.stop()后立即release(),但底层缓冲区还在回调。 - 解决 :用
setVolume(0f)代替stop()实现静音,避免频繁启停。
坑 4:pts 单位错误导致音画不同步
- 现象:视频正常,但声音延迟或加速。
- 根因 :网络层传的是毫秒,MediaCodec 需要微秒,忘记
* 1000。 - 解决 :
queueInputBuffer时pts * 1000。
八、与编码侧的联动关系
css
[小程序端麦克风]
↓ AAC-ADTS
[网络 P2P 传输]
↓ AAC-ADTS
[设备端 BaseAudioDecoder] ──→ AudioTrack → 扬声器
↑ sharedSessionId
[设备端 BaseAudioEncoder] ←── AudioRecord ← 麦克风
↓ AAC-ADTS
[网络 P2P 传输]
↓ AAC-ADTS
[小程序端播放器]
核心:共享 AudioSessionId 是中间那条虚线,让系统 AEC 同时作用于采集和播放。
总结
本文从 AAC-ADTS 接收、MediaCodec 硬解码、AudioTrack 实时播放三个环节,拆解了 Android 设备端音频 inbound 链路。核心要点:
- 共享 AudioSessionId 是根治回音的关键设计
- 解码器通过
csd-0+KEY_IS_ADTS识别 AAC 流格式 - 单线程池保证 MediaCodec 线程安全
setVolume(0f)代替stop()实现无感静音- 释放时先停线程、再等 200ms、最后释放资源