API Docs for: v0.1.0
Show:

File: src\audio\AudioPlayer.js

var AudioPlayer = require('./AudioPlayer'),
    EventEmitter = require('../utils/EventEmitter'),
    utils = require('../utils/utils'),
    inherit = require('../utils/inherit'),
    support = require('../utils/support');

/**
 * Grapefruit Audio API, provides an easy interface to use HTML5 Audio
 * The GF Audio API was based on [Howler.js](https://github.com/goldfire/howler.js)
 *
 * @class AudioPlayer
 * @extends Object
 * @uses EventEmitter
 * @constructor
 * @param manager {AudioManager} AudioManager instance for this audio player
 * @param audio {Object} The preloaded audio file object
 * @param audio.data {ArrayBuffer|Audio} The actual audio data
 * @param audio.webAudio {Boolean} Whether the file is using webAudio or not
 * @param audio.decoded {Boolean} Whether the data has been decoded yet or not
 * @param [settings] {Object} All the settings for this player instance
 * @param [settings.autoplay=false] {Boolean} Whether to automatically start playing the audio file
 * @param [settings.loop=false] {Boolean} Whether the audio should loop or not
 * @param [settings.pos3d] {Array<Number>} The 3d position of the audio to play in the form [x, y, z]
 * @param [settings.sprite] {Object} The audio sprite, if this audio clip has multiple sounds in it.
 *      This object is in the form `{ 'sound': [start, duration] }`, and you can use them with `.play('sound')`.
 */
var AudioPlayer = function(manager, audio, settings) {
    EventEmitter.call(this);

    /**
     * The source of the audio, the actual URL to load up
     *
     * @property src
     * @type String
     */
    this.src = '';

    /**
     * The game instance this player belongs to
     *
     * @property game
     * @type Game
     */
    this.game = manager.game;

    /**
     * The cache key that uniquely identifies this piece of audio
     *
     * @property key
     * @type String
     */
    this.key = audio.key;

    /**
     * Play the audio immediately after loading
     *
     * @property autoplay
     * @type Boolean
     * @default false
     */
    this.autoplay = false;

    /**
     * Override the format determined from the extension with this extension
     *
     * @property format
     * @type String
     * @default null
     */
    this.format = null;

    /**
     * Replay the audio immediately after finishing
     *
     * @property loop
     * @type Boolean
     * @default false
     */
    this.loop = false;

    /**
     * A 3D position where the audio should sound like it is coming from
     *
     * @property pos3d
     * @type Array<Number>
     * @default [0, 0, -0.5]
     */
    this.pos3d = [0, 0, -0.5];

    /**
     * A sound sprite that maps string keys to [start, duration] arrays. These can
     * be used to put multiple sound bits in one file, and play them separately
     *
     * @property sprite
     * @type Object
     * @default {}
     */
    this.sprite = {};

    /**
     * The volume of the audio player
     *
     * @property volume
     * @type Number
     * @default 1
     */
    Object.defineProperty(this, 'volume', {
        get: this.getVolume.bind(this),
        set: this.setVolume.bind(this)
    });

    /**
     * The preloaded audio file object
     *
     * @property _file
     * @type Object
     * @private
     */
    this._file = audio;

    /**
     * The current volume of the player
     *
     * @property _volume
     * @type Number
     * @private
     */
    this._volume = 1;

    /**
     * The full duration of the file to play
     *
     * @property _duration
     * @type Number
     * @private
     */
    this._duration = 0;

    /**
     * Has this player data been loaded?
     *
     * @property _loaded
     * @type Boolean
     * @private
     */
    this._loaded = false;

    /**
     * The manager of the player
     *
     * @property _volum_manager
     * @type AudioManager
     * @private
     */
    this._manager = manager;

    /**
     * Does the browser support WebAudio API
     *
     * @property _webAudio
     * @type Boolean
     * @private
     */
    this._webAudio = support.webAudio;

    /**
     * The actual player nodes, these are either WebAudio Nodes
     * or HTML5 Audio elements.
     *
     * @property _nodes
     * @type Array
     * @private
     */
    this._nodes = [];

    /**
     * Array of timeouts to track end events
     *
     * @property _onendTimer
     * @type Array
     * @private
     */
    this._onendTimer = [];

    //mixin user's settings
    utils.setValues(this, settings);

    if(this._webAudio) {
        this._setupAudioNode();
    }

    this._load();

    /**
     * Fired when the player is ready to play
     *
     * @event ready
     * @param source {String} The source URL that will be used as the audio source
     */

    /**
     * Fired when audio starts playing
     *
     * @event play
     * @param id {String} The id of the node that is used to play the audio
     */

    /**
     * Fired when audio is paused
     *
     * @event paused
     * @param id {String} The id of the node that is paused
     */

    /**
     * Fired when audio finishes playing
     *
     * @event end
     * @param id {String} The id of the node that has finished playing
     */
};

