/** * @typedef {Object} AudioTimeoutOptions * @property {AudioContext} [context] * @property {function(): any} [callback] */ /** * A special error class used for cancellation. */ class AudioTimeoutCancellation extends Error {} /** * A framework for scheduled audio events with more precise and synchronized timing than using window.setTimeout. * This approach creates an empty audio buffer of the desired duration played using the shared game audio context. * The onended event of the AudioBufferSourceNode provides a very precise way to synchronize audio events. * For audio timing, this is preferable because it avoids numerous issues with window.setTimeout. * * @example Using a callback function * ```js * function playForDuration(sound, duration) { * sound.play(); * const wait = new AudioTimeout(duration, {callback: () => sound.stop()}) * } * ``` * * @example Using an awaited Promise * ```js * async function playForDuration(sound, duration) { * sound.play(); * const timeout = new AudioTimeout(delay); * await timeout.complete; * sound.stop(); * } * ``` * * @example Using the wait helper * ```js * async function playForDuration(sound, duration) { * sound.play(); * await AudioTimeout.wait(duration); * sound.stop(); * } * ``` */ export default class AudioTimeout { /** * Create an AudioTimeout by providing a delay and callback. * @param {number} delayMS A desired delay timing in milliseconds * @param {AudioTimeoutOptions} [options] Additional options which modify timeout behavior */ constructor(delayMS, {callback, context}={}) { if ( !(typeof delayMS === "number") ) throw new Error("Numeric timeout duration must be provided"); this.#callback = callback; this.complete = new Promise((resolve, reject) => { this.#resolve = resolve; this.#reject = reject; // Immediately evaluated if ( delayMS <= 0 ) return this.end(); // Create and play a blank AudioBuffer of the desired delay duration context ||= game.audio.music; const seconds = delayMS / 1000; const sampleRate = context.sampleRate; const buffer = new AudioBuffer({length: seconds * sampleRate, sampleRate}); this.#sourceNode = new AudioBufferSourceNode(context, {buffer}); this.#sourceNode.onended = this.end.bind(this); this.#sourceNode.start(); }) // The promise may get cancelled .catch(err => { if ( err instanceof AudioTimeoutCancellation ) return; throw err; }); } /** * Is the timeout complete? * This can be used to await the completion of the AudioTimeout if necessary. * The Promise resolves to the returned value of the provided callback function. * @type {Promise<*>} */ complete; /** * The resolution function for the wrapping Promise. * @type {Function} */ #resolve; /** * The rejection function for the wrapping Promise. * @type {Function} */ #reject; /** * A scheduled callback function * @type {Function} */ #callback; /** * The source node used to maintain the timeout * @type {AudioBufferSourceNode} */ #sourceNode; /* -------------------------------------------- */ /** * Cancel an AudioTimeout by ending it early, rejecting its completion promise, and skipping any callback function. */ cancel() { if ( !this.#reject ) return; const reject = this.#reject; this.#resolve = this.#reject = undefined; reject(new AudioTimeoutCancellation("AudioTimeout cancelled")); this.#sourceNode.onended = null; this.#sourceNode.stop(); } /* -------------------------------------------- */ /** * End the timeout, either on schedule or prematurely. Executing any callback function */ end() { const resolve = this.#resolve; this.#resolve = this.#reject = undefined; resolve(this.#callback?.()); } /* -------------------------------------------- */ /** * Schedule a task according to some audio timeout. * @param {number} delayMS A desired delay timing in milliseconds * @param {AudioTimeoutOptions} [options] Additional options which modify timeout behavior * @returns {Promise} A promise which resolves as a returned value of the callback or void */ static async wait(delayMS, options) { const timeout = new this(delayMS, options); return timeout.complete; } }