@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.
241 lines (204 loc) • 7.36 kB
JavaScript
/**
* Schedules segment loading and connection around the current playback timeframe.
*/
export default class TrackScheduler {
#scheduleNotBefore;
#timeoutId;
/**
* @param {import('./Track.js').default} track
* @param {import('./Stack.js').default} stack
*/
constructor(track, stack) {
this.track = track;
this.stack = stack;
}
/**
* Clears scheduling state and disconnects all segments.
*/
reset() {
this.stack.disconnectAll();
this.#scheduleNotBefore = undefined;
if (this.#timeoutId) {
clearTimeout(this.#timeoutId);
this.#timeoutId = null;
}
}
/**
* Runs a scheduling pass for the current timeframe.
*
* @param {import('../core/Timeframe.js').default} timeframe
* @param {boolean} [force]
* @returns {Promise<void>}
*/
async runSchedulePass(timeframe, force) {
if (this.#timeoutId) {
clearTimeout(this.#timeoutId);
this.#timeoutId = null;
}
if (force) this.#scheduleNotBefore = undefined;
if (this.#scheduleNotBefore !== undefined && timeframe.currentTime < this.#scheduleNotBefore) {
this.#queueNextPass(timeframe);
return;
}
const currentSegment = this.stack.getAt(timeframe.currentTime);
this.evictOldCaches(currentSegment);
const segments = this.getNextSegments(timeframe, currentSegment);
if (!segments.length) {
this.#queueNextPass(timeframe);
return;
}
// Immediately mark them as in-transit to prevent concurrent runSchedulePass calls
// (triggered by rapid ticks) from picking up the same segments before the loop reaches them.
segments.forEach((segment) => {
segment.$inTransit = true;
});
await Promise.all(
segments.map(async (segment) => {
// Re-check just in case they were ready'd or disconnected externally
if (!segment.isReady) {
await this.scheduleAt(timeframe, segment);
} else {
segment.$inTransit = false;
}
}),
);
this.#queueNextPass(timeframe);
}
/**
* Queues the next scheduler pass based on the current scheduling boundary.
*
* @param {import('../core/Timeframe.js').default} timeframe
* @private
*/
#queueNextPass(timeframe) {
// Guard against being called after the track has been destroyed (controller or ac may
// be null after destroy()).
if (!this.track.controller?.ac) return;
// If nothing is scheduled yet, we still need a recovery heartbeat while the audio
// context is not running (e.g. suspended during buffering after an aborted scheduleAt).
// Without this, the scheduler goes completely idle and nothing ever re-triggers loading.
if (this.#scheduleNotBefore === undefined) {
if (this.track.controller.ac.state !== 'running') {
this.#timeoutId = setTimeout(() => {
if (!this.track.controller?.ac) return;
this.runSchedulePass(this.track.controller.currentTimeframe, true);
}, 500);
}
return;
}
// We want to run slightly before the scheduled boundary to give networking a headstart,
// though the lookahead loop handles 10 seconds ahead anyway.
let waitMs = (this.#scheduleNotBefore - timeframe.currentTime) * 1000;
// We enforce a minimum safe wait time so it doesn't spin wildly,
// but caps out to pause/background safeties.
if (waitMs < 0 || Number.isNaN(waitMs)) waitMs = 0;
// We only wait a maximum of 1000ms while paused, just to ensure if the state
// changes beneath us the scheduler will eventually catch up and re-sync.
if (this.track.controller.ac.state !== 'running') {
waitMs = Math.min(waitMs, 1000);
}
// Minimum 10ms boundary
waitMs = Math.max(10, waitMs);
this.#timeoutId = setTimeout(() => {
// Re-read current timeframe to get actual current time, rather than the cached one
if (!this.track.controller?.ac) return;
this.runSchedulePass(this.track.controller.currentTimeframe);
}, waitMs);
}
/**
* Ensures a specific segment is loaded and connected for the active timeframe.
*
* @param {import('../core/Timeframe.js').default} timeframe
* @param {any} segment
* @returns {Promise<void>}
*/
async scheduleAt(timeframe, segment) {
try {
this.track.controller?.notify('loading-start', this.track);
// Let scheduleAt strictly rely on the caller setting it, but re-assert just in case
segment.$inTransit = true;
// load the segment
if (!segment.isLoaded) await segment.load().promise;
const start = timeframe.calculateRealStart(segment);
const offset = timeframe.calculateOffset(segment);
const stop = timeframe.realEnd;
// connect it to the audio
await segment.connect({
ac: this.track.controller.ac,
destination: this.track.gainNode,
start,
offset,
stop,
});
this.#scheduleNotBefore = segment.end - segment.duration / 2;
this.stack.recalculateStartTimes(segment);
} catch (err) {
if (err.name !== 'AbortError') {
this.track.controller?.notify('error', err);
}
} finally {
segment.$inTransit = false;
this.track.controller?.notify('loading-end', this.track);
}
}
/**
* Returns segments that should be loaded within the scheduler lookahead window.
*
* @param {import('../core/Timeframe.js').default} timeframe
* @param {any} currentSegment
* @returns {Array<any>}
*/
getNextSegments(timeframe, currentSegment) {
if (!currentSegment) return [];
const segments = [];
const LOOKAHEAD_DURATION_SECONDS = 10;
let segment = currentSegment;
let accumulatedLookahead = 0;
while (segment && accumulatedLookahead < LOOKAHEAD_DURATION_SECONDS) {
// If we crossed the timeframe boundary, wrap around or stop
if (segment.start >= timeframe.end) {
break; // Stop looking once outside of the buffering window
}
if (!segment.$inTransit && !segment.isReady && !segments.includes(segment)) {
segments.push(segment);
}
// Increment our window by the duration (relative), measuring only what's left for the current segment
const segmentDurationLeft =
segment === currentSegment
? Math.max(0, segment.end - timeframe.currentTime)
: segment.duration;
accumulatedLookahead += segmentDurationLeft;
segment = segment.next;
}
return segments;
}
/**
* Unloads decoded audio caches outside the immediate playback window.
*
* @param {any} currentSegment
*/
evictOldCaches(currentSegment) {
if (!currentSegment) return;
// Fast eviction. We only attempt to clean up bounds exactly slightly outside the target play window.
const evictQueue = [];
let lookbehind = currentSegment;
let i = 0;
while (i < 4) {
if (lookbehind) lookbehind = lookbehind.prev;
i += 1;
}
if (lookbehind) evictQueue.push(lookbehind);
let lookahead = currentSegment;
i = 0;
while (i < 4) {
if (lookahead) lookahead = lookahead.next;
i += 1;
}
if (lookahead) evictQueue.push(lookahead);
evictQueue.forEach((segment) => {
if (segment && segment.isLoaded && !segment.isReady) {
segment.unloadCache();
}
});
}
}