diff --git a/android/app/src/main/java/com/openipc/decoder/DecodePlanner.java b/android/app/src/main/java/com/openipc/decoder/DecodePlanner.java index 7494f178..ad2d5132 100644 --- a/android/app/src/main/java/com/openipc/decoder/DecodePlanner.java +++ b/android/app/src/main/java/com/openipc/decoder/DecodePlanner.java @@ -43,6 +43,11 @@ final class DecodePlanner { static final double FAILED_MARGIN = 0.9; /** Quiet time after which a load that failed is tried once more: it may have been a fluke. */ static final long FAILED_RETRY_MS = 60 * 60_000; + /** + * After the grid starts, every decoder starts at once and all of them lose frames for a + * second or two: no decision on losses that early (#148). Set by the app; 0 in tests. + */ + static final long WARMUP_MS = 10_000; static final class Cell { int index; @@ -70,7 +75,7 @@ static final class Cell { } } - enum Action { NONE, PROMOTE, DEMOTE, PAUSE } + enum Action { NONE, PROMOTE, DEMOTE, PAUSE, RESUME } static final class Decision { final Action action; @@ -98,6 +103,15 @@ static final class Decision { private final Map unknownSince = new HashMap<>(); private long quietSinceMs = -1; private long lastChangeMs = -1; + private long warmupMs; + private long startMs = -1; + /** Quiet time before a paused cell gets another try; doubles with every pause (#148). */ + private long resumeWaitMs = PROBE_QUIET_MS; + /** The last lossy sample; promotions do not move it, unlike quietSinceMs. */ + private long lastLossMs = -1; + + /** No loss-based decision for this long after the first step (#148). */ + void setWarmup(long ms) { warmupMs = ms; } /** * Never learnt below this (#126): losses at a lower load come from a stalled system (a hung @@ -149,6 +163,11 @@ static long fullLoad(int width, int height, float fps) { Decision step(List cells, long nowMs) { if (quietSinceMs < 0) quietSinceMs = nowMs; + if (startMs < 0) startMs = nowMs; + // Starting decoders lose frames together: losses then decide nothing, and the time is + // not quiet either; promotions that fit go on (Qodo on #149) + boolean warming = nowMs - startMs < warmupMs; + if (warming) quietSinceMs = nowMs; long load = 0; boolean anyLossy = false; for (Cell c : cells) { @@ -156,14 +175,16 @@ Decision step(List cells, long nowMs) { if (c.paused || c.pinned) continue; if (c.full && c.known) load += c.fullLoad; else if (!c.full) load += c.keyLoad; - anyLossy |= c.lossy; + anyLossy |= c.lossy && !warming; } while (!lossyTimes.isEmpty() && nowMs - lossyTimes.peekFirst() >= LOSS_WINDOW_MS) { lossyTimes.pollFirst(); } + if (lastLossMs < 0) lastLossMs = nowMs; if (anyLossy) { lossyTimes.addLast(nowMs); quietSinceMs = nowMs; + lastLossMs = nowMs; } else if (nowMs - quietSinceMs >= PROBE_QUIET_MS && load > proven) { // A load that played without losses long enough is within this device's means proven = load; @@ -202,6 +223,8 @@ Decision step(List cells, long nowMs) { for (int i = cells.size() - 1; i >= 0; i--) { Cell c = cells.get(i); if (!c.paused && c.playing && !c.pinned) { + // The next try of a paused cell waits longer each time + resumeWaitMs = Math.min(resumeWaitMs * 2, PROBE_MAX_WAIT_MS); return change(nowMs, Action.PAUSE, c.index); } } @@ -209,6 +232,18 @@ Decision step(List cells, long nowMs) { } if (anyLossy) return Decision.NONE; + // A paused cell gets another try on key frames after a quiet period, the highest + // priority first: a pause is not for ever (#148) + if (nowMs - lastLossMs >= resumeWaitMs) { + for (Cell c : cells) { + if (c.paused) { + quietSinceMs = nowMs; + lastLossMs = nowMs; + return change(nowMs, Action.RESUME, c.index); + } + } + } + // Promote the highest-priority key-frame cell whose load is known for (Cell c : cells) { if (c.full || c.paused || !c.playing || c.pinned) { diff --git a/android/app/src/main/java/com/openipc/decoder/Decoder.java b/android/app/src/main/java/com/openipc/decoder/Decoder.java index 5cc00cae..abcfbf33 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -387,7 +387,8 @@ private void setSlides(boolean on) { for (QuadCell c : cells) if (c != null) c.setKeyFramesOnly(true); } // Back to adaptive: plan from scratch, cells are promoted again where they fit - if (!on && quadEnabled) decodePlanner = newPlanner(); + // The decoders keep running: no warm-up (Qodo on #149) + if (!on && quadEnabled) decodePlanner = newPlanner(false); Toast.makeText(this, on ? "Slides: key frames only" : "Multi-view", Toast.LENGTH_SHORT).show(); } @@ -1025,7 +1026,7 @@ public void run() { private void startStatsTick() { quadContainer.removeCallbacks(quadStatsTick); - decodePlanner = newPlanner(); + decodePlanner = newPlanner(true); quadContainer.postDelayed(quadStatsTick, 1000); } @@ -1042,17 +1043,21 @@ private void startStatsTick() { static final long MIN_CAPACITY = DecodePlanner.fullLoad(1920, 1080, 30); private DecodePlanner decodePlanner; - private DecodePlanner newPlanner() { + /** {@code warmup}: the grid's decoders start now, together (#148). */ + private DecodePlanner newPlanner(boolean warmup) { SharedPreferences pref = getSharedPreferences("settings", MODE_PRIVATE); if (pref.contains("decoder_capacity")) { pref.edit().remove("decoder_capacity").remove("decoder_failed_load").apply(); } long learnt = pref.getLong(PREF_DECODER_CAPACITY, 0); long failed = pref.getLong(PREF_DECODER_FAILED, 0); - if (learnt > 0) return new DecodePlanner(learnt, true, failed, MIN_CAPACITY); long declared = MediaCodecManager.declaredBlocksPerSec; - return new DecodePlanner(declared > 0 ? declared : DEFAULT_CAPACITY, false, failed, - MIN_CAPACITY); + DecodePlanner p = learnt > 0 ? new DecodePlanner(learnt, true, failed, MIN_CAPACITY) + : new DecodePlanner(declared > 0 ? declared : DEFAULT_CAPACITY, false, failed, + MIN_CAPACITY); + // The decoders start together and lose frames for a moment (#148) + if (warmup) p.setWarmup(DecodePlanner.WARMUP_MS); + return p; } /** @@ -1126,6 +1131,12 @@ private void planDecoding(QuadCell[] cells, long now) { showQuadOverlay(d.index); if (quadStats[d.index] != null) quadStats[d.index].setText(reason); break; + case RESUME: + Log.i(TAG, "Quiet for a while: Quad-" + d.index + " plays again"); + c.resume(); + // stop() took its audio route; the selected cell gets it back (Qodo on #149) + applyQuadAudio(); + break; default: break; } diff --git a/android/app/src/main/java/com/openipc/decoder/QuadCell.java b/android/app/src/main/java/com/openipc/decoder/QuadCell.java index 5c596444..de8fa35f 100644 --- a/android/app/src/main/java/com/openipc/decoder/QuadCell.java +++ b/android/app/src/main/java/com/openipc/decoder/QuadCell.java @@ -512,6 +512,15 @@ void pause(String reason) { stop(); } + /** Back after a pause by the planner (#148): on key frames, as a cell starts. */ + void resume() { + if (pausedReason == null) return; + pausedReason = null; + problem = null; + setKeyFramesOnly(true); + start(); + } + @Override public void onSdp(Sdp sdp) { // First the path, so a JPEG frame still being drawn no longer counts as current diff --git a/android/app/src/test/java/com/openipc/decoder/DecodePlannerTest.java b/android/app/src/test/java/com/openipc/decoder/DecodePlannerTest.java index 70eafd98..748f5c44 100644 --- a/android/app/src/test/java/com/openipc/decoder/DecodePlannerTest.java +++ b/android/app/src/test/java/com/openipc/decoder/DecodePlannerTest.java @@ -33,6 +33,7 @@ private static void apply(List cells, DecodePlanner.Decision if (d.action == DecodePlanner.Action.PROMOTE) c.full = true; if (d.action == DecodePlanner.Action.DEMOTE) c.full = false; if (d.action == DecodePlanner.Action.PAUSE) c.paused = true; + if (d.action == DecodePlanner.Action.RESUME) c.paused = false; } } @@ -86,6 +87,56 @@ public void keyFrameLossesWithNothingToDemotePauseAsLastResort() { assertEquals(2, d.index); } + @Test + public void lossesDuringTheWarmupDecideNothing() { + DecodePlanner p = new DecodePlanner(1, false); + p.setWarmup(DecodePlanner.WARMUP_MS); + List cells = keyCells(3, P1080_30); + // Every decoder starting at once loses frames (#148) + for (DecodePlanner.Cell c : cells) c.lossy = true; + long t = 0; + for (; t < DecodePlanner.WARMUP_MS; t += 1000) { + assertEquals(DecodePlanner.Action.NONE, p.step(cells, t).action); + } + // The same losses after the warm-up still count + DecodePlanner.Decision d = DecodePlanner.Decision.NONE; + for (int i = 0; i < DecodePlanner.LOSS_SAMPLES; i++, t += 1000) d = p.step(cells, t); + assertEquals(DecodePlanner.Action.PAUSE, d.action); + } + + @Test + public void aFittingCellIsPromotedDuringTheWarmup() { + DecodePlanner p = new DecodePlanner(BUDGET, false); + p.setWarmup(DecodePlanner.WARMUP_MS); + List cells = keyCells(4, P1080_30); + // Losses of the start do not hold back what fits (Qodo on #149) + for (DecodePlanner.Cell c : cells) c.lossy = true; + assertEquals(DecodePlanner.Action.PROMOTE, p.step(cells, 0).action); + } + + @Test + public void aPausedCellPlaysAgainAfterAQuietPeriod() { + DecodePlanner p = new DecodePlanner(1, false); + List cells = keyCells(3, P1080_30); + for (DecodePlanner.Cell c : cells) c.lossy = true; + long t = 0; + DecodePlanner.Decision d = DecodePlanner.Decision.NONE; + for (int i = 0; i < DecodePlanner.LOSS_SAMPLES; i++, t += 1000) d = p.step(cells, t); + apply(cells, d); + assertTrue(cells.get(2).paused); + cells.get(2).playing = false; + for (DecodePlanner.Cell c : cells) c.lossy = false; + // Quiet: after the (doubled) wait the paused cell gets another try (#148) + d = DecodePlanner.Decision.NONE; + long pausedAt = t; + for (; d.action != DecodePlanner.Action.RESUME && t < pausedAt + 10 * 60_000; t += 1000) { + d = p.step(cells, t); + } + assertEquals(DecodePlanner.Action.RESUME, d.action); + assertEquals(2, d.index); + assertTrue(t - pausedAt >= DecodePlanner.PROBE_QUIET_MS); + } + @Test public void aQuietLoadIsProvenAndAProbeMayExceedTheCapacity() { DecodePlanner p = new DecodePlanner(P1080_30, false); // one camera fits