Browse Source

android: start call capture before playback setup

master
evgeny 2 days ago
parent
commit
00666499fe
  1. 55
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt
  2. 39
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt
  3. 5
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt

55
tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt

@ -63,6 +63,8 @@ class CallAudioEngine {
/* Для замера задержки AEC (getTimestamp) — счётчики кадров play/capture. */
@Volatile private var playFramesWritten = 0L
@Volatile private var capFramesRead = 0L
@Volatile private var startElapsedMs = 0L
@Volatile private var captureElapsedMs = 0L
private val mainHandler = Handler(Looper.getMainLooper())
private val aecDelayRunnable = Runnable { measureAecDelay() }
private val failed = AtomicBoolean(false)
@ -88,11 +90,13 @@ class CallAudioEngine {
/** Запустить полнодуплекс для звонка. Возвращает true при успехе. */
fun start(id: Long): Boolean {
stop()
startElapsedMs = SystemClock.elapsedRealtime()
if (!NativeLib.callAudioStart(id)) {
LogManager.addLog("ERROR", "CallAudio", "native callAudioStart failed id=$id")
return false
}
val nativeMs = SystemClock.elapsedRealtime() - startElapsedMs
callId = id
muted = false
@ -102,17 +106,21 @@ class CallAudioEngine {
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
prevMode = am.mode
prevSpeaker = am.isSpeakerphoneOn
var stageAt = SystemClock.elapsedRealtime()
try {
am.mode = AudioManager.MODE_IN_COMMUNICATION
} catch (e: Exception) {
LogManager.addLog("WARN", "CallAudio", "set audio mode failed: ${e.message}")
}
val modeMs = SystemClock.elapsedRealtime() - stageAt
/* Маршрут выхода: автоследование за гарнитурой (воткнули → HEADSET,
* вынули → сохранённый маршрут, по умолчанию тихая связь). */
router.fallbackRoute = if (router.preferredRoute == AudioRoute.SPEAKER) AudioRoute.SPEAKER else AudioRoute.EARPIECE
router.setAutoHeadset(true)
stageAt = SystemClock.elapsedRealtime()
router.start()
val routeMs = SystemClock.elapsedRealtime() - stageAt
/* AEC: включён только на громкой связи (спикерфон); на гарнитуре/разговорном
* динамике эхо незначимо. Задержка рендер→захват замеряется чуть позже. */
@ -123,12 +131,32 @@ class CallAudioEngine {
// ── AudioRecord (захват) ──
wiredActive = router.route == AudioRoute.HEADSET && router.wiredInputDevice() != null
wantedWired = wiredActive
stageAt = SystemClock.elapsedRealtime()
audioRecord = createAudioRecord(wiredActive)
if (audioRecord == null) {
stop(); return false
}
val recordCreateMs = SystemClock.elapsedRealtime() - stageAt
/* Захват начинает отправку сразу; подготовка playback больше его не задерживает. */
router.onInputDevice = { _ ->
wantedWired = router.route == AudioRoute.HEADSET && router.wiredInputDevice() != null
}
active = true
captureElapsedMs = SystemClock.elapsedRealtime()
audioRecord?.startRecording()
if (audioRecord?.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
LogManager.addLog("ERROR", "CallAudio", "capture not running id=$id record=${audioRecord?.recordingState}")
stop(); return false
}
val recordStartMs = SystemClock.elapsedRealtime() - captureElapsedMs
captureThread = Thread({ runWorker(id, "capture") { captureLoop() } }, "utun-call-capture").apply { start() }
LogManager.addLog("INFO", "CallAudio",
"capture started id=$id totalMs=${SystemClock.elapsedRealtime() - startElapsedMs} " +
"nativeMs=$nativeMs modeMs=$modeMs routeMs=$routeMs recordCreateMs=$recordCreateMs recordStartMs=$recordStartMs")
// ── AudioTrack (воспроизведение) ──
stageAt = SystemClock.elapsedRealtime()
val trackMin = AudioTrack.getMinBufferSize(
SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT)
if (trackMin <= 0) {
@ -159,29 +187,23 @@ class CallAudioEngine {
глобальной маршрутизации на ряде устройств). */
router.onOutputDevice = { dev -> applyTrackOutputDevice(dev) }
applyTrackOutputDevice(router.currentOutputDevice)
val trackCreateMs = SystemClock.elapsedRealtime() - stageAt
/* Смена маршрута → пересчитать, нужен ли захват с проводной гарнитуры. */
router.onInputDevice = { _ ->
wantedWired = router.route == AudioRoute.HEADSET && router.wiredInputDevice() != null
}
active = true
audioRecord?.startRecording()
stageAt = SystemClock.elapsedRealtime()
audioTrack?.play()
if (audioRecord?.recordingState != AudioRecord.RECORDSTATE_RECORDING ||
audioTrack?.playState != AudioTrack.PLAYSTATE_PLAYING) {
LogManager.addLog("ERROR", "CallAudio",
"I/O not running id=$id record=${audioRecord?.recordingState} track=${audioTrack?.playState}")
if (audioTrack?.playState != AudioTrack.PLAYSTATE_PLAYING) {
LogManager.addLog("ERROR", "CallAudio", "playback not running id=$id track=${audioTrack?.playState}")
stop(); return false
}
captureThread = Thread({ runWorker(id, "capture") { captureLoop() } }, "utun-call-capture").apply { start() }
val trackStartMs = SystemClock.elapsedRealtime() - stageAt
playThread = Thread({ runWorker(id, "play") { playLoop() } }, "utun-call-play").apply { start() }
/* Через ~300мс буферы устройства заполнены — замеряем реальную задержку AEC. */
mainHandler.postDelayed(aecDelayRunnable, 300)
LogManager.addLog("INFO", "CallAudio", "started id=$id wired=$wiredActive trackBuf=$trackBuf")
LogManager.addLog("INFO", "CallAudio",
"started id=$id totalMs=${SystemClock.elapsedRealtime() - startElapsedMs} " +
"trackCreateMs=$trackCreateMs trackStartMs=$trackStartMs wired=$wiredActive trackBuf=$trackBuf")
return true
} catch (e: Exception) {
LogManager.addLog("ERROR", "CallAudio", "start failed id=$id: ${e.message}")
@ -286,6 +308,11 @@ class CallAudioEngine {
}
read += n
}
if (frame == 0L) {
val now = SystemClock.elapsedRealtime()
LogManager.addLog("INFO", "CallAudio",
"first capture id=$callId totalMs=${now - startElapsedMs} recordToFrameMs=${now - captureElapsedMs}")
}
/* диагностика: уровень захваченного сигнала (тишина vs звук) */
if (sinceSwap < 10 || frame % 100L == 0L) {
LogManager.addLog("DEBUG", "CallAudio",

39
tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt

@ -12,6 +12,7 @@ import android.media.AudioManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import com.utun.chat.ChatApplication
/** Аудио-выход звонка: динамик (earpiece) ↔ громкая связь (speaker) ↔ гарнитура. */
@ -83,10 +84,13 @@ class CallAudioRouter {
private var deviceCallback: AudioDeviceCallback? = null
private var btHeadset: BluetoothHeadset? = null
private var btProfileProxyBound = false
/* Неизменяемый снимок, обновляется на main; capture читает уже найденные устройства. */
@Volatile private var devices: Array<AudioDeviceInfo>? = null
fun start() {
if (started) return
started = true
val startedAt = SystemClock.elapsedRealtime()
val cb = object : AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) = onDevicesChanged()
@ -97,11 +101,16 @@ class CallAudioRouter {
bindBtHeadsetProfile()
val devicesAt = SystemClock.elapsedRealtime()
devices = am.getDevices(AudioManager.GET_DEVICES_ALL)
val devicesMs = SystemClock.elapsedRealtime() - devicesAt
hasEarpiece = findBuiltinEarpiece() != null
route = if (autoHeadset) (if (isHeadsetConnected()) AudioRoute.HEADSET else fallbackRoute) else preferredRoute
applyRoute(route)
refresh()
LogManager.addLog("INFO", "CallAudioRouter", "started (hasEarpiece=$hasEarpiece autoHeadset=$autoHeadset fallback=$fallbackRoute)")
LogManager.addLog("INFO", "CallAudioRouter",
"started totalMs=${SystemClock.elapsedRealtime() - startedAt} devicesMs=$devicesMs " +
"devices=${devices?.size} hasEarpiece=$hasEarpiece autoHeadset=$autoHeadset fallback=$fallbackRoute")
}
fun stop() {
@ -119,6 +128,7 @@ class CallAudioRouter {
} catch (e: Exception) {
LogManager.addLog("WARN", "CallAudioRouter", "stop cleanup failed: ${e.message}")
}
devices = null
LogManager.addLog("INFO", "CallAudioRouter", "stopped")
}
@ -156,6 +166,7 @@ class CallAudioRouter {
/** Немедленно переоценить маршрут (например, после включения автоследования). */
fun reapply() {
if (!started) return
devices = am.getDevices(AudioManager.GET_DEVICES_ALL)
refresh()
}
@ -231,27 +242,27 @@ class CallAudioRouter {
}
private fun findBuiltinSpeaker(): AudioDeviceInfo? =
am.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull {
it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER
(devices ?: am.getDevices(AudioManager.GET_DEVICES_ALL)).firstOrNull {
it.isSink && it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER
}
private fun findBuiltinEarpiece(): AudioDeviceInfo? =
am.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull {
it.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE
(devices ?: am.getDevices(AudioManager.GET_DEVICES_ALL)).firstOrNull {
it.isSink && it.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE
}
/* ── входные устройства захвата (для строки аудиоисточников рации) ── */
fun builtinMicDevice(): AudioDeviceInfo? =
am.getDevices(AudioManager.GET_DEVICES_INPUTS).firstOrNull {
it.type == AudioDeviceInfo.TYPE_BUILTIN_MIC
(devices ?: am.getDevices(AudioManager.GET_DEVICES_ALL)).firstOrNull {
it.isSource && it.type == AudioDeviceInfo.TYPE_BUILTIN_MIC
}
fun wiredInputDevice(): AudioDeviceInfo? =
am.getDevices(AudioManager.GET_DEVICES_INPUTS).firstOrNull { isWiredType(it.type) }
(devices ?: am.getDevices(AudioManager.GET_DEVICES_ALL)).firstOrNull { it.isSource && isWiredType(it.type) }
fun btInputDevice(): AudioDeviceInfo? =
am.getDevices(AudioManager.GET_DEVICES_INPUTS).firstOrNull {
it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
(devices ?: am.getDevices(AudioManager.GET_DEVICES_ALL)).firstOrNull {
it.isSource && it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
}
private fun refreshInputDevices() {
@ -268,6 +279,8 @@ class CallAudioRouter {
/* ── мониторинг гарнитуры ── */
private fun onDevicesChanged() {
if (!started) return
devices = am.getDevices(AudioManager.GET_DEVICES_ALL)
/* если в режиме HEADSET появилось SCO-устройство — пере-применяем маршрут,
* иначе SCO не замаршрутизируется автоматически */
if (route == AudioRoute.HEADSET) applyRoute(route)
@ -313,11 +326,11 @@ class CallAudioRouter {
findWiredDevice() != null || findBtScoDevice() != null || isBtHeadsetProfileConnected()
private fun findWiredDevice(): AudioDeviceInfo? =
am.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull { isWiredType(it.type) }
(devices ?: am.getDevices(AudioManager.GET_DEVICES_ALL)).firstOrNull { it.isSink && isWiredType(it.type) }
private fun findBtScoDevice(): AudioDeviceInfo? =
am.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull {
it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
(devices ?: am.getDevices(AudioManager.GET_DEVICES_ALL)).firstOrNull {
it.isSink && it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
}
private fun isWiredType(type: Int): Boolean = when (type) {

5
tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt

@ -302,6 +302,9 @@ object CallController : TelecomCallManager.Listener {
}
36 -> {
if (st.phase == CallPhase.ACTIVE) return
// Убираем гудки до переключения режима и запуска звонкового аудиотракта.
CallRingback.stop()
IncomingCallManager.stopRinging()
val owner = service
if (owner == null || !owner.startAudio(id)) {
LogManager.addLog("ERROR", "Call", "accepted without working audio/service id=$id")
@ -309,8 +312,6 @@ object CallController : TelecomCallManager.Listener {
return
}
accepting = false
CallRingback.stop()
IncomingCallManager.stopRinging()
TelecomCallManager.setActive(id)
_state.value = st.copy(phase = CallPhase.ACTIVE, durationSeconds = 0, startedAtElapsedRealtime = SystemClock.elapsedRealtime())
owner.audio.router.onChanged = { route, headset -> routeChanged(route, headset) }

Loading…
Cancel
Save