fix Live Call echo: proper AEC pipeline for Android voice communication

The model was talking to itself because the mic picked up speaker output.
Three fixes that together enable Android's hardware echo cancellation:

1. AudioManager.MODE_IN_COMMUNICATION — tells the platform this is a
   voice call so the audio routing + AEC pipeline activates
2. USAGE_VOICE_COMMUNICATION on AudioTrack — links playback to the
   capture path so AEC knows what to subtract from the mic signal
3. Explicit AcousticEchoCanceler on the AudioRecord session as fallback

Also enables speakerphone mode (isSpeakerphoneOn) since MODE_IN_COMMUNICATION
defaults to earpiece routing. Cleanup restores previous audio mode.
This commit is contained in:
Bruno Deanoz 2026-06-24 13:45:44 +02:00
parent 1b4cc475f9
commit 0baab7db9e

View file

@ -5,16 +5,17 @@ import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.media.AudioAttributes import android.media.AudioAttributes
import android.media.AudioFormat import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioRecord import android.media.AudioRecord
import android.media.AudioTrack import android.media.AudioTrack
import android.media.MediaRecorder import android.media.MediaRecorder
import android.media.audiofx.AcousticEchoCanceler
import android.util.Base64 import android.util.Base64
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import kotlin.math.abs
import kotlin.math.sqrt import kotlin.math.sqrt
/** /**
@ -51,8 +52,11 @@ class AudioPipeline(private val context: Context) {
var onChunk: ((base64Pcm: String) -> Unit)? = null var onChunk: ((base64Pcm: String) -> Unit)? = null
var onAmplitude: ((Float) -> Unit)? = null var onAmplitude: ((Float) -> Unit)? = null
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private var previousAudioMode = AudioManager.MODE_NORMAL
private var recorder: AudioRecord? = null private var recorder: AudioRecord? = null
private var track: AudioTrack? = null private var track: AudioTrack? = null
private var aec: AcousticEchoCanceler? = null
private var captureJob: Job? = null private var captureJob: Job? = null
@Volatile private var ducking = false @Volatile private var ducking = false
@ -87,6 +91,24 @@ class AudioPipeline(private val context: Context) {
} }
recorder = rec recorder = rec
previousAudioMode = audioManager.mode
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
@Suppress("DEPRECATION")
audioManager.isSpeakerphoneOn = true
if (AcousticEchoCanceler.isAvailable()) {
try {
aec = AcousticEchoCanceler.create(rec.audioSessionId)
aec?.enabled = true
Log.i(TAG, "AEC enabled (session=${rec.audioSessionId})")
} catch (e: Exception) {
Log.w(TAG, "AEC init failed: ${e.message}")
}
} else {
Log.w(TAG, "AEC not available on this device")
}
rec.startRecording() rec.startRecording()
capturing = true capturing = true
@ -121,8 +143,11 @@ class AudioPipeline(private val context: Context) {
try { try {
recorder?.stop() recorder?.stop()
} catch (_: IllegalStateException) {} } catch (_: IllegalStateException) {}
aec?.release()
aec = null
recorder?.release() recorder?.release()
recorder = null recorder = null
audioManager.mode = previousAudioMode
} }
// ─── Playback ───────────────────────────────────────────────────────── // ─── Playback ─────────────────────────────────────────────────────────
@ -137,7 +162,7 @@ class AudioPipeline(private val context: Context) {
val t = AudioTrack.Builder() val t = AudioTrack.Builder()
.setAudioAttributes( .setAudioAttributes(
AudioAttributes.Builder() AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANT) .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build() .build()
) )