Source: src/utils/sfx.js

import { utils, nav, store } from './../storymode.js';

// Sound Effect class
// ------------------

// Requires:
// - bin/js/pixi-sound.js
// - bin/js/pixi-sound.js.map (optional)

// USAGE:
// if (!sfx.ready){
//   sfx.on('ready', this.onready, this)
// } else {
//   onready();
// }
// // ...
// onready(){
//   sfx.off('ready', this.onready, this)
// }

const DEFAULT_SFX_ENABLED = true;
const DEFAULT_BGLOOP_ENABLED = true;

/**
 * Sound and audio manager utilising the PixiJS Sound plugin.
 * <br>Docs: <a href="https://pixijs.io/sound/docs/index.html" >https://pixijs.io/sound/docs/index.html</a>
 * <br>- If no audio assets are queued in the project then this script will not be requested and is not needed.
 * <br>- If the PIXI Sound script is not already loaded by the HTML, it will be loaded on first user interaction with the document from the following file path:
 * <br>- `(build0)/js/pixi-sound.js`
 * @extends PIXI.utils.EventEmitter
 * @hideconstructor
 * @example
// app.js
sfx.bgLoopVolume = 0.6; // Set bg audio volume factor.
sfx.volume = 0.8; // Global volume factor.
sfx.waitForInteractionToLoad = false; // Will load immediately when able after initial assets and scene are ready.
sfx.setBgLoop('bg_loop'); // Assign bg loop.
sfx.enqueueBgResources({bg_loop_parent: {path: 'audio/bg_loop.mp3', sprites: {bg_loop: {start:0.5, end:4.5}}); // A bg audio as sprite to assist looping.
createApp(...)

// sampleScene.js
const {sfx} = require(`storymode`);
export default class MyClass extends Scene {
    static getSfxResources(){
      return {
        ding: 'sfx/ding.mp3',
        dong: {path: 'sfx/dong.mp3'}, // Optional path property
        mastertrack: {path:'sfx/master-track.mp3', sprites:{ // Optionally define sprites
          explosion: {start:0.5, end:1.0},
          success_bling: {start:1.0, end: 2.0}
        }},
        _fireloop: {path:'audio/fire.mp3', sprites:{
          fireloop: {start:0.2, end:2.8, loop:true}, // Looping
        }},
        ouch: {multi:true, total:3, prefix:'_ouch_', random:true, rezero:false}, // Create a multi sound that will step through members each subsequent call
        _ouch_0: {path:'sfx/ouch_0.mp3'},
        _ouch_1: {path:'sfx/ouch_1.mp3'},
        _ouch_2: {path:'sfx/ouch_2.mp3'},
      };
    }
    // ...
    sfx.playSFX('ding');
    sfx.playSFX('explosion', {start:1, complete:myFunction}); // Play sprite
  }
}
 */
class SFX extends PIXI.utils.EventEmitter {

  /**
   * Creates a new SFX module.
   * <br>This instance is created by `storymode` during startup.
   * @constructor
   */
  constructor(){

    super();

    this._sfxready = false;
    this._bgready = false;

    this._bgResources = null;
    this._enableLoadCalled = false;

    this._sfxVolume = 1.0;
    this._bgLoopVolume = 1.0;
    this._volume = 1.0;

    this.resources = {};
    this.bgLoopSlug = null;

    this._sfxEnabled = false;
    this._bgLoopEnabled = false;

    this._waitForInteractionToLoad = true;

    this._useSfxStateForBg = false;

    this.fader = {volume:1.0};

    /**
     * A path to the pixi sound library js file.
     *<br>- Default is 'js/vendor/pixi-sound.js'
     *<br>- If editing this value do so before `storymode.createApp` is called.
     * @type {string}
     */
    this.pixisoundJSPath = 'js/vendor/pixi-sound.js';



  }

  // Called after store has chance to be configured

  loadPrefs(){

    let _sfxEnabled = store.load('sfx.sfxEnabled')
    this._sfxEnabled = _sfxEnabled === null ? DEFAULT_SFX_ENABLED : _sfxEnabled === '1';

    let _bgLoopEnabled = store.load('sfx.bgLoopEnabled')
    this._bgLoopEnabled = _bgLoopEnabled === null ? DEFAULT_BGLOOP_ENABLED : _bgLoopEnabled === '1';

    this.syncBgState();



  }

