Browse Source

Track Android call capture and playback clocks

stream-multiview
evgeny 6 days ago
parent
commit
e975d24a73
  1. 42
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt
  2. 41
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioTiming.kt
  3. 47
      tools/chatgui-android/app/src/test/java/com/utun/chat/data/CallAudioTimingTest.kt

42
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) {

41
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
}
}

47
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))
}
}
Loading…
Cancel
Save