纯算法AEC:播录并行场景的回声消除实战笔记

引言

最近在做一款 AI 语音应用,场景类似"实时通话":一边让 TTS 播报,一边把麦克风打开做 STT。

问题在于,扬声器出来的声音下一秒就会被麦克风原封不动地录回去,STT 立刻把它当成用户再说一遍,形成"自己听懂自己"的无限循环。

为了切断这条回声通路,我试了一圈硬件方案无果后,决定用纯算法在软件层把播报声音从录音里"抠"掉。

参考 WebRTC

在我原有的设计里,TTS 播报走的是系统自带播放器,录音又来自 Android 系统原生的 AudioRecord,这是两条并行没有交集的数据处理线。如果继续保持原有设计,根本不可能实现效果,于是我参考 WebRTC 的设计。

把 TTS 和录音全部"绑架"进 WebRTC 的轨道,让引擎重新掌控生命线。

这里我用到了开源库:

java 复制代码
implementation 'io.github.webrtc-sdk:android:137.7151.05'

方案设计

我画了一张流程设计的简图如下:

  1. 蓝色泳道 (TTS) : 负责产生声音。数据进入 AECSchedule 后,不仅是为了让用户听到(通过扬声器),更是为了告诉 AEC 算法"这是我们自己发出的声音,请不要把它当成用户说的话"。
  2. 橙色泳道 (AECSchedule) : 核心处理区。
    • 关键点 : 虚线箭头表示 AudioTrack 播放的声音被用作 参考信号 (Reference Signal) 。
    • 处理 : WebRTC 引擎 (ADM) 将 麦克风采集的声音 减去 参考信号 ,从而消除回声。
  3. 绿色泳道 (STT) : 最终消费者。它接收到的音频是经过 AEC(回声消除)和 NS(降噪)处理后的纯净人声,从而提高识别准确率。

方案流程非常清晰简单,下面是附上的 AECSchedule 源码,可以直接复制使用

kotlin 复制代码
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioTrack
import com.jiale.business_voice.config.TTSAudioConfig
import com.jiale.commom.tools.log.JLog
import org.webrtc.*
import org.webrtc.audio.AudioDeviceModule
import org.webrtc.audio.JavaAudioDeviceModule
import java.util.concurrent.atomic.AtomicBoolean

/**
 * 基于 WebRTC 的回声消除调度器 (AECSchedule)
 *
 * 主要职责:
 * 1. 播放 (Playback): 管理 AudioTrack 以 VOICE_COMMUNICATION 模式播放 TTS 数据。
 *    这确保系统 AEC 将其视为"参考信号" (Reference Signal)。
 * 2. 录音 (Recording): 使用 WebRTC 的 JavaAudioDeviceModule (ADM) 采集音频。
 *    ADM 自动处理系统硬件 AEC/NS/AGC 配置。
 *    采集到的纯净音频通过回调暴露给外部。
 */
class AECSchedule(private val context: Context, private val outputCallback: (ByteArray) -> Unit) {

    companion object {
        private const val TAG = "AECSchedule"
        
        // 音频配置
        private val SAMPLE_RATE = TTSAudioConfig.SAMPLE_RATE
        private const val AUDIO_USAGE = AudioAttributes.USAGE_VOICE_COMMUNICATION
        private const val AUDIO_CONTENT_TYPE = AudioAttributes.CONTENT_TYPE_SPEECH
        
        // WebRTC ID
        private const val AUDIO_TRACK_ID = "ARDAMSa0"
        private const val STREAM_ID = "ARDAMS"

        // WebRTC 约束条件 Keys
        private const val CONSTRAINT_ECHO_CANCELLATION = "googEchoCancellation"
        private const val CONSTRAINT_AUTO_GAIN_CONTROL = "googAutoGainControl"
        private const val CONSTRAINT_NOISE_SUPPRESSION = "googNoiseSuppression"
        private const val CONSTRAINT_HIGHPASS_FILTER = "googHighpassFilter"
    }

    // --- 播放相关 (Speaker) ---
    private var audioTrack: AudioTrack? = null

