UNPKG

@firstcoders/hls-web-audio

Version:

`@firstcoders/hls-web-audio` plays multiple streamed audio tracks in sync using the Web Audio API. It is designed for stem-like playback where each track can be independently loaded, buffered, scheduled, and mixed.

214 lines (159 loc) 8.26 kB
# Scheduling Mechanics This document describes how `hls-web-audio` schedules audio segments into the Web Audio API without polling, how buffering recovery works, and how each component participates in that flow. ## Design Goal All scheduling computation happens at precomputed, event-driven moments — not on a fixed polling interval. The scheduler calculates when it will next be needed, arms a single timeout, and sleeps until then. --- ## Key Roles | Class | Role | | ------------------ | --------------------------------------------------------------------------------------------------------------- | | `PlaybackTimeline` | Tracks the playhead anchor (`anchor`) and computes `currentTime` as a pure read | | `Timeframe` | Snapshot of timing parameters used for Web Audio scheduling math; owns the anchor calculation via `setAnchor()` | | `PlaybackEngine` | Monitors readiness; enforces loop and boundary transitions; triggers buffering recovery | | `TrackScheduler` | Per-track lookahead scheduler; manages the precomputed wake-up boundary | | `Stack` | Doubly linked list of segments; fast traversal around the current playhead position | | `AudioSegment` | Segment lifecycle: load buffer → decode → connect source node → disconnect | --- ## Time Anchor (`anchor`) Because `AudioContext.currentTime` ticks continuously and cannot be reset, we tie playhead position to the global clock via an anchor: ``` currentTime = AudioContext.currentTime - anchor ``` `anchor` is set once at play start and reset on every seek or loop wrap: ```js // Timeframe.setAnchor(contextTime, trackTime) this.anchor = contextTime - trackTime; ``` `PlaybackTimeline.currentTime` is a **pure read** — it never mutates state or fires events. All boundary enforcement (loop wrapping, offset underrun) happens inside `PlaybackEngine._engineTick()`, which is the only place the anchor is intentionally reset. --- ## PlaybackEngine — Engine Tick `_engineTick()` is **not** a polling loop. It runs in two situations: 1. The `AudioContext` fires a `statechange` event (e.g. transitions from `suspended` to `running` on `play()`). 2. A `setTimeout` armed by the previous tick fires at a precomputed moment. Each tick: 1. Reads `currentTime`. 2. Enforces boundaries (offset underrun resets anchor to `offset`; loop end resets anchor to `offset` and reschedules; non-loop end fires `end`). 3. Checks whether all tracks have a ready segment at the playhead (`shouldAndCanPlay`). 4. Transitions buffering state if needed. 5. Computes when the next check is due: - **Not buffering:** `(end of the soonest currently-playing segment) - now`, minus 10ms to give the scheduler a head start. - **Buffering:** every 100ms to detect when data arrives. - Never less than 50ms. Importantly, the engine tick **does not** directly schedule or load segments. It delegates that solely to `TrackScheduler` via `track.runSchedulePass(true)`. --- ## TrackScheduler — Lookahead Scheduling `runSchedulePass(timeframe, force?)` is the entry point. It: 1. Cancels any pending `#timeoutId`. 2. Clears `#scheduleNotBefore` if `force` is true (e.g. after a seek or region change). 3. Returns early (and re-arms the next pass) if `currentTime` has not yet reached `#scheduleNotBefore`. 4. Finds the current segment in the `Stack` via `getAt(currentTime)`. 5. Calls `getNextSegments()` to build a lookahead window up to 10 seconds ahead. 6. Marks candidates `$inTransit` atomically to prevent duplicate work from concurrent calls. 7. Awaits `scheduleAt()` for each candidate. 8. Calls `#queueNextPass()` to arm the next self-wakeup. ### Scheduling Frequency `runSchedulePass` fires at exactly these moments: | Trigger | Source | Meaning | | ----------------------------------- | ----------------------- | ---------------------------- | | `'start'` event | `Track.eStart` listener | User pressed play | | `'seek'` event | `Track.onSeek()` | User seeked | | `'offset'` / `'playDuration'` event | `Track.#reset()` | Region changed | | `#queueNextPass` self-timer | Internal | Precomputed boundary reached | | Buffering recovery via engine tick | `PlaybackEngine` | Aborted schedule was retried | It does **not** run on a fixed interval during normal playback. The self-timer is the only recurring call, and its interval is computed per pass. ### Lookahead Window and `#scheduleNotBefore` After successfully connecting segment `S`, the scheduler sets: ```js this.#scheduleNotBefore = S.end - S.duration / 2; ``` This means: "don't bother running again until we are halfway through `S`." `#queueNextPass` converts this into a `setTimeout`: ```js waitMs = (#scheduleNotBefore - currentTime) * 1000; ``` For a 5-second HLS segment this is roughly a 2.5-second sleepnot a tight loop. While the audio context is suspended (paused or buffering), `waitMs` is capped at 1000ms so the scheduler eventually re-syncs if state changes beneath it. ### Recovery Heartbeat If `#scheduleNotBefore` is `undefined` (i.e. no segment has been successfully scheduled yet, or the last `scheduleAt` was aborted before setting it) **and** the audio context is not running, `#queueNextPass` arms a 500ms recovery timeout: ```js if (this.#scheduleNotBefore === undefined) { if (this.track.controller.ac.state !== 'running') { this.#timeoutId = setTimeout(() => { this.runSchedulePass(..., true); // forced }, 500); } return; } ``` Without this, an `AbortError` during `scheduleAt` (e.g. from a rapid seek while a connection was in flight) would leave the scheduler permanently idle. The engine's buffering poll eventually calls `runSchedulePass(true)` on every tick, but the heartbeat provides a tighter safety net inside the scheduler itself. --- ## `scheduleAt` — Connecting a Segment ``` load (if not cached) → decode → connect source node into audio graph ``` Timing parameters are derived from `Timeframe`: ```js start = timeframe.calculateRealStart(segment); // anchor + segment.start offset = timeframe.calculateOffset(segment); // currentTime - segment.start, clamped to 0 stop = timeframe.realEnd; // anchor + offset + playDuration ``` A `Symbol` connection guard (`$currentConnection`) prevents stale in-flight connections from completing after the segment has been disconnected. If the symbol doesn't match when decoding finishes, the operation throws `AbortError` and is silently ignored. --- ## Seek and Region Changes On any seek (`fixAnchor`): 1. The anchor is reset to `contextTime - seekTarget` via `Timeframe.setAnchor`. 2. A `'seek'` event is fired with the requested `t`, `pct`, and `remaining`. 3. `Track.onSeek()` calls `stack.disconnectAll(timeframe)` then `runSchedulePass(force=true)`. `disconnectAll` has a proximity heuristic: for in-transit segments within 15 seconds of the new `currentTime`, it preserves the network fetch (no `cancel()`) but still calls `disconnect()` to invalidate any stale in-flight `connect()` call and clears `$inTransit`. This means the segment will be re-scheduled with fresh timeframe parameters against the same already-downloaded buffer. --- ## Buffering Lifecycle ``` track.shouldAndCanPlay = false → engine enters buffering → ac.suspend() → 'pause-start' event fires → engine polls every 100ms → on each poll: track.runSchedulePass(true) is called → scheduler loads + connects the missing segment → shouldAndCanPlay = true for all tracks → ac.resume() → 'pause-end' event fires → engine resumes normal scheduling ticks ``` The buffering poll drives recovery; the scheduler's heartbeat is a fallback for the case where the scheduler is idle and the engine's `runSchedulePass` call itself was aborted mid-flight.