call: add SpeexDSP AEC (acoustic echo cancellation) for desktop + Android
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user