3 changed files with 262 additions and 72 deletions
@ -0,0 +1,94 @@
|
||||
package com.utun.chat.data |
||||
|
||||
import java.util.ArrayDeque |
||||
|
||||
/** Один рабочий вход и один готовый выход. Codec владеет внутренним конвейером; |
||||
* число поданных кадров и часы показа не ограничивают заполнение этого конвейера. |
||||
* Все вызовы принадлежат одному player worker. Ошибки логирует владелец вместе с session ID. */ |
||||
internal class StreamDecodePump<F : StreamDecodePump.Frame>( |
||||
private val submit: (F) -> Boolean, |
||||
private val receive: () -> Output?, |
||||
private val release: (Output, Boolean) -> Unit |
||||
) { |
||||
interface Frame { val pts: Long } |
||||
data class Output(val index: Int, val pts: Long, val eos: Boolean = false, val empty: Boolean = false) |
||||
private val submitted = ArrayDeque<Long>() |
||||
var input: F? = null |
||||
private set |
||||
var output: Output? = null |
||||
private set |
||||
var firstOutputPts: Long? = null |
||||
private set |
||||
var lastInputPts = -1L |
||||
private set |
||||
var inputCount = 0L |
||||
private set |
||||
var outputCount = 0L |
||||
private set |
||||
var shown = 0L |
||||
private set |
||||
var discarded = 0L |
||||
private set |
||||
var decoderLeadUs = 0L |
||||
private set |
||||
var framePeriodUs = 33333L |
||||
private set |
||||
var ended = false |
||||
private set |
||||
private var waitingSince = -1L |
||||
private var lastInputAt = -1L |
||||
val wantsInput: Boolean get() = input == null && output == null && !ended |
||||
val oldestSubmittedPts: Long get() = submitted.firstOrNull() ?: -1L |
||||
val inflight: Int get() = submitted.size |
||||
|
||||
fun offer(frame: F, now: Long) { |
||||
// Уже запрошенный ответ может прийти после появления будущего output. |
||||
check(input == null && !ended) { "Decoder already owns a working frame" } |
||||
// Новые данные после сетевой паузы получают собственное окно ожидания decoder progress. |
||||
if (lastInputAt < 0 || now - lastInputAt > 1_000_000) waitingSince = now |
||||
input = frame |
||||
} |
||||
|
||||
/** Выход проверяется перед каждым входом. Свободный input buffer берут лишь для готового кадра. */ |
||||
fun step(position: Long, now: Long) { |
||||
drain(position, now) |
||||
if (output != null || ended) return |
||||
val frame = input |
||||
if (frame != null && submit(frame)) { |
||||
check(lastInputPts < frame.pts) { "Decoder input PTS did not advance" } |
||||
if (lastInputPts >= 0) framePeriodUs = (frame.pts - lastInputPts).coerceIn(1, 1_000_000) |
||||
lastInputPts = frame.pts; lastInputAt = now; inputCount++; submitted.addLast(frame.pts); input = null |
||||
drain(position, now) |
||||
} |
||||
} |
||||
|
||||
private fun drain(position: Long, now: Long) { |
||||
repeat(8) { |
||||
if (ended) return |
||||
if (output == null) { |
||||
val next = receive() ?: return |
||||
output = next |
||||
if (!next.empty) { |
||||
if (firstOutputPts == null) firstOutputPts = next.pts |
||||
outputCount++; decoderLeadUs = (lastInputPts - next.pts).coerceAtLeast(0) |
||||
while (submitted.isNotEmpty() && submitted.first <= next.pts) submitted.removeFirst() |
||||
} |
||||
waitingSince = now |
||||
} |
||||
val next = output!! |
||||
if (!next.empty && next.pts > position) return |
||||
val render = !next.empty && next.pts >= position - 100000 |
||||
release(next, render); output = null |
||||
if (!next.empty) { if (render) shown++ else discarded++ } |
||||
if (next.eos) ended = true |
||||
} |
||||
} |
||||
|
||||
/** Нехватка сети и ожидание PTS допустимы; зависший decoder при наличии данных — ошибка. */ |
||||
fun checkProgress(now: Long, finishing: Boolean) { |
||||
val hasWork = input != null || (lastInputAt >= 0 && now - lastInputAt < 1_000_000) || finishing |
||||
check(ended || output != null || !hasWork || waitingSince < 0 || now - waitingSince <= 10_000_000) { |
||||
"Decoder made no output progress: inputs=$inputCount outputs=$outputCount inflight=$inflight lastPTS=$lastInputPts" |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,79 @@
|
||||
package com.utun.chat.data |
||||
|
||||
import org.junit.Assert.* |
||||
import org.junit.Test |
||||
import java.util.ArrayDeque |
||||
|
||||
class StreamDecodePumpTest { |
||||
private data class Frame(override val pts: Long) : StreamDecodePump.Frame |
||||
private class Codec(val depth: Int) { |
||||
val queued = ArrayDeque<Frame>() |
||||
var released = 0 |
||||
var blocked = false |
||||
var finishing = false |
||||
var held = false |
||||
var nextIndex = 0 |
||||
val pump = StreamDecodePump<Frame>( |
||||
submit = { if (blocked) false else { queued.addLast(it); true } }, |
||||
receive = { |
||||
if (held || (queued.size < depth && !finishing) || queued.isEmpty()) null |
||||
else { held = true; StreamDecodePump.Output(nextIndex++, queued.removeFirst().pts) } |
||||
}, |
||||
release = { _, _ -> check(held); held = false; released++ } |
||||
) |
||||
} |
||||
|
||||
@Test fun fourAndEightFramePipelinesStartWithFrozenPresentationClock() { |
||||
for (depth in listOf(4, 8)) { |
||||
val codec = Codec(depth) |
||||
repeat(depth) { i -> |
||||
assertTrue(codec.pump.wantsInput) |
||||
codec.pump.offer(Frame(i * 66666L), i * 66666L) |
||||
codec.pump.step(0, i * 66666L) |
||||
} |
||||
assertEquals(depth.toLong(), codec.pump.inputCount) |
||||
assertEquals(1L, codec.pump.outputCount) |
||||
assertEquals(1, codec.released) |
||||
assertEquals(0L, codec.pump.firstOutputPts) |
||||
assertEquals((depth - 1) * 66666L, codec.pump.decoderLeadUs) |
||||
} |
||||
} |
||||
|
||||
@Test fun futureOutputStopsReadsAndNeverReleasesEarly() { |
||||
val codec = Codec(1) |
||||
codec.pump.offer(Frame(100000), 0); codec.pump.step(0, 0) |
||||
assertFalse(codec.pump.wantsInput); assertNotNull(codec.pump.output); assertEquals(0, codec.released) |
||||
codec.pump.step(99999, 15_000_000); codec.pump.checkProgress(15_000_000, false) |
||||
assertEquals(0, codec.released) |
||||
codec.pump.step(100000, 15_000_001) |
||||
assertEquals(1, codec.released); assertTrue(codec.pump.wantsInput) |
||||
} |
||||
|
||||
@Test fun blockedCodecKeepsOneInputAndReportsStall() { |
||||
val codec = Codec(4); codec.blocked = true |
||||
codec.pump.offer(Frame(0), 0); codec.pump.step(0, 0) |
||||
assertNotNull(codec.pump.input); assertFalse(codec.pump.wantsInput) |
||||
try { codec.pump.checkProgress(10_000_001, false); fail("Stalled decoder must fail") } |
||||
catch (expected: IllegalStateException) { assertTrue(expected.message!!.contains("no output progress")) } |
||||
} |
||||
|
||||
@Test fun networkPauseKeepsReferencesAndAllowsIncompletePipelineToResume() { |
||||
val codec = Codec(4) |
||||
repeat(2) { i -> codec.pump.offer(Frame(i * 40000L), i * 40000L); codec.pump.step(0, i * 40000L) } |
||||
codec.pump.checkProgress(20_000_000, false) |
||||
repeat(2) { i -> |
||||
val now = 20_000_000 + i * 40000L |
||||
codec.pump.offer(Frame((i + 2) * 40000L), now); codec.pump.step(0, now); codec.pump.checkProgress(now, false) |
||||
} |
||||
assertEquals(4L, codec.pump.inputCount); assertEquals(1, codec.released) |
||||
} |
||||
|
||||
@Test fun finishDrainsShortStreamAndPreservesTheLastPicture() { |
||||
val codec = Codec(8) |
||||
repeat(3) { i -> codec.pump.offer(Frame(i * 40000L), i * 40000L); codec.pump.step(0, i * 40000L) } |
||||
assertEquals(0, codec.released); codec.finishing = true |
||||
codec.pump.step(0, 120000); assertEquals(1, codec.released); assertFalse(codec.pump.wantsInput) |
||||
codec.pump.step(40000, 160000); assertEquals(2, codec.released) |
||||
codec.pump.step(80000, 200000); assertEquals(3, codec.released) |
||||
} |
||||
} |
||||
Loading…
Reference in new issue