  /**
   * Will be `true` when the sound engine and registered audio assets are all loaded and ready to play.
   * <br>This will be set to true before background audio assets are loaded.
   * @readonly
   * @type {!boolean}
   */
  get sfxready(){
    return this._sfxready;
  }

  /**
   * Will be `true` when the background audio assets queued by `sfx.enqueueBgResources()` are loaded.
   * @readonly
   * @type {!boolean}
   */
  get bgready(){
    return this._bgready;
  }

  /**
   * Whether sound effects are enabled.
   * @type {boolean}
   */
  get sfxEnabled(){
    return this._sfxEnabled;
  }

  set sfxEnabled(enabled){
    if (enabled === this._sfxEnabled){
      return;
    }
    this.toggleSfxEnabled();
  }

  /**
   * Whether background audio is enabled.
   * @type {boolean}
   */
  get bgLoopEnabled(){
    return this._bgLoopEnabled;
  }

  set bgLoopEnabled(enabled){
    if (enabled === this._bgLoopEnabled){
      return;
    }
    this.toggleBgLoopEnabled();
  }

  /**
   * Sets the middleware to be used for loader intances created by the `sfx` class.
   * @returns {PIXI.ILoaderMiddleware} loaderMiddleware
   * @private
   */
  setLoaderMiddleware(loaderMiddleware){
    this._loaderMiddleware = loaderMiddleware;
  }

  /**
   * If `true` then will piggy back bg loop enabled to auto update to always follow sfx volume.
   * @type {boolean}
   */
  set useSfxStateForBg(_useSfxStateForBg){
    this._useSfxStateForBg = _useSfxStateForBg;
    this.syncBgState();
  }

  /**
   * If `useSfxStateForBg` is set to `true` will update `bgLoopEnabled` to match `_sfxEnabled`
   * @private
   */
  syncBgState(){
    if (this._useSfxStateForBg){
      this.bgLoopEnabled = this._sfxEnabled;
    }
  }

  /**
   * Toggle sound effect enabled state.
   */
  toggleSfxEnabled(){

    this._sfxEnabled = !this._sfxEnabled;
    store.save('sfx.sfxEnabled', this._sfxEnabled ? '1' : '0')

    /**
     * Called when sound effects enabled state changes.
     * @event SFX#sfx_enabled_change
     */
    this.emit('sfx_enabled_change');
    this.syncBgState();

    if (!this._sfxEnabled && !this._bgLoopEnabled){
      this.stopAll();
    }

  }

  /**
   * Toggle background audio enabled state.
   */
  toggleBgLoopEnabled(){

    this._bgLoopEnabled = !this._bgLoopEnabled;
    store.save('sfx.bgLoopEnabled', this._bgLoopEnabled ? '1' : '0')
    if (this._bgLoopEnabled){
      this._resumeBgLoop();
    } else {
      this._stopBgLoop();
    }

    /**
     * Called when bg loop enabled state changes.
     * @event SFX#bgloop_enabled_change
     */
    this.emit('bgloop_enabled_change');

  }

  // Volume
  // ------

  /**
   * The sfx volume level from 0.0 to 1.0.
   * @type {number}
   */
  get sfxVolume(){
    return this._sfxVolume;
  }
  set sfxVolume(volume){
    this._sfxVolume = volume;
  }

  /**
   * The background volume level from 0.0 to 1.0.
   * @type {number}
   */
  get bgLoopVolume(){
    return this._bgLoopVolume;
  }
  set bgLoopVolume(volume){
    this._bgLoopVolume = volume;
    this.updateBgLoopVolume();
  }

  /**
   * Global volume factor.
   * @type {number}
   */
  get volume(){
    return this._volume;
  }
  set volume(volume){
    this._volume = volume;
    this.updateBgLoopVolume();
  }


