Phase 21.2: back-pressure to bound the unplayed decoded region
Shared scheduler fill signal (forward water-marks + hard byte cap) pauses the C# read loop above high-water and, for Opus, stops the demux/decode feed so WebCodecs queues stay near-empty. Routes through the existing cancellation discipline; releases the latch on clear/seek.
This commit is contained in:
@@ -85,6 +85,14 @@ function buf(duration: number): AudioBuffer {
|
||||
return { duration } as AudioBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* A decoded buffer carrying realistic byte-footprint fields (length + numberOfChannels) for the
|
||||
* OQ3 byte-ceiling test. Models 48 kHz stereo float PCM: length = duration × 48000 frames, 2 ch.
|
||||
*/
|
||||
function bufBytes(duration: number): AudioBuffer {
|
||||
return { duration, length: Math.round(duration * 48000), numberOfChannels: 2 } as AudioBuffer;
|
||||
}
|
||||
|
||||
function makeScheduler(cm: FakeContextManager): PlaybackScheduler {
|
||||
// The scheduler only uses the subset FakeContextManager implements.
|
||||
return new PlaybackScheduler(cm as unknown as AudioContextManager);
|
||||
@@ -325,6 +333,114 @@ test('eviction via handleSourceEnded: position exact, live bufferIndex decrement
|
||||
}
|
||||
});
|
||||
|
||||
// === Phase 21.2 back-pressure: the forward water-mark signal =================================
|
||||
//
|
||||
// The signal is pure given the clock + buffer durations + the playhead position, so it is
|
||||
// testable in Node with the same fakes. We drive forward lookahead by adding buffers (fill) and
|
||||
// advancing the clock (drain), and assert the hysteresis latch and the OQ3 byte ceiling.
|
||||
|
||||
/**
|
||||
* Fill the scheduler with `count` 1 s buffers, start playback at t=0, and advance the schedule
|
||||
* cursor to the end so nextBufferIndex does not pin anything. Leaves all `count` buffers decoded
|
||||
* and the playhead at the clock position the caller sets afterwards.
|
||||
*/
|
||||
function fillAndStart(s: PlaybackScheduler, cm: FakeContextManager, count: number): void {
|
||||
for (let i = 0; i < count; i++) s.addBuffer(buf(1));
|
||||
cm.now = 0;
|
||||
s.playFromPosition(0);
|
||||
advanceCursorToEnd(s, cm);
|
||||
}
|
||||
|
||||
// High-water reached → production pauses; the signal reflects the forward lookahead.
|
||||
test('isProductionPaused latches true when forward lookahead reaches high-water', () => {
|
||||
const cm = new FakeContextManager();
|
||||
const s = makeScheduler(cm);
|
||||
s.setForwardWindow(10, 5, 0); // high 10s, low 5s, byte cap disabled
|
||||
fillAndStart(s, cm, 40); // 40s decoded, track [0,40)
|
||||
|
||||
cm.now = 0; // playhead at 0 → forward lookahead = 40s ≥ 10s high-water
|
||||
assertEqual(s.getForwardLookaheadSeconds(), 40, 'lookahead is full decoded tail at t=0');
|
||||
assertEqual(s.isProductionPaused(), true, 'pauses at/above high-water');
|
||||
});
|
||||
|
||||
// Below high-water but above low-water while NOT yet paused → stays unpaused (no premature pause).
|
||||
test('isProductionPaused stays false in the hysteresis band before the high-water crossing', () => {
|
||||
const cm = new FakeContextManager();
|
||||
const s = makeScheduler(cm);
|
||||
s.setForwardWindow(10, 5, 0);
|
||||
fillAndStart(s, cm, 8); // 8s decoded
|
||||
|
||||
cm.now = 0; // lookahead 8s: between low(5) and high(10), never latched → unpaused
|
||||
assertEqual(s.isProductionPaused(), false, 'no pause until high-water is actually reached');
|
||||
});
|
||||
|
||||
// Hysteresis: once paused at high-water, stays paused through the band until lookahead drains
|
||||
// below low-water, then resumes. Drain is modeled by advancing the clock (playhead moves forward,
|
||||
// shrinking forward lookahead).
|
||||
test('isProductionPaused holds through the band and resumes only below low-water', () => {
|
||||
const cm = new FakeContextManager();
|
||||
const s = makeScheduler(cm);
|
||||
s.setForwardWindow(10, 5, 0);
|
||||
fillAndStart(s, cm, 40); // track [0,40)
|
||||
|
||||
cm.now = 0;
|
||||
assertEqual(s.isProductionPaused(), true, 'latched at high-water (40s ahead)');
|
||||
|
||||
// Playhead at 32 → lookahead 8s: in the band (5..10) → must STAY paused (hysteresis).
|
||||
cm.now = 32;
|
||||
assertEqual(s.getForwardLookaheadSeconds(), 8, 'lookahead drained to 8s');
|
||||
assertEqual(s.isProductionPaused(), true, 'still paused inside the band');
|
||||
|
||||
// Playhead at 36 → lookahead 4s ≤ low-water 5 → resume.
|
||||
cm.now = 36;
|
||||
assertEqual(s.getForwardLookaheadSeconds(), 4, 'lookahead below low-water');
|
||||
assertEqual(s.isProductionPaused(), false, 'resumes below low-water');
|
||||
|
||||
// Refill back over high-water re-latches (the next chunk would re-pause).
|
||||
for (let i = 0; i < 20; i++) s.addBuffer(buf(1)); // +20s decoded ahead
|
||||
assertEqual(s.isProductionPaused(), true, 're-latches when fill exceeds high-water again');
|
||||
});
|
||||
|
||||
// OQ3 hard byte ceiling pauses production independent of the time window, and releases as soon as
|
||||
// the footprint is back under the cap (no separate low-water band on the byte guard).
|
||||
test('OQ3 byte ceiling pauses regardless of the time window', () => {
|
||||
const cm = new FakeContextManager();
|
||||
const s = makeScheduler(cm);
|
||||
// Each 1s buffer here is 48000 frames × 2 ch × 4 bytes = 384000 bytes. Cap at ~1.5 MB ≈ 4 buffers.
|
||||
const perBuffer = 48000 * 2 * 4;
|
||||
s.setForwardWindow(1000, 500, perBuffer * 4); // time window huge so only the byte cap can fire
|
||||
for (let i = 0; i < 6; i++) s.addBuffer(bufBytes(1)); // 6 buffers > 4-buffer cap
|
||||
cm.now = 0;
|
||||
s.playFromPosition(0);
|
||||
advanceCursorToEnd(s, cm);
|
||||
|
||||
cm.now = 0;
|
||||
if (s.getDecodedByteEstimate() <= perBuffer * 4) {
|
||||
throw new Error('test setup: byte estimate should exceed the cap');
|
||||
}
|
||||
assertEqual(s.isProductionPaused(), true, 'byte ceiling pauses even with a huge time window');
|
||||
});
|
||||
|
||||
// clear() / clearForSeek() release the latch so a fresh stream/seek starts unthrottled (C2).
|
||||
test('clear and clearForSeek release the back-pressure latch (C2 latency parity)', () => {
|
||||
const cm = new FakeContextManager();
|
||||
const s = makeScheduler(cm);
|
||||
s.setForwardWindow(10, 5, 0);
|
||||
fillAndStart(s, cm, 40);
|
||||
cm.now = 0;
|
||||
assertEqual(s.isProductionPaused(), true, 'latched');
|
||||
|
||||
s.clear();
|
||||
// After clear there are no buffers, lookahead is 0, and the latch is reset → unpaused.
|
||||
assertEqual(s.isProductionPaused(), false, 'clear resets the latch and empties fill');
|
||||
|
||||
fillAndStart(s, cm, 40);
|
||||
cm.now = 0;
|
||||
assertEqual(s.isProductionPaused(), true, 'latched again after refill');
|
||||
s.clearForSeek();
|
||||
assertEqual(s.isProductionPaused(), false, 'clearForSeek resets the latch');
|
||||
});
|
||||
|
||||
// --- run -------------------------------------------------------------------------------------
|
||||
if (failures.length > 0) {
|
||||
console.error(failures.join('\n'));
|
||||
|
||||
Reference in New Issue
Block a user