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

一、数据流程:

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
}

关键点

  1. 线程安全decoderAAC 是从网络线程/SDK 回调里调用的,解码和播放放在 mAudioExecutor 单线程池中,避免多线程同时操作 MediaCodec。
  2. pts 单位转换pts * 1000,网络层通常传毫秒,MediaCodec 需要微秒。
  3. 解码器未就绪的兜底if (mAudioCodec == null) { mAudioTrack?.write(data...) } ------ 如果解码器初始化失败,直接把原始数据塞给 AudioTrack。虽然可能是噪音,但保证通话不中断。
  4. 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
    }
}
  1. 先立 flagisStopped = true,让 decoderAAC 新数据直接返回。
  2. 等线程结束awaitTermination(200ms),给正在解码的一帧留时间,超时才强制 shutdownNow
  3. 先停播放,再释放解码器 :如果先释放 mAudioCodec,但 mAudioTrack 还在播放缓冲区的数据,可能导致末尾爆音。
  4. 异常吞掉stop() / release() 在非法状态下可能抛异常,全部 try-catch,保证释放流程不中断。

七、踩坑记录

坑 1:不共享 AudioSessionId,导致小程序端回音严重

  • 现象:设备端扬声器播放小程序端声音,小程序端又能听到自己的回声。
  • 根因:AudioRecord 和 AudioTrack 各自独立的 SessionId,系统 AEC 无法关联输入输出。
  • 解决 :Encoder 创建 AudioRecord 后,把 audioSessionId 传给 Decoder 的 AudioTrack,共用同一会话。

坑 2:解码器未就绪时直接 write 原始 AAC 数据,扬声器发出噪音

  • 现象:通话建立初期,扬声器发出"哒哒哒"的爆音。
  • 根因decoderAACmAudioCodec == null 时直接 mAudioTrack.write(data),但 data 是 AAC 不是 PCM。
  • 解决:增加格式判断,非 PCM 数据不直接播放;或等解码器初始化完成后再开始收包。

坑 3:频繁 start/stop AudioTrack 导致崩溃

  • 现象:通话中切换扬声器/听筒,应用崩溃。
  • 根因AudioTrack.stop() 后立即 release(),但底层缓冲区还在回调。
  • 解决 :用 setVolume(0f) 代替 stop() 实现静音,避免频繁启停。

坑 4:pts 单位错误导致音画不同步

  • 现象:视频正常,但声音延迟或加速。
  • 根因 :网络层传的是毫秒,MediaCodec 需要微秒,忘记 * 1000
  • 解决queueInputBufferpts * 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、最后释放资源

相关推荐
Refrain_zc2 小时前
Android 音视频通话核心二 —— 音频编码详解记录
kotlin
QING6185 小时前
如何使用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