  /**
   * Optionally call this method to queue global background audio resources.
   * <br>- These assets will be loaded after all other assets are loaded and ready to use.
   * <br>- Should be called before or during `storymode.createApp()` callback.
   * @param {Object} bgResources - Global resources to load. Eg. `{ding: 'sfx/ding.mp3', dong: 'sfx/dong.mp3'}`.
   * @example
// app.js
sfx.waitForInteractionToLoad = false;
sfx.setBgLoop('cello'); // Will be played when ready
sfx.enqueueBgResources({cello: 'audio/cello.mp3'});
createApp(...)

// Alternatively use a sprite for smoother looping:

// app.js
sfx.setBgLoop('spriteloop');
sfx.enqueueBgResources({bg_loop: 'audio/bg_loop.mp3',
bg_loop_sprite: {path:'audio/my-music.mp3', sprites:{
 spriteloop: {start:0.1, end:4.9},
}}});
   */
  enqueueBgResources(bgResources){
    this._bgResources = bgResources;
  }

  // If `false` then will load immediately when able, rather than waiting for user to interact with the DOM
  // Must be caled before or during storymode.createApp callback

  /**
   * If `false` then will load immediately when able, rather than waiting for user to interact with the DOM. Should be called before or during `storymode.createApp()` callback.
   * @type {boolean}
   */
  set waitForInteractionToLoad(_waitForInteractionToLoad){
    this._waitForInteractionToLoad = _waitForInteractionToLoad;
  }

  get waitForInteractionToLoad(){
    return this._waitForInteractionToLoad
  }

  /**
   * Called by `storymode` after initial load. Indicates that audio can commence loading.
   * @private
   */
  _enableLoad(){

    // Ensure this method is only call once
    if (this._enableLoadCalled){

      return;
    }

    this._enableLoadCalled = true;

    // Check if any resources are loaded
    if ((this._bgResources && Object.keys(this._bgResources).length > 0) || this.anySfxResources()){
      if (this._waitForInteractionToLoad){
        // Wait for interaction then load sound
        let pointerupFn = () => {
          document.removeEventListener('pointerup', pointerupFn);
          this.beginLoad();
        }
        document.addEventListener('pointerup', pointerupFn);
      } else {
        this.beginLoad();
      }
    }
  }

  /**
   * Scans scenes fror registered sfx resources and returns `true` if any were found.
   * @returns {boolean} resourcesFound
   * @private
   */
  anySfxResources(){

    for (let _sceneid in nav.scenes){
      let _r = nav.scenes[_sceneid].class.getSfxResources();
      if (_r){
        if (Object.keys(_r).length > 0){
          return true;
        }
      }
    }

    return false;

  }

  // First the script must be loaded - if not already

  /**
   * Begin the loading of queued resources.
   * <br>Ensures that `pixi-sound.js` is loaded before continuing.
   * @private
   */
  beginLoad(){
    if (PIXI.sound){
      PIXI.sound.init(); // Required after disposing
      this.onScriptLoaded();
    } else {
      utils.loadScript(this.pixisoundJSPath, this.onScriptLoaded.bind(this))
    }
  }

  /**
   * Returns a new loader instance using middleware, if any.
   * @private
   */
  createLoader(){
    let _loader = new PIXI.Loader();
    if (this._loaderMiddleware){
      _loader.pre(this._loaderMiddleware);
    }
    return _loader;
  }


  /**
   * Optionally call this method gradually change the global volume by a set factor.
   * <br>- Can be called anytime, before `storymode.createApp()`.
   * <br>- Should be called before or during `storymode.createApp()` callback.
   * @param {number} volume - Volume factor from 0.0 to 1.0. Not releated to sfx / bg volume values.
   * @param {number} duration - The fade duration.
   */
  fadeGlobalVolume(volume, dur = 5.0){

    gsap.killTweensOf(this.fader);
    if (dur < 0.001){
      this.fader.volume = volume
      this.syncFader();
    } else {
      gsap.to(this.fader, dur,  {volume:volume, ease:Linear.easeNone, onUpdate:this.syncFader.bind(this)});
    }

  }

  syncFader(){
    if (PIXI && PIXI.sound){
      PIXI.sound.volumeAll = this.fader.volume;
    }
  }

