引言
最近在做一款 AI 语音应用,场景类似"实时通话":一边让 TTS 播报,一边把麦克风打开做 STT。
问题在于,扬声器出来的声音下一秒就会被麦克风原封不动地录回去,STT 立刻把它当成用户再说一遍,形成"自己听懂自己"的无限循环。
为了切断这条回声通路,我试了一圈硬件方案无果后,决定用纯算法在软件层把播报声音从录音里"抠"掉。
参考 WebRTC
在我原有的设计里,TTS 播报走的是系统自带播放器,录音又来自 Android 系统原生的 AudioRecord,这是两条并行没有交集的数据处理线。如果继续保持原有设计,根本不可能实现效果,于是我参考 WebRTC 的设计。
把 TTS 和录音全部"绑架"进 WebRTC 的轨道,让引擎重新掌控生命线。
这里我用到了开源库:
java
implementation 'io.github.webrtc-sdk:android:137.7151.05'
方案设计
我画了一张流程设计的简图如下:

- 蓝色泳道 (TTS) : 负责产生声音。数据进入 AECSchedule 后,不仅是为了让用户听到(通过扬声器),更是为了告诉 AEC 算法"这是我们自己发出的声音,请不要把它当成用户说的话"。
- 橙色泳道 (AECSchedule) : 核心处理区。
- 关键点 : 虚线箭头表示 AudioTrack 播放的声音被用作 参考信号 (Reference Signal) 。
- 处理 : WebRTC 引擎 (ADM) 将 麦克风采集的声音 减去 参考信号 ,从而消除回声。
- 绿色泳道 (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