    private val audioManager: AudioManager by lazy {
        context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
    }

    // --- 录音相关 (Mic & Processing) ---
    private var peerConnectionFactory: PeerConnectionFactory? = null
    private var audioDeviceModule: AudioDeviceModule? = null
    private var webrtcAudioSource: AudioSource? = null
    private var webrtcLocalTrack: org.webrtc.AudioTrack? = null
    private var dummyPeerConnection: PeerConnection? = null
    
    private val isRecording = AtomicBoolean(false)
    @Volatile
    private var hasLoggedRecordInfo = false

    // ============================================================================================
    // 播放逻辑 (TTS)
    // ============================================================================================

    /**
     * 接收 TTS 音频数据并进行播放。
     * 这些数据将作为 AEC 的"参考信号"。
     */
    fun onTTSAudioData(data: ByteArray) {
        // 确保音频路由到扬声器(广播模式)而不是听筒
        setSpeakerphoneOn(true)
        
        if (audioTrack == null) {
            initAudioTrack()
        }
        
        audioTrack?.let { track ->
            if (track.playState != AudioTrack.PLAYSTATE_PLAYING) {
                try {
                    track.play()
                } catch (e: Exception) {
                    JLog.e(TAG, "启动 AudioTrack 失败", e)
                    return
                }
            }
            track.write(data, 0, data.size)
        }
    }

    /**
     * 停止 TTS 播放并释放 AudioTrack 资源。
     */
    fun stopPlay() {
        safeExecute("停止播放") {
            audioTrack?.let {
                if (it.playState == AudioTrack.PLAYSTATE_PLAYING) {
                    it.stop()
                }
                it.release()
            }
        }
        audioTrack = null
    }

    /**
     * 设置扬声器状态。
     * True: 路由到扬声器 (外放)。
     * False: 路由到听筒 (通话)。
     */
    private fun setSpeakerphoneOn(on: Boolean) {
        safeExecute("设置扬声器") {
            if (audioManager.mode != AudioManager.MODE_IN_COMMUNICATION) {
                audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
            }
            if (audioManager.isSpeakerphoneOn != on) {
                audioManager.isSpeakerphoneOn = on
                JLog.i(TAG, "设置扬声器状态: $on")
            }
        }
    }

    private fun initAudioTrack() {
        safeExecute("初始化 AudioTrack") {
            val minBufferSize = AudioTrack.getMinBufferSize(
                SAMPLE_RATE,
                AudioFormat.CHANNEL_OUT_MONO,
                AudioFormat.ENCODING_PCM_16BIT
            )

            val attributes = AudioAttributes.Builder()
                .setUsage(AUDIO_USAGE)
                .setContentType(AUDIO_CONTENT_TYPE)
                .build()

            val format = AudioFormat.Builder()
                .setEncoding(AudioFormat.ENCODING_PCM_16BIT)
                .setSampleRate(SAMPLE_RATE)
                .setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
                .build()

            audioTrack = AudioTrack(
                attributes,
                format,
                minBufferSize,
                AudioTrack.MODE_STREAM,
                AudioManager.AUDIO_SESSION_ID_GENERATE
            )
            
            JLog.i(TAG, "AudioTrack 已初始化 (VOICE_COMMUNICATION 模式)")
        }
    }

    // ============================================================================================
    // 录音逻辑 (WebRTC Engine)
    // ============================================================================================

    /**
     * 启动 WebRTC 引擎进行录音。
     * 音频数据将经过处理 (AEC/NS) 并通过 outputCallback 返回。
     */
    fun startRecording() {
        if (isRecording.get()) {
            JLog.w(TAG, "录音已在进行中")
            return
        }

        JLog.i(TAG, "正在启动 WebRTC 录音...")
        hasLoggedRecordInfo = false
        
        try {
            // 0. 确保音频模式为 IN_COMMUNICATION 以启用硬件 AEC
            setSpeakerphoneOn(true)

            // 1. 初始化 WebRTC 全局上下文
            initWebRTCContext()

            // 2. 创建音频设备模块 (引擎核心)
            createAudioDeviceModule()

            // 3. 创建 PeerConnectionFactory (协调者)
            createPeerConnectionFactory()

            // 4. 创建音频源和轨道 (管道)
            createAudioPipeline()

            // 5. 创建虚拟 PeerConnection 以强制 ADM 开始录音
            startDummyPeerConnection()

            isRecording.set(true)
            JLog.i(TAG, "WebRTC 录音启动成功")

        } catch (e: Exception) {
            JLog.e(TAG, "启动 WebRTC 录音失败", e)
            releaseWebRTC() // 失败时清理资源
        }
    }

