From 67007d634a0157d5f5e5b8f2f4bbc74abee54d70 Mon Sep 17 00:00:00 2001 From: pedroSG94 Date: Wed, 12 Aug 2026 22:04:38 +0200 Subject: [PATCH 01/10] fixing bitratemanager --- .../java/com/pedro/common/BitrateManager.kt | 1 + .../com/pedro/common/BitrateManagerTest.kt | 33 +++++++ .../com/pedro/library/base/Camera1Base.java | 5 +- .../com/pedro/library/base/Camera2Base.java | 4 +- .../com/pedro/library/base/DisplayBase.java | 1 + .../com/pedro/library/base/FromFileBase.java | 1 + .../pedro/library/util/BitrateAdapter.java | 11 ++- .../pedro/library/util/BitrateAdapterTest.kt | 89 +++++++++++++++++++ 8 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 library/src/test/java/com/pedro/library/util/BitrateAdapterTest.kt diff --git a/common/src/main/java/com/pedro/common/BitrateManager.kt b/common/src/main/java/com/pedro/common/BitrateManager.kt index 0afd4d822b..081c04843e 100644 --- a/common/src/main/java/com/pedro/common/BitrateManager.kt +++ b/common/src/main/java/com/pedro/common/BitrateManager.kt @@ -48,5 +48,6 @@ open class BitrateManager(private val bitrateChecker: BitrateChecker) { fun reset() { bitrate = 0 bitrateOld = 0 + timeStamp = TimeUtils.getCurrentTimeMillis() } } \ No newline at end of file diff --git a/common/src/test/java/com/pedro/common/BitrateManagerTest.kt b/common/src/test/java/com/pedro/common/BitrateManagerTest.kt index defa7ffcf2..8eea939824 100644 --- a/common/src/test/java/com/pedro/common/BitrateManagerTest.kt +++ b/common/src/test/java/com/pedro/common/BitrateManagerTest.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule @@ -90,4 +91,36 @@ class BitrateManagerTest { val marginError = 20 assertTrue(expectedResult - marginError <= resultValue.firstValue && resultValue.firstValue <= expectedResult + marginError) } + + @Test + fun `GIVEN an idle instance WHEN reset and measure a second THEN report the real bitrate`() = runTest { + val bitrateManager = BitrateManager(connectChecker) + //the instance is created when the client is built, the stream may start much later + fakeTime += 300_000 + bitrateManager.reset() + + fakeTime += 1000 + bitrateManager.calculateBitrate(3_000_000L) + + val resultValue = argumentCaptor() + verify(connectChecker, times(1)).onNewBitrate(resultValue.capture()) + assertEquals(3_000_000L, resultValue.firstValue) + } + + @Test + fun `GIVEN a measured bitrate WHEN reset THEN start a new window instead of averaging the pause`() = runTest { + val bitrateManager = BitrateManager(connectChecker) + fakeTime += 1000 + bitrateManager.calculateBitrate(1_000_000L) + + //a reconnection: the sender is stopped for a while and started again + fakeTime += 120_000 + bitrateManager.reset() + fakeTime += 1000 + bitrateManager.calculateBitrate(2_000_000L) + + val resultValue = argumentCaptor() + verify(connectChecker, times(2)).onNewBitrate(resultValue.capture()) + assertEquals(2_000_000L, resultValue.secondValue) + } } diff --git a/library/src/main/java/com/pedro/library/base/Camera1Base.java b/library/src/main/java/com/pedro/library/base/Camera1Base.java index bf742cfcd6..296d251a98 100644 --- a/library/src/main/java/com/pedro/library/base/Camera1Base.java +++ b/library/src/main/java/com/pedro/library/base/Camera1Base.java @@ -293,8 +293,11 @@ public boolean prepareVideo(int width, int height, int fps, int bitrate, int iFr } FormatVideoEncoder formatVideoEncoder = glInterface == null ? FormatVideoEncoder.YUV420Dynamical : FormatVideoEncoder.SURFACE; - return videoEncoder.prepareVideoEncoder(width, height, fps, bitrate, rotation, iFrameInterval, + + boolean result = videoEncoder.prepareVideoEncoder(width, height, fps, bitrate, rotation, iFrameInterval, formatVideoEncoder, profile, level); + forceFpsLimit(true); + return result; } /** diff --git a/library/src/main/java/com/pedro/library/base/Camera2Base.java b/library/src/main/java/com/pedro/library/base/Camera2Base.java index 609f1142d2..090148b051 100644 --- a/library/src/main/java/com/pedro/library/base/Camera2Base.java +++ b/library/src/main/java/com/pedro/library/base/Camera2Base.java @@ -364,8 +364,10 @@ public boolean prepareVideo( iFrameInterval, FormatVideoEncoder.SURFACE, profile, level); if (!result) return false; } - return videoEncoder.prepareVideoEncoder(width, height, fps, bitrate, rotation, + boolean result = videoEncoder.prepareVideoEncoder(width, height, fps, bitrate, rotation, iFrameInterval, FormatVideoEncoder.SURFACE, profile, level); + forceFpsLimit(true); + return result; } public boolean prepareVideo( diff --git a/library/src/main/java/com/pedro/library/base/DisplayBase.java b/library/src/main/java/com/pedro/library/base/DisplayBase.java index 955ca21275..cabae172ad 100644 --- a/library/src/main/java/com/pedro/library/base/DisplayBase.java +++ b/library/src/main/java/com/pedro/library/base/DisplayBase.java @@ -171,6 +171,7 @@ public boolean prepareVideo(int width, int height, int fps, int bitrate, int rot glStreamInterface.setIsPortrait(isPortrait); } } + forceFpsLimit(true); return videoInitialized; } diff --git a/library/src/main/java/com/pedro/library/base/FromFileBase.java b/library/src/main/java/com/pedro/library/base/FromFileBase.java index f6cca639ed..ac742c7c06 100644 --- a/library/src/main/java/com/pedro/library/base/FromFileBase.java +++ b/library/src/main/java/com/pedro/library/base/FromFileBase.java @@ -214,6 +214,7 @@ private boolean finishPrepareVideo(int bitRate, int rotation, int profile, int if (!result) return false; result = videoDecoder.prepareVideo(videoEncoder.getInputSurface()); videoEnabled = result; + forceFpsLimit(true); return result; } diff --git a/library/src/main/java/com/pedro/library/util/BitrateAdapter.java b/library/src/main/java/com/pedro/library/util/BitrateAdapter.java index a1f34d940b..7736adbccc 100644 --- a/library/src/main/java/com/pedro/library/util/BitrateAdapter.java +++ b/library/src/main/java/com/pedro/library/util/BitrateAdapter.java @@ -21,6 +21,8 @@ */ public class BitrateAdapter { + private static final float MAX_BITRATE_TOLERANCE = 0.95f; + public interface Listener { void onBitrateAdapted(int bitrate); } @@ -74,7 +76,8 @@ public void adaptBitrate(long actualBitrate, boolean hasCongestion) { private int getBitrateAdapted(int bitrate) { if (bitrate >= maxBitrate) { //You have high speed and max bitrate. Keep max speed oldBitrate = maxBitrate; - } else if (bitrate <= oldBitrate * 0.9f) { //You have low speed and bitrate too high. Reduce bitrate by 10%. + } else if (bitrate <= oldBitrate * 0.9f || isStuckAtMaxBitrate(bitrate)) { + //You have low speed and bitrate too high. Reduce bitrate by 10%. oldBitrate = (int) (bitrate * decreaseRange); } else { //You have high speed and bitrate too low. Increase bitrate by 10%. oldBitrate = (int) (bitrate * increaseRange); @@ -83,8 +86,12 @@ private int getBitrateAdapted(int bitrate) { return oldBitrate; } + private boolean isStuckAtMaxBitrate(int bitrate) { + return oldBitrate >= maxBitrate && bitrate < maxBitrate * MAX_BITRATE_TOLERANCE; + } + private int getBitrateAdapted(int bitrate, boolean hasCongestion) { - if (bitrate >= maxBitrate) { //You have high speed and max bitrate. Keep max speed + if (bitrate >= maxBitrate && !hasCongestion) { //You have high speed and max bitrate. Keep max speed oldBitrate = maxBitrate; } else if (hasCongestion) { //You have low speed and bitrate too high. Reduce bitrate by 10%. oldBitrate = (int) (bitrate * decreaseRange); diff --git a/library/src/test/java/com/pedro/library/util/BitrateAdapterTest.kt b/library/src/test/java/com/pedro/library/util/BitrateAdapterTest.kt new file mode 100644 index 0000000000..8219be2220 --- /dev/null +++ b/library/src/test/java/com/pedro/library/util/BitrateAdapterTest.kt @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.library.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * maxBitrate is 3200000 of video plus 64000 of audio, the configuration reported in issue #2177. + * adaptBitrate only produces a value every 5 samples. + */ +class BitrateAdapterTest { + + private val maxBitrate = 3264000 + + private fun adapt(samples: List): List { + val results = mutableListOf() + val adapter = BitrateAdapter { results.add(it) } + adapter.setMaxBitrate(maxBitrate) + samples.forEach { adapter.adaptBitrate(it) } + return results + } + + private fun adapt(samples: List, hasCongestion: Boolean): List { + val results = mutableListOf() + val adapter = BitrateAdapter { results.add(it) } + adapter.setMaxBitrate(maxBitrate) + samples.forEach { adapter.adaptBitrate(it, hasCongestion) } + return results + } + + @Test + fun `GIVEN a link faster than max WHEN adapt THEN keep max bitrate`() { + val results = adapt(List(5) { 3400000L }) + assertEquals(listOf(3264000), results) + } + + @Test + fun `GIVEN a link a bit slower than max WHEN adapt THEN go below the link instead of pinning at max`() { + //3150000 is fast enough to stay above oldBitrate * 0.9, so the decrease branch was never + //taken and the bitrate stayed pinned at maxBitrate over a link that cannot carry it + val results = adapt(List(5) { 3150000L }) + assertEquals(listOf(2441249), results) + assertTrue(results.first() < 3150000) + } + + @Test + fun `GIVEN a link a bit slower than max WHEN adapt many times THEN never settle above the link`() { + val link = 3150000L + val results = mutableListOf() + val adapter = BitrateAdapter { results.add(it) } + adapter.setMaxBitrate(maxBitrate) + var configured = maxBitrate + repeat(10 * 5) { + //the sender can only push what the link carries + adapter.adaptBitrate(minOf(configured.toLong(), link)) + configured = results.lastOrNull() ?: configured + } + assertEquals(10, results.size) + assertTrue("settled above the link: $results", results.count { it > link } < results.size) + } + + @Test + fun `GIVEN congestion WHEN measured bitrate reaches max THEN reduce anyway`() { + val results = adapt(List(5) { 4000000L }, hasCongestion = true) + assertEquals(listOf(3100000), results) + } + + @Test + fun `GIVEN no congestion WHEN measured bitrate reaches max THEN keep max bitrate`() { + val results = adapt(List(5) { 4000000L }, hasCongestion = false) + assertEquals(listOf(3264000), results) + } +} From 378a74100322cdfcb6ea012067b2b9d176359dd5 Mon Sep 17 00:00:00 2001 From: pedroSG94 Date: Thu, 13 Aug 2026 11:10:38 +0200 Subject: [PATCH 02/10] add getSuspendContext to extensions --- common/src/main/java/com/pedro/common/Extensions.kt | 13 +++++++++++++ .../java/com/pedro/library/base/Camera1Base.java | 1 - 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/common/src/main/java/com/pedro/common/Extensions.kt b/common/src/main/java/com/pedro/common/Extensions.kt index 9078e4a3af..761e2b60bf 100644 --- a/common/src/main/java/com/pedro/common/Extensions.kt +++ b/common/src/main/java/com/pedro/common/Extensions.kt @@ -26,9 +26,11 @@ import android.media.MediaFormat import android.os.Build import android.os.Handler import android.os.Looper +import android.util.Log import android.view.Surface import androidx.annotation.RequiresApi import com.pedro.common.frame.MediaFrame +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.io.IOException @@ -47,6 +49,7 @@ import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.ThreadPoolExecutor import java.util.concurrent.TimeUnit import kotlin.coroutines.Continuation +import kotlin.coroutines.CoroutineContext /** * Created by pedro on 3/11/23. @@ -366,3 +369,13 @@ fun ByteBuffer.clone(data: ByteArray): ByteBuffer { source.get(data, 0, length) return ByteBuffer.wrap(data, 0, length).slice() } + +@JvmOverloads +fun getSuspendContext(dispatcher: CoroutineDispatcher = Dispatchers.IO) = object: Continuation { + override val context: CoroutineContext + get() = dispatcher + + override fun resumeWith(result: Result) { + result.exceptionOrNull()?.let { Log.e("getSuspendContext", "Error", it) } + } +} \ No newline at end of file diff --git a/library/src/main/java/com/pedro/library/base/Camera1Base.java b/library/src/main/java/com/pedro/library/base/Camera1Base.java index 296d251a98..55095cb2e3 100644 --- a/library/src/main/java/com/pedro/library/base/Camera1Base.java +++ b/library/src/main/java/com/pedro/library/base/Camera1Base.java @@ -293,7 +293,6 @@ public boolean prepareVideo(int width, int height, int fps, int bitrate, int iFr } FormatVideoEncoder formatVideoEncoder = glInterface == null ? FormatVideoEncoder.YUV420Dynamical : FormatVideoEncoder.SURFACE; - boolean result = videoEncoder.prepareVideoEncoder(width, height, fps, bitrate, rotation, iFrameInterval, formatVideoEncoder, profile, level); forceFpsLimit(true); From cd6ea8378ddfd62506f03f495402f1c5a42ba3de Mon Sep 17 00:00:00 2001 From: pedroSG94 Date: Thu, 13 Aug 2026 13:14:29 +0200 Subject: [PATCH 03/10] add new bitrate adapter --- .../pedro/streamer/rotation/CameraFragment.kt | 11 +- .../library/util/QueueAwareBitrateAdapter.kt | 100 ++++++++++++++++ .../util/QueueAwareBitrateAdapterTest.kt | 108 ++++++++++++++++++ 3 files changed, 213 insertions(+), 6 deletions(-) create mode 100644 library/src/main/java/com/pedro/library/util/QueueAwareBitrateAdapter.kt create mode 100644 library/src/test/java/com/pedro/library/util/QueueAwareBitrateAdapterTest.kt diff --git a/app/src/main/java/com/pedro/streamer/rotation/CameraFragment.kt b/app/src/main/java/com/pedro/streamer/rotation/CameraFragment.kt index d284319668..06afde8ad1 100644 --- a/app/src/main/java/com/pedro/streamer/rotation/CameraFragment.kt +++ b/app/src/main/java/com/pedro/streamer/rotation/CameraFragment.kt @@ -39,7 +39,7 @@ import com.pedro.extrasources.CameraXSource import com.pedro.library.base.StreamBase import com.pedro.library.base.recording.RecordController import com.pedro.library.generic.GenericStream -import com.pedro.library.util.BitrateAdapter +import com.pedro.library.util.QueueAwareBitrateAdapter import com.pedro.streamer.R import com.pedro.streamer.utils.PathUtils import com.pedro.streamer.utils.toast @@ -95,10 +95,8 @@ class CameraFragment: Fragment(), ConnectChecker { private val aBitrate = 128 * 1000 private var recordPath = "" //Bitrate adapter used to change the bitrate on fly depend of the bandwidth. - private val bitrateAdapter = BitrateAdapter { - genericStream.setVideoBitrateOnFly(it) - }.apply { - setMaxBitrate(vBitrate + aBitrate) + private val bitrateAdapter = QueueAwareBitrateAdapter(maxBitrate = vBitrate + aBitrate) { + genericStream.setVideoBitrateOnFly(it - aBitrate) } @SuppressLint("ClickableViewAccessibility") @@ -203,6 +201,7 @@ class CameraFragment: Fragment(), ConnectChecker { } override fun onConnectionStarted(url: String) { + bitrateAdapter.reset() } override fun onConnectionSuccess() { @@ -223,7 +222,7 @@ class CameraFragment: Fragment(), ConnectChecker { override fun onStreamingStats(report: StreamingStatsReport) { onMainThreadHandler { - bitrateAdapter.adaptBitrate(report.smoothedBitrate, genericStream.getStreamClient().hasCongestion()) + bitrateAdapter.onStreamingStats(report) if (report.throughput != Throughput.UNKNOWN) { txtBitrate.text = String.format( Locale.getDefault(), diff --git a/library/src/main/java/com/pedro/library/util/QueueAwareBitrateAdapter.kt b/library/src/main/java/com/pedro/library/util/QueueAwareBitrateAdapter.kt new file mode 100644 index 0000000000..d475ccc000 --- /dev/null +++ b/library/src/main/java/com/pedro/library/util/QueueAwareBitrateAdapter.kt @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2026 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.library.util + +import com.pedro.common.StreamingStatsReport +import com.pedro.common.Throughput + +/** + * Alternative to [BitrateAdapter] driven by the send queue instead of by the measured bitrate. + * + * [BitrateAdapter] probes upwards blindly and only reduces when the measured bitrate falls far + * enough below the configured one, so a link slightly slower than the target makes it oscillate + * above and below the real capacity. This one reads the queue: when frames start piling up the + * link is the limit, so it records the bitrate the link actually delivered and stays below it + * instead of climbing back to the maximum. + * + * Feed it from onStreamingStats and apply [Listener.onBitrateAdapted] to the video encoder. + * [maxBitrate] is the whole wire budget, video plus audio, because that is what the report + * measures, so subtract the audio bitrate before applying it to video. + */ + +class QueueAwareBitrateAdapter( + private val maxBitrate: Int, + minBitrate: Int, + private val listener: Listener +) { + + constructor(maxBitrate: Int, listener: Listener): this(maxBitrate, maxBitrate / 10, listener) + + fun interface Listener { + fun onBitrateAdapted(bitrate: Int) + } + + private companion object { + const val QUEUE_ALERT_FRACTION = 0.15f //seconds of video allowed in queue + const val BACKOFF = 0.90f + const val PROBE = 1.05f + const val HOLD_SECONDS = 4 + const val CEILING_MARGIN = 0.97f + const val CEILING_TTL = 60 + } + + private val floor = minBitrate.coerceIn(1, maxBitrate) + private var target = maxBitrate + private var ceiling = maxBitrate + private var good = 0 + private var age = 0 + + fun onStreamingStats(report: StreamingStatsReport) { + val alertBytes = (target / 8) * QUEUE_ALERT_FRACTION + val congested = report.throughput == Throughput.INSUFFICIENT || report.queueBytesOut > alertBytes + if (congested) { + //BitrateManager reports 0 until its first window closes, that is not a measurement + if (report.smoothedBitrate > 0) { + ceiling = minOf(ceiling.toLong(), report.smoothedBitrate).toInt() + target = (ceiling * BACKOFF).toInt().coerceAtLeast(floor) + good = 0 + age = 0 + listener.onBitrateAdapted(target) + } + } else { + good++ + if (good >= HOLD_SECONDS) { + good = 0 + val cap = if (ceiling >= maxBitrate) maxBitrate else (ceiling * CEILING_MARGIN).toInt() + val next = minOf((target * PROBE).toInt(), cap) + if (next != target) { + target = next + listener.onBitrateAdapted(target) + } + } + } + if (++age > CEILING_TTL) { + ceiling = maxBitrate + age = 0 + } + } + + fun reset() { + target = maxBitrate + ceiling = maxBitrate + good = 0 + age = 0 + listener.onBitrateAdapted(target) + } +} diff --git a/library/src/test/java/com/pedro/library/util/QueueAwareBitrateAdapterTest.kt b/library/src/test/java/com/pedro/library/util/QueueAwareBitrateAdapterTest.kt new file mode 100644 index 0000000000..8b1f751829 --- /dev/null +++ b/library/src/test/java/com/pedro/library/util/QueueAwareBitrateAdapterTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.pedro.library.util + +import com.pedro.common.StreamingStatsReport +import com.pedro.common.Throughput +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * maxBitrate is 3200000 of video plus 64000 of audio, the configuration reported in issue #2177. + */ +class QueueAwareBitrateAdapterTest { + + private val maxBitrate = 3264000 + + private fun report(smoothedBitrate: Long, queueBytesOut: Long, throughput: Throughput) = + StreamingStatsReport( + bytesOutPerSecond = 0, + queueBytesOut = queueBytesOut, + totalBytesOut = 0, + queueCongestionPercent = 0f, + throughput = throughput, + bitrate = 0, + smoothedBitrate = smoothedBitrate, + ) + + private fun healthy(bitrate: Int) = report(bitrate.toLong(), 0, Throughput.SUFFICIENT) + + @Test + fun `GIVEN a healthy link WHEN adapt for a long time THEN keep the whole configured bitrate`() { + //the encoder starts at maxBitrate, an adapter that never touches it is the correct result + var configured = maxBitrate + val adapter = QueueAwareBitrateAdapter(maxBitrate) { configured = it } + repeat(300) { adapter.onStreamingStats(healthy(maxBitrate)) } + assertEquals(maxBitrate, configured) + } + + @Test + fun `GIVEN the queue piling up WHEN adapt THEN drop below the bitrate the link delivered`() { + var last = 0 + val adapter = QueueAwareBitrateAdapter(maxBitrate) { last = it } + adapter.onStreamingStats(report(3000000, 500000, Throughput.INSUFFICIENT)) + assertEquals(2700000, last) + } + + @Test + fun `GIVEN no measurement yet WHEN the queue piles up THEN do not report a zero bitrate`() { + val emitted = mutableListOf() + val adapter = QueueAwareBitrateAdapter(maxBitrate) { emitted.add(it) } + //BitrateManager reports 0 until its first one second window closes + adapter.onStreamingStats(report(0, 900000, Throughput.INSUFFICIENT)) + assertEquals(emptyList(), emitted) + } + + @Test + fun `GIVEN a single bad second WHEN it recovers THEN never go under the floor`() { + var last = 0 + val adapter = QueueAwareBitrateAdapter(maxBitrate) { last = it } + adapter.onStreamingStats(report(50000, 900000, Throughput.INSUFFICIENT)) + assertEquals(maxBitrate / 10, last) + } + + @Test + fun `GIVEN a throttled session WHEN reset THEN go back to max and tell the encoder`() { + var last = 0 + val adapter = QueueAwareBitrateAdapter(maxBitrate) { last = it } + adapter.onStreamingStats(report(1000000, 900000, Throughput.INSUFFICIENT)) + assertTrue(last < maxBitrate) + + adapter.reset() + assertEquals(maxBitrate, last) + } + + @Test + fun `GIVEN a link slower than max WHEN adapt in a closed loop THEN stay under it almost always`() { + val link = 3000000 + var configured = maxBitrate + val adapter = QueueAwareBitrateAdapter(maxBitrate) { configured = it } + var secondsOverTheLink = 0 + repeat(600) { + val congested = configured > link + if (congested) secondsOverTheLink++ + //the link only carries what it carries, and the queue grows while we push more + adapter.onStreamingStats( + if (congested) report(link.toLong(), 500000, Throughput.INSUFFICIENT) + else healthy(configured) + ) + } + //it re-tests the link once every CEILING_TTL, so it goes over briefly by design + assertTrue("over the link $secondsOverTheLink seconds of 600", secondsOverTheLink < 60) + } +} From 969d335f13ebd9c7d017ca0b5a610dac6ebef62e Mon Sep 17 00:00:00 2001 From: pedroSG94 Date: Mon, 17 Aug 2026 22:00:38 +0200 Subject: [PATCH 04/10] fixing new bitrate adapter --- .../library/util/QueueAwareBitrateAdapter.kt | 24 +++++++++++++++---- .../util/QueueAwareBitrateAdapterTest.kt | 16 +++++++++++-- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/library/src/main/java/com/pedro/library/util/QueueAwareBitrateAdapter.kt b/library/src/main/java/com/pedro/library/util/QueueAwareBitrateAdapter.kt index d475ccc000..f377b3aba2 100644 --- a/library/src/main/java/com/pedro/library/util/QueueAwareBitrateAdapter.kt +++ b/library/src/main/java/com/pedro/library/util/QueueAwareBitrateAdapter.kt @@ -52,21 +52,35 @@ class QueueAwareBitrateAdapter( const val HOLD_SECONDS = 4 const val CEILING_MARGIN = 0.97f const val CEILING_TTL = 60 + //a transient must not define the link, so the capacity is the best second of this window + const val CAPACITY_WINDOW = 15 + //and even a real drop cannot halve the ceiling twice in a row + const val MAX_CEILING_DROP = 0.5f + //each probe closes part of the distance to the ceiling, so recovering from a deep drop + //does not take minutes + const val GAP_CLOSE = 0.25f } private val floor = minBitrate.coerceIn(1, maxBitrate) + private val recent = ArrayDeque() private var target = maxBitrate private var ceiling = maxBitrate private var good = 0 private var age = 0 fun onStreamingStats(report: StreamingStatsReport) { + //BitrateManager reports 0 until its first window closes, that is not a measurement + if (report.smoothedBitrate > 0) { + recent.addLast(report.smoothedBitrate) + if (recent.size > CAPACITY_WINDOW) recent.removeFirst() + } val alertBytes = (target / 8) * QUEUE_ALERT_FRACTION val congested = report.throughput == Throughput.INSUFFICIENT || report.queueBytesOut > alertBytes if (congested) { - //BitrateManager reports 0 until its first window closes, that is not a measurement - if (report.smoothedBitrate > 0) { - ceiling = minOf(ceiling.toLong(), report.smoothedBitrate).toInt() + val measured = recent.maxOrNull() + if (measured != null) { + val dropped = minOf(ceiling.toLong(), measured).toInt() + ceiling = maxOf(dropped, (ceiling * MAX_CEILING_DROP).toInt()) target = (ceiling * BACKOFF).toInt().coerceAtLeast(floor) good = 0 age = 0 @@ -77,7 +91,8 @@ class QueueAwareBitrateAdapter( if (good >= HOLD_SECONDS) { good = 0 val cap = if (ceiling >= maxBitrate) maxBitrate else (ceiling * CEILING_MARGIN).toInt() - val next = minOf((target * PROBE).toInt(), cap) + val gapStep = target + ((cap - target) * GAP_CLOSE).toInt() + val next = minOf(maxOf(gapStep, (target * PROBE).toInt()), cap) if (next != target) { target = next listener.onBitrateAdapted(target) @@ -95,6 +110,7 @@ class QueueAwareBitrateAdapter( ceiling = maxBitrate good = 0 age = 0 + recent.clear() listener.onBitrateAdapted(target) } } diff --git a/library/src/test/java/com/pedro/library/util/QueueAwareBitrateAdapterTest.kt b/library/src/test/java/com/pedro/library/util/QueueAwareBitrateAdapterTest.kt index 8b1f751829..b27249577c 100644 --- a/library/src/test/java/com/pedro/library/util/QueueAwareBitrateAdapterTest.kt +++ b/library/src/test/java/com/pedro/library/util/QueueAwareBitrateAdapterTest.kt @@ -69,11 +69,23 @@ class QueueAwareBitrateAdapterTest { } @Test - fun `GIVEN a single bad second WHEN it recovers THEN never go under the floor`() { + fun `GIVEN a single terrible second WHEN it is the only measurement THEN do not collapse`() { var last = 0 val adapter = QueueAwareBitrateAdapter(maxBitrate) { last = it } + //a second delivering 50 kbps used to define the link and drop the bitrate to the floor adapter.onStreamingStats(report(50000, 900000, Throughput.INSUFFICIENT)) - assertEquals(maxBitrate / 10, last) + assertEquals(1468800, last) + assertTrue("collapsed to $last", last > maxBitrate / 4) + } + + @Test + fun `GIVEN a healthy stretch WHEN one second goes bad THEN keep the capacity seen before`() { + var last = 0 + val adapter = QueueAwareBitrateAdapter(maxBitrate) { last = it } + repeat(10) { adapter.onStreamingStats(healthy(3000000)) } + adapter.onStreamingStats(report(50000, 900000, Throughput.INSUFFICIENT)) + //the window still remembers the good seconds, so the transient is ignored + assertEquals(2700000, last) } @Test From e34e12c99e92a37e7190f8a25e6c3ec91eaf6b7b Mon Sep 17 00:00:00 2001 From: pedroSG94 Date: Wed, 19 Aug 2026 09:26:30 +0200 Subject: [PATCH 05/10] fix clear regions --- .../encoder/input/video/Camera2ApiManager.kt | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/encoder/src/main/java/com/pedro/encoder/input/video/Camera2ApiManager.kt b/encoder/src/main/java/com/pedro/encoder/input/video/Camera2ApiManager.kt index c80166cba0..c1c3c12966 100644 --- a/encoder/src/main/java/com/pedro/encoder/input/video/Camera2ApiManager.kt +++ b/encoder/src/main/java/com/pedro/encoder/input/video/Camera2ApiManager.kt @@ -403,6 +403,13 @@ class Camera2ApiManager(context: Context) { } } + private fun clearRegions(): Array? { + val sensor = cameraCharacteristics?.secureGet( + CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE) ?: return null + return arrayOf(MeteringRectangle(0, 0, sensor.width(), sensor.height(), + MeteringRectangle.METERING_WEIGHT_DONT_CARE)) + } + /** * @param mode value from CameraCharacteristics.CONTROL_AWB_MODE_* */ @@ -413,8 +420,7 @@ class Camera2ApiManager(context: Context) { if (!modes.contains(mode)) return false val maxRegionsAwb = characteristics.secureGet(CameraCharacteristics.CONTROL_MAX_REGIONS_AWB) ?: 0 if (maxRegionsAwb > 0) { - val clearRect = MeteringRectangle(0, 0, 0, 0, MeteringRectangle.METERING_WEIGHT_DONT_CARE) - builderInputSurface.set(CaptureRequest.CONTROL_AWB_REGIONS, arrayOf(clearRect)) + clearRegions()?.let { builderInputSurface.set(CaptureRequest.CONTROL_AWB_REGIONS, it) } } builderInputSurface.set(CaptureRequest.CONTROL_AWB_MODE, mode) isAutoWhiteBalanceEnabled = applyRequest(builderInputSurface) @@ -462,8 +468,7 @@ class Camera2ApiManager(context: Context) { if (!modes.contains(CaptureRequest.CONTROL_AE_MODE_ON)) return false val maxRegionsAe = characteristics.secureGet(CameraCharacteristics.CONTROL_MAX_REGIONS_AE) ?: 0 if (maxRegionsAe > 0) { - val clearRect = MeteringRectangle(0, 0, 0, 0, MeteringRectangle.METERING_WEIGHT_DONT_CARE) - builderInputSurface.set(CaptureRequest.CONTROL_AE_REGIONS, arrayOf(clearRect)) + clearRegions()?.let { builderInputSurface.set(CaptureRequest.CONTROL_AE_REGIONS, it) } } builderInputSurface.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON) isAutoExposureEnabled = applyRequest(builderInputSurface) @@ -784,8 +789,7 @@ class Camera2ApiManager(context: Context) { builderInputSurface.setTag("") val maxRegionsAf = characteristics.secureGet(CameraCharacteristics.CONTROL_MAX_REGIONS_AF) ?: 0 if (maxRegionsAf > 0) { - val clearRect = MeteringRectangle(0, 0, 0, 0, MeteringRectangle.METERING_WEIGHT_DONT_CARE) - builderInputSurface.set(CaptureRequest.CONTROL_AF_REGIONS, arrayOf(clearRect)) + clearRegions()?.let { builderInputSurface.set(CaptureRequest.CONTROL_AF_REGIONS, it) } } builderInputSurface.set(CaptureRequest.CONTROL_AF_TRIGGER, CameraMetadata.CONTROL_AF_TRIGGER_CANCEL) builderInputSurface.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_OFF) From da779104f999b1f95ab4c0e52380ac18fafbb7d1 Mon Sep 17 00:00:00 2001 From: pedroSG94 Date: Thu, 20 Aug 2026 13:25:08 +0200 Subject: [PATCH 06/10] add setCustomRequest and setCustomOnCaptureCompletedCallback to camera2base --- .../com/pedro/library/base/Camera2Base.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/library/src/main/java/com/pedro/library/base/Camera2Base.java b/library/src/main/java/com/pedro/library/base/Camera2Base.java index 090148b051..c66dfc4ac3 100644 --- a/library/src/main/java/com/pedro/library/base/Camera2Base.java +++ b/library/src/main/java/com/pedro/library/base/Camera2Base.java @@ -19,6 +19,8 @@ import android.content.Context; import android.graphics.Point; import android.hardware.camera2.CameraCharacteristics; +import android.hardware.camera2.CaptureRequest; +import android.hardware.camera2.TotalCaptureResult; import android.media.MediaCodec; import android.media.MediaFormat; import android.media.MediaRecorder; @@ -67,6 +69,8 @@ import java.util.Arrays; import java.util.List; +import kotlin.Unit; + /** * Wrapper to stream with camera2 api and microphone. Support stream with SurfaceView, TextureView, * OpenGlView(Custom SurfaceView that use OpenGl) and Context(background mode). All views use @@ -151,6 +155,29 @@ public void setCustomAudioEffect(CustomAudioEffect customAudioEffect) { microphoneManager.setCustomAudioEffect(customAudioEffect); } + public interface RequestListener { + void onRequest(CaptureRequest.Builder builder); + } + + public interface CaptureResultListener { + void onCaptureResult(TotalCaptureResult result); + } + + public boolean setCustomRequest(RequestListener listener) { + return cameraManager.setCustomRequest((builder) -> { + if (listener != null) listener.onRequest(builder); + return Unit.INSTANCE; + }); + } + + public void setCustomOnCaptureCompletedCallback(CaptureResultListener listener) { + cameraManager.setCustomOnCaptureCompletedCallback(listener == null ? null : + (session, request, result) -> { + listener.onCaptureResult(result); + return Unit.INSTANCE; + }); + } + /** * @param callback get fps while record or stream */ From 0c3e1ee1db04090b8b552f48a0262ff94362f0b9 Mon Sep 17 00:00:00 2001 From: pedroSG94 Date: Mon, 24 Aug 2026 21:40:43 +0200 Subject: [PATCH 07/10] stop gl after encoders --- library/src/main/java/com/pedro/library/base/StreamBase.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/src/main/java/com/pedro/library/base/StreamBase.kt b/library/src/main/java/com/pedro/library/base/StreamBase.kt index ff1332b087..84f6dc8de4 100644 --- a/library/src/main/java/com/pedro/library/base/StreamBase.kt +++ b/library/src/main/java/com/pedro/library/base/StreamBase.kt @@ -540,10 +540,10 @@ abstract class StreamBase( audioSource.stop() glInterface.removeMediaCodecSurface() glInterface.removeMediaCodecRecordSurface() - if (!isOnPreview) glInterface.stop() videoEncoder.stop() videoEncoderRecord.stop() audioEncoder.stop() + if (!isOnPreview) glInterface.stop() if (!isRecording) recordController.resetFormats() } From c3cc3f57c3a15cf687616fd6dfce7ff5f0b6b97b Mon Sep 17 00:00:00 2001 From: pedroSG94 Date: Tue, 25 Aug 2026 11:17:36 +0200 Subject: [PATCH 08/10] fix QueueAwareBitrateAdapter measured --- .../library/util/QueueAwareBitrateAdapter.kt | 8 ++--- .../util/QueueAwareBitrateAdapterTest.kt | 34 ++++++++++++++++--- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/library/src/main/java/com/pedro/library/util/QueueAwareBitrateAdapter.kt b/library/src/main/java/com/pedro/library/util/QueueAwareBitrateAdapter.kt index f377b3aba2..ef2728a327 100644 --- a/library/src/main/java/com/pedro/library/util/QueueAwareBitrateAdapter.kt +++ b/library/src/main/java/com/pedro/library/util/QueueAwareBitrateAdapter.kt @@ -52,7 +52,7 @@ class QueueAwareBitrateAdapter( const val HOLD_SECONDS = 4 const val CEILING_MARGIN = 0.97f const val CEILING_TTL = 60 - //a transient must not define the link, so the capacity is the best second of this window + //a transient must not define the link, so the capacity is averaged over this window const val CAPACITY_WINDOW = 15 //and even a real drop cannot halve the ceiling twice in a row const val MAX_CEILING_DROP = 0.5f @@ -69,15 +69,15 @@ class QueueAwareBitrateAdapter( private var age = 0 fun onStreamingStats(report: StreamingStatsReport) { - //BitrateManager reports 0 until its first window closes, that is not a measurement - if (report.smoothedBitrate > 0) { + if (report.smoothedBitrate > 0 && report.queueBytesOut > 0) { recent.addLast(report.smoothedBitrate) if (recent.size > CAPACITY_WINDOW) recent.removeFirst() } val alertBytes = (target / 8) * QUEUE_ALERT_FRACTION val congested = report.throughput == Throughput.INSUFFICIENT || report.queueBytesOut > alertBytes if (congested) { - val measured = recent.maxOrNull() + val measured = if (recent.isNotEmpty()) recent.average().toLong() + else report.smoothedBitrate.takeIf { it > 0 } if (measured != null) { val dropped = minOf(ceiling.toLong(), measured).toInt() ceiling = maxOf(dropped, (ceiling * MAX_CEILING_DROP).toInt()) diff --git a/library/src/test/java/com/pedro/library/util/QueueAwareBitrateAdapterTest.kt b/library/src/test/java/com/pedro/library/util/QueueAwareBitrateAdapterTest.kt index b27249577c..c286ae9e62 100644 --- a/library/src/test/java/com/pedro/library/util/QueueAwareBitrateAdapterTest.kt +++ b/library/src/test/java/com/pedro/library/util/QueueAwareBitrateAdapterTest.kt @@ -79,13 +79,14 @@ class QueueAwareBitrateAdapterTest { } @Test - fun `GIVEN a healthy stretch WHEN one second goes bad THEN keep the capacity seen before`() { + fun `GIVEN backlogged seconds WHEN one goes bad THEN average them instead of trusting the worst`() { var last = 0 val adapter = QueueAwareBitrateAdapter(maxBitrate) { last = it } - repeat(10) { adapter.onStreamingStats(healthy(3000000)) } + //seconds with frames waiting are real link measurements, they build the capacity window + repeat(10) { adapter.onStreamingStats(report(3000000, 500000, Throughput.INSUFFICIENT)) } adapter.onStreamingStats(report(50000, 900000, Throughput.INSUFFICIENT)) - //the window still remembers the good seconds, so the transient is ignored - assertEquals(2700000, last) + //the average absorbs the transient instead of letting it define the link + assertTrue("collapsed to $last", last > 2000000) } @Test @@ -117,4 +118,29 @@ class QueueAwareBitrateAdapterTest { //it re-tests the link once every CEILING_TTL, so it goes over briefly by design assertTrue("over the link $secondsOverTheLink seconds of 600", secondsOverTheLink < 60) } + + @Test + fun `GIVEN a bursty link WHEN the queue drains in a burst THEN do not read the burst as capacity`() { + var last = 0 + val adapter = QueueAwareBitrateAdapter(maxBitrate) { last = it } + //a shaped link stalls and then drains the backlog at twice the rate. Taking the best second + //would read 6 Mbps as capacity on a link that only carries 2.5 + val pattern = listOf(2500000L, 0L, 6000000L, 2500000L, 1000000L, 6000000L, 2500000L) + repeat(3) { + pattern.forEach { adapter.onStreamingStats(report(it, 500000, Throughput.INSUFFICIENT)) } + } + assertTrue("aimed at $last, above what the link carries", last < 2500000) + } + + @Test + fun `GIVEN an empty queue WHEN adapt THEN do not use it as a capacity measurement`() { + var last = 0 + val adapter = QueueAwareBitrateAdapter(maxBitrate) { last = it } + //ten healthy seconds at max, the link was never the limit so they say nothing about capacity + repeat(10) { adapter.onStreamingStats(healthy(maxBitrate)) } + //now the link backs up and only delivers 1 Mbps. Had the healthy seconds been used as + //measurements the window would average near maxBitrate and barely reduce anything + adapter.onStreamingStats(report(1000000, 500000, Throughput.INSUFFICIENT)) + assertEquals(1468800, last) + } } From 3456aa7fe69b37979ff2eaf879c980080ddfb029 Mon Sep 17 00:00:00 2001 From: pedroSG94 Date: Tue, 25 Aug 2026 18:46:29 +0200 Subject: [PATCH 09/10] fixing tcp collapse --- .../library/util/QueueAwareBitrateAdapter.kt | 8 +++-- .../util/QueueAwareBitrateAdapterTest.kt | 36 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/library/src/main/java/com/pedro/library/util/QueueAwareBitrateAdapter.kt b/library/src/main/java/com/pedro/library/util/QueueAwareBitrateAdapter.kt index ef2728a327..c11b092e24 100644 --- a/library/src/main/java/com/pedro/library/util/QueueAwareBitrateAdapter.kt +++ b/library/src/main/java/com/pedro/library/util/QueueAwareBitrateAdapter.kt @@ -63,13 +63,15 @@ class QueueAwareBitrateAdapter( private val floor = minBitrate.coerceIn(1, maxBitrate) private val recent = ArrayDeque() + private var hasMeasured = false private var target = maxBitrate private var ceiling = maxBitrate private var good = 0 private var age = 0 fun onStreamingStats(report: StreamingStatsReport) { - if (report.smoothedBitrate > 0 && report.queueBytesOut > 0) { + if (report.smoothedBitrate > 0) hasMeasured = true + if (report.queueBytesOut > 0 && hasMeasured) { recent.addLast(report.smoothedBitrate) if (recent.size > CAPACITY_WINDOW) recent.removeFirst() } @@ -90,7 +92,8 @@ class QueueAwareBitrateAdapter( good++ if (good >= HOLD_SECONDS) { good = 0 - val cap = if (ceiling >= maxBitrate) maxBitrate else (ceiling * CEILING_MARGIN).toInt() + val cap = if (ceiling >= maxBitrate) maxBitrate + else (ceiling * CEILING_MARGIN).toInt().coerceAtLeast(floor) val gapStep = target + ((cap - target) * GAP_CLOSE).toInt() val next = minOf(maxOf(gapStep, (target * PROBE).toInt()), cap) if (next != target) { @@ -111,6 +114,7 @@ class QueueAwareBitrateAdapter( good = 0 age = 0 recent.clear() + hasMeasured = false listener.onBitrateAdapted(target) } } diff --git a/library/src/test/java/com/pedro/library/util/QueueAwareBitrateAdapterTest.kt b/library/src/test/java/com/pedro/library/util/QueueAwareBitrateAdapterTest.kt index c286ae9e62..b74b5f67e1 100644 --- a/library/src/test/java/com/pedro/library/util/QueueAwareBitrateAdapterTest.kt +++ b/library/src/test/java/com/pedro/library/util/QueueAwareBitrateAdapterTest.kt @@ -143,4 +143,40 @@ class QueueAwareBitrateAdapterTest { adapter.onStreamingStats(report(1000000, 500000, Throughput.INSUFFICIENT)) assertEquals(1468800, last) } + + @Test + fun `GIVEN a link that collapses WHEN it probes back up THEN never go under the floor`() { + val emitted = mutableListOf() + val adapter = QueueAwareBitrateAdapter(maxBitrate) { emitted.add(it) } + //a link that keeps failing drives the ceiling down; the probe branch used to cap the + //target at the collapsed ceiling, ignoring the floor and reaching zero + repeat(40) { + adapter.onStreamingStats(report(20000, 900000, Throughput.INSUFFICIENT)) + repeat(5) { adapter.onStreamingStats(healthy(20000)) } + } + val lowest = emitted.min() + assertTrue("emitted $lowest, under the floor", lowest >= maxBitrate / 10) + } + + @Test + fun `GIVEN a link that stalls WHEN frames are waiting THEN count the stall as a measurement`() { + var last = 0 + val adapter = QueueAwareBitrateAdapter(maxBitrate) { last = it } + //one real value first, so the adapter knows BitrateManager is producing measurements + adapter.onStreamingStats(report(2000000, 500000, Throughput.INSUFFICIENT)) + val afterSlowLink = last + //now the link stops delivering entirely while frames pile up + repeat(10) { adapter.onStreamingStats(report(0, 900000, Throughput.INSUFFICIENT)) } + assertTrue("stalls did not lower the estimate: $afterSlowLink -> $last", last < afterSlowLink) + } + + @Test + fun `GIVEN no measurement yet WHEN the queue piles up THEN ignore the zero as a capacity value`() { + var last = 0 + val adapter = QueueAwareBitrateAdapter(maxBitrate) { last = it } + //BitrateManager reports 0 until its first window closes; with a queue already growing that + //zero must not be read as "the link delivers nothing" + adapter.onStreamingStats(report(0, 900000, Throughput.INSUFFICIENT)) + assertEquals(0, last) + } } From 8af50f0926019960adbaef634eb09cc7a99ff783 Mon Sep 17 00:00:00 2001 From: pedroSG94 Date: Wed, 26 Aug 2026 12:35:38 +0200 Subject: [PATCH 10/10] allow change ceilingttl --- .../library/util/QueueAwareBitrateAdapter.kt | 33 ++++++++++------ .../util/QueueAwareBitrateAdapterTest.kt | 39 +++++++++++++++++++ 2 files changed, 60 insertions(+), 12 deletions(-) diff --git a/library/src/main/java/com/pedro/library/util/QueueAwareBitrateAdapter.kt b/library/src/main/java/com/pedro/library/util/QueueAwareBitrateAdapter.kt index c11b092e24..6d03c365df 100644 --- a/library/src/main/java/com/pedro/library/util/QueueAwareBitrateAdapter.kt +++ b/library/src/main/java/com/pedro/library/util/QueueAwareBitrateAdapter.kt @@ -36,29 +36,38 @@ import com.pedro.common.Throughput class QueueAwareBitrateAdapter( private val maxBitrate: Int, minBitrate: Int, + /** + * Seconds to check if the network improved doing set bitrate to max and decrease again if the network still is bad. + * Use 0 or less if you never want check if the network improved. 300s by default + */ + private val ceilingTtl: Int, private val listener: Listener ) { - constructor(maxBitrate: Int, listener: Listener): this(maxBitrate, maxBitrate / 10, listener) + constructor(maxBitrate: Int, listener: Listener): + this(maxBitrate, maxBitrate / 10, DEFAULT_CEILING_TTL, listener) + + constructor(maxBitrate: Int, minBitrate: Int, listener: Listener): + this(maxBitrate, minBitrate, DEFAULT_CEILING_TTL, listener) fun interface Listener { fun onBitrateAdapted(bitrate: Int) } - private companion object { - const val QUEUE_ALERT_FRACTION = 0.15f //seconds of video allowed in queue - const val BACKOFF = 0.90f - const val PROBE = 1.05f - const val HOLD_SECONDS = 4 - const val CEILING_MARGIN = 0.97f - const val CEILING_TTL = 60 + companion object { + const val DEFAULT_CEILING_TTL = 300 + private const val QUEUE_ALERT_FRACTION = 0.15f //seconds of video allowed in queue + private const val BACKOFF = 0.90f + private const val PROBE = 1.05f + private const val HOLD_SECONDS = 4 + private const val CEILING_MARGIN = 0.97f //a transient must not define the link, so the capacity is averaged over this window - const val CAPACITY_WINDOW = 15 + private const val CAPACITY_WINDOW = 15 //and even a real drop cannot halve the ceiling twice in a row - const val MAX_CEILING_DROP = 0.5f + private const val MAX_CEILING_DROP = 0.5f //each probe closes part of the distance to the ceiling, so recovering from a deep drop //does not take minutes - const val GAP_CLOSE = 0.25f + private const val GAP_CLOSE = 0.25f } private val floor = minBitrate.coerceIn(1, maxBitrate) @@ -102,7 +111,7 @@ class QueueAwareBitrateAdapter( } } } - if (++age > CEILING_TTL) { + if (ceilingTtl > 0 && ++age > ceilingTtl) { ceiling = maxBitrate age = 0 } diff --git a/library/src/test/java/com/pedro/library/util/QueueAwareBitrateAdapterTest.kt b/library/src/test/java/com/pedro/library/util/QueueAwareBitrateAdapterTest.kt index b74b5f67e1..bfa821c1d7 100644 --- a/library/src/test/java/com/pedro/library/util/QueueAwareBitrateAdapterTest.kt +++ b/library/src/test/java/com/pedro/library/util/QueueAwareBitrateAdapterTest.kt @@ -179,4 +179,43 @@ class QueueAwareBitrateAdapterTest { adapter.onStreamingStats(report(0, 900000, Throughput.INSUFFICIENT)) assertEquals(0, last) } + + @Test + fun `GIVEN a ttl of zero WHEN the link stays the same THEN never climb over it again`() { + val link = 2800000 + var configured = maxBitrate + val adapter = QueueAwareBitrateAdapter(maxBitrate, maxBitrate / 10, 0) { configured = it } + var secondsOverTheLink = 0 + repeat(1800) { + val over = configured > link + if (over) secondsOverTheLink++ + adapter.onStreamingStats( + if (over) report(link.toLong(), 500000, Throughput.INSUFFICIENT) + else healthy(configured) + ) + } + //without re-testing it settles under the link and stays there + assertTrue("over the link $secondsOverTheLink seconds of 1800", secondsOverTheLink < 30) + assertTrue("settled at $configured, over the link", configured <= link) + } + + @Test + fun `GIVEN the default ttl WHEN the link stays the same THEN re-test it now and then`() { + val link = 2800000 + var configured = maxBitrate + val adapter = QueueAwareBitrateAdapter(maxBitrate) { configured = it } + var overshoots = 0 + var wasOver = false + repeat(1800) { + val over = configured > link + if (over && !wasOver) overshoots++ + wasOver = over + adapter.onStreamingStats( + if (over) report(link.toLong(), 500000, Throughput.INSUFFICIENT) + else healthy(configured) + ) + } + //1800 seconds at the default ttl of 300 leaves a handful of re-tests, not one per minute + assertTrue("re-tested $overshoots times in 1800s", overshoots in 1..8) + } }