3 changed files with 129 additions and 1 deletions
@ -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 |
||||
} |
||||
} |
||||
@ -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…
Reference in new issue