diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt index 1bf67e3d..12e6369e 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt +++ b/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", diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt index 588183bb..539ea360 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt +++ b/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? = null fun start() { if (started) return started = true + val startedAt = SystemClock.elapsedRealtime() val cb = object : AudioDeviceCallback() { override fun onAudioDevicesAdded(addedDevices: Array?) = 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) { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt index 228f57af..71be5c98 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt +++ b/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) }