    /**
     * 停止录音并释放 WebRTC 资源。
     */
    fun stopRecording() {
        if (!isRecording.get()) return
        
        JLog.i(TAG, "正在停止 WebRTC 录音...")
        isRecording.set(false)
        releaseWebRTC()
        JLog.i(TAG, "WebRTC 录音已停止")
    }

    // --- WebRTC 内部初始化流程 ---

    private fun initWebRTCContext() {
        val options = PeerConnectionFactory.InitializationOptions.builder(context)
            .setEnableInternalTracer(false)
            .createInitializationOptions()
        PeerConnectionFactory.initialize(options)
    }

    private fun createAudioDeviceModule() {
        // JavaAudioDeviceModule 是标准的 Android 实现。
        // 它管理 AudioRecord 和 AudioTrack。
        // 关键点:它负责设置硬件 AEC (如果可用)。
        audioDeviceModule = JavaAudioDeviceModule.builder(context)
            .setUseHardwareAcousticEchoCanceler(true)
            .setUseHardwareNoiseSuppressor(true)
            .setAudioRecordErrorCallback(object : JavaAudioDeviceModule.AudioRecordErrorCallback {
                override fun onWebRtcAudioRecordInitError(p0: String?) = JLog.e(TAG, "录音初始化错误: $p0")
                override fun onWebRtcAudioRecordStartError(p0: JavaAudioDeviceModule.AudioRecordStartErrorCode?, p1: String?) = JLog.e(TAG, "录音启动错误: $p1")
                override fun onWebRtcAudioRecordError(p0: String?) = JLog.e(TAG, "录音运行时错误: $p0")
            })
            .setAudioRecordStateCallback(object : JavaAudioDeviceModule.AudioRecordStateCallback {
                override fun onWebRtcAudioRecordStart() = JLog.i(TAG, "ADM: 录音已开始")
                override fun onWebRtcAudioRecordStop() = JLog.i(TAG, "ADM: 录音已停止")
            })
            // 使用 SamplesReadyCallback 直接从 ADM 获取原始音频数据
            // 这绕过了完整的 PeerConnection 媒体流传输,更直接高效
            .setSamplesReadyCallback { audioSamples ->
                processAudioSamples(audioSamples)
            }
            .createAudioDeviceModule()
    }

    private fun processAudioSamples(audioSamples: JavaAudioDeviceModule.AudioSamples) {
        // 检查录音状态,防止关闭过程中的数据泄漏
        if (!isRecording.get()) return

        if (!hasLoggedRecordInfo) {
            JLog.i(TAG, "WebRTC 录音格式: ${audioSamples.sampleRate}Hz, ${audioSamples.channelCount} 声道, 格式=${audioSamples.audioFormat}, 大小=${audioSamples.data.size}")
            hasLoggedRecordInfo = true
        }

        var processedBytes = audioSamples.data

        // 1. 立体声转单声道 (如果需要)
        if (audioSamples.channelCount == 2) {
            processedBytes = stereoToMono(processedBytes)
        }

        // 2. 重采样 (如果需要)
        if (audioSamples.sampleRate == 48000) {
            processedBytes = resample48kTo16k(processedBytes)
        }

        // 回调给 STT
        outputCallback(processedBytes)
    }

    private fun createPeerConnectionFactory() {
        val options = PeerConnectionFactory.Options()
        peerConnectionFactory = PeerConnectionFactory.builder()
            .setOptions(options)
            .setAudioDeviceModule(audioDeviceModule)
            .createPeerConnectionFactory()
    }

