/** * 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();