From e975d24a73dc015bfb1f36ce0035056ba0865c03 Mon Sep 17 00:00:00 2001 From: evgeny Date: Wed, 14 Oct 2026 19:36:11 +0200 Subject: [PATCH] Track Android call capture and playback clocks --- .../com/utun/chat/data/CallAudioEngine.kt | 42 ++++++++++++++++- .../com/utun/chat/data/CallAudioTiming.kt | 41 ++++++++++++++++ .../com/utun/chat/data/CallAudioTimingTest.kt | 47 +++++++++++++++++++ 3 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioTiming.kt create mode 100644 tools/chatgui-android/app/src/test/java/com/utun/chat/data/CallAudioTimingTest.kt 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 50485a64..33b1b952 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 @@ -298,6 +298,9 @@ class CallAudioEngine { var rec = audioRecord var frame = 0L var sinceSwap = 0 + val clock = CallCaptureClock() + val stamp = AudioTimestamp() + var loggedMeasured: Boolean? = null while (active) { /* проводная гарнитура появилась/исчезла — меняем источник захвата */ if (wantedWired != wiredActive) { @@ -315,6 +318,9 @@ class CallAudioEngine { rec?.startRecording() } sinceSwap = 0 + capFramesRead = 0 + clock.reset() + loggedMeasured = null } val r = rec ?: return if (r.recordingState != AudioRecord.RECORDSTATE_RECORDING) { @@ -347,8 +353,16 @@ class CallAudioEngine { sinceSwap++ frame++ val out = if (muted) silence else buf + val stampOk = r.getTimestamp(stamp, AudioTimestamp.TIMEBASE_MONOTONIC) == AudioRecord.SUCCESS + val ptsUs = clock.firstSampleUs(capFramesRead, FRAME_SAMPLES, System.nanoTime() / 1000, + if (stampOk) stamp.framePosition else null, if (stampOk) stamp.nanoTime / 1000 else null) + if (loggedMeasured != clock.measured) { + loggedMeasured = clock.measured + LogManager.addLog(if (clock.measured) "INFO" else "WARN", "CallAudio", + "capture clock id=$callId measured=${clock.measured} frame=$capFramesRead ptsUs=$ptsUs") + } capFramesRead += FRAME_SAMPLES - if (!NativeLib.callAudioFeed(callId, out)) { + if (!NativeLib.callAudioFeed(callId, out, ptsUs)) { LogManager.addLog("WARN", "CallAudio", "callAudioFeed returned false") } } @@ -408,6 +422,15 @@ class CallAudioEngine { val frameNs = FRAME_MS * 1_000_000L var nextNs = System.nanoTime() var firstWrite = true + val snapshot = LongArray(8) + if (!NativeLib.callAudioClock(callId, System.nanoTime() / 1000, snapshot)) { + reportFailure(callId, "playback clock unavailable") + return + } + val epoch = snapshot[0] + val head = CallPlaybackHead() + var clockFailed = false + var logAtUs = 0L while (active) { val track = audioTrack ?: return val n = NativeLib.callAudioPull(callId, buf) @@ -438,6 +461,23 @@ class CallAudioEngine { offset += written playFramesWritten += written } + val nowUs = System.nanoTime() / 1000 + val played = head.update(track.playbackHeadPosition, playFramesWritten) + if (played != null) { + NativeLib.callAudioProgress(callId, epoch, played, nowUs) + } else if (!clockFailed) { + clockFailed = true + // Отрицательная позиция немедленно инвалидирует снимок в native. + NativeLib.callAudioProgress(callId, epoch, -1, nowUs) + LogManager.addLog("ERROR", "CallAudio", + "playback clock invalid id=$callId epoch=$epoch written=$playFramesWritten; audio continues") + } + if (nowUs - logAtUs >= 1_000_000 && NativeLib.callAudioClock(callId, nowUs, snapshot)) { + logAtUs = nowUs + LogManager.addLog("DEBUG", "CallAudio", + "clock epoch=${snapshot[0]} source=${snapshot[1]} played=${snapshot[2]} written=${snapshot[3]} " + + "ptsUs=${snapshot[4]} valid=${snapshot[5]} measured=${snapshot[6]} heldUs=${snapshot[7]}") + } } if (firstWrite && active) { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioTiming.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioTiming.kt new file mode 100644 index 00000000..099c27d6 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioTiming.kt @@ -0,0 +1,41 @@ +package com.utun.chat.data + +/** Шкала AudioRecord после startRecording. Без timestamp продолжаем последний якорь по числу сэмплов. */ +internal class CallCaptureClock { + private var originUs = Long.MIN_VALUE + var measured = false + private set + + fun reset() { originUs = Long.MIN_VALUE; measured = false } + + fun firstSampleUs(first: Long, count: Int, nowUs: Long, stampFrame: Long?, stampUs: Long?): Long { + if (stampFrame != null && stampUs != null && stampFrame >= 0 && stampUs > 0 && stampUs <= nowUs + 20_000) { + val candidate = stampUs - stampFrame * 1_000_000 / 48_000 + val start = candidate + first * 1_000_000 / 48_000 + if (start >= 0 && start <= nowUs) { + originUs = candidate + measured = true + } + } + if (originUs == Long.MIN_VALUE) originUs = nowUs - (first + count) * 1_000_000 / 48_000 + return originUs + first * 1_000_000 / 48_000 + } +} + +/** AudioTrack head — unsigned 32 bits. Регрессия или выход за записанный PCM запрещает часы до нового устройства. */ +internal class CallPlaybackHead { + private var raw = 0L + private var position = 0L + var failed = false + private set + + fun update(head: Int, written: Long): Long? { + if (failed) return null + val next = head.toLong() and 0xffffffffL + val delta = (next - raw) and 0xffffffffL + if (delta > 0x7fffffffL || position + delta > written) { failed = true; return null } + raw = next + position += delta + return position + } +} diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/CallAudioTimingTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/CallAudioTimingTest.kt new file mode 100644 index 00000000..da0d510c --- /dev/null +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/CallAudioTimingTest.kt @@ -0,0 +1,47 @@ +package com.utun.chat.data + +import org.junit.Assert.* +import org.junit.Test + +class CallAudioTimingTest { + @Test fun captureFallbackUsesSamplesInsteadOfReadCompletionJitter() { + val c = CallCaptureClock() + assertEquals(980_000L, c.firstSampleUs(0, 960, 1_000_000, null, null)) + assertEquals(1_000_000L, c.firstSampleUs(960, 960, 1_035_000, null, null)) + assertFalse(c.measured) + assertEquals(1_030_000L, c.firstSampleUs(1920, 960, 1_050_000, 2400, 1_040_000)) + assertTrue(c.measured) + assertEquals(1_050_000L, c.firstSampleUs(2880, 960, 1_080_000, null, null)) + c.reset() + assertFalse(c.measured) + assertEquals(1_980_000L, c.firstSampleUs(0, 960, 2_000_000, null, null)) + } + + @Test fun invalidCaptureTimestampDoesNotReplaceAnchor() { + val c = CallCaptureClock() + assertEquals(980_000L, c.firstSampleUs(0, 960, 1_000_000, -1, 1_000_000)) + assertEquals(1_000_000L, c.firstSampleUs(960, 960, 1_020_000, 960, 2_000_000)) + assertFalse(c.measured) + } + + @Test fun headMeasuresPlayedSamplesAndUnwrapsUnsignedCounter() { + val h = CallPlaybackHead() + assertEquals(0L, h.update(0, 960)) + assertEquals(240L, h.update(240, 960)) + assertEquals(240L, h.update(240, 1920)) + assertEquals(0x70000000L, h.update(0x70000000, 0x70000000L)) + assertEquals(0xe0000000L, h.update(0xe0000000.toInt(), 0xe0000000L)) + assertEquals(0x100000080L, h.update(128, 0x100000080L)) + assertFalse(h.failed) + } + + @Test fun resetAndImpossibleHeadFailClosedUntilNewDevice() { + val h = CallPlaybackHead() + assertEquals(480L, h.update(480, 960)) + assertNull(h.update(0, 960)) + assertTrue(h.failed) + assertNull(h.update(960, 960)) + assertNull(CallPlaybackHead().update(1000, 960)) + assertEquals(0L, CallPlaybackHead().update(0, 0)) + } +}