diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallOutputRecovery.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallOutputRecovery.kt new file mode 100644 index 00000000..bff7e344 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallOutputRecovery.kt @@ -0,0 +1,36 @@ +package com.utun.chat.data + +/** Только playback worker. Ошибка ограничивает повтор в текущем маршруте, не в следующем. */ +internal class CallOutputRecovery { + var deviceId = Int.MIN_VALUE + private set + var failures = 0 + private set + + fun route(id: Int): Boolean { + if (id == deviceId) return false + deviceId = id; failures = 0 + return true + } + + fun failed(): Boolean { failures++; return failures <= 2 } + val tryFast: Boolean get() = failures == 0 +} + +/** Локальный счётчик нового устройства начинается с нуля; общий PCM-счётчик не сбрасывается. */ +internal class CallOutputPosition { + var base = 0L + private set + private var head = CallPlaybackHead() + + fun reset() { base = 0; head = CallPlaybackHead() } + + fun reopen(written: Long) { + require(written >= base) + base = written + head = CallPlaybackHead() + } + + fun played(raw: Int, written: Long): Long? = head.update(raw, written - base)?.let { base + it } + fun timestamp(local: Long): Long = if (local < 0) -1 else base + local +} diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/CallOutputRecoveryTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/CallOutputRecoveryTest.kt new file mode 100644 index 00000000..f256d52d --- /dev/null +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/CallOutputRecoveryTest.kt @@ -0,0 +1,31 @@ +package com.utun.chat.data + +import org.junit.Assert.* +import org.junit.Test + +class CallOutputRecoveryTest { + @Test fun routeChangeRestoresFastAttemptButDuplicateConfirmationDoesNot() { + val p = CallOutputRecovery() + assertTrue(p.route(3)); assertTrue(p.tryFast) + assertTrue(p.failed()); assertFalse(p.tryFast) + assertFalse(p.route(3)); assertFalse(p.tryFast) + assertTrue(p.failed()); assertFalse(p.failed()) + assertTrue(p.route(2)); assertTrue(p.tryFast); assertEquals(0, p.failures) + assertTrue(p.route(3)); assertTrue(p.tryFast) + } + + @Test fun replacementKeepsAcceptedPcmPositionAndPartialBlockRemainder() { + val p = CallOutputPosition() + assertEquals(480L, p.played(480, 960)) + // 288 сэмплов следующего блока успели записаться; остаток 672 ещё принадлежит worker. + val accepted = 960L + 288 + p.reopen(accepted) + assertEquals(accepted, p.played(0, accepted)) + assertEquals(accepted + 96, p.played(96, accepted + 672)) + assertEquals(accepted + 48, p.timestamp(48)) + assertEquals(-1L, p.timestamp(-1)) + assertNull(p.played(1000, accepted + 672)) + p.reopen(1920) + assertEquals(1920L, p.played(0, 1920)) + } +}