inherit(AudioPlayer, Object, {
    /**
     * Load the audio file for this player, this is called from the ctor
     * there is no reason to call it manually.
     *
     * @method _load
     * @return {AudioPlayer}
     * @private
     */
    _load: function() {
        var self = this,
            audio = this._file;

        //if using web audio, load up the buffer
        if(audio.webAudio) {
            //if not yet decoded, decode before loading the buffer
            if(!audio.decoded) {
                this._manager.ctx.decodeAudioData(audio.data, function(buffer) {
                    if(buffer) {
                        audio.data = buffer;
                        audio.decoded = true;
                        self._loadSoundBuffer(buffer);
                    }
                });
            } else {
                this._loadSoundBuffer(audio.data);
            }
        }
        //otherwise create some Audio nodes
        else {
            //create a new adio node
            var node = audio.data.cloneNode();
            this._nodes.push(node);

            //setup the audio node
            node._pos = 0;
            node.volume = this._manager.muted ? 0 : this._volume * this._manager.volume;

            //setup the event listener to start playing the sound when it has buffered
            this._duration = node.duration;

            //setup a default sprite
            this.sprite._default = [0, node.duration * 1000];

            //check if loaded
            if(!this._loaded) {
                this._loaded = true;
                this.emit('ready', this.src);
            }

            //if autoplay then start it
            if(this.autoplay) {
                this.play();
            }
        }

        return this;
    },
    /**
     * Play a sound from the current time (0 by default).
     *
     * @method play
     * @param [sprite] {String} Plays from the specified position in the sound sprite definition.
     * @param [callback] {Function} Returns the unique playback id for this sound instance.
     * @return {AudioPlayer} Returns itself.
     * @chainable
     */
    play: function(sprite, cb) {
        var self = this;

        if(typeof sprite === 'function') {
            cb = sprite;
            sprite = null;
        }

        //if no sprite specified, use default
        if(!sprite) {
            sprite = '_default';
        }

        //if we haven't loaded yet, wait until we do
        if(!this._loaded) {
            this.on('ready', function() {
                self.play(sprite, cb);
            });

            return this;
        }

        //if the sprite doesn't exist, play nothing
        if(!this.sprite[sprite]) {
            if(typeof cb === 'function') cb();
            return this;
        }

        //get an audio node to use to play
        this._inactiveNode(function(node) {
            var pos = node._pos > 0 ? node._pos : self.sprite[sprite][0] / 1000,
            duration = (self.sprite[sprite][1] / 1000) - node._pos,
            loop = (self.loop || self.sprite[sprite][2]),
            soundId = (typeof cb === 'string') ? cb : (Math.round(Date.now() * Math.random()) + ''),
            timerId;

            node._sprite = sprite;

            //after the audio finishes:
            (function(o) {
                timerId = setTimeout(function() {
                    //if looping restsart it
                    if(!self._webAudio && o.loop) {
                        self.stop(o.id, o.timer).play(o.sprite, o.id);
                    }

                    // set web audio node to paused
                    if(self._webAudio && !o.loop) {
                        self._nodeById(o.id).paused = true;
                    }

                    //end the track if it is HTML audio and a sprite
                    if(!self._webAudio && !o.loop) {
                        self.stop(o.id, o.timer);
                    }

                    //fire off the end event
                    self.emit('end', o.id);
                }, duration * 1000);

                //store the timer
                self._onendTimer.push(timerId);

                //remember which timer to kill
                o.timer = timerId;
            })({
                id: soundId,
                sprite: sprite,
                loop: loop
            });

            //setup webAudio functions
            if(self._webAudio) {
                //set the play id to this node and load into context
                node.id = soundId;
                node.paused = false;
                self._refreshBuffer([loop, pos, duration], soundId);
                self._playStart = self._manager.ctx.currentTime;
                node.gain.value = self._volume;

                if(typeof node.bufferSource.start === 'undefined') {
                    node.bufferSource.noteGrainOn(0, pos, duration);
                } else {
                    node.bufferSource.start(0, pos, duration);
                }
            } else {
                if(node.readyState === 4) {
                    node.id = soundId;
                    node.currentTime = pos;
                    node.muted = self._manager.muted;
                    node.volume = self._volume * self._manager.volume;
                    node.play();
                } else {
                    self._clearEndTimer(timerId);

                    (function() {
                        var sound = self,
                            playSpr = sprite,
                            fn = cb,
                            newNode = node;

                        var evt = function() {
                            sound.play(playSpr, fn);

                            //clear listener
                            newNode.removeEventListener('canplaythrough', evt, false);
                        };
                        newNode.addEventListener('canplaythrough', evt, false);
                    })();

                    return self;
                }
            }

            self.emit('play', soundId);

            if(typeof cb === 'function')
                cb(soundId);
        });

        return this;
    },
    /**
     * Pause playback and save the current position.
     *
     * @method pause
     * @param [id] {String} The play instance ID.
     * @param [timerId] {String} Clear the correct timeout ID.
     * @return {AudioPlayer} Returns itself.
     * @chainable
     */
    pause: function(id, timerId) {
        var self = this;

        //if we haven't loaded this sound yet, wait until we play it to pause it
        if(!this._loaded) {
            this.on('play', function() {
                self.play(id, timerId);
            });

            return this;
        }

        //clear the onend timer
        this._clearEndTimer(timerId || 0);

        var activeNode = id ? this._nodeById(id) : this._activeNode();
        if(activeNode) {
            if(this._webAudio) {
                //ensure the sound was created
                if(!activeNode.bufferSource)
                    return this;

                activeNode.paused = true;
                activeNode._pos += this._manager.ctx.currentTime - this._playStart;

                if(typeof activeNode.bufferSource.stop === 'undefined') {
                    activeNode.bufferSource.noteOff(0);
                } else {
                    activeNode.bufferSource.stop(0);
                }
            } else {
                activeNode._pos = activeNode.currentTime;
                activeNode.pause();
            }
        }

        this.emit('pause', activeNode ? activeNode.id : id);

        return this;
    },
    /**
     * Stop playback and reset to start.
     *
     * @method stop
     * @param [id] {String} The play instance ID.
     * @param [timerId] {String} Clear the correct timeout ID.
     * @return {AudioPlayer} Returns itself.
     * @chainable
     */
    stop: function(id, timerId) {
        var self = this;

        //if we haven't loaded this sound yet, wait until we play it to stop it
        if(!this._loaded) {
            this.on('play', function() {
                self.stop(id, timerId);
            });

            return this;
        }

        //clear onend timer
        this._clearEndTimer(timerId || 0);

        var activeNode = id ? this._nodeById(id) : this._activeNode();
        if(activeNode) {
            activeNode._pos = 0;

            if(this._webAudio) {
                if(!activeNode.bufferSource)
                    return this;

                activeNode.paused = true;

                if(typeof activeNode.bufferSource.stop === 'undefined') {
                    activeNode.bufferSource.noteOff(0);
                } else {
                    activeNode.bufferSource.stop(0);
                }
            } else {
                activeNode.pause();
                activeNode.currentTime = 0;
            }
        }

        return this;
    },
    /**
     * Mute this sound.
     *
     * @method mute
     * @param [id] {String} The play instance ID.
     * @return {AudioPlayer} Returns itself.
     * @chainable
     */
    mute: function(id) {
        return this.setMuted(true, id);
    },
    /**
     * Unmute this sound.
     *
     * @method unmute
     * @param [id] {String} The play instance ID.
     * @return {AudioPlayer} Returns itself.
     * @chainable
     */
    unmute: function(id) {
        return this.setMuted(false, id);
    },
    /**
     * Set the muted state of this sound.
     *
     * @method setMuted
     * @param muted {Boolean}
     * @param [id] {String} The play instance ID.
     * @return {AudioPlayer} Returns itself.
     * @chainable
     */
    setMuted: function(muted, id) {
        var self  = this;

        //if we haven't loaded this sound yet, wait until we play it to mute it
        if(!this._loaded) {
            this.on('play', function() {
                self.setMuted(muted, id);
            });

            return this;
        }

        var activeNode = id ? this._nodeById(id) : this._activeNode();
        if(activeNode) {
            if(this._webAudio) {
                activeNode.gain.value = muted ? 0 : this._volume;
            } else {
                activeNode.volume =  muted ? 0 : this._volume;
            }
        }

        return this;
    },
    /**
     * Set the position of playback.
     *
     * @method seek
     * @param pos {Number} The position to move current playback to.
     * @param [id] {String} The play instance ID.
     * @return {AudioPlayer} Returns itself.
     * @chainable
     */
    seek: function(pos, id) {
        var self = this;

        //if we haven't loaded this sound yet, wait until it is to seek it
        if(!this._loaded) {
            this.on('ready', function() {
                self.seek(pos, id);
            });

            return this;
        }

        //if position is < 0, or invalid, then set to 0
        if(!pos || pos < 0)
            pos = 0;

        var activeNode = id ? this._nodeById(id) : this._activeNode();
        if(activeNode) {
            if(this._webAudio) {
                activeNode._pos = pos;
                this.pause(activeNode.id).play(activeNode._sprite, id);
            } else {
                activeNode.currentTime = pos;
            }
        }

        return this;
    },
    /**
     * Get the position of playback.
     *
     * @method getPosition
     * @param [id] {String} The play instance ID.
     * @return {Number}
     */
    getPosition: function(id) {
        var self = this;

        //if we haven't loaded this sound yet, wait until it is to seek it
        if(!this._loaded) {
            this.on('ready', function() {
                self.getPosition(id);
            });

            return this;
        }

        var activeNode = id ? this._nodeById(id) : this._activeNode();
        if(activeNode) {
            if(this._webAudio) {
                return activeNode._pos + (this._manager.ctx.currentTime - this._playStart);
            } else {
                return activeNode.currentTime;
            }
        }

        return 0;
    },
    /**
     * Fade a currently playing sound between two volumes.
     *
     * @method fade
     * @param from {Number} The volume to fade from (0.0 to 1.0).
     * @param to {Number} The volume to fade to (0.0 to 1.0).
     * @param len {Number} Time in milliseconds to fade.
     * @param [id] {String} The play instance ID.
     * @param [callback] {Function} Fired when the fade is complete.
     * @return {AudioPlayer} Returns itself.
     * @chainable
     */
    fade: function(from, to, len, id, cb) {
        var self = this,
            diff = Math.abs(from - to),
            dir = from > to ? 'dowm' : 'up',
            steps = diff / 0.01,
            stepTime = len / steps;

        if(typeof id === 'function') {
            cb = id;
            id = null;
        }

        //if we haven't loaded this sound yet, wait until it is to seek it
        if(!this._loaded) {
            this.on('ready', function() {
                self.fade(from, to, len, id, cb);
            });

            return this;
        }

        this.setVolume(from, id);

        for(var i = 1; i <= steps; ++i) {
            var change = this._volume + ((dir === 'up' ? 0.01 : -0.01) * i),
                vol = Math.round(1000 * change) / 1000,
                wait = stepTime * i;

            this._doFadeStep(vol, wait, to, id, cb);
        }
    },
    /**
     * Returns the current volume of the player
     *
     * @method getVolume
     * @return {Number} The current volume
     */
    getVolume: function() {
        return this._volume;
    },
    /**
     * Sets the current volume of the player
     *
     * @method setVolume
     * @param vol {Number} The current volume
     * @param [id] {String} The play instance ID.
     * @return {AudioPlayer} Returns itself.
     * @chainable
     */
    setVolume: function(vol, id) {
        var self = this;

        // make sure volume is a number
        vol = parseFloat(vol);

        //if we haven't loaded this sound yet, wait until we play it to change the volume
        if(!this._loaded) {
            this.on('play', function() {
                self.setVolume(vol, id);
            });

            return this;
        }

        //set the volume
        if(vol >= 0 && vol <= 1) {
            this._volume = vol;

            var activeNode = id ? this._nodeById(id) : this._activeNode();
            if(activeNode) {
                if(this._webAudio) {
                    activeNode.gain.volume = vol;
                } else {
                    activeNode.volume = vol * this._manager.volume;
                }
            }
        }

        return this;
    },
    /**
     * Set the 3D position of the audio source.
     * The most common usage is to set the 'x' position
     * to affect the left/right ear panning. Setting any value higher than
     * 1.0 will begin to decrease the volume of the sound as it moves further away.
     * NOTE: This only works with Web Audio API, HTML5 Audio playback
     * will not be affected.
     *
     * @method setPosition
     * @param x {Number} The x-position of the playback from -1000.0 to 1000.0
     * @param y {Number} The y-position of the playback from -1000.0 to 1000.0
     * @param z {Number} The z-position of the playback from -1000.0 to 1000.0
     * @param [id] {String} The play instance ID.
     * @return {AudioPlayer} Returns itself.
     * @chainable
     */
    setPosition: function(x, y, z, id) {
        var self = this;

        //set a default for the optional 'y' and 'z'
        x = !x ? 0 : x;
        y = !y ? 0 : y;
        z = (!z && z !== 0) ? -0.5 : z;

        //if we haven't loaded this sound yet, wait until we play it to change the position
        if(!this._loaded) {
            this.on('play', function() {
                self.setPosition(x, y, z, id);
            });

            return this;
        }

        if(this._webAudio) {
            var activeNode = id ? this._nodeById(id) : this._activeNode();
            if(activeNode) {
                this.pos3d[0] = x;
                this.pos3d[1] = y;
                this.pos3d[2] = z;
                activeNode.panner.setPosition(x, y, z);
            }
        }

        return this;
    },
    /**
     * Performs a step in the fade transition
     *
     * @method _doFadeStep
     * @private
     */
    _doFadeStep: function(vol, wait, end, id, cb) {
        var self = this;

        setTimeout(function() {
            self.setVolume(vol, id);

            if(vol === end) {
                if(typeof cb === 'function')
                    cb();
            }
        }, wait);
    },
    /**
     * Get an audio node by ID.
     *
     * @method _nodeById
     * @return {AudioPlayer} Audio node.
     * @private
     */
    _nodeById: function(id) {
        var node = this._nodes[0]; //default return value

        //find the node with this ID
        for(var i = 0, il = this._nodes.length; i < il; ++i) {
            if(this._nodes[i].id === id) {
                node = this._nodes[i];
                break;
            }
        }

        return node;
    },
    /**
     * Get the first active audio node.
     *
     * @method _activeNode
     * @return {AudioPlayer} Audio node.
     * @private
     */
    _activeNode: function() {
        var node;

        //find the first playing node
        for(var i = 0, il = this._nodes.length; i < il; ++i) {
            if(!this._nodes[i].paused) {
                node = this._nodes[i];
                break;
            }
        }

        //remove excess inactive nodes
        this._drainPool();

        return node;
    },
    /**
     * Get the first inactive audio node.
     * If there is none, create a new one and add it to the pool.
     *
     * @method _inactiveNode
     * @param cb {Function} callback Function to call when the audio node is ready.
     * @private
     */
    _inactiveNode: function(cb) {
        var node;

        //find first inactive node to recycle
        for(var i = 0, il = this._nodes.length; i < il; ++i) {
            if(this._nodes[i].paused && this._nodes[i].readyState === 4) {
                cb(node = this._nodes[i]);
                break;
            }
        }

        //remove excess inactive nodes
        this._drainPool();

        if(node) return;

        //create new node if there are no inactives
        if(this._webAudio) {
            node = this._setupAudioNode();
            cb(node);
        } else {
            this._load();
            node = this._nodes[this.nodes.length - 1];
            node.addEventListener('loadedmetadata', function() {
                cb(node);
            });
        }
    },
    /**
     * If there are more than 5 inactive audio nodes in the pool, clear out the rest.
     *
     * @method _drainPool
     * @private
     */
    _drainPool: function() {
        var inactive = 0,
            i = 0, il = 0;

        //count inactive nodes
        for(i = 0, il = this._nodes.length; i < il; ++i) {
            if(this._nodes[i].paused) {
                inactive++;
            }
        }

        //remove excess inactive nodes
        for(i = this._nodes.length - 1; i >= 0; --i) {
            if(inactive <= 5)
                break;

            if(this._nodes[i].paused) {
                inactive--;
                this._nodes.splice(i, 1);
            }
        }
    },
    /**
     * Clear 'onend' timeout before it ends.
     *
     * @method _clearEndTimer
     * @param timerId {Number} timerId The ID of the sound to be cancelled.
     * @private
     */
    _clearEndTimer: function(timerId) {
        var timer = this._onendTimer.indexOf(timerId);

        //make sure the timer is cleared
        timer = timer >= 0 ? timer : 0;

        if(this._onendTimer[timer]) {
            clearTimeout(this._onendTimer[timer]);
            this._onendTimer.splice(timer, 1);
        }
    },
    /**
     * Setup the gain node and panner for a Web Audio instance.
     *
     * @method _setupAudioNode
     * @return {Object} The new audio node.
     * @private
     */
    _setupAudioNode: function() {
        var node = this._manager.ctx.createGain ? this._manager.ctx.createGain() : this._manager.ctx.createGainNode();

        this._nodes.push(node);

        //create gain node
        node.gain.value = this._volume;
        node.paused = true;
        node._pos = 0;
        node.readyState = 4;
        node.connect(this._manager.masterGain);

        //create the panner
        node.panner = this._manager.ctx.createPanner();
        node.panner.setPosition(this.pos3d[0], this.pos3d[1], this.pos3d[2]);
        node.panner.connect(node);

        return node;
    },
    /**
     * Finishes loading the Web Audio API sound and fires the loaded event
     *
     * @method loadSound
     * @param buffer {Object} The decoded buffer sound source.
     * @private
     */
    _loadSoundBuffer: function(buffer) {
        this._duration = buffer ? buffer.duration : this._duration;

        //setup a default sprite
        this.sprite._default = [0, this._duration * 1000];

        //fire the load event
        if(!this._loaded) {
            this._loaded = true;
            this.emit('ready', this.src);
        }

        //if autoplay is appropriate
        if(this.autoplay) {
            this.play();
        }
    },
    /**
     * Load the sound back into the buffer source.
     *
     * @method refreshBuffer
     * @param loop {Array} Loop boolean, pos, and duration.
     * @param [id] {String} The play instance ID.
     * @private
     */
    _refreshBuffer: function(loop, id) {
        var node = this._nodeById(id);

        //setup the buffer source for playback
        node.bufferSource = this._manager.ctx.createBufferSource();
        node.bufferSource.buffer = this._file.data;
        node.bufferSource.connect(node.panner);
        node.bufferSource.loop = loop[0];

        if(loop[0]) {
            node.bufferSource.loopStart = loop[1];
            node.bufferSource.loopEnd = loop[1] + loop[2];
        }
    }
});

module.exports = AudioPlayer;