Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ android {
applicationId "com.openipc.decoder"
minSdk 21
targetSdk 36
versionCode 34
versionName "2.09"
versionCode 35
versionName "2.10"

buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\""
}
Expand Down
83 changes: 63 additions & 20 deletions android/app/src/main/java/com/openipc/decoder/DecodePlanner.java
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@ final class DecodePlanner {
static final long PROBE_MAX_WAIT_MS = 10 * 60_000;
/** Promotions stay this far below a load that failed: the load estimates are noisy. */
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. */
/**
* Time after the last demotion (or the last such retry) after which a load that failed is
* tried once more: it may have been a fluke. Not from the last lossy sample, which key-frame
* cells reset now and then (#189).
*/
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
Expand Down Expand Up @@ -109,6 +113,15 @@ static final class Decision {
private long resumeWaitMs = PROBE_QUIET_MS;
/** The last lossy sample; promotions do not move it, unlike quietSinceMs. */
private long lastLossMs = -1;
/** The last demotion or failed-load retry: the next retry is timed from it (#189). */
private long lastFailMs = -1;
/**
* Losses below a proven load count as a transient once (#189): demoted, but the capacity
* and the failed load stay at least at the proven load. The next such demotion is handled
* as any other failure (the proven load was wrong after all), unless a load at least as
* high has played PROBE_QUIET_MS without losses again in between (Qodo on #190).
*/
private boolean transientUsed;

/** No loss-based decision for this long after the first step (#148). */
void setWarmup(long ms) { warmupMs = ms; }
Expand Down Expand Up @@ -181,20 +194,25 @@ Decision step(List<Cell> cells, long nowMs) {
lossyTimes.pollFirst();
}
if (lastLossMs < 0) lastLossMs = nowMs;
if (lastFailMs < 0) lastFailMs = 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;
probeWaitMs = PROBE_QUIET_MS;
if (load > capacity) {
capacity = load;
learnt = true;
} else if (nowMs - quietSinceMs >= PROBE_QUIET_MS && load >= proven) {
// Proven again: a later transient is forgiven once more (#189)
transientUsed = false;
if (load > proven) {
// A load that played without losses long enough is within this device's means
proven = load;
probeWaitMs = PROBE_QUIET_MS;
if (load > capacity) {
capacity = load;
learnt = true;
}
// A failure at about this load was a fluke: do not hold promotions back by it
if (load >= failedLoad * FAILED_MARGIN) failedLoad = Long.MAX_VALUE;
}
// A failure at about this load was a fluke: do not hold promotions back by it
if (load >= failedLoad * FAILED_MARGIN) failedLoad = Long.MAX_VALUE;
}
if (lastChangeMs >= 0 && nowMs - lastChangeMs < SETTLE_MS) return Decision.NONE;

Expand All @@ -203,6 +221,16 @@ Decision step(List<Cell> cells, long nowMs) {
for (int i = cells.size() - 1; i >= 0; i--) {
Cell c = cells.get(i);
if (c.full && !c.paused && c.playing && c.known) {
lastFailMs = nowMs;
// Below a load that played quietly: a transient (other apps, a stalled
// stream), the first time; only the cell goes back (#189)
if (load < proven && !transientUsed) {
transientUsed = true;
capacity = Math.max(minCapacity, Math.max(proven, Math.min(capacity,
Math.round(load * 0.9))));
learnt = true;
return change(nowMs, Action.DEMOTE, c.index);
}
// Losses at this load: the capacity is below it, but not below a proven load.
// Remembered for this session even below the floor; only stored values at
// or below it are distrusted after a restart (#126)
Expand Down Expand Up @@ -244,7 +272,9 @@ Decision step(List<Cell> cells, long nowMs) {
}
}

// Promote the highest-priority key-frame cell whose load is known
// Promote the highest-priority key-frame cell whose load is known; when it is held back,
// a lower one that fits within the capacity and below a failed load (#189)
boolean first = true;
for (Cell c : cells) {
if (c.full || c.paused || !c.playing || c.pinned) {
// Not connected (an offline camera) or paused: nothing to wait for
Expand All @@ -260,24 +290,37 @@ Decision step(List<Cell> cells, long nowMs) {
}
unknownSince.remove(c.index);
long after = load - c.keyLoad + c.fullLoad;
// Never up to a load that failed, not even within the capacity, unless it has been
// quiet for very long
if (after >= failedLoad * FAILED_MARGIN
&& nowMs - quietSinceMs < FAILED_RETRY_MS) return Decision.NONE;
boolean belowFailed = after < failedLoad * FAILED_MARGIN;
boolean fits = after <= capacity;
if (!first) {
// Out of priority order only what surely fits: no probe, no retry (#189)
if (fits && belowFailed) return promote(nowMs, c.index);
continue;
}
first = false;
// Never up to a load that failed, not even within the capacity, unless the last
// failure was very long ago; that retry restarts the wait (#189)
boolean retry = !belowFailed && nowMs - lastFailMs >= FAILED_RETRY_MS;
if (!belowFailed && !retry) continue;
// Beyond the capacity only after a quiet period
boolean probe = nowMs - quietSinceMs >= probeWaitMs;
if (fits || probe) {
// Every promotion starts a new quiet period: a load counts as proven only
// after it has itself played for PROBE_QUIET_MS without losses
quietSinceMs = nowMs;
return change(nowMs, Action.PROMOTE, c.index);
if (retry) lastFailMs = nowMs;
return promote(nowMs, c.index);
}
return Decision.NONE;
}
return Decision.NONE;
}

/**
* Every promotion starts a new quiet period: a load counts as proven only after it has
* itself played for PROBE_QUIET_MS without losses.
*/
private Decision promote(long nowMs, int index) {
quietSinceMs = nowMs;
return change(nowMs, Action.PROMOTE, index);
}

private static int countPlaying(List<Cell> cells) {
int n = 0;
for (Cell c : cells) if (!c.paused && c.playing) n++;
Expand Down
153 changes: 153 additions & 0 deletions android/app/src/test/java/com/openipc/decoder/DecodePlannerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -406,4 +406,157 @@ public void aSoftwareCellNeitherLoadsNorPausesTheHardware() {
assertTrue(!cells.get(0).paused && !cells.get(1).paused);
assertEquals(P1080_30, p.capacity());
}

/** Steps once per second until a decision other than NONE, at most {@code ms}; applied. */
private static DecodePlanner.Decision until(DecodePlanner p, List<DecodePlanner.Cell> cells,
long[] t, long ms) {
DecodePlanner.Decision d = DecodePlanner.Decision.NONE;
for (long end = t[0] + ms; d.action == DecodePlanner.Action.NONE && t[0] < end; t[0] += 1000) {
d = p.step(cells, t[0]);
apply(cells, d);
}
return d;
}

/** Three cells play a load of {@code each} x 3 quietly until it is proven. */
private static List<DecodePlanner.Cell> provenAtThree(DecodePlanner p, long each, long[] t) {
List<DecodePlanner.Cell> cells = keyCells(3, each);
for (int i = 0; i < 3; i++) until(p, cells, t, 10_000);
assertEquals(3, full(cells));
for (long end = t[0] + DecodePlanner.PROBE_QUIET_MS + 1000; t[0] < end; t[0] += 1000) {
assertEquals(DecodePlanner.Action.NONE, p.step(cells, t[0]).action);
}
return cells;
}

@Test
public void aTransientLossBelowAProvenLoadKeepsTheCapacity() {
// The #189 timeline: 312 338 proven, then losses at 239 719 (other apps, a stall)
long floor = DecodePlanner.fullLoad(1920, 1080, 30);
DecodePlanner p = new DecodePlanner(464_979, true, 446_770, floor);
long[] t = {0};
List<DecodePlanner.Cell> cells = provenAtThree(p, 312_338 / 3, t);
for (DecodePlanner.Cell c : cells) c.fullLoad = 239_719 / 3;
cells.get(0).lossy = true;
DecodePlanner.Decision d = until(p, cells, t, 10_000);
assertEquals(DecodePlanner.Action.DEMOTE, d.action);
assertEquals(2, d.index);
assertTrue("not below the proven load", p.capacity() >= 312_336);
assertEquals("the failed load stays", 446_770, p.failedLoad());
// The losses stop: the cell goes back to full rate at once
cells.get(0).lossy = false;
d = until(p, cells, t, 10_000);
assertEquals(DecodePlanner.Action.PROMOTE, d.action);
assertEquals(2, d.index);
}

@Test
public void repeatedLossesBelowAProvenLoadCountAsAFailure() {
long floor = DecodePlanner.fullLoad(1920, 1080, 30);
DecodePlanner p = new DecodePlanner(464_979, true, 446_770, floor);
long[] t = {0};
List<DecodePlanner.Cell> cells = provenAtThree(p, 312_338 / 3, t);
long each = 239_719 / 3;
for (DecodePlanner.Cell c : cells) c.fullLoad = each;
cells.get(0).lossy = true;
assertEquals(DecodePlanner.Action.DEMOTE, until(p, cells, t, 10_000).action);
cells.get(0).lossy = false;
assertEquals(DecodePlanner.Action.PROMOTE, until(p, cells, t, 10_000).action);
// The same losses again, even much later (Qodo on #190): the proven load was wrong
for (long end = t[0] + 15 * 60_000; t[0] < end; t[0] += 1000) apply(cells, p.step(cells, t[0]));
assertEquals(3, full(cells));
cells.get(0).lossy = true;
DecodePlanner.Decision d = until(p, cells, t, 10_000);
assertEquals(DecodePlanner.Action.DEMOTE, d.action);
assertEquals(3 * each, p.failedLoad());
assertEquals(floor, p.capacity());
}

@Test
public void aTransientIsForgivenAgainOnceTheProvenLoadPlaysQuietlyAgain() {
long floor = DecodePlanner.fullLoad(1920, 1080, 30);
DecodePlanner p = new DecodePlanner(464_979, true, 446_770, floor);
long[] t = {0};
List<DecodePlanner.Cell> cells = provenAtThree(p, 312_338 / 3, t);
for (DecodePlanner.Cell c : cells) c.fullLoad = 239_719 / 3;
cells.get(0).lossy = true;
assertEquals(DecodePlanner.Action.DEMOTE, until(p, cells, t, 10_000).action);
cells.get(0).lossy = false;
assertEquals(DecodePlanner.Action.PROMOTE, until(p, cells, t, 10_000).action);
// The proven load plays without losses again
for (DecodePlanner.Cell c : cells) c.fullLoad = 312_338 / 3;
for (long end = t[0] + DecodePlanner.PROBE_QUIET_MS + 1000; t[0] < end; t[0] += 1000) {
apply(cells, p.step(cells, t[0]));
}
// Another transient: still not a failure
for (DecodePlanner.Cell c : cells) c.fullLoad = 239_719 / 3;
cells.get(0).lossy = true;
assertEquals(DecodePlanner.Action.DEMOTE, until(p, cells, t, 10_000).action);
assertTrue(p.capacity() >= 312_336);
assertEquals(446_770, p.failedLoad());
}

@Test
public void aLightCellIsPromotedWhenTheFirstOneIsHeldBackByAFailedLoad() {
// The state of #189 in the session after the demotion: capacity at the floor, failed at
// 239 719 (after a restart, a value below the floor is not trusted); nine cells on key
// frames, about 50 000 blocks/s in all
DecodePlanner p = new DecodePlanner(DecodePlanner.fullLoad(1920, 1080, 30), true, 239_719);
List<DecodePlanner.Cell> cells = keyCells(9, 0);
cells.get(0).fullLoad = 180_000; // the U cell: up to about 221 000, too close to 239 719
cells.get(0).keyLoad = 9_000;
for (int i = 1; i < 8; i++) {
cells.get(i).fullLoad = DecodePlanner.fullLoad(1920, 1080, 20); // about 162 000
cells.get(i).keyLoad = 5_000;
}
cells.get(8).fullLoad = DecodePlanner.fullLoad(1280, 720, 2); // about 7 000
cells.get(8).keyLoad = 6_000;
long[] t = {0};
for (int i = 0; i < 20; i++) until(p, cells, t, 1000);
assertTrue(!cells.get(0).full);
assertTrue("the first that fits plays at full rate", cells.get(1).full);
assertTrue("and the light one", cells.get(8).full);
assertEquals(2, full(cells));
}

@Test
public void onlyTheFirstCellIsProbedBeyondTheCapacity() {
DecodePlanner p = new DecodePlanner(P1080_30, false);
List<DecodePlanner.Cell> cells = keyCells(3, P1080_30);
cells.get(0).fullLoad = 2 * P1080_30; // does not fit
cells.get(1).fullLoad = P1080_30 / 2; // fits
long[] t = {0};
DecodePlanner.Decision d = until(p, cells, t, 1000);
assertEquals(DecodePlanner.Action.PROMOTE, d.action);
assertEquals("a lower cell that fits does not wait", 1, d.index);
// The third would fit a probe as well, but the probe keeps the priority order
d = until(p, cells, t, 2 * DecodePlanner.PROBE_QUIET_MS);
assertEquals(DecodePlanner.Action.PROMOTE, d.action);
assertEquals(0, d.index);
}

@Test
public void aFailedLoadIsRetriedAnHourAfterTheDemotionDespiteSparseLosses() {
DecodePlanner p = new DecodePlanner(P1080_30, false);
List<DecodePlanner.Cell> cells = keyCells(2, P1080_30);
long[] t = {0};
until(p, cells, t, 1000);
until(p, cells, t, 40_000); // the probe of the second camera
assertEquals(2, full(cells));
cells.get(0).lossy = true;
assertEquals(DecodePlanner.Action.DEMOTE, until(p, cells, t, 10_000).action);
long demotedAt = t[0];
cells.get(0).lossy = false;
// A single lost key frame now and then: never a demotion, but no quiet hour either
DecodePlanner.Decision d = DecodePlanner.Decision.NONE;
for (; d.action == DecodePlanner.Action.NONE && t[0] < demotedAt + 2 * DecodePlanner.FAILED_RETRY_MS;
t[0] += 1000) {
cells.get(1).lossy = (t[0] - demotedAt) % (20 * 60_000) < 1000;
d = p.step(cells, t[0]);
}
assertEquals(DecodePlanner.Action.PROMOTE, d.action);
assertEquals(1, d.index);
assertTrue(t[0] - demotedAt >= DecodePlanner.FAILED_RETRY_MS);
assertTrue(t[0] - demotedAt < DecodePlanner.FAILED_RETRY_MS + 20 * 60_000);
}
}
Loading