Auto-throttle visualizer under sustained Opus decode pressure; strip streaming investigation instrumentation

This commit is contained in:
daniel-c-harvey
2026-06-26 06:00:05 -04:00
parent 76f7f389a3
commit 374f09150f
7 changed files with 313 additions and 159 deletions
@@ -0,0 +1,93 @@
/**
* Shared decode-pressure signal — the seam that lets the audio decode pipeline protect itself
* from the WebGL visualizer under CPU contention.
*
* THE PROBLEM (browser-confirmed): with hardware acceleration OFF the WaveformVisualizer's WebGL2
* lava-lamp software-renders on the main thread. WebCodecs Opus decode also runs on the main thread,
* so a 60 fps software render starves decode → it falls behind realtime → playback underruns. Turning
* the visualizer off makes decode keep up perfectly. With HW accel ON the render is on the GPU and
* there is no contention; WAV/lossless decodes synchronously and never pressures decode either.
*
* THE SEAM: this module is a singleton shared by two otherwise-independent browser module graphs —
* the audio pipeline (`js/audio/*`, the PRODUCER) and the visualizer (`js/visualizer/*`, the
* CONSUMER) — because an ES module is instantiated once per URL. The producer reports decode stress;
* the consumer reads {@link DecodePressureSignal.isUnderPressure} each frame and throttles its render
* cadence so the main thread yields time back to decode. No routing through C#, no constructor growth.
*
* HYSTERESIS (no flap): the signal engages only on SUSTAINED stress (≥ ENGAGE_EVENTS reports within
* ENGAGE_WINDOW_MS) and releases only after SUSTAINED recovery (no stress for RELEASE_QUIET_MS, and
* never before a MIN_ENGAGED_MS dwell). A lone startup-ramp blip never engages; once engaged the
* throttle cannot toggle off frame-to-frame.
*
* HEALTHY-CASE NO-OP: when decode keeps up nothing ever calls report(), so {@link isUnderPressure}
* stays false forever and the consumer runs at full quality. This protection only activates under
* genuine, sustained decode starvation.
*/
/** Stress reports required within {@link ENGAGE_WINDOW_MS} to engage the throttle. */
export const ENGAGE_EVENTS = 5;
/** Sliding window (ms) over which {@link ENGAGE_EVENTS} stress reports count toward engaging. */
export const ENGAGE_WINDOW_MS = 2500;
/** Stress-free dwell (ms) required before the throttle releases. */
export const RELEASE_QUIET_MS = 1500;
/** Minimum engaged dwell (ms) before release is even considered — the anti-flap floor. */
export const MIN_ENGAGED_MS = 1000;
type Clock = () => number;
export class DecodePressureSignal {
// Timestamps of recent stress reports, pruned to the engage window. Length ≥ ENGAGE_EVENTS is the
// "sustained pressure" condition. Bounded by the window, so this never grows unbounded.
private stressTimestamps: number[] = [];
private lastStressMs = Number.NEGATIVE_INFINITY;
private engaged = false;
private engagedAtMs = 0;
// Clock injectable purely for deterministic unit tests; production uses performance.now().
constructor(private readonly now: Clock = () => performance.now()) {}
/**
* Report one unit of decode stress — decode falling behind realtime. Called by the producer at
* each genuine lag event: the WebCodecs decode queue staying non-empty past its yield ceiling
* (OpusStreamDecoder) and the scheduler parking on a mid-stream underrun (PlaybackScheduler).
*/
report(): void {
const t = this.now();
this.lastStressMs = t;
this.stressTimestamps.push(t);
this.prune(t);
}
/**
* Whether decode is under sustained pressure right now. Pure read for the caller, but it ADVANCES
* the hysteresis latch (engage on sustained stress, release on sustained quiet past the min dwell)
* — so the transition is evaluated lazily on the clock, identical whether called once or per frame.
*/
isUnderPressure(): boolean {
const t = this.now();
this.prune(t);
if (this.engaged) {
const engagedFor = t - this.engagedAtMs;
const quietFor = t - this.lastStressMs;
if (engagedFor >= MIN_ENGAGED_MS && quietFor >= RELEASE_QUIET_MS) {
this.engaged = false;
}
} else if (this.stressTimestamps.length >= ENGAGE_EVENTS) {
this.engaged = true;
this.engagedAtMs = t;
}
return this.engaged;
}
/** Drop stress timestamps older than the engage window so the count reflects only the live window. */
private prune(t: number): void {
const cutoff = t - ENGAGE_WINDOW_MS;
while (this.stressTimestamps.length > 0 && this.stressTimestamps[0] < cutoff) {
this.stressTimestamps.shift();
}
}
}
/** The process-wide signal both the audio pipeline and the visualizer share. */
export const decodePressure = new DecodePressureSignal();