  /**
   * Commence resource loading, after pixi sound JS is loaded.
   * @private
   */
  onScriptLoaded(){

    // PIXI.sound.Sound.volumeAll(0.5);

    // Set global volume
    // PIXI.sound.volumeAll = 0.1;

    // PIXI.sound.volumeAll = 0.1
    //  PIXI.sound.volumeAll = 1.0;

    this.syncFader();

    // Load all resources registered with static scene method: `getSfxResources()`

    this._loader = this.createLoader();
    this.spritesByParentSound = {};
    this.parentSoundBySprite = {};
    this.multis = {};
    this.concurrentTracking = {};
    this.loopSfx = {};
    let _resources = {};

    for (let _sceneid in nav.scenes){
      let _r = nav.scenes[_sceneid].class.getSfxResources();
      if (_r){

        for (let _soundID in _r){
          if (_resources[_soundID]){
            if (_r[_soundID] !== _resources[_soundID]){ // Identical queued; ignore
              throw new Error('SFX: Duplicate resource identifier: `'+_soundID+'`');
            }
          } else {
            // Optionally set concurrent to limit how many of this sfx (or multi) can play concurrently
            if (_r[_soundID].multi){
              // Multi sounds are lists of other sounds
              // - Each time they are called they step through their list
              // - They have the option to be randomised
              let m = {};
              if (_r[_soundID].prefix && _r[_soundID].total){
                m.ids = [];
                for (let j = 0; j < _r[_soundID].total; j++){
                  m.ids.push(_r[_soundID].prefix + String(j));
                }
              } else if (_r[_soundID].ids){
                m.ids = _r[_soundID].ids
              } else {
                throw new Error('SFX: Misconfigured multisound');
              }
              m.random =  _r[_soundID].random ? true : false; // Items will be shuffled on start and every manual reset
              m.rezero =  _r[_soundID].rezero === false ? false : true; // Whether to go back to index 0 automatically or stay on last index
              m._orig_ids = m.ids.slice();

              this.multis[_soundID] = m;
              if (_r[_soundID].concurrent && _r[_soundID].concurrent > 0){
                this.concurrentTracking[_soundID] = {concurrent: _r[_soundID].concurrent, _playcount:0};
              }
              if (_r[_soundID].loop){
                throw new Error('SFX: Multi sounds don\'t support `loop` ('+_soundID+').');
              }

              this.resetMulti(_soundID, true);
            } else {
              const path = _r[_soundID].path ? _r[_soundID].path : _r[_soundID];
              if (_r[_soundID].sprites){
                if (_r[_soundID].concurrent){
                  throw new Error('SFX: Sprite parents don\'t support `concurrent` ('+_soundID+').');
                }
                if (_r[_soundID].loop){
                  throw new Error('SFX: Sprite parents don\'t support `loop` ('+_soundID+').');
                }
                this.spritesByParentSound[_soundID] = _r[_soundID].sprites;
                for (let _spriteID in _r[_soundID].sprites){
                  // Individual spriute
                  this.parentSoundBySprite[_spriteID] = _soundID;
                  if (_r[_soundID].sprites[_spriteID].concurrent && _r[_soundID].sprites[_spriteID].concurrent > 0){
                    this.concurrentTracking[_spriteID] = {concurrent: _r[_soundID].sprites[_spriteID].concurrent, _playcount:0};
                    delete _r[_soundID].sprites[_spriteID].concurrent; // Remove any props except start/end
                  }
                  if (_r[_soundID].sprites[_spriteID].loop){
                    this.loopSfx[_spriteID] = true;
                    delete _r[_soundID].sprites[_spriteID].loop; // Remove any props except start/end
                  }
                }
              } else { // Non sprite sound effect
                if (_r[_soundID].concurrent && _r[_soundID].concurrent > 0){
                  this.concurrentTracking[_soundID] = {concurrent: _r[_soundID].concurrent, _playcount:0};
                }
                if (_r[_soundID].loop){
                  this.loopSfx[_soundID] = true;
                }
              }
              _resources[_soundID] = path;
            }
          }
        }
      }
    }

    let anySfx = false;
    for (let _soundID in _resources){
      anySfx = true;
      this._loader.add(_soundID, _resources[_soundID]);
    }

    if (anySfx){
      this._loader.load(this.onResourcesLoaded.bind(this));
    } else {
      this.onResourcesLoaded(this._loader, {});
    }

  }