    private fun createAudioPipeline() {
        // 4.1 创建音频源 (AudioSource)
        // 添加 Google 特定的约束条件,提示引擎启用处理
        val constraints = MediaConstraints().apply {
            mandatory.add(MediaConstraints.KeyValuePair(CONSTRAINT_ECHO_CANCELLATION, "true"))
            mandatory.add(MediaConstraints.KeyValuePair(CONSTRAINT_AUTO_GAIN_CONTROL, "true"))
            mandatory.add(MediaConstraints.KeyValuePair(CONSTRAINT_NOISE_SUPPRESSION, "true"))
            mandatory.add(MediaConstraints.KeyValuePair(CONSTRAINT_HIGHPASS_FILTER, "true"))
        }
        webrtcAudioSource = peerConnectionFactory?.createAudioSource(constraints)

        // 4.2 创建本地音频轨道 (AudioTrack)
        webrtcLocalTrack = peerConnectionFactory?.createAudioTrack(AUDIO_TRACK_ID, webrtcAudioSource)
        webrtcLocalTrack?.setEnabled(true)
    }

    private fun startDummyPeerConnection() {
        try {
            val rtcConfig = PeerConnection.RTCConfiguration(emptyList())
            rtcConfig.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN

            dummyPeerConnection = peerConnectionFactory?.createPeerConnection(rtcConfig, object : SimplePeerConnectionObserver() {
                // 我们不需要处理任何 P2P 事件,因为这只是一个本地 Loopback
            })
            
            // 将本地轨道添加到连接中以触发 ADM 录音
            if (webrtcLocalTrack != null) {
                dummyPeerConnection?.addTrack(webrtcLocalTrack, listOf(STREAM_ID))
                JLog.i(TAG, "已添加本地轨道到虚拟 PeerConnection")
            }

            // 关键步骤:创建 Offer 并设置 Local Description 以激活媒体流
            val constraints = MediaConstraints()
            dummyPeerConnection?.createOffer(object : SimpleSdpObserver() {
                override fun onCreateSuccess(sessionDescription: SessionDescription?) {
                    JLog.i(TAG, "虚拟 Offer 已创建")
                    dummyPeerConnection?.setLocalDescription(object : SimpleSdpObserver() {
                        override fun onSetSuccess() {
                            JLog.i(TAG, "虚拟本地描述已设置 - 引擎应已激活")
                        }
                    }, sessionDescription)
                }
            }, constraints)

        } catch (e: Exception) {
            JLog.e(TAG, "启动虚拟 PeerConnection 失败", e)
        }
    }

    private fun releaseWebRTC() {
        JLog.i(TAG, "正在释放 WebRTC 资源...")
        
        // 1. 关闭 PeerConnection (停止管道)
        safeExecute("释放 PeerConnection") {
            dummyPeerConnection?.dispose()
            JLog.i(TAG, "PeerConnection 已释放")
        }
        dummyPeerConnection = null

        // 2. 释放本地轨道
        safeExecute("释放 LocalTrack") {
            webrtcLocalTrack?.let {
                it.setEnabled(false)
                it.dispose()
                JLog.i(TAG, "LocalTrack 已释放")
            }
        }
        webrtcLocalTrack = null
        
        // 3. 释放音频源
        safeExecute("释放 AudioSource") {
            webrtcAudioSource?.dispose()
            JLog.i(TAG, "AudioSource 已释放")
        }
        webrtcAudioSource = null
        
        // 4. 释放工厂
        safeExecute("释放 PeerConnectionFactory") {
            peerConnectionFactory?.dispose()
            JLog.i(TAG, "PeerConnectionFactory 已释放")
        }
        peerConnectionFactory = null
        
        // 5. 释放 ADM (对于停止麦克风至关重要)
        safeExecute("释放 AudioDeviceModule") {
            audioDeviceModule?.let {
                // 解除可能的状态锁定
                it.setSpeakerMute(false) 
                it.setMicrophoneMute(false)
                it.release()
                JLog.i(TAG, "AudioDeviceModule 已释放 - 麦克风应已停止")
            }
        }
        audioDeviceModule = null
    }

