call: add SpeexDSP AEC (acoustic echo cancellation) for desktop + Android

This commit is contained in:
evgeny committed 2026-09-29 17:01:30 +03:00
1 parent 1176411b79
commit f12f61d7ee
35 files changed
+5122 -30

No files matched your search

@@ -6,8 +6,11 @@ import android.media.AudioDeviceInfo
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioRecord
import android.media.AudioTimestamp
import android.media.AudioTrack
import android.media.MediaRecorder
import android.os.Handler
import android.os.Looper
import com.utun.chat.ChatApplication
/**
@@ -27,6 +30,11 @@ class CallAudioEngine {
const val SAMPLE_RATE = 48000
const val FRAME_MS = 20
const val FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS / 1000 // 960
/** Задержка рендер→захват AEC по умолчанию (кадров), если замер недоступен. */
const val AEC_DEFAULT_DELAY_FRAMES = 4
/** Акустический ход эха ~3мс (сэмплы @48кГц), добавляется к замеру. */
const val AEC_ACOUSTIC_SAMPLES = 150L
}
private var audioRecord: AudioRecord? = null
@@ -44,6 +52,12 @@ class CallAudioEngine {
@Volatile private var muted = false
/* Для замера задержки AEC (getTimestamp) — счётчики кадров play/capture. */
@Volatile private var playFramesWritten = 0L
@Volatile private var capFramesRead = 0L
private val mainHandler = Handler(Looper.getMainLooper())
private val aecDelayRunnable = Runnable { measureAecDelay() }
fun isActive(): Boolean = active
/** Mute: пир слышит тишину (отправляем нулевые кадры вместо захваченного PCM). */
@@ -80,6 +94,12 @@ class CallAudioEngine {
router.start()
/* AEC: включён только на громкой связи (спикерфон); на гарнитуре/разговорном
* динамике эхо незначимо. Задержка рендер→захват замеряется чуть позже. */
playFramesWritten = 0L
capFramesRead = 0L
syncAecForRoute(router.route)
// ── AudioRecord (захват) ──
val recMin = AudioRecord.getMinBufferSize(
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT)
@@ -134,6 +154,9 @@ class CallAudioEngine {
priority = Thread.MAX_PRIORITY; start()
}
/* Через ~300мс буферы устройства заполнены — замеряем реальную задержку AEC. */
mainHandler.postDelayed(aecDelayRunnable, 300)
LogManager.addLog("INFO", "CallAudio", "started id=$id recBuf=$recMin trackBuf=$trackBuf")
return true
}
@@ -141,6 +164,7 @@ class CallAudioEngine {
fun stop() {
if (!active) return
active = false
mainHandler.removeCallbacks(aecDelayRunnable)
captureThread?.join(1000); captureThread = null
playThread?.join(1000); playThread = null
@@ -172,6 +196,7 @@ class CallAudioEngine {
read += n
}
val out = if (muted) silence else buf
capFramesRead += FRAME_SAMPLES
if (!NativeLib.callAudioFeed(callId, out)) {
LogManager.addLog("WARN", "CallAudio", "callAudioFeed returned false")
}
@@ -182,21 +207,64 @@ class CallAudioEngine {
private fun playLoop() {
val buf = ShortArray(FRAME_SAMPLES)
val silence = ShortArray(FRAME_SAMPLES)
val frameNs = FRAME_MS * 1_000_000L
var nextNs = System.nanoTime()
while (active) {
val t0 = System.nanoTime()
val track = audioTrack ?: return
val n = NativeLib.callAudioPull(callId, buf)
if (n > 0) track.write(buf, 0, n)
if (n < FRAME_SAMPLES) track.write(silence, 0, FRAME_SAMPLES - n)
val elapsedMs = (System.nanoTime() - t0) / 1_000_000
val sleepMs = FRAME_MS - elapsedMs
if (sleepMs > 0) {
try { Thread.sleep(sleepMs) } catch (_: InterruptedException) { return }
playFramesWritten += FRAME_SAMPLES
/* абсолютный deadline: не даём Thread.sleep накопить систематический дрейф
* (важно для стабильного выравнивания рендер↔захват в AEC). */
nextNs += frameNs
val waitNs = nextNs - System.nanoTime()
if (waitNs > 0) {
try { Thread.sleep(waitNs / 1_000_000L, (waitNs % 1_000_000L).toInt()) }
catch (_: InterruptedException) { return }
} else {
nextNs = System.nanoTime() /* отстали — сбрасываем, не копим долг */
}
}
LogManager.addLog("DEBUG", "CallAudio", "play loop done")
}
/** AEC включается только на громкой связи (спикерфон). */
fun syncAecForRoute(route: AudioRoute) {
val on = route == AudioRoute.SPEAKER
LogManager.addLog("INFO", "CallAudio", "AEC ${if (on) "on" else "off"} (route=$route)")
NativeLib.callAudioSetAec(on)
}
/** Замер реальной задержки рендер→захват и применение к AEC (один раз после старта). */
private fun measureAecDelay() {
if (!active) return
val track = audioTrack ?: return
val rec = audioRecord ?: return
try {
val tTs = AudioTimestamp()
val rTs = AudioTimestamp()
val trackOk = track.getTimestamp(tTs)
val recOk = rec.getTimestamp(rTs, AudioTimestamp.TIMEBASE_MONOTONIC) == AudioRecord.SUCCESS
if (!trackOk || !recOk) {
LogManager.addLog("WARN", "CallAudio", "getTimestamp unavailable; AEC delay = $AEC_DEFAULT_DELAY_FRAMES frames")
NativeLib.callAudioSetAecDelay(AEC_DEFAULT_DELAY_FRAMES)
return
}
val trackLatency = (playFramesWritten - tTs.framePosition).coerceAtLeast(0L)
val recLatency = (rTs.framePosition - capFramesRead).coerceAtLeast(0L)
val total = trackLatency + recLatency + AEC_ACOUSTIC_SAMPLES
val delayFrames = ((total + FRAME_SAMPLES - 1) / FRAME_SAMPLES).toInt().coerceIn(1, 20)
LogManager.addLog("INFO", "CallAudio",
"AEC delay measured: trackLat=$trackLatency recLat=$recLatency total=$total -> $delayFrames frames")
NativeLib.callAudioSetAecDelay(delayFrames)
} catch (e: Exception) {
LogManager.addLog("WARN", "CallAudio", "AEC delay measure failed: ${e.message}")
NativeLib.callAudioSetAecDelay(AEC_DEFAULT_DELAY_FRAMES)
}
}
private fun restoreAudioMode() {
try {
val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager
@@ -156,6 +156,10 @@ object NativeLib {
fun callAudioPull(callId: Long, out: ShortArray): Int = nativeCallAudioPull(callId, out)
fun callAudioStop() { nativeCallAudioStop() }
/* AEC (эхоподавление): включается по спикерфону; delay — измеренная задержка в кадрах. */
fun callAudioSetAec(enabled: Boolean) { nativeCallAudioSetAec(enabled) }
fun callAudioSetAecDelay(frames: Int) { nativeCallAudioSetAecDelay(frames) }
/* ── Рация (PTT walkie-talkie) ── */
fun radioSetActive(groupId: Long, on: Boolean) { nativeRadioSetActive(groupId, on) }
fun radioSubscribers(groupId: Long): Int = nativeRadioSubscribers(groupId)
@@ -263,6 +267,8 @@ object NativeLib {
private external fun nativeCallAudioFeed(callId: Long, samples: ShortArray): Boolean
private external fun nativeCallAudioPull(callId: Long, out: ShortArray): Int
private external fun nativeCallAudioStop()
private external fun nativeCallAudioSetAec(enabled: Boolean)
private external fun nativeCallAudioSetAecDelay(frames: Int)
/* ── Рация JNI ── */
private external fun nativeRadioSetActive(groupId: Long, on: Boolean)
@@ -611,6 +611,7 @@ class ChatViewModel : ViewModel() {
callAudio.router.onChanged = { r, headset ->
_audioRoute.value = r
_headsetAvailable.value = headset
callAudio.syncAecForRoute(r)
}
_audioRoute.value = callAudio.router.route
_headsetAvailable.value = callAudio.router.headsetAvailable