  /**
   * Resets the counter associated with the multisound to zero, if random is set to true will shuffle its members.
   * @param {string} soundID - The multi sound identifier.
   */
  resetMulti(soundID, force = false, enableRandom = true){
    if (!force && (!this._sfxready || !this._sfxEnabled || !this.multis || !this.multis[soundID])){
      return;
    }
    this.multis[soundID]._index = -1;
    if (!enableRandom){
      this.multis[soundID].ids = this.multis[soundID]._orig_ids.slice(); // Restore original order
    } else if (this.multis[soundID].random){
      this.multis[soundID].ids = utils.shuffle(this.multis[soundID].ids);
    }
  }

  /**
   * Gets the current counter index for the given multisound.
   * @param {string} soundID - The multi sound identifier.
   * @returns {int} counter - The multi sound counter (zero-indexed).
   */
  getMultiCounter(soundID){
    if (!this._sfxready || !this._sfxEnabled || !this.multis || !this.multis[soundID]){
      return -1;
    }
    return this.multis[soundID]._index;
  }

  /**
   * Gets the total individual sounds for the given multisound.
   * @param {string} soundID - The multi sound identifier.
   * @returns {int} total
   */
  getMultiTotal(soundID){
    if (!this._sfxready || !this._sfxEnabled || !this.multis || !this.multis[soundID]){
      return -1;
    }
    return this.multis[soundID].ids.length
  }

  /**
   * Sets whether the given multisound should rezero after getting to the last sound in the sequence.
   * @param {string} soundID - The multi sound identifier.
   * @param {boolean} rezero - Whether to go back to zero or stay on last sound.
   */
  setMultiRezero(soundID, rezero){
    if (!this._sfxready || !this._sfxEnabled || !this.multis || !this.multis[soundID]){
      return -1;
    }
    return this.multis[soundID].rezero = rezero;
  }

  /**
   * Called after sfx assets are loaded and ready to play.
   * <br>- SFX will be playable after this method is called.
   * @param {PIXI.Loader} loader - The loader instance.
   * @param {Object} resources - Loaded sfx audio assets.
   * @private
   */
  onResourcesLoaded(loader, resources){

    this._loader = this.destroyLoaderReturnNull(this._loader);

    this.resources = resources;
    this._sfxready = true;



    // Add sprites to any loaded SFX sounds
    for (let _soundID in this.spritesByParentSound){
      if (this.resources[_soundID] && this.resources[_soundID].sound){
        this.resources[_soundID].sound.addSprites(this.spritesByParentSound[_soundID]);
      }
    }
    // Configure all sounds
    for (let _soundID in this.resources){
      this.resources[_soundID].sound.loop = false;
      this.resources[_soundID].sound.singleInstance = false;
    }
    delete this.spritesByParentSound;

    //delete this.spritesByParentSound[_soundID]; // No longer needed
    /**
     * Called when audio library and audio files are loaded, though background files may still be downloading.
     * @event SFX#sfxready
     * @example
if (!sfx.sfxready){
 sfx.on('sfxready', this.onSfxReady, this)
} else {
 onSfxReady();
}

onSfxReady() {
 sfx.off('sfxready', this.onSfxReady, this)
}
     */
    this.emit('sfxready');

    // SFX can now be played. Load the background next.
    this.loadBgResources();

  }

  /**
   * Begin the load of background (low priority) resournces.
   * @private
   */
  loadBgResources(){

    if (this._bgResources){

      this.spritesByParentSound = {};

      this._loader = this.createLoader();

      for (let _soundID in this._bgResources){
        const path = this._bgResources[_soundID].path ? this._bgResources[_soundID].path : this._bgResources[_soundID];

        if (this._bgResources[_soundID].sprites){
          // Save the sprites to apply to the sound after it has loaded
          this.spritesByParentSound[_soundID] = this._bgResources[_soundID].sprites;
          for (let _spriteID in this._bgResources[_soundID].sprites){
            // Individual spriute
            this._bgResources[_soundID].sprites[_spriteID].loop = true
            this.parentSoundBySprite[_spriteID] = _soundID;
          }
        }

        this._loader.add(_soundID, path);
      }

      this._loader.load(this.onBgResourcesLoaded.bind(this));

    }

  }


