引言
最近在做一款 AI 語音應用,場景類似“實時通話”:一邊讓 TTS 播報,一邊把麥克風打開做 STT。
問題在於,揚聲器出來的聲音下一秒就會被麥克風原封不動地錄回去,STT 立刻把它當成用户再説一遍,形成“自己聽懂自己”的無限循環。
為了切斷這條回聲通路,我試了一圈硬件方案無果後,決定用純算法在軟件層把播報聲音從錄音裏“摳”掉。
參考 WebRTC
在我原有的設計裏,TTS 播報走的是系統自帶播放器,錄音又來自 Android 系統原生的 AudioRecord,這是兩條並行沒有交集的數據處理線。如果繼續保持原有設計,根本不可能實現效果,於是我參考 WebRTC 的設計。
把 TTS 和錄音全部“綁架”進 WebRTC 的軌道,讓引擎重新掌控生命線。
這裏我用到了開源庫:
implementation 'io.github.webrtc-sdk:android:137.7151.05'
方案設計
我畫了一張流程設計的簡圖如下:
- 藍色泳道 (TTS) : 負責產生聲音。數據進入 AECSchedule 後,不僅是為了讓用户聽到(通過揚聲器),更是為了告訴 AEC 算法“這是我們自己發出的聲音,請不要把它當成用户説的話”。
-
橙色泳道 (AECSchedule) : 核心處理區。
- 關鍵點 : 虛線箭頭表示 AudioTrack 播放的聲音被用作 參考信號 (Reference Signal) 。
- 處理 : WebRTC 引擎 (ADM) 將 麥克風採集的聲音 減去 參考信號 ,從而消除回聲。
- 綠色泳道 (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() // 務必釋放硬件資源
}
效果演示(帶聲音)
【AEC 回聲消除測試-視頻演示】
https://www.bilibili.com/video/BV1u4BTBaEyg/?aid=115767672577...