systems/audio.system.js

import {CURVES, Curve, MATHEMATICS, Sound, Stage, System, UTILS, Vector2} from '../index.js';

/**
 * The threshold of the attenuation value change before taking it into account.
 * @type {number}
 * @constant
 * @private
 */
const $THRESHOLD_ATTENUATION = 1 / 100;

/**
 * The threshold of the panning value change before taking it into account.
 * @type {number}
 * @constant
 * @private
 */
const $THRESHOLD_PANNING = 1 / 100;

/**
 * Creates audio systems.
 *
 * @example
 *
 * const system = new SystemAudio();
 */
class SystemAudio extends System {

    /**
     * @typedef {object} TypeDataAudio An audio data.
     * @property {GainNode} $attenuation The attenuation gain node.
     * @property {AudioBufferSourceNode} $audio The audio buffer source node.
     * @property {StereoPannerNode} $panning The stereo panning node.
     * @property {number} $startTime The start time of the audio in the audio context timeline.
     * @property {GainNode} $volume The volume gain node.
     * @private
     */

    /**
     * Stores the safe delay before removing the audio context when the system terminates.
     * @type {number}
     * @public
     * @readonly
     * @static
     */
    static DELAY_CONTEXT_CLEAR_SAFE = 1000;

    /**
     * Stores the amount of samples needed for curves.
     * @type {number}
     * @public
     * @readonly
     * @static
     */
    static SAMPLES_CURVES = 128;

    /**
     * Stores the cache of the audio assets.
     * @type {Map<string, AudioBuffer>}
     * @private
     */
    $cacheAudios;

    /**
     * Stores the cache of the values for the fade-out curve.
     * @type {Map<number, Float32Array>}
     * @private
     */
    $cacheValuesCurveFadeOut;

    /**
     * Stores the audio context.
     * @type {AudioContext}
     * @private
     */
    $context;

    /**
     * Stores the mapping between the playing sounds and their audio data.
     * @type {Map<Sound, TypeDataAudio>}
     * @private
     */
    $mappingSoundsPlaying;

    /**
     * Creates a new audio system.
     */
    constructor() {

        super();
    }

    /**
     * Creates the values for the fade-out curve.
     * @param {number} $volume The volume of the sound.
     * @returns {Float32Array}
     * @private
     */
    $createValuesCurveFadeOut($volume) {

        if (this.$cacheValuesCurveFadeOut.has($volume) === false) {

            const values = new Curve(CURVES.invert(CURVES.easeOut(2)))
            .getValues(SystemAudio.SAMPLES_CURVES)
            .map(($value) => ($volume * $value));

            this.$cacheValuesCurveFadeOut.set($volume, new Float32Array(values));
        }

        return this.$cacheValuesCurveFadeOut.get($volume);
    }

    /**
     * Creates the values for the transition curve.
     * @param {number} $source The source value.
     * @param {number} $target The target value.
     * @returns {Float32Array}
     * @private
     */
    $createValuesCurveTransition($source, $target) {

        const values = new Curve(CURVES.easeInOut(2))
        .getValues(SystemAudio.SAMPLES_CURVES)
        .map(($value) => ($source + ($target - $source) * $value));

        return new Float32Array(values);
    }

    /**
     * Gets the attenuation value of the sound from its distance from the given point of view.
     * @param {Vector2} $audio The position of the audio source.
     * @param {Vector2} $pointOfView The position of the point of view.
     * @param {number} $radius The radius within which the sound is audible.
     * @returns {number}
     * @private
     */
    $getAttenuation($audio, $pointOfView, $radius) {

        return MATHEMATICS.clamp(1 - Vector2.distanceEuclidean($pointOfView, $audio) / $radius);
    }

    /**
     * Gets the panning value of the sound from its position from the given point of view.
     * @param {Vector2} $audio The position of the audio source.
     * @param {Vector2} $pointOfView The position of the point of view.
     * @param {number} $framing The framing width (rendering resolution width).
     * @returns {number}
     * @private
     */
    $getPanning($audio, $pointOfView, $framing) {

        return MATHEMATICS.clamp(($audio.x - $pointOfView.x) / $framing, -1, 1);
    }

