addon/services/audio.js

import Ember from 'ember';
import fetch from 'ember-network/fetch';
import { Sound, Note, SampledNote, Track, BeatTrack, Sampler, Oscillator, Font } from 'ember-audio';
import { sortNotes, base64ToUint8, mungeSoundFont, frequencyMap } from 'ember-audio/utils';

/**
 * Provides the Audio Service
 *
 * @public
 * @module AudioService
 */

const {
  RSVP: { all, resolve },
  Error: EmberError,
  Logger,
  Service
} = Ember;

/**
 * A {{#crossLink "Ember.Service"}}Service{{/crossLink}} that provides methods
 * for interacting with the various
 * {{#crossLinkModule "Audio"}}{{/crossLinkModule}} classes and the Web Audio
 * API's {{#crossLink "AudioContext"}}{{/crossLink}}. This can be thought of as
 * the "entrypoint" to using ember-audio. An application using ember-audio
 * should use this service for all interactions with the Web Audio API.
 *
 *     Ember.Something.extend({
 *       audio: Ember.inject.service(),
 *
 *       loadSound() {
 *         return this.get('audio').load('some.mp3').asSound('some-sound');
 *       }
 *     });
 *
 *
 * @public
 * @class AudioService
 *
 * @todo consider creating a class called something like EmberAudioLoadResponse
 * to use in place of current POJO returned from load().
 *
 * @todo consider removing concept of "registers". They only exist at the moment
 * for their caching behavior. Might want to let users decide what is cached
 * for memory reasons? A long running app (like a game), might end up with lots
 * of sounds.
 */
