@firstcoders/hls-web-audio
Version:
166 lines (137 loc) • 4.48 kB
JavaScript
class Segment {
#sourceNode;
#arrayBuffer;
#audioBuffer;
#cacheClearTimeout;
/**
* @param {Object} param - The params
* @param {Object} param.src - The src url
* @param {Object} param.duration - The duration
* @param {Object} param.fetchOptions - Options to use when fetching the hls/m3u8
*/
constructor({ src, duration, fetchOptions = {} }) {
this.src = src;
this.duration = duration;
this.fetchOptions = fetchOptions;
}
destroy() {
// if we're loading currently, cancel
this.cancel();
// disconnect any connected audio nodes
if (this.isReady) this.disconnect();
// cleanup
this.#arrayBuffer = null;
this.#audioBuffer = null;
// this.#sourceNode = null; // reference is cleared on disconnect
}
load() {
// dont retry fetch requests that previously failed
// TODO allow injecting fetchRetry (do not implement retry logic in here)
if (this.fetchFailed) return { promise: Promise.reject(new Error('Fetch failed')) };
const abortController = new AbortController();
const promise = fetch(this.src, {
signal: abortController.signal,
...this.fetchOptions,
})
.then(async (r) => {
// store the audio data
this.#arrayBuffer = await r.arrayBuffer();
})
.catch((err) => {
if (err.name !== 'AbortError') {
// place a signpost so that repeated calls to `load` (due to a ticking clock) won't try and try again
this.fetchFailed = true;
}
// rethrow
// note we also rethrow AbortError as the promise must fail in this case so that the caller can handle it
throw err;
})
.finally(() => {
// unset signpost
this.loading = false;
// remove reference to promise
this.loadHandle = undefined;
});
// store reference to promise
this.loadHandle = {
promise,
cancel: () => abortController.abort(),
};
return this.loadHandle;
}
async connect({ destination, ac, start, offset, stop }) {
if (this.#sourceNode) throw new Error('Cannot connect a segment twice');
if (this.#cacheClearTimeout) clearTimeout(this.#cacheClearTimeout);
if (!this.#audioBuffer) {
if (!this.#arrayBuffer) throw new Error('Cannot connect. No audio data in buffer.');
this.#audioBuffer = await ac.decodeAudioData(this.#arrayBuffer);
}
// We no longer need the raw data, clear up memory
this.#arrayBuffer = null;
// update the expected duration (from m3u8 file) with the real duration from the decoded audio
this.duration = this.#audioBuffer.duration;
this.#sourceNode = ac.createBufferSource();
this.#sourceNode.buffer = this.#audioBuffer;
this.#sourceNode.connect(destination);
this.#sourceNode.onended = (e) =>
setTimeout(() => {
this.disconnect(e);
}, 0);
this.#sourceNode.start(start, offset);
this.#sourceNode.stop(stop);
}
disconnect() {
const sourceNode = this.#sourceNode;
if (sourceNode) {
sourceNode.disconnect();
sourceNode.stop();
// Important for memory management. Clearing onended removes any references to the node.
sourceNode.onended = () => {};
// some browsers (e.g. edge) don't like nulling the buffer
try {
sourceNode.buffer = null;
} catch (ex) {
// ignore
}
// remove reference
this.#sourceNode = null;
// schedule the cleanup of the cache
// we dont do this immediately so that if the sement is re-scheduled soon after it can benefit
// from an already decoded audio buffer. However we do need to clean it eventually for memory management.
this.#cacheClearTimeout = setTimeout(() => {
this.#audioBuffer = undefined;
}, 10000);
}
}
/**
* Whether the segment is ready for playback
*
* @returns {Boolean}
*/
get isReady() {
return !!this.#sourceNode;
}
/**
* Cancel any inflight xhr request
*/
cancel() {
// cancel any in-flight request
if (this.loadHandle) this.loadHandle.cancel();
this.loadHandle = null;
}
/**
* Get the end time for this segment
*
* @returns {Number}
*/
get end() {
return this.start !== undefined ? this.start + this.duration : undefined;
}
/**
* Whether the sement has audio data that is loaded
*/
get isLoaded() {
return !!this.#audioBuffer;
}
}
export default Segment;