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.

180 lines (155 loc) 5.54 kB
/** * Drives play, pause, buffering, and the periodic playback engine tick. */ export default class PlaybackEngine { /** * @param {import('./AudioController.js').default} controller */ constructor(controller) { this.controller = controller; this.isBuffering = false; this.desiredState = 'suspended'; this.tEngineNext = null; } /** * Resumes the audio context when playback is allowed and emits the start event. * * @returns {Promise<void>} */ async play() { this.desiredState = 'resumed'; if (typeof this.controller.duration !== 'number') throw new Error('Cannot play before loading content'); if (this.isBuffering) throw new Error('The player is buffering'); if (this.controller.ac.state === 'suspended') { await this.controller.ac.resume(); } if (typeof this.controller.timeline.anchor !== 'number') { this.controller.timeline.fixAnchor(this.controller.offset); } this.controller.fireEvent('start'); } /** * Suspends the audio context and emits the pause event. * * @returns {Promise<void>} */ async pause() { this.desiredState = 'suspended'; if (this.controller.ac.state !== 'suspended') await this.controller.ac.suspend(); this.controller.fireEvent('pause'); } /** * Restarts the playback engine tick loop. */ tick() { this.untick(); // The logic tick: check bounds and buffering gracefully this._engineTick(); } /** * Performs a single playback engine cycle and schedules the next wake-up. * * @private */ _engineTick() { if (this.tEngineNext) clearTimeout(this.tEngineNext); this.tEngineNext = null; const t = this.controller.currentTime; const endBound = this.controller.offset + this.controller.playDuration; // Determine if we need to buffer because the current playback segments have dried up const needsBuffering = this.controller.tracks.some((track) => !track.shouldAndCanPlay); // Boundary Enforcement: // This replaces the old getter-mutations in PlaybackTimeline.currentTime if (t !== undefined) { if (t < this.controller.offset) { this.controller.timeline.fixAnchor(this.controller.offset); this.tEngineNext = setTimeout(() => this._engineTick(), 10); return; } // If we crossed the boundary OR if we are very close to it and the audio ran out // (due to e.g. floating point precision ending a segment early), we just wrap/end immediately to avoid deadlocking. const isEffectivelyAtEnd = t >= endBound || (endBound - t < 0.15 && needsBuffering); if (isEffectivelyAtEnd) { if (this.controller.loop) { this.controller.timeline.fixAnchor(this.controller.offset); this.tEngineNext = setTimeout(() => this._engineTick(), 10); return; } this.controller.end(); return; } } if (needsBuffering && !this.isBuffering) { this.bufferingStart(); } if (needsBuffering) { // On every buffering poll tick, ensure the scheduler is actively trying to load the // missing segment. The $inTransit guard inside the scheduler prevents double-scheduling; // this only makes a real difference when the scheduler has gone idle (e.g. a previous // scheduleAt was aborted mid-flight and left no retry timeout behind). this.controller.tracks.forEach((track) => { if (typeof track.runSchedulePass === 'function') track.runSchedulePass(true); }); } else if (!needsBuffering && this.isBuffering) { this.bufferingEnd(); } // Determine when the engine should wake up next. if (this.controller.ac.state === 'running' || this.isBuffering) { let waitMs = 250; if (!this.isBuffering) { // calculate time until the end of the timeline let timeToNextCheck = this.controller.offset + this.controller.playDuration - t; // OR until the current ready segments end this.controller.tracks.forEach((track) => { const seg = track.stack.getAt(t); if (seg && seg.isReady) { const timeToSegEnd = seg.end - t; if (timeToSegEnd < timeToNextCheck) { timeToNextCheck = timeToSegEnd; } } }); waitMs = timeToNextCheck * 1000 - 10; } else { // If buffering, poll reasonably fast to unpause immediately when data arrives waitMs = 100; } waitMs = Math.max(50, waitMs); this.tEngineNext = setTimeout(() => this._engineTick(), waitMs); } } /** * Cancels any pending engine wake-up. */ untick() { if (this.tEngineNext) clearTimeout(this.tEngineNext); this.tEngineNext = null; } /** * Marks playback as buffering and suspends the audio context if needed. */ bufferingStart() { this.controller.fireEvent('pause-start'); this.isBuffering = true; if (this.controller.ac.state === 'running') this.controller.ac.suspend(); } /** * Ends buffering and resumes playback when playback is still desired. */ bufferingEnd() { if (this.desiredState === 'resumed') this.controller.ac.resume(); this.isBuffering = false; this.controller.fireEvent('pause-end'); } /** * Resets playback back to a suspended, unanchored state. * * @returns {Promise<void>} */ async reset() { await this.pause(); this.controller.timeline.anchor = undefined; this.desiredState = 'suspended'; } }