  /**
   * Called after background assets are loaded and ready to play.
   * <br>- Bg sounds will be playable after this method is called.
   * @param {PIXI.Loader} loader - The loader instance.
   * @param {Object} resources - Loaded bg audio assets.
   * @private
   */
  onBgResourcesLoaded(loader, resources){

    if (resources){
      // Add sprites to any loaded bg sounds
      for (let _soundID in this.spritesByParentSound){
        if (resources[_soundID] && resources[_soundID].sound){
          resources[_soundID].sound.addSprites(this.spritesByParentSound[_soundID]);
        }
      }
    }

    delete this.spritesByParentSound;

    this._loader = this.destroyLoaderReturnNull(this._loader);
    this.resources = utils.extend(this.resources, resources);
    this._bgready = true;

    /**
     * Called when background audio files are loaded, this will be after all other audio files queued by scenes.
     * @event SFX#bgready
     * @example
if (!sfx.bgready){
 sfx.on('bgready', this.onBgReady, this)
} else {
 onBgReady();
}

onBgReady() {
 sfx.off('bgready', this.onBgReady, this)
}
     */
    this.emit('bgready');

    this._bgResources = null;

    if (this._pendingBgLoopSlug){
      const tmpPendingBgLoopSlug = this._pendingBgLoopSlug;
      this._pendingBgLoopSlug = null;
      this.setBgLoop(tmpPendingBgLoopSlug);
    }

  }

  /**
   * Stop all sound playback.
   * <br>- Reset concurrent sound tracking.
   */
  stopAll(){

    if (!this._sfxready){
      return;
    }

    for (let soundID in this.concurrentTracking){
      this.concurrentTracking[soundID]._playcount = 0;
    }

    PIXI.sound.stopAll();

  }



  /**
   * Register a mixer object to affect volume factor of specified sounds.
   * <br>Set to null to remove.
   * <br>Supports simple prefix glob patterns in the format of `mysound_*`.
   * @param {Object} [mixerObj=null] - The configuration object. Eg. {sound_effect_1: 1.0, sound_effect_2:1.0, vo_*:1.0}.
   */
  registerMixer(mixerObj){
    this.mixerObj = mixerObj;
    // Make a look up for glob patterns. Eg. `mysound_*`
    this.mixerObjGlobs = [];
    this.mixerObjGlobFirstChar = '';
    if (this.mixerObj){
      for (let soundID in this.mixerObj){
        if (soundID.split('*').length > 1){
          if (!this.mixerObjGlobFirstChar.includes(soundID.charAt(0))){
            this.mixerObjGlobFirstChar+=soundID.charAt(0)
          }
          this.mixerObjGlobs.push(soundID);
        }
      }
    }
  }



  /**
   * Called after initial assets are loaded.
   *
   * @typedef {Object} SFX.PlaybackOptions
   * @property {number} [delay=0] Delay before playback, in seconds.
   * @property {volume} [volume=1.0] Volume multiplier.
   * @memberOf SFX
   */

