94 lines
4.5 KiB
TypeScript
94 lines
4.5 KiB
TypeScript
/**
|
|
* 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();
|