export default Service.extend({
  /**
   * An AudioContext instance from the Web Audio API. **NOT** available in all
   * browsers. Not available in any version of IE (except EDGE)
   * as of April 2016.
   *
   * @public
   * @property audioContext
   * @type {AudioContext}
   * @todo change this to audioContext to match other stuff, or change other stuff to audioContext
   */
  audioContext: new AudioContext(),

  /**
   * This acts as a register for Sound instances. Sound instances are placed in
   * the register by name, and can be called via audioService.getSound('name')
   *
   * @private
   * @property _sounds
   * @type {map}
   */
  _sounds: new Map(),

  /**
   * This acts as a register for Sampler instances. Sampler instances are placed
   * in the register by name, and can be called via audioService.getSampler('name')
   *
   * @private
   * @property _samplers
   * @type {map}
   */
  _samplers: new Map(),

  /**
   * This acts as a register for soundfonts. A font is just a `Map` of Note
   * objects which is placed in this register by name, and can be played like:
   * `audioService.getFont('some-font').play('Ab1');`
   *
   * @private
   * @property _fonts
   * @type {map}
   */
  _fonts: new Map(),

  /**
   * This acts as a register for Track instances. Track instances are placed in
   * this register by name, and can be called via audioService.getTrack('name')
   *
   * @private
   * @property _tracks
   * @type {map}
   */
  _tracks: new Map(),

  /**
   * This acts as a register for BeatTrack instances. BeatTrack instances are
   * placed in the register by name, and can be called via
   * audioService.getBeatTrack('name')
   *
   * @private
   * @property _beatTracks
   * @type {map}
   */
  _beatTracks: new Map(),

  /**
   * Acts as a proxy method, returns a POJO with methods that return the _load
   * and _loadFont methods so that in the end. See example.
   *
   * @example
   *     audio.load('some-url.wav').asSound('some-sound');
   *     audio.load('some-url.mp3').asTrack('some-track');
   *     audio.load(['some-url.mp3']).asSampler('some-sampler');
   *     audio.load(['some-url.mp3']).asBeatTrack('some-beat-track');
   *     audio.load('some-url.js').asFont('some-font');
   *
   * @public
   * @method load
   *
   * @param {string|array} src The URL location of an audio file. Will be used by
   * "fetch" to get the audio file. Can be a local or a relative URL. An array
   * of URLs is required if a beatTrack is being loaded via `.asBeatTrack` or
   * `.asSampler`.
   *
   * @return {object} returns a POJO that contains a few methods that curry
   * "src" "type" and "name" over to
   * {{#crossLink "Audio/_load:method"}}{{/crossLink}} and
   * {{#crossLink "Audio/_loadFont:method"}}{{/crossLink}} and allow you to
   * specify what type of Sound you'd like created.
   *
   * @todo find a better way than returning a POJO
   */
  load(src) {
    const audioContext = this.get('audioContext');
    const _load = this._load.bind(this);
    const _loadFont = this._loadFont.bind(this);
    const _loadBeatTrack = this._loadBeatTrack.bind(this);
    const _createSoundsArray = this._createSoundsArray.bind(this);
    const samplersRegister = this.get('_samplers');
    const { createNoteArray } = this;

    return {
      /*
       * Creates a Sound instance from a src URL.
       *
       * @param {string} name The name that this Sound instance will be
       * registered as in the "_sounds" register.
       *
       * @return {promise|Sound} Returns a promise that resolves to a Sound
       * instance. The promise resolves when the Sound instance's AudioBuffer
       * (audio data) is finished loading.
       */
      asSound(name) {
        return _load(name, src, 'sound');
      },

      /*
       * Creates a Track instance from a src URL.
       *
       * @param {string} name The name that this Track instance will be
       * registered as in the "_tracks" register.
       *
       * @return {promise|Track} Returns a promise that resolves to a Track
       * instance. The promise resolves when the Track instance's AudioBuffer
       * (audio data) is finished loading.
       */
      asTrack(name) {
        return _load(name, src, 'track');
      },

      /*
       * Creates a BeatTrack instance from an array of src URLs.
       *
       * @param {string} name The name that this BeatTrack instance will be
       * registered as in the "_beatTracks" register
       *
       * @return {promise|Track} Returns a promise that resolves to a BeatTrack
       * instance. The promise resolves when the BeatTrack instance's AudioBuffer
       * (audio data) is finished loading.
       */
      asBeatTrack(name) {
        return _loadBeatTrack(name, src);
      },

      /*
       * Creates a font instance from a src URL.
       *
       * @param {string} name The name that this font will be registered as in
       * the "_fonts" register.
       *
       * @return {promise|array} Returns a promise that resolves to an Array of
       * sorted note names. The promise resolves when the soundfont file is
       * finished loading and it's audio data has been successfully decoded.
       */
      asFont(name) {
        return _loadFont(name, src);
      },

      /*
       * Creates a Sampler instance from an array of src URLs.
       *
       * @param {string} name The name that this Sampler instance will be
       * registered as in the _samplers register
       *
       * @return {promise|Sampler} Returns a promise that resolves to a Sampler
       * instance. The promise resolves when all the Sound instances loaded into
       * the Sampler instance are finished loading.
       */
      asSampler(name) {
        return _createSoundsArray(name, src).then((soundsArray) => {
          const sounds = new Set(soundsArray);
          const sampler = Sampler.create({ sounds, audioContext, name });

          samplersRegister.set(name, sampler);

          return sampler;
        });
      },

      /*
       * Creates an array of note instances from a JSON file.
       *
       * @param {string} name The name that this Sampler instance will be
       * registered as in the _samplers register
       *
       * @return {promise|array|Note} Returns a promise that resolves to an array
       * of Note instances.
       */
      asNoteArray() {
        return fetch(src)
          .then((response) => response.json())
          .then(createNoteArray);
      }
    };
  },

  /**
   * Creates an array of Note objects from a json object containing notes and
   * frequency values.
   *
   * @public
   * @method createNoteArray
   *
   * @param {object|null} json Optionally provided json object. If not provided,
   * the object returned from utils/frequencyMap is used.
   *
   * @return {array|Note}
   * @todo allow createNoteArray to accept array of note names with no frequencies
   */
  createNoteArray(json) {
    const notes = [];

    if (!json) {
      json = frequencyMap;
    }

    for (let noteName in json) {
      notes.push(Note.create({ frequency: json[noteName] }));
    }

    return notes;
  },

  /**
   * Creates a Sound instance with it's audioBuffer filled with one sample's
   * worth of white noise.
   *
   * @public
   * @method createWhiteNoise
   *
   * @param {object} opts An object passed into the Sound instance.
   *
   * @return {Sound} The created white noise Sound instance.
   */
  createWhiteNoise(opts={}) {
    const audioContext = this.get('audioContext');
    const bufferSize = audioContext.sampleRate;
    const audioBuffer = audioContext.createBuffer(1, bufferSize, bufferSize);
    const output = audioBuffer.getChannelData(0);

    for (let i = 0; i < bufferSize; i++) {
      output[i] = Math.random() * 2 - 1;
    }

    return Sound.create(Object.assign(opts, { audioContext, audioBuffer }));
  },

  /**
   * Creates an Oscillator instance.
   *
   * @public
   * @method createOscillator
   *
   * @param {object} opts An object passed into the Oscillator instance.
   *
   * @return {Oscillator} The created Oscillator instance.
   */
  createOscillator(opts={}) {
    const audioContext = this.get('audioContext');
    return Oscillator.create(Object.assign(opts, { audioContext }));
  },

  /**
   * Gets a BeatTrack instance by name from the _beatTracks register.
   *
   * @public
   * @method getBeatTrack
   * @param {string} name The name of the BeatTrack instance that should be
   * retrieved from the _beatTracks register.
   *
   * @return {BeatTrack} Returns the BeatTrack instance that matches the
   * provided name.
   */
  getBeatTrack(name) {
    return this.get('_beatTracks').get(name);
  },

  /**
   * Gets a Sound instance by name from the _sounds register
   *
   * @public
   * @method getSound
   *
   * @param {string} name The name of the sound that should be retrieved
   * from the _sounds register.
   *
   * @return {Sound} returns the Sound instance that matches the provided name.
   */
  getSound(name) {
    return this.get('_sounds').get(name);
  },

  /**
   * Gets a Track instance by name from the _tracks register
   *
   * @public
   * @method getTrack
   *
   * @param {string} name The name of the Track instance that should be
   * retrieved from the _tracks register.
   *
   * @return {Track} Returns the Track instance that matches the provided name.
   */
  getTrack(name) {
    return this.get('_tracks').get(name);
  },

  /**
   * Gets a soundfont Map by name from the _fonts register and allows it to be
   * played via the returned POJO containing a method called `play`.
   *
   * @example
   *     audio.getFont('some-font').play('Ab1');
   *
   * @public
   * @method getFont
   *
   * @param {string} name The name of the Map that should be retrieved
   * from the _fonts register.
   *
   * @return {object} Returns a POJO that has a `play` method which allows a
   * note from the requested font to be played.
   */
  getFont(name) {
    return this.get('_fonts').get(name);
  },

  /**
   * Gets a Sampler instance by name from the _samplers register
   *
   * @public
   * @method getSampler
   *
   * @param {string} name The name of the sampler that should be retrieved
   * from the _samplers register.
   *
   * @return {Sampler} returns the Sampler instance that matches the provided name.
   */
  getSampler(name) {
    return this.get('_samplers').get(name);
  },

  /**
  * Gets all instances of requested type and calls
  * {{#crossLink "Sound/stop:method"}}{{/crossLink}} on each.
   *
   * @public
   * @method stopAll
   *
   * @param {string} type='tracks' The type of the register that you wish
   * to stop all instances of. Can be `'tracks'`, or `'sounds'`.
   */
  stopAll(type='tracks') {
    for (let sound of this.get(`_${type}`).values()) {
      sound.stop();
    }
  },

  /**
   * Gets all Track instances and calls
   * {{#crossLink "Sound/pause:method"}}{{/crossLink}} on each. Only works for
   * tracks because only Track instances are pause-able.
   *
   * @public
   * @method pauseAll
   */
  pauseAll() {
    for (let sound of this.get('_tracks').values()) {
      sound.pause();
    }
  },

  /**
   * Given a sound's name and type, removes the sound from it's register.
   *
   * @public
   * @method removeFromRegister
   *
   * @param {string} type The type of sound that should be removed. Can be
   * 'sound', 'track', 'font', 'beatTrack', or 'sampler'.
   *
   * @param {string} name The name of the sound that should be removed.
   */
  removeFromRegister(type, name) {
    const register = this._getRegisterFor(type);
    register.set(name, null);
  },

  /**
   * Gets a register by it's type.
   *
   * @private
   * @method _getRegisterFor
   * @param {string} type Which register to return.
   * @return {map}
   */
  _getRegisterFor(type) {
    switch (type) {
      case 'sound':
        return this.get('_sounds');
      case 'track':
        return this.get('_tracks');
      case 'beatTrack':
        return this.get('_beatTracks');
      case 'sampler':
        return this.get('_samplers');
      case 'font':
        return this.get('_fonts');
    }
  },

  /**
   * Creates an {{#crossLinkModule "Audio"}}Audio Class{{/crossLinkModule}}
   * instance (which is based on which "type" is specified), and passes "props"
   * to the new instance.
   *
   * @private
   * @method _createSoundFor
   *
   * @param {string} type The type of
   * {{#crossLinkModule "Audio"}}Audio Class{{/crossLinkModule}} to be created.
   *
   * @param {object} props POJO to pass to the new instance
   *
   * @return {Sound|Track|BeatTrack}
   */
  _createSoundFor(type, props) {
    switch (type) {
      case 'track':
        return Track.create(props);
      case 'beatTrack':
        return BeatTrack.create(props);
      case 'sampler':
        return Sampler.create(props);
      default:
        return Sound.create(props);
    }
  },

  /**
   * Loads and decodes an audio file, creating a Sound, Track, or BeatTrack
   * instance (as determined by the "type" parameter) and places the instance
   * into it's corresponding register.
   *
   * @private
   * @method _load
   *
   * @param {string} name The name that the created instance should be
   * registered as.
   *
   * @param {string} src The URI location of an audio file. Will be used by
   * "fetch" to get the audio file. Can be a local or a relative URL
   *
   * @param {string} type Determines the type of object that should be created,
   * as well as which register the instance should be placed in. Can be 'sound',
   * 'track', or 'beatTrack'.
   *
   * @return {promise|Sound|Track|BeatTrack} Returns a promise which resolves
   * to an instance of a Sound, Track, or BeatTrack
   */
  _load(name, src, type) {
    const audioContext = this.get('audioContext');
    const register = this._getRegisterFor(type);

    if (register.has(name)) {
      return resolve(register.get(name));
    }

    return fetch(src)
      .then((response) => response.arrayBuffer())
      .then((arrayBuffer) => audioContext.decodeAudioData(arrayBuffer))
      .then((audioBuffer) => {
        const sound = this._createSoundFor(type, { audioBuffer, audioContext, name });
        register.set(name, sound);
        return sound;
      })
      .catch((err) => {
        console.error('ember-audio:', err);
        console.error('ember-audio:', 'This error was probably caused by a 404 or an incompatible audio file type');
      });
  },

  /**
   * 1. Creates a Font instance and places it in the fonts register.
   * 2. Loads a soundfont file and decodes all the notes.
   * 3. Creates a Note instance for each note.
   * 4. Places each note on the font, using the note's identifier as key.
   * 5. Returns a promise that resolves to an array of properly sorted Note
   * object instances.
   *
   * The notes are sorted the way that they would appear on a piano. In the
   * example, you can see how the note `Ab1` from the `font-name` soundfont
   * would be played:
   *
   * @example
   *     audio.getFont('font-name').play('Ab1');
   *
   * @private
   * @method _loadFont
   *
   * @param {string} instrumentName The name that you will refer to this sound
   * font by.
   *
   * @param {string} src The URI location of a soundfont file. Will be used by
   * "fetch" to get the soundfont file. Can be a local or a relative URL.
   *
   * @return {promise|array} Returns a promise that resolves when the sound font
   * has been successfully decoded. The promise resolves to an array of sorted note names.
   */
  _loadFont(instrumentName, src) {
    const fontsRegister = this._getRegisterFor('font');

    // If the font already exists, no need to load it up again.
    if (fontsRegister.has(instrumentName)) {
      const err = new EmberError(`ember-audio: You tried to load a soundfont instrument called "${name}", but it already exists. Repeatedly loading the same soundfont all willy-nilly is unnecessary and would have a negative impact on performance, so the previously loaded instrument has been cached and will be reused unless you explicitly remove it with "audioService.removeFromRegister('font', '${instrumentName}')"`);

      Logger.error(err);

      return resolve(fontsRegister.get(instrumentName));
    }

    // Create a Font instance and place it in the _fonts register
    fontsRegister.set(instrumentName, Font.create());

    return fetch(src).then((response) => response.text())

      // Strip extraneous stuff from soundfont (which is currently a long string)
      // and split by line into an array
      .then(mungeSoundFont)

      // Decode base64 to audio data, splitting each line from the sound font
      // into a key and value like, [noteName, decodedAudio]
      .then((audioData) => this._extractDecodedKeyValuePairs(audioData))

      // Create a Note instance for each note from the decoded audio data.
      // Also sets the note on the corresponding font in the _fonts register.
      .then((keyValuePairs) => this._createNoteObjectsForFont(keyValuePairs, instrumentName))

      .catch((err) => console.error('ember-audio:', err));
  },

  /**
   * Creates a BeatTrack instance from an array of URLs.
   *
   * @private
   * @method _loadBeatTrack
   *
   * @param {string} name The name that this BeatTrack instance will be
   * registered as on the _beatTracks register.
   *
   * @param {array} srcArray An array of strings that specify URLs to load as
   * Sounds.
   *
   * @return {Promise|BeatTrack} A promise that resolves to a BeatTrack instance.
   */
  _loadBeatTrack(name, srcArray) {
    const audioContext = this.get('audioContext');

    return this._createSoundsArray(name, srcArray).then((soundsArray) => {
      const sounds = new Set(soundsArray);
      return BeatTrack.create({ sounds, audioContext, name });
    });
  },

  /**
   * Accepts an array of URLs to audio files and creates a Sound instance for
   * each.
   *
   * @private
   * @method _createSoundsArray
   *
   * @param {string} name The base-name of the sound. If one were loading up
   * multiple kick drum samples, this might be 'kick'.
   *
   * @param {array} srcArray An array of strings. Each item being a URL to an
   * audio file that should be loaded and turned into a Sound instance.
   *
   * @return {Promise|array} A promise that resolves to an array of Sound objects.
   */
  _createSoundsArray(name, srcArray) {
    const sounds = srcArray.map((src, idx) => {
      return this._load(`${name}${idx}`, src, 'sound');
    });

    return all(sounds);
  },

  /**
   * Takes an array of base64 encoded strings (notes) and returns an array of
   * arrays like [[name, audio], [name, audio]]
   *
   * @private
   * @method _extractDecodedKeyValuePairs
   * @param {array} notes Array of base64 encoded strings.
   * @return {array} Returns an Array of arrays. Each inner array has two
   * values, `[noteName, decodedAudio]`.
   */
  _extractDecodedKeyValuePairs(notes) {
    const ctx = this.get('audioContext');
    const promises = [];

    function decodeNote(noteName, buffer) {
      // Get web audio api audio data from array buffer
      return ctx.decodeAudioData(buffer)

      // Set promise value to array with note name and decoded note data
      .then((decodedNote) => [noteName, decodedNote]);
    }

    for (let noteName in notes) {
      if (notes.hasOwnProperty(noteName)) {

        // Transform base64 note value to Uint8Array
        const noteValue = base64ToUint8(notes[noteName]);

        promises.push(decodeNote(noteName, noteValue.buffer));
      }
    }

    // Wait for array of promises to resolve before continuing
    return all(promises);
  },

  /**
   * Takes an array of arrays, each inner array acting as
   * a key-value pair in the form `[noteName, audioData]`. Each inner array is
   * transformed into a {{#crossLink "Note"}}{{/crossLink}} and the outer array
   * is returned. This method also sets each note on it's corresponding
   * instrument {{#crossLink "Map"}}{{/crossLink}} instance by name. Each note
   * is playable as seen in the example.
   *
   * @example
   *     audioService.getFont('font-name').play('Ab5');
   *
   * @private
   * @method _createNoteObjectsForFont
   *
   * @param {array} audioData Array of arrays, each inner array like
   * `[noteName, audioData]`.
   *
   * @param {string} instrumentName Name of the instrument each note belongs to.
   * This is the name that will be used to identify the instrument on the fonts
   * register.
   *
   * @return {array} Returns an Array of {{#crossLink "Note"}}Notes{{/crossLink}}
   */
  _createNoteObjectsForFont(audioData, instrumentName) {
    const audioContext = this.get('audioContext');
    const fontsRegister = this._getRegisterFor('font');
    const font = fontsRegister.get(instrumentName);

    const notes = audioData.map((note) => {
      const [ identifier, audioBuffer ] = note;
      return SampledNote.create({
        identifier,
        audioBuffer,
        audioContext
      });
    });

    font.set('notes', sortNotes(notes));

    return font;
  }
});