  /**
   * Plays requested audio resource.
   * @param {string} soundID - The multi sound identifier.
   * @param {SFX.PlaybackOptions|number} options - Playback options, if a number then will be set as the delay value.
   */
  playSFX(soundID, options = null, _concurrentSoundID = null){

    if (!this._sfxready || !this._sfxEnabled){
      return;
    }

    // Defaults - Can be supplied as option params
    let delay = -1;
    let volume = 1.0;


    let _optionBase = {}

    if (typeof options === 'number'){
      delay = options; // Assume number is delay;
      options = null;
    }  else if (options && typeof options === 'object'){
      if (typeof options.volume === 'number'){
        volume = options.volume;
      }
      if (typeof options.delay === 'number'){
        delay = options.delay;
        delete options.delay; // Remove delay as it will be applied now
      }
      // See: https://pixijs.io/sound/docs/PlayOptions.html
      if (typeof options.start === 'number'){
        _optionBase.start = options.start
      }
      if (typeof options.complete === 'function'){
        _optionBase.complete = options.complete
      }
      //if (typeof options.end === 'number'){
      //  _optionBase.end = options.end
      //}
    }

    if (delay > 0.0){
      utils.wait(this, delay, this.playSFX, [soundID, options]);
      return;
    }

    if (this.mixerObj) {

      let mixerFactor = 1.0;
      if (typeof this.mixerObj[soundID] !== 'undefined'){
        mixerFactor = this.mixerObj[soundID];
      } else if (this.mixerObjGlobs.length > 0 && this.mixerObjGlobFirstChar.includes(soundID.charAt(0))){ // Check if first char is registered as a glob
        // Check if glob if not exact match
        for (let mixerGlob of this.mixerObjGlobs){
          if (utils.globMatch(soundID, mixerGlob)){
            mixerFactor = this.mixerObj[mixerGlob];
            break;
          }
        }
      }

      if (mixerFactor !== 1.0){
        volume *= mixerFactor;
        // Apply volume to options, create options if it doens't exist
        // This is so a multi sound will have it's parent mixer volume
        if (options && typeof options === 'object'){
          options.volume = volume;
        } else {
          options = {volume:volume}
        }
      }
    }

    // Concurrent limits (per sound - not multi)
    let concurrentLimit = -1;
    let multiCall = _concurrentSoundID ? true : false;

    let concurrentSoundID = _concurrentSoundID ? _concurrentSoundID : soundID;
    if (this.concurrentTracking[concurrentSoundID]){
      concurrentLimit = this.concurrentTracking[concurrentSoundID].concurrent;
    }

    if (this.multis[soundID]){

      // Multi

      if (concurrentLimit > 0){ // Check if over limit so index doesn't have to tick uncessecarily
        if (this.concurrentTracking[concurrentSoundID]._playcount == concurrentLimit){
          return;
        }
      }

      this.multis[soundID]._index++;
      if (this.multis[soundID]._index >= this.multis[soundID].ids.length){
        if (this.multis[soundID].rezero){
          this.multis[soundID]._index = 0;
        } else {
          this.multis[soundID]._index = this.multis[soundID].ids.length-1;
        }
      }
      this.playSFX(this.multis[soundID].ids[this.multis[soundID]._index], options, soundID); // Pass 3rd parram to track concurrent limits

    } else {

      // Sprite / Non-sprite

      let options = _optionBase;

      options.volume = Math.max(0.0, Math.min(1.0, this._sfxVolume*this._volume*volume));

      // Get sound object based on if sprite or not
      let result = this.getSoundForID(soundID);
      if (result.isSprite){
        options.sprite = soundID
      }

      let sound = result.sound;

      if (sound){

        if (!multiCall && this.loopSfx[soundID]){ // Looping is ignored if called as child of multi
          options.loop = true;
          // Looping SFX have a concurrent limit of 1 applied automatically
          concurrentLimit = 1;
          if (!this.concurrentTracking[soundID]){
            this.concurrentTracking[soundID] = {}
            this.concurrentTracking[soundID].concurrent = 1;
            this.concurrentTracking[soundID]._playcount = 0;
          }
        }

        if (concurrentLimit > 0){
          if (this.concurrentTracking[concurrentSoundID]._playcount == concurrentLimit){
            return;
          }
          // Track how many are playing for current `concurrentSoundID`
          this.concurrentTracking[concurrentSoundID]._playcount++;
          if (!options.loop){ // No callback needed for loop
            let self = this;
            let cmp = options.complete;
            options.complete = ()=>{
              self.concurrentTracking[concurrentSoundID]._playcount = Math.max(0, self.concurrentTracking[concurrentSoundID]._playcount-1);
              if (cmp){
                cmp();
              }
            }
          }
        }
  
        sound.play(options);

      } else {
        window['con' + 'sole']['log']('SFX resource not found `'+soundID+'`')
      }

    }
  }

  /**
   * Stops playback of sfx audio.
   * @param {string} [soundID=null] - The sound identifier, set to null or '*' to stop all looping sfx audio.
   */
  stopSFX(soundID = null){

    if (!soundID || soundID == '*'){

      for (let resourceID in this.resources){
        if (this.resources[resourceID].sound){
          if (this.concurrentTracking[soundID]){
            this.concurrentTracking[soundID]._playcount = 0;
          }
          this.resources[resourceID].sound.stop();
        }
      }
      return;
    }

    let result = this.getSoundForID(soundID);
    if (result.sound){
      if (this.concurrentTracking[soundID]){
        this.concurrentTracking[soundID]._playcount = 0;
      }
      result.sound.stop();
    }

  }