    /**
     * 完全释放所有资源 (播放器和录音机)。
     */
    fun release() {
        stopPlay()
        stopRecording()
        // 重置音频模式
        safeExecute("重置音频模式") {
            audioManager.isSpeakerphoneOn = false
            audioManager.mode = AudioManager.MODE_NORMAL
        }
    }

    // ============================================================================================
    // 辅助方法和类
    // ============================================================================================

    private inline fun safeExecute(actionName: String, action: () -> Unit) {
        try {
            action()
        } catch (e: Exception) {
            JLog.e(TAG, "$actionName 失败", e)
        }
    }

    /**
     * 将 16-bit PCM 立体声转换为单声道 (丢弃右声道)。
     */
    private fun stereoToMono(stereoBytes: ByteArray): ByteArray {
        val monoBytes = ByteArray(stereoBytes.size / 2)
        for (i in 0 until monoBytes.size step 2) {
            val stereoIndex = i * 2
            monoBytes[i] = stereoBytes[stereoIndex]
            monoBytes[i + 1] = stereoBytes[stereoIndex + 1]
        }
        return monoBytes
    }

    /**
     * 将 16-bit PCM 48000Hz 降采样到 16000Hz (3:1 抽取)。
     */
    private fun resample48kTo16k(src: ByteArray): ByteArray {
        val sampleCount = src.size / 2
        val newSampleCount = sampleCount / 3
        val dst = ByteArray(newSampleCount * 2)

        for (i in 0 until newSampleCount) {
            val srcIndex = i * 3 * 2
            val dstIndex = i * 2
            dst[dstIndex] = src[srcIndex]
            dst[dstIndex + 1] = src[srcIndex + 1]
        }
        return dst
    }

    // --- 简化的 Observer 实现,避免冗余代码 ---

    private open class SimplePeerConnectionObserver : PeerConnection.Observer {
        override fun onSignalingChange(p0: PeerConnection.SignalingState?) {}
        override fun onIceConnectionChange(p0: PeerConnection.IceConnectionState?) {}
        override fun onIceConnectionReceivingChange(p0: Boolean) {}
        override fun onIceGatheringChange(p0: PeerConnection.IceGatheringState?) {}
        override fun onIceCandidate(p0: IceCandidate?) {}
        override fun onIceCandidatesRemoved(p0: Array<out IceCandidate>?) {}
        override fun onAddStream(p0: MediaStream?) {}
        override fun onRemoveStream(p0: MediaStream?) {}
        override fun onDataChannel(p0: DataChannel?) {}
        override fun onRenegotiationNeeded() {}
        override fun onAddTrack(p0: RtpReceiver?, p1: Array<out MediaStream>?) {}
    }

    private open class SimpleSdpObserver : SdpObserver {
        override fun onCreateSuccess(p0: SessionDescription?) {}
        override fun onSetSuccess() {}
        override fun onCreateFailure(p0: String?) { JLog.e(TAG, "SDP Create Error: $p0") }
        override fun onSetFailure(p0: String?) { JLog.e(TAG, "SDP Set Error: $p0") }
    }
}

使用方式

1. 初始化

调用方需要实例化 AECSchedule ,并提供一个回调函数来接收处理后的纯净音频数据(用于 STT)。

kotlin 复制代码
// 在 Activity 或 ViewModel 中
val aecSchedule = AECSchedule(context) { processedPcmData ->
    // [回调] 这里接收到的是经过回声消除和降噪后的纯净音频
    // 通常在这里将数据发送给 STT (语音识别) 引擎
    sttClient.sendAudio(processedPcmData)
}

2. 启动录音 (Start Recording)

当需要开始听用户说话时(例如唤醒后,或进入对话状态),调用 startRecording() 。

  • 作用 :启动 WebRTC 引擎,打开麦克风,开始采集并处理音频。
  • 时机 :通常在用户点击"开始说话"按钮,或系统检测到唤醒词后。
kotlin 复制代码
aecSchedule.startRecording()