    /**
     * Loads the audio from the given audio file content.
     * @param {Response} $content The audio file content.
     * @returns {Promise<AudioBuffer>}
     * @private
     */
    $loadAudio($content) {

        const promise = new Promise(($resolve) => {

            $content.arrayBuffer()
            .then(($bufferArray) => (this.$context.decodeAudioData($bufferArray)))
            .then(($bufferAudio) => {

                this.$cacheAudios.set($content.url, $bufferAudio);

                $resolve($bufferAudio);
            });
        });

        return promise;
    }

    /**
     * Prepares the audio from the given audio source.
     * @param {string} $audio The audio source.
     * @private
     */
    $prepareAudio($audio) {

        if (this.$cacheAudios.has($audio) === true) {

            return;
        }

        this.$cacheAudios.set($audio, undefined);

        fetch($audio)
        .then(($content) => (this.$loadAudio($content)));
    }

    /**
     * Terminates the given sound.
     * @param {Sound} $sound The sound to terminate.
     * @private
     */
    $terminateSound($sound) {

        const {$attenuation, $audio, $panning, $startTime, $volume} = this.$mappingSoundsPlaying.get($sound);

        if ($sound.loop === false
        && this.$context.currentTime > $startTime + Math.max(0, $audio.buffer.duration - ($sound.durationFadeOut / 1000))) {

            return;
        }

        $volume.gain.cancelScheduledValues(this.$context.currentTime);
        $volume.gain.setValueCurveAtTime(

            this.$createValuesCurveFadeOut($sound.volume),
            this.$context.currentTime,
            Math.min($audio.buffer.duration, $sound.durationFadeOut / 1000)
        );

        $audio.stop(this.$context.currentTime + Math.min($audio.buffer.duration, $sound.durationFadeOut / 1000));

        this.$mappingSoundsPlaying.delete($sound);

        $audio.onended = () => {

            $volume.disconnect();
            $attenuation.disconnect();
            $panning.disconnect();
            $audio.disconnect();
        };
    }

    /**
     * Checks if the system has loaded the given asset.
     * @param {string} $asset The asset source.
     * @returns {boolean}
     * @public
     */
    hasAssetLoaded($asset) {

        if (this.$initiated === false) {

            this.initiate();
        }

        return this.$cacheAudios.has($asset) === true;
    }

    /**
     * Loads the audio from the given audio file content.
     * @param {Response} $content The audio file content.
     * @returns {Promise<AudioBuffer>}
     * @public
     */
    loadAudio($content) {

        if (this.$initiated === false) {

            this.initiate();
        }

        if (this.$cacheAudios.has($content.url) === true) {

            const promise = new Promise(($resolve) => {

                const audio = this.$cacheAudios.get($content.url);

                $resolve(audio);
            });

            return promise;
        }

        this.$cacheAudios.set($content.url, undefined);

        return this.$loadAudio($content);
    }

    /**
     * Called when the system is being initiated.
     * @public
     */
    onInitiate() {

        this.$cacheAudios = new Map();
        this.$cacheValuesCurveFadeOut = new Map();
        this.$context = new AudioContext();
        this.$mappingSoundsPlaying = new Map();
    }

    /**
     * Called when the system is being terminated.
     * @returns {(undefined | Promise<void>)}
     * @public
     */
    onTerminate() {

        let delayFadeOut = 0;

        Array.from(this.$mappingSoundsPlaying.keys()).forEach(($sound) => {

            if ($sound.durationFadeOut > delayFadeOut) {

                delayFadeOut = $sound.durationFadeOut;
            }

            this.$terminateSound($sound);
        });

        const promise = new Promise(($resolve) => {

            const handler = () => {

                this.$context.close()
                .then(() => {

                    this.$context = undefined;

                    $resolve();
                });
            };

            window.setTimeout(handler, delayFadeOut + SystemAudio.DELAY_CONTEXT_CLEAR_SAFE);
        });

        return promise;
    }

