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. */