Track Android call capture and playback clocks
This commit is contained in:
1 parent
023f3a335e
commit
e975d24a73
3 files changed
+129
-1
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user