    /**
     * Called when the system is being updated by one tick update.
     * @param {object} $parameters The given parameters.
     * @param {Stage} $parameters.$stage The stage on which to execute the system.
     * @param {number} $parameters.$timetick The tick duration (in ms).
     * @public
     */
    onTick({$stage, $timetick}) {

        /**
         * @type {Array<Sound>}
         */
        const previous = Array.from(this.$mappingSoundsPlaying.keys());

        $stage.actors.forEach(($actor) => {

            $actor.sounds.forEach(($sound) => {

                if (this.$mappingSoundsPlaying.has($sound) === true) {

                    UTILS.extract($sound, previous);

                    const {$attenuation, $panning} = this.$mappingSoundsPlaying.get($sound);

                    const positionAudio = $actor.translation;
                    const positionPointOfView = $stage.pointOfView.translation;

                    if ($sound.stereo === true) {

                        const panningSource = $panning.pan.value;
                        const panningTarget = this.$getPanning(positionAudio, positionPointOfView, $stage.engine.getBoundariesFromFraming().halfSize.x);
                        const deltaPanning = panningTarget - panningSource;

                        if (Math.abs(deltaPanning) >= $THRESHOLD_PANNING) {

                            $panning.pan.cancelScheduledValues(this.$context.currentTime);
                            $panning.pan.setValueCurveAtTime(

                                this.$createValuesCurveTransition(panningSource, panningTarget),
                                this.$context.currentTime,
                                $timetick / 1000
                            );
                        }
                    }

                    if ($sound.radius < Number.POSITIVE_INFINITY) {

                        const attenuationSource = $attenuation.gain.value;
                        const attenuationTarget = this.$getAttenuation(positionAudio, positionPointOfView, $sound.radius);
                        const deltaAttenuation = attenuationTarget - attenuationSource;

                        if (Math.abs(deltaAttenuation) >= $THRESHOLD_ATTENUATION) {

                            $attenuation.gain.cancelScheduledValues(this.$context.currentTime);
                            $attenuation.gain.setValueCurveAtTime(

                                this.$createValuesCurveTransition(attenuationSource, attenuationTarget),
                                this.$context.currentTime,
                                $timetick / 1000
                            );
                        }
                    }

                    return;
                }

                this.$prepareAudio($sound.audio);

                if (this.$cacheAudios.has($sound.audio) === false) {

                    return;
                }

                if (this.$cacheAudios.get($sound.audio) === undefined) {

                    return;
                }

                const bufferAudio = this.$cacheAudios.get($sound.audio);

                const audio = this.$context.createBufferSource();
                audio.buffer = bufferAudio;

                const panning = this.$context.createStereoPanner();
                panning.pan.value = 0;

                if ($sound.stereo === true) {

                    panning.pan.value = this.$getPanning($actor.translation, $stage.pointOfView.translation, $stage.engine.getBoundariesFromFraming().halfSize.x);
                }

                const attenuation = this.$context.createGain();
                attenuation.gain.value = 1;

                if ($sound.radius < Number.POSITIVE_INFINITY) {

                    attenuation.gain.value = this.$getAttenuation($actor.translation, $stage.pointOfView.translation, $sound.radius);
                }

                const volume = this.$context.createGain();
                volume.gain.value = $sound.volume;

                audio.connect(panning);
                panning.connect(attenuation);
                attenuation.connect(volume);
                volume.connect(this.$context.destination);

                const timestamp = performance.now();

                UTILS.ready().then(() => {

                    const offset = performance.now() - timestamp;
                    audio.start(0, offset / 1000);
                });

                this.$mappingSoundsPlaying.set($sound, {

                    $attenuation: attenuation,
                    $audio: audio,
                    $panning: panning,
                    $startTime: this.$context.currentTime,
                    $volume: volume
                });

                if ($sound.loop === true) {

                    audio.loop = true;

                    return;
                }

                volume.gain.setValueCurveAtTime(

                    this.$createValuesCurveFadeOut($sound.volume),
                    this.$context.currentTime + Math.max(0, audio.buffer.duration - ($sound.durationFadeOut / 1000)),
                    Math.min(audio.buffer.duration, $sound.durationFadeOut / 1000)
                );

                audio.onended = () => {

                    volume.disconnect();
                    attenuation.disconnect();
                    panning.disconnect();
                    audio.disconnect();

                    this.$mappingSoundsPlaying.delete($sound);

                    $actor.removeSound($sound);
                    $actor.onSoundFinish($sound);
                };
            });
        });

        previous.forEach(($sound) => {

            this.$terminateSound($sound);
        });
    }
}

export {

    SystemAudio
};

export default SystemAudio;