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 0fb97d72..e7d5b773 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
@@ -25,7 +25,7 @@ import com.utun.chat.ChatApplication
* - play-поток: NativeLib.callAudioPull (C: джиттер-буфер → декод → SoundTouch) → AudioTrack.write
*
* Воспроизведение тянет PCM из единого голосового стека в C (call_audio): адаптивный
- * джиттер-буфер (кодированные кадры) + time-stretch держат задержку ~60мс и догоняют
+ * джиттер-буфер (кодированные кадры) с минимальным запасом 20мс + CallDelay догоняют
* после сетевых бёрстов. На Kotlin-стороне своего джиттера больше нет.
*/
class CallAudioEngine {
@@ -71,13 +71,19 @@ class CallAudioEngine {
@Volatile private var playFramesPlayed = 0L
@Volatile private var capFramesRead = 0L
private var playbackBuffer: CallPlaybackBuffer? = null // под trackLock
+ @Volatile private var routeGeneration = 0L
+ private data class OutputSample(val atUs: Long, val generation: Long, val latency: CallOutputLatency)
+ @Volatile private var outputSample: OutputSample? = null
@Volatile private var startElapsedMs = 0L
@Volatile private var captureElapsedMs = 0L
private val mainHandler = Handler(Looper.getMainLooper())
private val trackRouteListener = AudioRouting.OnRoutingChangedListener { output ->
synchronized(trackLock) {
val track = audioTrack
- if (active && track != null && output === track) configurePlaybackBuffer(track, "route")
+ if (active && track != null && output === track) {
+ routeGeneration++
+ configurePlaybackBuffer(track, "route")
+ }
}
}
private val aecDelayRunnable = Runnable { measureAecDelay() }
@@ -86,6 +92,12 @@ class CallAudioEngine {
fun isActive(): Boolean = active
+ fun outputLatency(id: Long): CallOutputLatency {
+ val sample = outputSample
+ return if (active && id == callId && sample != null && sample.generation == routeGeneration &&
+ System.nanoTime() / 1000 - sample.atUs in 0..500_000) sample.latency else CallOutputLatency()
+ }
+
/** Позднее создание Telecom Connection: сначала снимаем собственную заявку на audio mode. */
fun bindTelecom(connection: UtunConnection) {
if (!active || router.telecom === connection) return
@@ -128,6 +140,7 @@ class CallAudioEngine {
val nativeMs = SystemClock.elapsedRealtime() - startElapsedMs
callId = id
+ routeGeneration = 0; outputSample = null
router.bindTelecom(TelecomCallManager.connectionFor(id))
muted = false
failed.set(false)
@@ -194,12 +207,13 @@ class CallAudioEngine {
val minQueryMs = SystemClock.elapsedRealtime() - createdAt
val trackBuf = (trackMin * 2).coerceAtLeast(FRAME_SAMPLES * 4)
val constructorAt = SystemClock.elapsedRealtime()
- val track = AudioTrack(
- AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
- .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH).build(),
- AudioFormat.Builder().setSampleRate(SAMPLE_RATE).setEncoding(AudioFormat.ENCODING_PCM_16BIT)
- .setChannelMask(AudioFormat.CHANNEL_OUT_MONO).build(),
- trackBuf, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE)
+ val track = AudioTrack.Builder()
+ .setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
+ .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH).build())
+ .setAudioFormat(AudioFormat.Builder().setSampleRate(SAMPLE_RATE).setEncoding(AudioFormat.ENCODING_PCM_16BIT)
+ .setChannelMask(AudioFormat.CHANNEL_OUT_MONO).build())
+ .setBufferSizeInBytes(trackBuf).setTransferMode(AudioTrack.MODE_STREAM)
+ .setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY).build()
val constructorMs = SystemClock.elapsedRealtime() - constructorAt
audioTrack = track
if (!active) return@runWorker
@@ -489,7 +503,7 @@ class CallAudioEngine {
val stamp = AudioTimestamp()
while (!Thread.currentThread().isInterrupted) {
if (active) adjustPlaybackBuffer(track)
- if (trace.enabled && active) trace.device = samplePlaybackDevice(track, stamp)
+ if (active) trace.device = samplePlaybackDevice(track, stamp)
trace.flush()
Thread.sleep(200)
}
@@ -518,6 +532,7 @@ class CallAudioEngine {
/** Только reporter: Binder/внутренние locks AudioTrack не должны вызываться здесь из playback. */
private fun samplePlaybackDevice(track: AudioTrack, stamp: AudioTimestamp): CallAudioTrace.DeviceSample {
val beginNs = System.nanoTime()
+ val generation = routeGeneration
val stampOk = track.getTimestamp(stamp)
val stampEndNs = System.nanoTime()
val route = track.routedDevice
@@ -525,12 +540,13 @@ class CallAudioEngine {
val underruns = track.underrunCount.toLong()
val bufferFrames = track.bufferSizeInFrames.toLong()
val startFrames = if (Build.VERSION.SDK_INT >= 31) track.startThresholdInFrames.toLong() else bufferFrames
+ val performanceMode = track.performanceMode
val endNs = System.nanoTime()
return CallAudioTrace.DeviceSample(
route?.id?.toLong() ?: -1, route?.type?.toLong() ?: -1, underruns, bufferFrames, startFrames,
if (stampOk) stamp.framePosition else -1, if (stampOk) stamp.nanoTime else -1, stampOk,
beginNs / 1000, endNs / 1000, (stampEndNs - beginNs) / 1000,
- (routeEndNs - stampEndNs) / 1000, (endNs - routeEndNs) / 1000)
+ (routeEndNs - stampEndNs) / 1000, (endNs - routeEndNs) / 1000, generation, performanceMode)
}
private fun playLoop(trace: CallAudioTrace) {
@@ -546,6 +562,9 @@ class CallAudioEngine {
}
val epoch = snapshot[0]
val head = CallPlaybackHead()
+ val playbackClock = CallPlaybackClock()
+ var clockMode = ""
+ var outputUs = -1L
var clockFailed = false
val diagnostic = LongArray(8)
val row = LongArray(CallAudioTrace.WIDTH)
@@ -590,6 +609,7 @@ class CallAudioEngine {
return
}
Thread.sleep(2)
+ continue
} else {
blockedAt = 0L
offset += written
@@ -604,13 +624,26 @@ class CallAudioEngine {
observedPlayed = played
playFramesPlayed = played
operationNs = System.nanoTime()
- NativeLib.callAudioProgress(callId, epoch, played, nowUs)
+ val device = trace.device
+ val anchor = playbackClock.update(playFramesWritten, played, nowUs, routeGeneration,
+ device.generation, device.stampFrame, device.stampNs / 1000)
+ outputUs = if (anchor?.measured == true) (anchor.atUs - nowUs).coerceAtLeast(0) else -1
+ outputSample = OutputSample(nowUs, routeGeneration, CallOutputLatency(
+ ((playFramesWritten - played) * 1000 / SAMPLE_RATE).toInt(),
+ if (outputUs >= 0) (outputUs / 1000).toInt() else -1))
+ if (anchor != null) NativeLib.callAudioProgress(callId, epoch, anchor.position, anchor.atUs, nowUs, anchor.measured)
progressCallNs += System.nanoTime() - operationNs
+ val mode = if (anchor == null) "unavailable" else if (anchor.measured) "timestamp" else "head-estimate"
+ if (mode != clockMode) {
+ LogManager.addLog(if (mode == "unavailable") "WARN" else "INFO", "CallAudio",
+ "playback clock id=$callId mode=$mode routeGeneration=$routeGeneration outputUs=$outputUs")
+ clockMode = mode
+ }
} else if (!clockFailed) {
clockFailed = true
playFramesPlayed = -1
// Отрицательная позиция немедленно инвалидирует снимок в native.
- NativeLib.callAudioProgress(callId, epoch, -1, nowUs)
+ NativeLib.callAudioProgress(callId, epoch, -1, nowUs, nowUs, false)
LogManager.addLog("ERROR", "CallAudio",
"playback clock invalid id=$callId epoch=$epoch written=$playFramesWritten; audio continues")
}
@@ -627,6 +660,7 @@ class CallAudioEngine {
diagnostic.copyInto(row, 16); row[24] = offset.toLong()
row[25] = headCallNs / 1000; row[26] = progressCallNs / 1000; row[27] = writeSpanUs
row[28] = diagnosticUs; row[29] = nowUs - pullAtNs / 1000
+ row[35] = outputUs
trace.record(row)
}
@@ -680,7 +714,8 @@ class CallAudioEngine {
val level = if (actual == requested && (change == null || change.underruns == 0)) "INFO" else "WARN"
LogManager.addLog(level, "CallAudio",
"playback buffering id=$callId reason=$reason requestedFrames=$requested actualFrames=$actual " +
- "previousFrames=$previous startFrames=$start capacityFrames=$capacity underruns=${change?.underruns ?: 0} pacing=device")
+ "previousFrames=$previous startFrames=$start capacityFrames=$capacity underruns=${change?.underruns ?: 0} " +
+ "performanceRequested=${AudioTrack.PERFORMANCE_MODE_LOW_LATENCY} performanceActual=${track.performanceMode} pacing=device")
if (playReady && (previous != actual || reason == "route")) {
// Очередь сначала принимает новый размер; устаревшая задача не затронет следующий звонок.
mainHandler.post {
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
index 099c27d6..39a82a71 100644
--- 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
@@ -39,3 +39,38 @@ internal class CallPlaybackHead {
return position
}
}
+
+/** Якорь конца записанного PCM: позиция не регрессирует при переходе head → аппаратный timestamp. */
+internal class CallPlaybackClock {
+ data class Anchor(val position: Long, val atUs: Long, val measured: Boolean)
+ private var generation = -1L
+ private var stampFrame = -1L
+ private var stampUs = -1L
+ private var measuredOnce = false
+
+ fun update(written: Long, head: Long, nowUs: Long, routeGeneration: Long,
+ sampleGeneration: Long, frame: Long, timeUs: Long): Anchor? {
+ if (generation != routeGeneration) {
+ generation = routeGeneration
+ stampFrame = -1; stampUs = -1
+ }
+ if (head < 0 || head > written) return null
+ // AudioTimestamp может содержать только младшие 32 бита; разворачиваем относительно written.
+ var extended = frame
+ if (frame in 0..0xffffffffL) {
+ extended = (written and -0x100000000L) or frame
+ if (extended > written) extended -= 0x100000000L
+ }
+ if (sampleGeneration == generation && extended in 0..written && timeUs > 0 &&
+ timeUs in (nowUs - 500_000)..(nowUs + 200_000) && written - extended <= 96_000) {
+ stampFrame = extended; stampUs = timeUs; measuredOnce = true
+ }
+ if (stampFrame >= 0 && stampUs in (nowUs - 500_000)..(nowUs + 200_000)) {
+ val at = stampUs + (written - stampFrame) * 1_000_000 / 48_000
+ if (at in (nowUs - 20_000)..(nowUs + 2_000_000)) return Anchor(written, at, true)
+ }
+ // Исчезнувший аппаратный timestamp не заменяем head: это сдвинуло бы видео вперёд на задержку HAL.
+ if (measuredOnce) return null
+ return Anchor(written, nowUs + (written - head) * 1_000_000 / 48_000, false)
+ }
+}
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioTrace.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioTrace.kt
index 97f33fd4..c235d937 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioTrace.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioTrace.kt
@@ -3,13 +3,13 @@ package com.utun.chat.data
/** Только числа в playback-потоке; форматирование и вывод выполняет отдельный reporter. */
internal class CallAudioTrace(private val id: Long, private val emit: (String, String) -> Unit) {
companion object {
- const val WIDTH = 35
+ const val WIDTH = 37
const val CAPACITY = 512
// monoUs, routeId, routeType, written, played, pullUs, writeCallUs, pullGapExcessUs, zeroWrites,
// deviceUnderruns, bufferFrames, startFrames, stampFrame, stampNs, stampReadUs, stampOk,
// encodedMs, processingMs, reserveMs, spreadMs, pcmSamples, tempo100, jbUnderruns, waiting, accepted
// headCallUs, progressCallUs, writeSpanUs, jbSnapshotUs, blockUs,
- // deviceBeginUs, deviceEndUs, stampQueryUs, routeQueryUs, propertiesQueryUs
+ // deviceBeginUs, deviceEndUs, stampQueryUs, routeQueryUs, propertiesQueryUs, outputUs, performanceMode
private const val PRE_US = 2_000_000L
private const val POST_US = 3_000_000L
}
@@ -20,7 +20,8 @@ internal class CallAudioTrace(private val id: Long, private val emit: (String, S
val bufferFrames: Long = -1, val startFrames: Long = -1,
val stampFrame: Long = -1, val stampNs: Long = -1, val stampOk: Boolean = false,
val beginUs: Long = -1, val endUs: Long = -1,
- val stampQueryUs: Long = 0, val routeQueryUs: Long = 0, val propertiesQueryUs: Long = 0
+ val stampQueryUs: Long = 0, val routeQueryUs: Long = 0, val propertiesQueryUs: Long = 0,
+ val generation: Long = -1, val performanceMode: Int = -1
) {
fun copyTo(row: LongArray) {
row[1] = routeId; row[2] = routeType; row[9] = underruns
@@ -28,6 +29,7 @@ internal class CallAudioTrace(private val id: Long, private val emit: (String, S
row[12] = stampFrame; row[13] = stampNs; row[14] = beginUs; row[15] = if (stampOk) 1 else 0
row[30] = beginUs; row[31] = endUs
row[32] = stampQueryUs; row[33] = routeQueryUs; row[34] = propertiesQueryUs
+ row[36] = performanceMode.toLong()
}
}
@Volatile var device = DeviceSample()
@@ -139,7 +141,8 @@ internal class CallAudioTrace(private val id: Long, private val emit: (String, S
"stamp=${last[12]}@${last[13]} readUs=${last[14]} ok=${last[15]} " +
"deviceAgeUs=${if (last[31] >= 0) last[0] - last[31] else -1} " +
"stampQueryUs=${range { it[32] }} routeQueryUs=${range { it[33] }} propertiesQueryUs=${range { it[34] }} " +
- "buffer=${last[10]} start=${last[11]} fault=$faults suppressed=$suppressed")
+ "buffer=${last[10]} start=${last[11]} outputUs=${range { it[35] }} performanceMode=${last[36]} " +
+ "outputIncludesQueue=1 fault=$faults suppressed=$suppressed")
reportedDeviceUnderruns = last[9]; reportedJbUnderruns = last[22]
summary.clear()
}
@@ -151,7 +154,7 @@ internal class CallAudioTrace(private val id: Long, private val emit: (String, S
"bufferFrames,startFrames,stampFrame,stampNs,stampReadUs,stampOk," +
"encodedMs,processingMs,reserveMs,spreadMs,pcmSamples,tempo100,jbUnderruns,waiting,accepted," +
"headCallUs,progressCallUs,writeSpanUs,jbSnapshotUs,blockUs," +
- "deviceBeginUs,deviceEndUs,stampQueryUs,routeQueryUs,propertiesQueryUs")
+ "deviceBeginUs,deviceEndUs,stampQueryUs,routeQueryUs,propertiesQueryUs,outputUs,performanceMode")
// До пяти секунд, по четыре снимка в строке, с явным временем каждого.
history.filter { it[0] in (eventAt - PRE_US)..(eventAt + POST_US) }.chunked(4).forEach { group ->
emit("DEBUG", "audio detail call=$id rows=" + group.joinToString(";") { it.joinToString(",") })
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt
index db64f2e8..272eaf5f 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt
@@ -403,7 +403,8 @@ object CallController : TelecomCallManager.Listener {
39 -> if (st.phase == CallPhase.ACTIVE) {
val buf = ByteBuffer.wrap(data, 8, 16).order(ByteOrder.BIG_ENDIAN)
fun metric() = buf.short.toInt() and 0xFFFF
- _state.value = st.copy(stats = CallStats(metric(), metric(), metric(), metric(), metric(), metric(), metric(), metric()))
+ _state.value = st.copy(stats = CallStats(metric(), metric(), metric(), metric(), metric(), metric(), metric(), metric(),
+ service?.audio?.outputLatency(id) ?: CallOutputLatency()))
}
42 -> _state.value = st.copy(bgpPath = String(data, 8, data.size - 8))
44 -> {
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallState.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallState.kt
index fb3b5969..e7ff37ae 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallState.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallState.kt
@@ -15,9 +15,13 @@ enum class CallConnectionType(val labelRes: Int) {
data class CallStats(
val rttMs: Int, val bufferMs: Int, val tempoX100: Int, val dropped: Int,
- val underruns: Int, val minMs: Int, val maxMs: Int, val reserveMs: Int
+ val underruns: Int, val minMs: Int, val maxMs: Int, val reserveMs: Int,
+ val output: CallOutputLatency = CallOutputLatency()
)
+/** outputMs включает queueMs; это задержка конца записанного PCM, не сумма двух буферов. */
+data class CallOutputLatency(val queueMs: Int = -1, val outputMs: Int = -1)
+
data class CallState(
val callId: Long = 0,
val channelId: String = "",
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
index cc217013..0d71dfc7 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
@@ -268,7 +268,8 @@ object NativeLib {
fun callAudioStart(callId: Long): Boolean = nativeCallAudioStart(callId)
fun callAudioFeed(callId: Long, samples: ShortArray, capturePtsUs: Long): Boolean = nativeCallAudioFeed(callId, samples, capturePtsUs)
fun callAudioPull(callId: Long, out: ShortArray): Int = nativeCallAudioPull(callId, out)
- fun callAudioProgress(callId: Long, epoch: Long, position: Long, nowUs: Long) = nativeCallAudioProgress(callId, epoch, position, nowUs)
+ fun callAudioProgress(callId: Long, epoch: Long, position: Long, atUs: Long, nowUs: Long, measured: Boolean) =
+ nativeCallAudioProgress(callId, epoch, position, atUs, nowUs, measured)
/** out: epoch, source_epoch, played, written, pts_us, valid, measured, held_back_us. */
fun callAudioClock(callId: Long, nowUs: Long, out: LongArray): Boolean = nativeCallAudioClock(callId, nowUs, out)
/** call=debug: encoded_ms, processing_ms, reserve_ms, spread_ms, pcm_samples, tempo_x100, underruns, waiting. */
@@ -404,7 +405,7 @@ object NativeLib {
private external fun nativeCallAudioStart(callId: Long): Boolean
private external fun nativeCallAudioFeed(callId: Long, samples: ShortArray, capturePtsUs: Long): Boolean
private external fun nativeCallAudioPull(callId: Long, out: ShortArray): Int
- private external fun nativeCallAudioProgress(callId: Long, epoch: Long, position: Long, nowUs: Long)
+ private external fun nativeCallAudioProgress(callId: Long, epoch: Long, position: Long, atUs: Long, nowUs: Long, measured: Boolean)
private external fun nativeCallAudioClock(callId: Long, nowUs: Long, out: LongArray): Boolean
private external fun nativeCallAudioDiagnostics(callId: Long, out: LongArray): Boolean
private external fun nativeCallAudioStop()
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt
index b548872a..eeef9d5b 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt
@@ -257,6 +257,11 @@ fun CallScreen(
stats.minMs, stats.maxMs, "%.2f".format(stats.tempoX100 / 100.0), stats.rttMs, stats.dropped, stats.underruns),
modifier = Modifier.padding(horizontal = 16.dp), color = Color(0xFFAEBCC8),
fontSize = 12.sp, textAlign = TextAlign.Center)
+ Text(stringResource(R.string.call_output_stats,
+ stats.output.queueMs.takeIf { it >= 0 }?.toString() ?: "—",
+ stats.output.outputMs.takeIf { it >= 0 }?.toString() ?: "—"),
+ modifier = Modifier.padding(horizontal = 16.dp), color = Color(0xFFAEBCC8),
+ fontSize = 12.sp, textAlign = TextAlign.Center)
}
Spacer(Modifier.height(40.dp))
}
diff --git a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml
index 47c94cbc..2eaef868 100644
--- a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml
+++ b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml
@@ -374,6 +374,7 @@
"Не удалось сохранить файл"
"Передать приватный ключ канала участнику «%1$s»?\n\nКлюч передаётся зашифрованным и подписанным. Получатель станет совладельцем и сможет управлять участниками."
"Буфер: %1$s мс · Запас: %2$s мс\nЗа 10 с: минимум %3$s · максимум %4$s мс\nСкорость: %5$s× · RTT: %6$s мс\nПотери буфера: %7$s · Недостачи: %8$s"
+ "Аудиовыход: %2$s мс (включая очередь приложения: %1$s мс)"
- BGP: %1$d узел
- BGP: %1$d узла
diff --git a/tools/chatgui-android/app/src/main/res/values/strings.xml b/tools/chatgui-android/app/src/main/res/values/strings.xml
index fe48950a..d5d5a23c 100644
--- a/tools/chatgui-android/app/src/main/res/values/strings.xml
+++ b/tools/chatgui-android/app/src/main/res/values/strings.xml
@@ -375,6 +375,7 @@
"Could not save file"
"Send the channel private key to “%1$s”?\n\nThe key is transferred encrypted and signed. The recipient will become a co-owner and will be able to manage members."
"Buffer: %1$s ms · Reserve: %2$s ms\nOver 10 s: min %3$s · max %4$s ms\nSpeed: %5$s× · RTT: %6$s ms\nBuffer drops: %7$s · Underruns: %8$s"
+ "Audio output: %2$s ms (includes app queue: %1$s ms)"
- BGP: %1$d node
- BGP: %1$d nodes
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
index da0d510c..77d48618 100644
--- 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
@@ -4,6 +4,38 @@ import org.junit.Assert.*
import org.junit.Test
class CallAudioTimingTest {
+ @Test fun playbackUsesPresentationTimeAndMonotonicWrittenAnchor() {
+ val c = CallPlaybackClock()
+ val fallback = c.update(9600, 8640, 1_000_000, 1, 1, -1, -1)!!
+ assertFalse(fallback.measured)
+ assertEquals(1_020_000L, fallback.atUs)
+ val hardware = c.update(10560, 9600, 1_020_000, 1, 1, 4800, 1_000_000)!!
+ assertTrue(hardware.measured)
+ assertTrue(hardware.position >= fallback.position)
+ assertEquals(1_120_000L, hardware.atUs)
+ // Повторный timestamp и временный отказ запроса сохраняют тот же физический ход часов.
+ assertEquals(1_140_000L, c.update(11520, 10560, 1_040_000, 1, 1, -1, -1)!!.atUs)
+ assertNull(c.update(35520, 34560, 1_600_000, 1, 1, -1, -1))
+ }
+
+ @Test fun playbackRouteRejectsOldTimestampWithoutFallingBackToHead() {
+ val c = CallPlaybackClock()
+ assertTrue(c.update(9600, 8640, 1_000_000, 1, 1, 4800, 1_000_000)!!.measured)
+ assertNull(c.update(10560, 9600, 1_020_000, 2, 1, 5760, 1_020_000))
+ assertEquals(1_060_000L, c.update(11520, 10560, 1_040_000, 2, 2, 10560, 1_040_000)!!.atUs)
+ }
+
+ @Test fun playbackTimestampWrapAndInvalidValues() {
+ val c = CallPlaybackClock()
+ val written = 0x100000000L + 960
+ val a = c.update(written, written - 960, 1_000_000, 1, 1, 0, 1_000_000)!!
+ assertTrue(a.measured)
+ assertEquals(1_020_000L, a.atUs)
+ assertNull(c.update(written, -1, 1_000_000, 1, 1, 0, 1_000_000))
+ assertFalse(CallPlaybackClock().update(960, 0, 1_000_000, 1, 1, 1920, 1_000_000)!!.measured)
+ assertFalse(CallPlaybackClock().update(960, 0, 1_000_000, 1, 1, 0, 10_000_000)!!.measured)
+ }
+
@Test fun captureFallbackUsesSamplesInsteadOfReadCompletionJitter() {
val c = CallCaptureClock()
assertEquals(980_000L, c.firstSampleUs(0, 960, 1_000_000, null, null))
diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c
index abb009a5..d871b6f9 100644
--- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c
+++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c
@@ -2688,9 +2688,9 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeCallAudioStop(
}
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeCallAudioProgress(
- JNIEnv* env, jobject thiz, jlong callId, jlong epoch, jlong position, jlong nowUs) {
+ JNIEnv* env, jobject thiz, jlong callId, jlong epoch, jlong position, jlong atUs, jlong nowUs, jboolean measured) {
(void)env; (void)thiz;
- call_audio_playback_progress(callId, epoch, position, nowUs, nowUs, 0);
+ call_audio_playback_progress(callId, epoch, position, atUs, nowUs, measured);
}
/* Предвыделенный снимок: epoch, source_epoch, played, written, pts, valid, measured, held_back_us. */