博客 / 詳情

返回

純算法AEC:播錄並行場景的回聲消除實戰筆記

引言

最近在做一款 AI 語音應用,場景類似“實時通話”:一邊讓 TTS 播報,一邊把麥克風打開做 STT。

問題在於,揚聲器出來的聲音下一秒就會被麥克風原封不動地錄回去,STT 立刻把它當成用户再説一遍,形成“自己聽懂自己”的無限循環。

為了切斷這條回聲通路,我試了一圈硬件方案無果後,決定用純算法在軟件層把播報聲音從錄音裏“摳”掉。

參考 WebRTC

在我原有的設計裏,TTS 播報走的是系統自帶播放器,錄音又來自 Android 系統原生的 AudioRecord,這是兩條並行沒有交集的數據處理線。如果繼續保持原有設計,根本不可能實現效果,於是我參考 WebRTC 的設計。

把 TTS 和錄音全部“綁架”進 WebRTC 的軌道,讓引擎重新掌控生命線。

這裏我用到了開源庫:

implementation 'io.github.webrtc-sdk:android:137.7151.05'

方案設計

我畫了一張流程設計的簡圖如下:

267cd2b8ae0273f5498dfb21d0509155.png

  1. 藍色泳道 (TTS) : 負責產生聲音。數據進入 AECSchedule 後,不僅是為了讓用户聽到(通過揚聲器),更是為了告訴 AEC 算法“這是我們自己發出的聲音,請不要把它當成用户説的話”。
  2. 橙色泳道 (AECSchedule) : 核心處理區。

    • 關鍵點 : 虛線箭頭表示 AudioTrack 播放的聲音被用作 參考信號 (Reference Signal) 。
    • 處理 : WebRTC 引擎 (ADM) 將 麥克風採集的聲音 減去 參考信號 ,從而消除回聲。
  3. 綠色泳道 (STT) : 最終消費者。它接收到的音頻是經過 AEC(回聲消除)和 NS(降噪)處理後的純淨人聲,從而提高識別準確率。

方案流程非常清晰簡單,下面是附上的 AECSchedule 源碼,可以直接複製使用

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)。

// 在 Activity 或 ViewModel 中
val aecSchedule = AECSchedule(context) { processedPcmData ->
    // [回調] 這裏接收到的是經過回聲消除和降噪後的純淨音頻
    // 通常在這裏將數據發送給 STT (語音識別) 引擎
    sttClient.sendAudio(processedPcmData)
}

2. 啓動錄音 (Start Recording)

當需要開始聽用户説話時(例如喚醒後,或進入對話狀態),調用 startRecording() 。

  • 作用 :啓動 WebRTC 引擎,打開麥克風,開始採集並處理音頻。
  • 時機 :通常在用户點擊“開始説話”按鈕,或系統檢測到喚醒詞後。
aecSchedule.startRecording()

3. 播放 TTS 音頻 (Playback)

當有 TTS(語音合成)數據需要播放時, 必須 通過 AECSchedule 來播放,而不是自己創建 AudioTrack 。

  • 關鍵點 :只有通過 onTTSAudioData 播放的聲音,才能被 WebRTC 引擎捕捉為“參考信號”,從而在麥克風採集的音頻中將其消除(避免自言自語)。
// 假設 ttsData 是從 TTS 引擎獲取的 PCM 字節流,我這邊拿到的是 tts-websocket 中返回的 byte[] (音頻數據)
aecSchedule.onTTSAudioData(ttsData)

4. 停止播放 (Stop Playback)

如果需要打斷播放(例如用户點擊“停止”或開始新的對話),調用 stopPlay() 。

aecSchedule.stopPlay()

5. 停止錄音 (Stop Recording)

當不需要再聽用户説話時(例如識別結束,進入思考狀態),調用 stopRecording() 。

  • 注意 :這將釋放麥克風資源,系統狀態欄的錄音圖標(小綠點)應消失。
aecSchedule.stopRecording()

6. 銷燬資源 (Release)

在頁面銷燬或退出功能時,務必調用 release() 以徹底釋放所有硬件資源。

override fun onDestroy() {
    super.onDestroy()
    aecSchedule.release()
}

偽代碼流程

// 偽代碼: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() // 務必釋放硬件資源
}

image.png

效果演示(帶聲音)

【AEC 回聲消除測試-視頻演示】

https://www.bilibili.com/video/BV1u4BTBaEyg/?aid=115767672577...

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.