2 changed files with 184 additions and 0 deletions
@ -0,0 +1,68 @@
|
||||
package com.utun.chat.data |
||||
|
||||
/** Запас устройства, не сетевой JB. Владелец сериализует sample/applied/settle одним lock. */ |
||||
internal class CallPlaybackBuffer(val blockFrames: Int = 960) { |
||||
data class Change(val frames: Int, val reason: String, val underruns: Int = 0) |
||||
|
||||
private var actual = 0 |
||||
private var capacity = 0 |
||||
private var floor = blockFrames |
||||
private var previousUnderruns = 0 |
||||
private var previousPlayed = -1L |
||||
private var previousMs = 0L |
||||
private var stableSinceMs = 0L |
||||
private var settleUntilMs = 0L |
||||
private var probeIntervalMs = 30_000L |
||||
|
||||
fun reset(nowMs: Long, frames: Int, capacityFrames: Int, underruns: Int) { |
||||
require(frames > 0 && capacityFrames >= frames && underruns >= 0) |
||||
actual = frames |
||||
capacity = capacityFrames |
||||
floor = blockFrames |
||||
previousUnderruns = underruns |
||||
previousPlayed = -1 |
||||
previousMs = nowMs |
||||
probeIntervalMs = 30_000 |
||||
settle(nowMs) |
||||
} |
||||
|
||||
/** Старт и запрос/подтверждение маршрута не участвуют в обучении. */ |
||||
fun settle(nowMs: Long) { |
||||
settleUntilMs = nowMs + 2_000 |
||||
stableSinceMs = settleUntilMs |
||||
} |
||||
|
||||
fun sample(nowMs: Long, underruns: Int, played: Long): Change? { |
||||
val delta = underruns - previousUnderruns |
||||
val progress = played >= 0 && previousPlayed >= 0 && played > previousPlayed |
||||
val timely = nowMs - previousMs in 1..1_000 |
||||
previousUnderruns = underruns |
||||
previousPlayed = played |
||||
previousMs = nowMs |
||||
if (nowMs < settleUntilMs) return null |
||||
if (delta < 0) { |
||||
settle(nowMs) |
||||
return Change(actual, "counter-reset") |
||||
} |
||||
if (delta > 0) { |
||||
probeIntervalMs = (probeIntervalMs * 2).coerceAtMost(300_000) |
||||
settle(nowMs) |
||||
val next = (actual + blockFrames).coerceAtMost(capacity) |
||||
if (next == actual) settleUntilMs = nowMs + 10_000 |
||||
return Change(next, if (next > actual) "underrun" else "capacity-limit", delta) |
||||
} |
||||
if (!progress || !timely) stableSinceMs = nowMs |
||||
if (actual <= floor || nowMs - stableSinceMs < probeIntervalMs) return null |
||||
stableSinceMs = nowMs |
||||
return Change((actual - blockFrames).coerceAtLeast(floor), "stable-probe") |
||||
} |
||||
|
||||
/** Устройство может округлить/ограничить заявку: учимся по принятому размеру. */ |
||||
fun applied(nowMs: Long, change: Change, frames: Int, capacityFrames: Int) { |
||||
require(frames > 0 && capacityFrames >= frames) |
||||
if (change.frames < actual && frames >= actual) floor = frames |
||||
actual = frames |
||||
capacity = capacityFrames |
||||
settle(nowMs) |
||||
} |
||||
} |
||||
@ -0,0 +1,116 @@
|
||||
package com.utun.chat.data |
||||
|
||||
import org.junit.Assert.* |
||||
import org.junit.Test |
||||
|
||||
class CallPlaybackBufferTest { |
||||
private class Device(val capacity: Int = 7680, val minimum: Int = 960) { |
||||
val policy = CallPlaybackBuffer() |
||||
var now = 0L |
||||
var played = 0L |
||||
var underruns = 0 |
||||
var frames = 1920 |
||||
val changes = mutableListOf<CallPlaybackBuffer.Change>() |
||||
init { policy.reset(now, frames, capacity, underruns) } |
||||
fun tick(advance: Boolean = true, duration: Long = 200): CallPlaybackBuffer.Change? { |
||||
now += duration |
||||
if (advance) played += duration * 48 |
||||
val change = policy.sample(now, underruns, played) ?: return null |
||||
changes.add(change) |
||||
if (change.frames != frames) { |
||||
frames = change.frames.coerceIn(minimum, capacity) |
||||
policy.applied(now, change, frames, capacity) |
||||
} |
||||
return change |
||||
} |
||||
fun run(ms: Long, advance: Boolean = true) { repeat((ms / 200).toInt()) { tick(advance) } } |
||||
} |
||||
|
||||
@Test fun stableDeviceReachesOneBlockAndStopsProbing() { |
||||
val d = Device() |
||||
d.run(31_800) |
||||
assertEquals(1920, d.frames) |
||||
assertEquals("stable-probe", d.tick()?.reason) |
||||
assertEquals(960, d.frames) |
||||
d.run(300_000) |
||||
assertEquals(1, d.changes.size) |
||||
} |
||||
|
||||
@Test fun underrunGrowsImmediatelyAndDelaysNextProbe() { |
||||
val d = Device() |
||||
d.run(35_000) |
||||
d.underruns++ |
||||
assertEquals("underrun", d.tick()?.reason) |
||||
assertEquals(1920, d.frames) |
||||
d.run(61_800) |
||||
assertEquals(1920, d.frames) |
||||
assertEquals("stable-probe", d.tick()?.reason) |
||||
assertEquals(960, d.frames) |
||||
} |
||||
|
||||
@Test fun startupAndRouteUnderrunsDoNotTeachWrongSize() { |
||||
val d = Device() |
||||
d.underruns = 3 |
||||
assertNull(d.tick()) |
||||
d.run(20_000) |
||||
d.policy.settle(d.now) // Запрос маршрута ещё до framework callback. |
||||
d.underruns = 7 |
||||
d.run(1_000, false) |
||||
d.policy.reset(d.now, d.frames, d.capacity, d.underruns) // Подтверждённый новый выход. |
||||
d.underruns = 9 |
||||
d.run(31_800) |
||||
assertTrue(d.changes.isEmpty()) |
||||
assertEquals("stable-probe", d.tick()?.reason) |
||||
} |
||||
|
||||
@Test fun AcceptedHardwareMinimumIsNotProbedForever() { |
||||
val d = Device(minimum = 1440) |
||||
d.run(32_000) |
||||
assertEquals(1440, d.frames) |
||||
d.run(32_000) |
||||
assertEquals(1440, d.frames) |
||||
assertEquals(2, d.changes.size) |
||||
d.run(300_000) |
||||
assertEquals(2, d.changes.size) |
||||
} |
||||
|
||||
@Test fun noPlaybackOrMissingObservationsCannotProveStability() { |
||||
val d = Device() |
||||
d.run(60_000, false) |
||||
assertTrue(d.changes.isEmpty()) |
||||
d.run(20_000) |
||||
d.tick(duration = 40_000) |
||||
assertTrue(d.changes.isEmpty()) |
||||
d.run(29_800) |
||||
assertEquals("stable-probe", d.tick()?.reason) |
||||
} |
||||
|
||||
@Test fun realStarvationStillGrowsWhenHeadOrReporterStalls() { |
||||
val d = Device() |
||||
d.run(5_000) |
||||
d.underruns++ |
||||
assertEquals("underrun", d.tick(false, 2_000)?.reason) |
||||
assertEquals(2880, d.frames) |
||||
} |
||||
|
||||
@Test fun growthStopsAtCapacityAndRateLimitsWarnings() { |
||||
val d = Device(capacity = 1920) |
||||
d.run(5_000) |
||||
d.underruns++ |
||||
assertEquals("capacity-limit", d.tick()?.reason) |
||||
repeat(49) { d.underruns++; assertNull(d.tick()) } |
||||
assertEquals(1, d.changes.size) |
||||
assertEquals(1920, d.frames) |
||||
} |
||||
|
||||
@Test fun counterResetIsRebasedWithoutGrowingBuffer() { |
||||
val d = Device() |
||||
d.underruns = 10 |
||||
d.run(5_000) |
||||
d.underruns = 0 |
||||
assertEquals("counter-reset", d.tick()?.reason) |
||||
assertEquals(1920, d.frames) |
||||
d.run(32_000) |
||||
assertEquals(960, d.frames) |
||||
} |
||||
} |
||||
Loading…
Reference in new issue