/** * AudioRenderer - Web Audio API based audio playback with precise clock synchronization * Uses AudioContext as the master clock for A/V sync (60Hz smooth playback) */ import type { PCMFrame } from "../decode/SoftwareAudioDecoder"; export declare class AudioRenderer { private audioContext; private inputNode; /** A tap for drawing the sound, not for hearing it — see getLevels. Built * only when something asks, and hung off the gain node in parallel, so it * never sits between the audio and the speakers. */ private _analyser; private _analyserBins; private gainNode; private compressorNode; private scheduledTime; private isPlaying; private volume; private _playbackRate; private activeSources; /** * What each live source was made from, and when it is due. * * A scheduled source is a finished thing — post-stretch PCM with a start time * the audio thread already owns — and there is no reading back out of it. A * rate change needs to do exactly that: everything scheduled ahead of the * playhead is at the old rate and has to go, and up to SCHEDULE_LOOKAHEAD * seconds of media goes with it unless the buffer it was made from can be put * back. Keyed weakly so a source that has played out is collectable with its * entry. */ private sourceOrigin; private _muted; private droppedWhileSuspended; private firstBufferScheduledAt; /** * The context time this RUN of sound starts at — the moment the picture has * to wait out before the first of it reaches the speakers. See * secondsUntilAudible(). * * Not the same thing as firstBufferScheduledAt, which is the anchor the * media clock's arithmetic hangs off and is RE-BASED whenever that * arithmetic needs it to be — most importantly by a speed change, which * moves it to "now" so the old rate's elapsed time is not read at the new * one. Nothing about the sound restarts there; it is playing, and has been. * Reading that re-based anchor as a run start answers "how long until it is * audible" with the device's whole output latency, which on Bluetooth is a * quarter of a second of picture parked at every speed change. */ private soundRunStartedAt; private firstBufferMediaTime; private hasFirstBuffer; private _lastUnderrunAt; private _ducked; /** Output held silent across the unmute re-read — see silenceForResync. */ private _resyncSilenced; private static readonly DUCK_FADE_OUT; private static readonly DUCK_FADE_IN; private static readonly DUCK_CLEAR_MS; /** How often the duck re-asks whether it can lift — see duckForUnderrun. */ private static readonly DUCK_POLL_MS; private _duckReleaseTimer; private _suppressDuckUntil; private currentMediaTime; private maxScheduledMediaTime; /** * The sound is over, and no more of it is coming. * * getAudioClock() clamps to maxScheduledMediaTime so a runaway clock cannot * outrun what has been scheduled. That is right while more audio is on its * way and wrong once there is none: an audio track SHORTER than the picture * leaves the clock parked at the last sample for good, and everything that * follows it — the master clock and the renderer's own presentation timing * both read this — stops with it. Measured on a 4K AV1 MKV whose Opus track * ends at 193.4s against a picture running to 243.8s: playback stopped dead * at 3:13 with fifty seconds of video left. * * Once the player says the stream has ended AND the head has reached it, * report no clock at all. Both readers already treat a negative time as * "time this yourself", which is exactly right: there is no sound left to * sync to, and the picture should play out on its own. */ private _streamEnded; /** * A media-time origin that takes effect at a CONTEXT time still in the * future — how a gapless loop turns the corner. * * The audio clock is `firstBufferMediaTime + elapsed-since-the-anchor`, so * moving the origin back to zero for the next pass cannot be done when that * pass is SCHEDULED: its samples are queued a cushion ahead of the ones * being heard, and re-anchoring then would drop the clock to zero while the * end of the previous pass is still coming out of the speakers. * * So the new origin is armed with the context time its first sample begins * at, and getAudioClock()/getCurrentTime() adopt it once the output has * actually reached that point. Before the seam they read the old pass, after * it the new one, and the samples themselves were never interrupted. */ private _pendingAnchor; /** * A loop pass the player has started reading, whose first buffer has not * arrived yet. * * The seam used to be armed at `scheduledTime` the moment the pass began. * That is only where the old pass ends when nothing of it is still waiting — * and buffers DO wait: they sit in `_pending` as data until they come within * the lookahead, so on a file with a soundtrack a second of the old pass was * still to be scheduled behind that point, and the clock wrapped to zero a * second before the sound did. * * So the seam is found in the audio itself. The first buffer whose timestamp * jumps back is where the new pass begins; it is marked on the way in, and * the anchor is armed with the context time it is actually started at. */ private _loopArm; /** * The furthest media time scheduled for the NEXT pass while the old one is * still being heard. Kept apart because the clock clamps to * maxScheduledMediaTime, and letting the new pass's small numbers in early * would pin the old pass's clock at them. */ private _nextPassMaxMediaTime; /** Called by the player when the demuxer has no more audio to give. */ endOfStream(): void; /** Has the sound already been let go for this run? */ isStreamEnded(): boolean; /** Fresh audio is coming (a seek, a new source): the sound is not over. */ clearEndOfStream(): void; private lastDecodeTime; private scheduledCount; private isRebufferingForRateChange; private preservePitch; private signalsmith; private signalsmithLoading; private signalsmithSampleRate; /** Pending background build of the stretcher — see warmStretcherSoon(). */ private stretcherWarmTimer; private _decodedSampleRate; private _stableAudio; /** The state the GRAPH is actually wired for, so a repeated request for the * state we are already in doesn't tear the chain apart to rebuild it * identically — see setStableAudio. */ private _wiredStableAudio; private _sinkId; private static readonly GAIN_RAMP_TIME; /** …and how slowly a level nobody asked for is walked back, which wants to * be gentle rather than quick: it is a correction to a mistake, not a * response to the viewer, and it must not read as a jump. */ private static readonly GAIN_RESTORE_TIME; private static readonly FADE_OUT_TIME; private contextStateHandler; /** * Latches sharedContextActivated the moment the context reaches "running". * Separate from contextStateHandler on purpose: that one is stable audio's * recovery monitor and is only wired when stable audio is on, while "audio * got unlocked this session" is a fact every path needs — see init(). */ private contextActivationHandler; private recoveryAttempts; private static readonly MAX_RECOVERY_ATTEMPTS; private starvationStartTime; private isStarved; private static readonly STARVATION_THRESHOLD; private keepaliveEl; private keepaliveUrl; constructor(); /** * Lazily create a near-silent looping