  /**
   * Gets info about a registered sound.
   * @param {string} soundID - The sound identifier.
   * @returns {Object} info
   * @private
   */
  getSoundForID(soundID){
    let result = {};
    if (this.parentSoundBySprite[soundID] && this.resources[this.parentSoundBySprite[soundID]]){
      result.sound = this.resources[this.parentSoundBySprite[soundID]].sound
      result.isSprite = true;
    } else if (this.resources[soundID]){
      result.sound = this.resources[soundID].sound;
      result.isSprite = false;
    }
    return result;
  }



  /**
   * Sets the resource id to be the looping background music track.
   * @param {string} soundID - The sound identifier.
   */
  setBgLoop(soundID){

    // Hold on to for later
    if (!this._bgready){
      this._pendingBgLoopSlug = soundID
      return;
    }

    this._stopBgLoop();

    if (this.bgLoopSlug && this.bgLoopSlug == soundID){
      return;
    }

    if (soundID == null){
      this.bgLoopSlug = null;
      return;
    }

    let result = this.getSoundForID(soundID);
    if (result.sound){
      this.bgLoopSlug = soundID;
      if (this._bgLoopEnabled){
        this._resumeBgLoop();
      }
    } else {
      window['con' + 'sole']['log']('BG loop resource not found `'+soundID+'`')
    }
  }

  /**
   * Stops background playback.
   * @private
   */
  _stopBgLoop(){
    if (this.bgLoopSlug){
      let result = this.getSoundForID(this.bgLoopSlug);
      if (result.sound){
        result.sound.stop();
      }
    }
  }

  /**
   * Resumes paused background playback.
   * @private
   */
  _resumeBgLoop(){

    if (this._bgLoopEnabled && this.bgLoopSlug){
      let result = this.getSoundForID(this.bgLoopSlug);
      if (result.sound){
        let options = {singleInstance: true, volume: this._bgLoopVolume*this._volume}
        if (result.isSprite){
          options.sprite = this.bgLoopSlug;
        }
        result.sound.play(options);
      }
    }
  }

  /**
   * Resyncs the background playback volume.
   * @private
   */
  updateBgLoopVolume(){
    if (this.resources && this.bgLoopSlug){
      let result = this.getSoundForID(this.bgLoopSlug);
      if (result.sound){
        result.sound.volume = this._bgLoopVolume*this._volume
      }
    }
  }

  /**
   * Removes loader events, resets and destorys.
   * @private
   */
  destroyLoaderReturnNull(loader){
    loader.onComplete.detachAll();
    loader.onLoad.detachAll();
    loader.onError.detachAll();
    loader.onProgress.detachAll();
    loader.onStart.detachAll();
    loader.reset();
    loader.destroy();
    return null;
  }


 /**
  * Called by `storymode.destroy()`. Removes all loaded assets.
  * @param {boolean} reset - If true then will be able to be used again after calling `fx._enableLoad()`
  * @private
  */
  destroy(reset){

    // Stop playback
    this.stopAll();

    // Reset refs
    if (reset){
      this._sfxready = false; // Need these states to re-trigger
      this._bgready = false; // Need these states to re-trigger
      this.spritesByParentSound = {};
      this.parentSoundBySprite = {};
      this.multis = {};
      this.concurrentTracking = {};
      this.loopSfx = {};
      this.resources = {}
      this._enableLoadCalled = false;
      this._waitForInteractionToLoad = false;
      this.bgLoopSlug = null;   // Need to re-trigger
    } else {
      this.spritesByParentSound = null
      this.parentSoundBySprite = null
      this.multis = null
      this.concurrentTracking =null
      this.loopSfx = null
      this._bgResources = null;
      this.mixerObj = null;
    }

    // Delete all sounds

    for (let resource in this.resources){
      if (this.resources[resource].sound){
        this.resources[resource].sound.stop();
        //this.resources[resource].sound.destroy(); // Either or
        PIXI.sound.remove(resource); // Either or
        delete this.resources[resource]
      }
    }
    this.resources = null;

    // Remove loader
    if (this._loader){
      this._loader = this.destroyLoaderReturnNull(this._loader);
    }

    // Purge event listeners
    this.removeAllListeners();

    // Closes the sound library.
    // This will release/destroy the AudioContext(s).
    // Can be used safely if you want to initialize the sound library later. Use init method.
    if (PIXI.sound){
      PIXI.sound.close();
    }

  }

}

export default SFX;