3. 播放 TTS 音频 (Playback)

当有 TTS(语音合成)数据需要播放时, 必须 通过 AECSchedule 来播放,而不是自己创建 AudioTrack 。

  • 关键点 :只有通过 onTTSAudioData 播放的声音,才能被 WebRTC 引擎捕捉为"参考信号",从而在麦克风采集的音频中将其消除(避免自言自语)。
kotlin 复制代码
// 假设 ttsData 是从 TTS 引擎获取的 PCM 字节流,我这边拿到的是 tts-websocket 中返回的 byte[] (音频数据)
aecSchedule.onTTSAudioData(ttsData)

4. 停止播放 (Stop Playback)

如果需要打断播放(例如用户点击"停止"或开始新的对话),调用 stopPlay() 。

kotlin 复制代码
aecSchedule.stopPlay()

5. 停止录音 (Stop Recording)

当不需要再听用户说话时(例如识别结束,进入思考状态),调用 stopRecording() 。

  • 注意 :这将释放麦克风资源,系统状态栏的录音图标(小绿点)应消失。
kotlin 复制代码
aecSchedule.stopRecording()

6. 销毁资源 (Release)

在页面销毁或退出功能时,务必调用 release() 以彻底释放所有硬件资源。

kotlin 复制代码
override fun onDestroy() {
    super.onDestroy()
    aecSchedule.release()
}

伪代码流程

kotlin 复制代码
// 伪代码:AEC 调度流程

// 1. 初始化 (Init)
// --------------------------------------------------------------------------------
// 创建 AEC 调度器,并定义处理后的音频去向(给 STT)
val aecSchedule = AECSchedule(context) { processedPcmData ->
    // 回调:接收经过回声消除的纯净音频
    sttClient.sendAudio(processedPcmData)
}

// 2. 录音流程 (Recording Loop)
// --------------------------------------------------------------------------------
fun startSession() {
    // 启动 STT 引擎(设置为外部音频源模式)
    sttClient.startExternalAudioMode()
    
    // 启动 AEC 录音(打开麦克风 + WebRTC 处理)
    aecSchedule.startRecording()
}

fun stopSession() {
    // 停止录音
    aecSchedule.stopRecording()
    // 停止 STT
    sttClient.stop()
}

// 3. 播放流程 (Playback with AEC)
// --------------------------------------------------------------------------------
fun speak(text: String) {
    // 调用 TTS 引擎生成音频
    ttsClient.synthesize(text, object : TTSCallback {
        
        // 关键点:拦截 TTS 的音频数据
        override fun onAudioData(pcmData: ByteArray) {
            // 将 TTS 音频喂给 AECSchedule 进行播放
            // 这样 WebRTC 才能将其识别为"参考信号"并消除
            aecSchedule.onTTSAudioData(pcmData)
        }
    })
}

// 4. 资源释放 (Cleanup)
// --------------------------------------------------------------------------------
fun onDestroy() {
    aecSchedule.release() // 务必释放硬件资源
}

效果演示(带声音)

【AEC 回声消除测试-视频演示】 b23.tv/kUEJYuP

相关推荐
ok406lhq3 小时前
unity游戏调用SDK支付返回游戏会出现画面移位的问题
android·游戏·unity·游戏引擎·sdk
成都大菠萝4 小时前
2-2-2 快速掌握Kotlin-函数&Lambda
android
成都大菠萝4 小时前
2-1-1 快速掌握Kotlin-kotlin中变量&语句&表达式
android
CC.GG4 小时前
【C++】STL----封装红黑树实现map和set
android·java·c++
renke33644 小时前
Flutter 2025 跨平台工程体系:从 iOS/Android 到 Web/Desktop,构建真正“一次编写,全端运行”的产品
android·flutter·ios
儿歌八万首5 小时前
Android 自定义 View :打造一个跟随滑动的丝滑指示器
android
yueqc15 小时前
Android System Lib 梳理
android·lib
Zender Han6 小时前
Flutter 中 AbsorbPointer 与 IgnorePointer 的区别与使用场景详解
android·flutter·ios