audio.js


"use strict"

/** Audio for use with gameify. Usually you'll access this through the gameify object.
 * @example // Use audio via gameify
 * // This is the most common way
 * import { gameify } from "./gameify/gameify.js"
 * let myScreen = new gameify.Screen(document.querySelector("#my-canvas"), 600, 400);
 * let mySound = new gameify.audio.Sound("my-sound.mp3");
 * myScreen.audio.add(mySound);
 * @example // Import just audio
 * import { audio } from "./gameify/audio.js"
 * let audioManager = new audio.AudioManager();
 * let mySound = new audio.Sound("my-sound.mp3");
 * audioManager.add(mySound);
 */
export let audio = {
    /** An audio manager. Controlls multiple sounds at once.
     * Screen objects come with an AudioManager (Screen.audio), but you can
     * create your own for more fine-grained control. This allows you to easily
     * control the volume of different parts of your game independently, i.e.
     * seperate SFX, menu, and gameplay sound sliders.
     * @alias gameify.audio.AudioManager
     * @constructor
     * @example import { gameify } from "./gameify/gameify.js"
     * const myAudioManager = new gameify.audio.AudioManager();
     * const mySound = new gameify.audio.Sound("my-sound.mp3");
     * myAudioManager.add(mySound);
    */
    AudioManager: function () {
        /** Sounds this AudioManager controlls. Don't modify this - Use add() and remove() to add/remove sounds.
         * AudioManagers are set to 20% volume by default, to protect your ears.
         * @readonly
         */
        this.sounds = [];

        this._volume = 0.2;

        /** Adjust the volume of all controlled sounds
         * @param {Number} volume - The volume modifier, from 0 to 1
         */
        this.setVolume = (volume) => {
            this._volume = volume;
            this.sounds.forEach(sound => {
                // Sounds take their AudioManager's volume into account
                // When setting the volume, so we simply use their setVolume
                // method.
                sound.setVolume(sound.getVolume());
            });
        }

        /** Get the volume of the AudioManager. Note that this volume is mixed
         * with the volume of each sound. Use Sound.getCalculatedVolume to get
         * the actual volume of each sound.
         * @returns {Number}
         */
        this.getVolume = () => {
            return this._volume;
        }

        /** Add a sound to the AudioManager
         * @param {gameify.audio.Sound} sound - The sound to add
         */
        this.add = (sound) => {
            this.sounds.push(sound);
            sound.audioManager = this;
            sound.setVolume(sound.getVolume());
        }
    },

    /** Creates a sound that can be played
     * @alias gameify.audio.Sound
     * @constructor
     * @example let mySound = new gameify.audio.Sound("my-sound.mp3");
     * @arg {String} path - The path to the sound file
     */
    Sound: class {
        constructor(path) {
            this.path = path;
            this.changePath(path);
        }

        /** The sound path. Modifying this will not do anything.
         * @readonly
         */
        path;
        audioManager = undefined;
        /** If the sound has loaded yet
         * @readonly
         */
        loaded = false;
        audio = undefined;
        
        #volume = 1;

        /** Creates a object from JSON data
         * @method
         * @arg {Object|Array} data - Serialized object data (from object.toJSON)
         * @arg {Function} ref - A function that returns a name for other objects, so they can be restored later
         * @returns {gameify.Image}
         */
        static fromJSON = (data, find) => {
            if (Array.isArray(data)) {
                // Be backwards compatible
                console.warn('Save is using the old (de)serialization format for Sound.');
                const obj = new audio.Sound(data[0]);
                obj.setLoop(data[1]);
                obj.setVolume(data[2]);
                return obj;
            }

            const obj = new audio.Sound(data.path);
            obj.setLoop(data.loop);
            obj.setVolume(data.volume);
            return obj;
        }
        
        /** Convert the object to JSON
         * @method
         * @arg {string} [key] - Key object is stored under (unused, here for consistency with e.g. Date.toJSON, etc.)
         * @arg {function} ref - A function that returns a name for other objects, so they can be restored later
         * @returns {Object}
         */
        toJSON = (key, ref) => {
            return {
                path: this.path,
                loop: this.getLoop(),
                volume: this.getVolume()
            };
        }

        /** Check if the sound currently is playing
         * @returns {Boolean}
         * @method
         */
        isPlaying = () => {
            return !this.audio.paused;
        }

        /** Play the sound. Please be aware some browsers block autoplay by default.
         * @method
         */
        play = () => {
            if (!this.audioManager) {
                throw new Error('You need to add this song to an AudioManager (i.e. myScreen.audio.add(mySound))');
            }
            this.audio.play();
        }

        /** Pause the sound
         * @method
         */
        pause = () => {
            this.audio.pause();
        }

        /** Stop the sound (resets the seek time)
         * @method
         */
        stop = () => {
            this.pause();
            this.seek(0);
        }

        /** Choose whether the sound should loop or not (default is not looping)
         * @method
         * @param {Boolean} loop - If the sound should loop
         */
        setLoop = (loop) => {
            this.audio.loop = loop;
        }
        /** Check if the sound is set to loop
         * @method
         * @return {Boolean} If the sound is set to loop
         */
        getLoop = () => {
            return this.audio.loop;
        }

        /** Get the duration of the audio (in seconds)
         * @method
         * @return {Number} The duration of the sound in seconds
         */
        getDuration = () => {
            return this.audio.duration;
        }

        /** Get the current seek time of the sound
         * @method
         * @return {Number} The current seek time, in seconds
         */
        getCurrentTime = () => {
            return this.audio.currentTime;
        }

        /** Seek to a specific point in the audio
         * @method
         * @param {Number} time - The time to seek to, in seconds
         */
        seek = (time) => {
            this.audio.currentTime = time;
        }

        /** Seek a percentage of the way through the audio
         * @method
         * @param {Number} time - Where to seek to, from 0 to 1
         */
        seekRelative = (time) => {
            this.seek(this.getDuration() * time);
        }

        /** Sets the volume of the sound
         * @method
         * @param {Number} volume - The desired volume level, a number between 0 and 1.
         */
        setVolume = (volume) => {
            if (volume < 0 || volume > 1) {
                console.warn('Volume should be between 0 and 1');
            }
            this.#volume = Math.max(0, Math.min(1, volume));
            const nv = this.getCalculatedVolume();
            this.audio.volume = nv;
            if (nv == 0) this.audio.muted = true;
            else this.audio.muted = false;
        }
        /** Get the volume of the sound. Note that this volume is mixed with the
         * audioManager's volume to get the actual volume. Use getCalculatedVolume()
         * to get the actual volume the sound will be played at.
         * @see {gameify.audio.Sound.getCalculatedVolume}
         * @method
         * @return {Number} The volume of the sound, between 0 and 1
         */
        getVolume = () => {
            return this.#volume;
        }
        /** Get the calculated volume of the sound (after audioManager volume is applied)
         * @method
         * @return {Number} The calculated volume of the sound, between 0 and 1
         */
        getCalculatedVolume = () => {
            let vol = this.audioManager?.getVolume();
            if (vol === undefined) vol = .2 // Use .2 default when no audioManager is set
            return this.#volume * vol;
        }

        /** Change and load a new image path. Resets the image's crop
         * @method
         * @param {string} path - The new image path
         */
        changePath = (path) => {
            this.path = path;
            if (path !== undefined) {
                this.audio = document.createElement('audio');
                this.audio.src = path;
                this.audio.oncanplaythrough = () => {
                    this.loaded = true;
                    if (this.loadFunction) { this.loadFunction(); }
                }
            }
        }

        /** Set a function to be run when the sound is loaded (Can be played through w/o buffering)
         * @method
         * @param {Function} callback - The function to be called when the sound is loaded.
         */
        onLoad = (callback) => {
            this.loadFunction = callback;
        }
    }
}