From 0baab7db9e249bc32d79ff2fe42be85696a61bc1 Mon Sep 17 00:00:00 2001 From: Bruno Deanoz Date: Wed, 24 Jun 2026 13:45:44 +0200 Subject: [PATCH] fix Live Call echo: proper AEC pipeline for Android voice communication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../java/dev/kaizen/app/live/AudioPipeline.kt | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/dev/kaizen/app/live/AudioPipeline.kt b/app/src/main/java/dev/kaizen/app/live/AudioPipeline.kt index 064b276..a43cf48 100644 --- a/app/src/main/java/dev/kaizen/app/live/AudioPipeline.kt +++ b/app/src/main/java/dev/kaizen/app/live/AudioPipeline.kt @@ -5,16 +5,17 @@ import android.content.Context import android.content.pm.PackageManager import android.media.AudioAttributes import android.media.AudioFormat +import android.media.AudioManager import android.media.AudioRecord import android.media.AudioTrack import android.media.MediaRecorder +import android.media.audiofx.AcousticEchoCanceler import android.util.Base64 import android.util.Log import androidx.core.content.ContextCompat import kotlinx.coroutines.* import java.nio.ByteBuffer import java.nio.ByteOrder -import kotlin.math.abs import kotlin.math.sqrt /** @@ -51,8 +52,11 @@ class AudioPipeline(private val context: Context) { var onChunk: ((base64Pcm: String) -> 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 track: AudioTrack? = null + private var aec: AcousticEchoCanceler? = null private var captureJob: Job? = null @Volatile private var ducking = false @@ -87,6 +91,24 @@ class AudioPipeline(private val context: Context) { } 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() capturing = true @@ -121,8 +143,11 @@ class AudioPipeline(private val context: Context) { try { recorder?.stop() } catch (_: IllegalStateException) {} + aec?.release() + aec = null recorder?.release() recorder = null + audioManager.mode = previousAudioMode } // ─── Playback ───────────────────────────────────────────────────────── @@ -137,7 +162,7 @@ class AudioPipeline(private val context: Context) { val t = AudioTrack.Builder() .setAudioAttributes( AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_ASSISTANT) + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) .build() )