import AudioTimeout from "./timeout.mjs"; import EventEmitterMixin from "../../common/utils/event-emitter.mjs"; /** * @typedef {import("./_types.mjs").SoundPlaybackOptions} SoundPlaybackOptions * @typedef {import("./_types.mjs").SoundScheduleCallback} SoundScheduleCallback */ /** * A container around an AudioNode which manages sound playback in Foundry Virtual Tabletop. * Each Sound is either an AudioBufferSourceNode (for short sources) or a MediaElementAudioSourceNode (for long ones). * This class provides an interface around both types which allows standardized control over playback. * @alias foundry.audio.Sound * @see {EventEmitterMixin} */ export default class Sound extends EventEmitterMixin(Object) { /** * Construct a Sound by providing the source URL and other options. * @param {string} src The audio source path, either a relative path or a remote URL * @param {object} [options] Additional options which configure the Sound * @param {AudioContext} [options.context] A non-default audio context within which the sound should play * @param {boolean} [options.forceBuffer] Force use of an AudioBufferSourceNode even if the audio duration is long */ constructor(src, {context, forceBuffer=false}={}) { super(); Object.defineProperties(this, { id: {value: Sound.#nodeId++, writable: false, enumerable: true, configurable: false}, src: {value: src, writable: false, enumerable: true, configurable: false} }); this.#context = context || game.audio.music; this.#forceBuffer = forceBuffer; } /** * The sequence of container loading states. * @enum {Readonly} */ static STATES = Object.freeze({ FAILED: -1, NONE: 0, LOADING: 1, LOADED: 2, STARTING: 3, PLAYING: 4, PAUSED: 5, STOPPING: 6, STOPPED: 7 }); /** * The maximum duration, in seconds, for which an AudioBufferSourceNode will be used. * Otherwise, a MediaElementAudioSourceNode will be used. * @type {number} */ static MAX_BUFFER_DURATION = 10 * 60; // 10 Minutes /** * An incrementing counter used to assign each Sound a unique id. * @type {number} */ static #nodeId = 0; /** @override */ static emittedEvents = ["load", "play", "pause", "end", "stop"]; /** * A unique integer identifier for this sound. * @type {number} */ id; /** * The audio source path. * Either a relative path served by the running Foundry VTT game server or a remote URL. * @type {string} */ src; /** * The audio context within which this Sound is played. * @type {AudioContext} */ get context() { return this.#context; } #context; /** * When this Sound uses an AudioBuffer, this is an AudioBufferSourceNode. * @type {AudioBufferSourceNode} */ #bufferNode; /** * When this Sound uses an HTML Audio stream, this is a MediaElementAudioSourceNode. * @type {MediaElementAudioSourceNode} */ #mediaNode; /** * The AudioSourceNode used to control sound playback. * @type {AudioBufferSourceNode|MediaElementAudioSourceNode} */ get sourceNode() { return this.#bufferNode || this.#mediaNode; } /** * The GainNode used to control volume for this sound. * @type {GainNode} */ gainNode; /** * An AudioBuffer instance, if this Sound uses an AudioBufferSourceNode for playback. * @type {AudioBuffer|null} */ buffer = null; /** * An HTMLAudioElement, if this Sound uses a MediaElementAudioSourceNode for playback. * @type {HTMLAudioElement|null} */ element = null; /** * Playback configuration options specified at the time that Sound#play is called. * @type {SoundPlaybackOptions} */ #playback = { delay: 0, duration: undefined, fade: 0, loop: false, loopStart: 0, loopEnd: undefined, offset: 0, onended: null, volume: 1.0 }; /** * Force usage of an AudioBufferSourceNode regardless of audio duration? * @type {boolean} */ #forceBuffer = false; /** * The life-cycle state of the sound. * @see {Sound.STATES} * @type {number} * @protected */ _state = Sound.STATES.NONE; /** * Has the audio file been loaded either fully or for streaming. * @type {boolean} */ get loaded() { if ( this._state < Sound.STATES.LOADED ) return false; return !!(this.buffer || this.element); } /** * Did the audio file fail to load. * @type {boolean} */ get failed() { return this._state === Sound.STATES.FAILED; } /** * Is this sound currently playing? * @type {boolean} */ get playing() { return (this._state === Sound.STATES.STARTING) || (this._state === Sound.STATES.PLAYING); } /** * Does this Sound use an AudioBufferSourceNode? * Otherwise, the Sound uses a streamed MediaElementAudioSourceNode. * @type {boolean} */ get isBuffer() { return !!this.buffer && (this.sourceNode instanceof AudioBufferSourceNode); } /** * A convenience reference to the GainNode gain audio parameter. * @type {AudioParam} */ get gain() { return this.gainNode?.gain; } /** * The AudioNode destination which is the output target for the Sound. * @type {AudioNode} */ destination; /** * Record the pipeline of nodes currently used by this Sound. * @type {AudioNode[]} */ #pipeline = []; /** * A pipeline of AudioNode instances to be applied to Sound playback. * @type {AudioNode[]} */ effects = []; /** * The currently playing volume of the sound. * Undefined until playback has started for the first time. * @type {number} */ get volume() { return this.gain?.value; } set volume(value) { if ( !this.gainNode || !Number.isFinite(value) ) return; const ct = this.#context.currentTime; this.gain.cancelScheduledValues(ct); this.gain.value = value; this.gain.setValueAtTime(value, ct); // Immediately schedule the new value } /** * The time in seconds at which playback was started. * @type {number} */ startTime; /** * The time in seconds at which playback was paused. * @type {number} */ pausedTime; /** * The total duration of the audio source in seconds. * @type {number} */ get duration() { if ( this._state < Sound.STATES.LOADED ) return undefined; if ( this.buffer ) { const {loop, loopStart, loopEnd} = this.#playback; if ( loop && Number.isFinite(loopStart) && Number.isFinite(loopEnd) ) return loopEnd - loopStart; return this.buffer.duration; } return this.element?.duration; } /** * The current playback time of the sound. * @type {number} */ get currentTime() { if ( !this.playing ) return undefined; if ( this.pausedTime ) return this.pausedTime; let time = this.#context.currentTime - this.startTime; if ( Number.isFinite(this.duration) ) time %= this.duration; return time; } /** * Is the sound looping? * @type {boolean} */ get loop() { return this.#playback.loop; } set loop(value) { const loop = this.#playback.loop = Boolean(value); if ( this.#bufferNode ) this.#bufferNode.loop = loop; else if ( this.element ) this.element.loop = loop; } /** * A set of scheduled events orchestrated using the Sound#schedule function. * @type {Set} */ #scheduledEvents = new Set(); /** * An operation in progress on the sound which must be queued. * @type {Promise} */ #operation; /** * A delay timeout before the sound starts or stops. * @type {AudioTimeout} */ #delay; /** * An internal reference to some object which is managing this Sound instance. * @type {Object|null} * @internal */ _manager = null; /* -------------------------------------------- */ /* Life-Cycle Methods */ /* -------------------------------------------- */ /** * Load the audio source and prepare it for playback, either using an AudioBuffer or a streamed HTMLAudioElement. * @param {object} [options={}] Additional options which affect resource loading * @param {boolean} [options.autoplay=false] Automatically begin playback of the sound once loaded * @param {SoundPlaybackOptions} [options.autoplayOptions] Playback options passed to Sound#play, if autoplay * @returns {Promise} A Promise which resolves to the Sound once it is loaded */ async load({autoplay=false, autoplayOptions={}}={}) { const {STATES} = Sound; // Await audio unlock if ( game.audio.locked ) { game.audio.debug(`Delaying load of sound "${this.src}" until after first user gesture`); await game.audio.unlock; } // Wait for another ongoing operation if ( this.#operation ) { await this.#operation; return this.load({autoplay, autoplayOptions}); } // Queue loading if ( !this.loaded ) { this._state = STATES.LOADING; this.#context ||= game.audio.music; try { this.#operation = this._load(); await this.#operation; this._state = STATES.LOADED; this.dispatchEvent(new Event("load")); } catch(err) { console.error(err); this._state = STATES.FAILED; } finally { this.#operation = undefined; } } // Autoplay after load if ( autoplay && !this.failed && !this.playing ) { // noinspection ES6MissingAwait this.play(autoplayOptions); } return this; } /* -------------------------------------------- */ /** * An inner method which handles loading so that it can be de-duplicated under a single shared Promise resolution. * This method is factored out to allow for subclasses to override loading behavior. * @returns {Promise} A Promise which resolves once the sound is loaded * @throws {Error} An error if loading failed for any reason * @protected */ async _load() { // Attempt to load a cached AudioBuffer this.buffer = game.audio.buffers.getBuffer(this.src) || null; this.element = null; // Otherwise, load the audio as an HTML5 audio element to learn its playback duration if ( !this.buffer ) { const element = await this.#createAudioElement(); const isShort = (element?.duration || Infinity) <= Sound.MAX_BUFFER_DURATION; // For short sounds create and cache the audio buffer and use an AudioBufferSourceNode if ( isShort || this.#forceBuffer ) { this.buffer = await this.#createAudioBuffer(); game.audio.buffers.setBuffer(this.src, this.buffer); Sound.#unloadAudioElement(element); } else this.element = element; } } /* -------------------------------------------- */ /** * Begin playback for the Sound. * This method is asynchronous because playback may not start until after an initially provided delay. * The Promise resolves *before* the fade-in of any configured volume transition. * @param {SoundPlaybackOptions} [options] Options which configure the beginning of sound playback * @returns {Promise} A Promise which resolves once playback has started (excluding fade) */ async play(options={}) { // Signal our intention to start immediately const {STATES} = Sound; if ( ![STATES.LOADED, STATES.PAUSED, STATES.STOPPED].includes(this._state) ) return this; this._state = STATES.STARTING; // Wait for another ongoing operation if ( this.#operation ) { await this.#operation; return this.play(options); } // Configure options if ( typeof options === "number" ) { options = {offset: options}; if ( arguments[1] instanceof Function ) options.onended = arguments[1]; foundry.utils.logCompatibilityWarning("Sound#play now takes an object of playback options instead of " + "positional arguments.", {since: 12, until: 14}); } // Queue playback try { this.#operation = this.#queuePlay(options); await this.#operation; this._state = STATES.PLAYING; } finally { this.#operation = undefined; } return this; } /* -------------------------------------------- */ /** * An inner method that is wrapped in an enqueued promise. See {@link Sound#play}. */ async #queuePlay(options={}) { // Configure playback this.#configurePlayback(options); const {delay, fade, offset, volume} = this.#playback; // Create the audio pipeline including gainNode and sourceNode used for playback this._createNodes(); this._connectPipeline(); // Delay playback start if ( delay ) { await this.wait(delay * 1000); if ( this._state !== Sound.STATES.STARTING ) return; // We may no longer be starting if the delay was cancelled } // Begin playback this._play(); // Record state change this.startTime = this.#context.currentTime - offset; this.pausedTime = undefined; // Set initial volume this.volume = fade ? 0 : volume; if ( fade ) this.fade(volume, {duration: fade}); this.#onStart(); } /* -------------------------------------------- */ /** * Begin playback for the configured pipeline and playback options. * This method is factored out so that subclass implementations of Sound can implement alternative behavior. * @protected */ _play() { const {loop, loopStart, loopEnd, offset, duration} = this.#playback; if ( this.buffer ) { this.#bufferNode.loop = loop; if ( loop && Number.isFinite(loopStart) && Number.isFinite(loopEnd) ) { this.#bufferNode.loopStart = loopStart; this.#bufferNode.loopEnd = loopEnd; } this.#bufferNode.onended = this.#onEnd.bind(this); this.#bufferNode.start(0, offset, duration); } else if ( this.element ) { this.element.loop = loop; this.element.currentTime = offset; this.element.onended = this.#onEnd.bind(this); this.element.play(); } game.audio.debug(`Beginning playback of Sound "${this.src}"`); } /* -------------------------------------------- */ /** * Pause playback of the Sound. * For AudioBufferSourceNode this stops playback after recording the current time. * Calling Sound#play will resume playback from the pausedTime unless some other offset is passed. * For a MediaElementAudioSourceNode this simply calls the HTMLAudioElement#pause method directly. */ pause() { const {STATES} = Sound; if ( this._state !== STATES.PLAYING ) { throw new Error("You may only call Sound#pause for a Sound which is PLAYING"); } this._pause(); this.pausedTime = this.currentTime; this._state = STATES.PAUSED; this.#onPause(); } /* -------------------------------------------- */ /** * Pause playback of the Sound. * This method is factored out so that subclass implementations of Sound can implement alternative behavior. * @protected */ _pause() { if ( this.isBuffer ) { this.#bufferNode.onended = undefined; this.#bufferNode.stop(0); } else this.element.pause(); game.audio.debug(`Pausing playback of Sound "${this.src}"`); } /* -------------------------------------------- */ /** * Stop playback for the Sound. * This method is asynchronous because playback may not stop until after an initially provided delay. * The Promise resolves *after* the fade-out of any configured volume transition. * @param {SoundPlaybackOptions} [options] Options which configure the stopping of sound playback * @returns {Promise} A Promise which resolves once playback is fully stopped (including fade) */ async stop(options={}) { // Signal our intention to stop immediately if ( !this.playing ) return this; this._state = Sound.STATES.STOPPING; this.#delay?.cancel(); // Wait for another operation to conclude if ( this.#operation ) { await this.#operation; return this.stop(options); } // Queue stop try { this.#operation = this.#queueStop(options); await this.#operation; this._state = Sound.STATES.STOPPED; } finally { this.#operation = undefined; } return this; } /* -------------------------------------------- */ /** * An inner method that is wrapped in an enqueued promise. See {@link Sound#stop}. */ async #queueStop(options) { // Immediately disconnect the onended callback if ( this.#bufferNode ) this.#bufferNode.onended = undefined; if ( this.#mediaNode ) this.element.onended = undefined; // Configure playback settings this.#configurePlayback(options); const {delay, fade, volume} = this.#playback; // Fade out if ( fade ) await this.fade(volume, {duration: fade}); else this.volume = volume; // Stop playback if ( delay ) { await this.wait(delay * 1000); if ( this._state !== Sound.STATES.STOPPING ) return; // We may no longer be stopping if the delay was cancelled } this._stop(); // Disconnect the audio pipeline this._disconnectPipeline(); // Record state changes this.#bufferNode = this.#mediaNode = undefined; this.startTime = this.pausedTime = undefined; this.#onStop(); } /* -------------------------------------------- */ /** * Stop playback of the Sound. * This method is factored out so that subclass implementations of Sound can implement alternative behavior. * @protected */ _stop() { this.gain.cancelScheduledValues(this.context.currentTime); if ( this.buffer && this.sourceNode && (this._state === Sound.STATES.PLAYING) ) this.#bufferNode.stop(0); else if ( this.element ) { Sound.#unloadAudioElement(this.element); this.element = null; } game.audio.debug(`Stopping playback of Sound "${this.src}"`); } /* -------------------------------------------- */ /** * Fade the volume for this sound between its current level and a desired target volume. * @param {number} volume The desired target volume level between 0 and 1 * @param {object} [options={}] Additional options that configure the fade operation * @param {number} [options.duration=1000] The duration of the fade effect in milliseconds * @param {number} [options.from] A volume level to start from, the current volume by default * @param {string} [options.type=linear] The type of fade easing, "linear" or "exponential" * @returns {Promise} A Promise that resolves after the requested fade duration */ async fade(volume, {duration=1000, from, type="linear"}={}) { if ( !this.gain ) return; const ramp = this.gain[`${type}RampToValueAtTime`]; if ( !ramp ) throw new Error(`Invalid fade type ${type} requested`); // Cancel any other ongoing transitions const startTime = this.#context.currentTime; this.gain.cancelScheduledValues(startTime); // Immediately schedule the starting volume from ??= this.gain.value; this.gain.setValueAtTime(from, startTime); // Ramp to target volume ramp.call(this.gain, volume, startTime + (duration / 1000)); // Wait for the transition if ( volume !== from ) await this.wait(duration); } /* -------------------------------------------- */ /** * Wait a certain scheduled duration within this sound's own AudioContext. * @param {number} duration The duration to wait in milliseconds * @returns {Promise} A promise which resolves after the waited duration */ async wait(duration) { this.#delay = new AudioTimeout(duration, {context: this.#context}); await this.#delay.complete; this.#delay = undefined; } /* -------------------------------------------- */ /** * Schedule a function to occur at the next occurrence of a specific playbackTime for this Sound. * @param {SoundScheduleCallback} fn A function that will be called with this Sound as its single argument * @param {number} playbackTime The desired playback time at which the function should be called * @returns {Promise} A Promise which resolves to the returned value of the provided function once * it has been evaluated. * * @example Schedule audio playback changes * ```js * sound.schedule(() => console.log("Do something exactly 30 seconds into the track"), 30); * sound.schedule(() => console.log("Do something next time the track loops back to the beginning"), 0); * sound.schedule(() => console.log("Do something 5 seconds before the end of the track"), sound.duration - 5); * ``` */ async schedule(fn, playbackTime) { // Determine the amount of time until the next occurrence of playbackTime const {currentTime, duration} = this; playbackTime = Math.clamp(playbackTime, 0, duration); if ( this.#playback.loop && Number.isFinite(duration) ) { while ( playbackTime < currentTime ) playbackTime += duration; } const deltaMS = Math.max(0, (playbackTime - currentTime) * 1000); // Wait for an AudioTimeout completion then invoke the scheduled function const timeout = new AudioTimeout(deltaMS, {context: this.#context}); this.#scheduledEvents.add(timeout); try { await timeout.complete; return fn(this); } catch {} finally { this.#scheduledEvents.delete(timeout); } } /* -------------------------------------------- */ /** * Update the array of effects applied to a Sound instance. * Optionally a new array of effects can be assigned. If no effects are passed, the current effects are re-applied. * @param {AudioNode[]} [effects] An array of AudioNode effects to apply */ applyEffects(effects) { if ( Array.isArray(effects) ) this.effects = effects; this._disconnectPipeline(); this._connectPipeline(); game.audio.debug(`Applied effects to Sound "${this.src}": ${this.effects.map(e => e.constructor.name)}`); } /* -------------------------------------------- */ /* Playback Events */ /* -------------------------------------------- */ /** * Additional workflows when playback of the Sound begins. */ #onStart() { game.audio.playing.set(this.id, this); // Track playing sounds this.dispatchEvent(new Event("play")); } /* -------------------------------------------- */ /** * Additional workflows when playback of the Sound is paused. */ #onPause() { this.#cancelScheduledEvents(); this.dispatchEvent(new Event("pause")); } /* -------------------------------------------- */ /** * Additional workflows when playback of the Sound concludes. * This is called by the AudioNode#onended callback. */ async #onEnd() { await this.stop(); this.#playback.onended?.(this); this.dispatchEvent(new Event("end")); } /* -------------------------------------------- */ /** * Additional workflows when playback of the Sound is stopped, either manually or by concluding its playback. */ #onStop() { game.audio.playing.delete(this.id); this.#cancelScheduledEvents(); this.dispatchEvent(new Event("stop")); } /* -------------------------------------------- */ /* Helper Methods */ /* -------------------------------------------- */ /** * Create an HTML5 Audio element which has loaded the metadata for the provided source. * @returns {Promise} A created HTML Audio element * @throws {Error} An error if audio element creation failed */ async #createAudioElement() { game.audio.debug(`Loading audio element "${this.src}"`); return new Promise((resolve, reject) => { const element = new Audio(); element.autoplay = false; element.crossOrigin = "anonymous"; element.preload = "metadata"; element.onloadedmetadata = () => resolve(element); element.onerror = () => reject(`Failed to load audio element "${this.src}"`); element.src = this.src; }); } /* -------------------------------------------- */ /** * Ensure to safely unload a media stream * @param {HTMLAudioElement} element The audio element to unload */ static #unloadAudioElement(element) { element.onended = undefined; element.pause(); element.src = ""; element.remove(); } /* -------------------------------------------- */ /** * Load an audio file and decode it to create an AudioBuffer. * @returns {Promise} A created AudioBuffer * @throws {Error} An error if buffer creation failed */ async #createAudioBuffer() { game.audio.debug(`Loading audio buffer "${this.src}"`); try { const response = await foundry.utils.fetchWithTimeout(this.src); const arrayBuffer = await response.arrayBuffer(); return this.#context.decodeAudioData(arrayBuffer); } catch(err) { err.message = `Failed to load audio buffer "${this.src}"`; throw err; } } /* -------------------------------------------- */ /** * Create any AudioNode instances required for playback of this Sound. * @protected */ _createNodes() { this.gainNode ||= this.#context.createGain(); this.destination ||= (this.#context.gainNode ?? this.#context.destination); // Prefer a context gain if present const {buffer, element: mediaElement} = this; if ( buffer ) this.#bufferNode = new AudioBufferSourceNode(this.#context, {buffer}); else if ( mediaElement ) this.#mediaNode = new MediaElementAudioSourceNode(this.#context, {mediaElement}); } /* -------------------------------------------- */ /** * Create the audio pipeline used to play this Sound. * The GainNode is reused each time to link volume changes across multiple playbacks. * The AudioSourceNode is re-created every time that Sound#play is called. * @protected */ _connectPipeline() { if ( !this.sourceNode ) return; this.#pipeline.length = 0; // Start with the sourceNode let node = this.sourceNode; this.#pipeline.push(node); // Connect effect nodes for ( const effect of this.effects ) { node.connect(effect); effect.onConnectFrom?.(node); // Special behavior to inform the effect node it has been connected node = effect; this.#pipeline.push(effect); } // End with the gainNode node.connect(this.gainNode); this.#pipeline.push(this.gainNode); this.gainNode.connect(this.destination); } /* -------------------------------------------- */ /** * Disconnect the audio pipeline once playback is stopped. * Walk backwards along the Sound##pipeline from the Sound#destination, disconnecting each node. * @protected */ _disconnectPipeline() { for ( let i=this.#pipeline.length-1; i>=0; i-- ) { const node = this.#pipeline[i]; node.disconnect(); } } /* -------------------------------------------- */ /** * Configure playback parameters for the Sound. * @param {SoundPlaybackOptions} Provided playback options */ #configurePlayback({delay, duration, fade, loop, loopStart, loopEnd, offset, onended, volume}={}) { // Some playback options only update if they are explicitly passed this.#playback.loop = loop ?? this.#playback.loop; this.#playback.loopStart = loopStart ?? this.#playback.loopStart; this.#playback.loopEnd = loopEnd ?? this.#playback.loopEnd; this.#playback.volume = volume ?? this.#playback.volume; this.#playback.onended = onended !== undefined ? onended : this.#playback.onended; // Determine playback offset and duration timing const loopTime = (this.#playback.loopEnd ?? Infinity) - this.#playback.loopStart; // Starting offset offset ??= this.#playback.loopStart; if ( Number.isFinite(this.pausedTime) ) offset += this.pausedTime; // Loop forever if ( this.#playback.loop ) duration ??= undefined; // Play once else if ( Number.isFinite(loopTime) ) { offset = Math.clamp(offset, this.#playback.loopStart, this.#playback.loopEnd); duration ??= loopTime; duration = Math.min(duration, loopTime); } // Some playback options reset unless they are explicitly passed this.#playback.delay = delay ?? 0; this.#playback.offset = offset; this.#playback.duration = duration; this.#playback.fade = fade ?? 0; } /* -------------------------------------------- */ /** * Cancel any scheduled events which have not yet occurred. */ #cancelScheduledEvents() { for ( const timeout of this.#scheduledEvents ) timeout.cancel(); this.#scheduledEvents.clear(); } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ static get LOAD_STATES() { foundry.utils.logCompatibilityWarning("AudioContainer.LOAD_STATES is deprecated in favor of Sound.STATES", {since: 12, until: 14}); return this.STATES; } /** * @deprecated since v12 * @ignore */ get loadState() { foundry.utils.logCompatibilityWarning("AudioContainer#loadState is deprecated in favor of Sound#_state", {since: 12, until: 14}); return this._state; } /** * @deprecated since v12 * @ignore */ get container() { foundry.utils.logCompatibilityWarning("Sound#container is deprecated without replacement because the Sound and " + "AudioContainer classes are now merged", {since: 12, until: 14}); return this; } /** * @deprecated since v12 * @ignore */ get node() { foundry.utils.logCompatibilityWarning("Sound#node is renamed Sound#sourceNode", {since: 12, until: 14}); return this.sourceNode; } /** * @deprecated since v12 * @ignore */ on(eventName, fn, {once=false}={}) { foundry.utils.logCompatibilityWarning("Sound#on is deprecated in favor of Sound#addEventListener", {since: 12, until: 14}); return this.addEventListener(eventName, fn, {once}); } /** * @deprecated since v12 * @ignore */ off(eventName, fn) { foundry.utils.logCompatibilityWarning("Sound#off is deprecated in favor of Sound#removeEventListener", {since: 12, until: 14}); return this.removeEventListener(eventName, fn); } /** * @deprecated since v12 * @ignore */ emit(eventName) { foundry.utils.logCompatibilityWarning("Sound#emit is deprecated in favor of Sound#dispatchEvent", {since: 12, until: 14}); const event = new Event(eventName, {cancelable: true}); return this.dispatchEvent(event); } }