addon/classes/connection.js

import Ember from 'ember';

/**
 * Provides classes that interact with the Web Audio API indirectly by providing
 * data models for the classes in the Audio module to consume.
 *
 * @public
 * @module AudioHelpers
 */

const {
  A,
  Object: EmberObject,
  on
} = Ember;

/**
 * This class represents a single connection in a Sound instance's connections
 * array. It is mostly just a wrapper around an AudioNode instance. It defines
 * some standards for how to handle the behaviors of different AudioNode types.
 * Most connections create their corresponding AudioNode immediately, but some
 * AudioNodes are "throw-away" and have to be created each time a Sound instance
 * is played.
 *
 * Most properties in this class just define how to go about getting/creating an
 * AudioNode instance and setting it on this class' `node` property. Some define
 * how to set properties on the AudioNode instance after it has been created.
 *
 * @public
 * @class Connection
 */
const Connection = EmberObject.extend({

  /**
   * The name of the connection. This is the name that can be used to
   * get an AudioNode instance via the
   * {{#crossLink "Connectable/getNodeFrom:method"}}{{/crossLink}} method, or a
   * Connection instance via the
   * {{#crossLink "Connectable/getConnection"}}{{/crossLink}} method.
   *
   * @public
   * @property name
   * @type {string}
   */
  name: null,

  /**
   * If an AudioNode instance already exists and is accessible to the Sound
   * instance, the path to the node can be placed here. If this value is
   * specified, all options except `name` become useless. If `node` is specified,
   * it will override this option and the AudioNode supplied to `node` will be
   * used.
   *
   * @example
   *     // Uses the Audio Node instance from:
   *     // soundInstance.get('audioContext.destination')
   *     {
   *       name: 'destination',
   *       path: 'audioContext.destination'
   *     }
   *
   * @public
   * @property path
   * @type {string}
   */
  path: null,

  /**
   * If `createCommand` is specified, the object at this location (relative to
   * the Sound instance) will be used as the "source" of the `createCommand`.
   *
   * @example
   *     // Creates the AudioNode by calling:
   *     // this.get('audioContext')[createCommand]();
   *     {
   *       source: 'audioContext'
   *       createCommand: createGain
   *     }
   *
   * @public
   * @property source
   * @type {string}
   */
  source: null,

  /**
  * If `source` is specified, this method will be called on the object that was
  * retrieved from `source`. The value returned from this method is set on the
  * `node` property.
  *
  * @example
  *     // results in the `node` property being created like:
  *     // this.get('audioContext').createGain();
  *     {
  *       source: 'audioContext'
  *       createCommand: 'createGain'
  *     }
  *
  * @public
  * @property createCommand
  * @type {string}
  */
  createCommand: null,

  /**
   * An array of POJOs that specify properties that need to be set on a node
   * when any of the {{#crossLink "Playable/play:method"}}{{/crossLink}} methods
   * are called. For instance, an
   * {{#crossLink "AudioBufferSourceNode"}}{{/crossLink}} must be created at
   * play time, because they can only be played once and then they are
   * immediately thrown away.
   *
   * Valid keys are:
   *
   * `attrNameOnNode` {string} Determines which property on the node should be
   * set to the value. This can be a nested accessor (ie. `'gain.value'`).
   *
   * `relativePath` {string} Determines where on `this` (the Sound instance) to
   * get the value. This can be a nested accessor (ie. `'gainNode.gain.value'`).
   *
   * `value` {mixed} The direct value to set. If used along with `relativePath`,
   * this will act as a default value and the value at `relativePath` will take
   * precedence.
   *
   * @example
   *     // Causes gainNode.gain.value = soundInstance.get('gainValue') || 1;
   *     // to be called at play-time
   *
   *     {
   *       name: 'gainNode',
   *       onPlaySetAttrsOnNode: [
   *         {
   *           attrNameOnNode: 'gain.value',
   *           relativePath: 'gainValue',
   *           value: 1
   *         }
   *       ]
   *     }
   *
   * @public
   * @property onPlaySetAttrsOnNode
   * @type {Ember.MutableArray}
   * @default Ember.A() via _initArrays
   */
  onPlaySetAttrsOnNode: null,

  /**
   * Items in this array are set at play-time on the `node` via an exponential
   * ramp that ends at the specified time.
   *
   * A convenience setter method called
   * {{#crossLink "Connection/onPlaySet:method"}}{{/crossLink}} exists for this
   * array and should be used unless it does not allow enough freedom for your
   * use-case.
   *
   * @example
   *     // at play time: connection.node.gain.exponentialRampToValueAtTime(0.1, 1)
   *     {
   *       key: 'gain',
   *       value: 0.1,
   *       endTime: 1
   *     }
   *     // the same thing can be accomplished like:
   *     connection.onPlaySet('gain').to(0.1).endingAt(1)
   *
   * @public
   * @property exponentialRampToValuesAtTime
   * @type {Ember.MutableArray}
   * @default Ember.A() via _initArrays
   */
  exponentialRampToValuesAtTime: null,

  /**
   * Items in this array are set at play-time on the `node` via a linear ramp
   * that ends at the specified time.
   *
   * A convenience setter method called
   * {{#crossLink "Connection/onPlaySet:method"}}{{/crossLink}} exists for this
   * array and should be used unless it does not allow enough freedom for your
   * use-case.
   *
   * @example
   *     // at play time: connection.node.gain.linearRampToValueAtTime(0.1, 1)
   *     {
   *       key: 'gain',
   *       value: 0.1,
   *       endTime: 1
   *     }
   *     // the same thing can be accomplished like:
   *     connection.onPlaySet('gain').to(0.1).endingAt(1, 'linear')
   *
   * @public
   * @property linearRampToValuesAtTime
   * @type {Ember.MutableArray}
   * @default Ember.A() via _initArrays
   */
  linearRampToValuesAtTime: null,

  /**
   * Items in this array are set at play-time on the `node` via an exponential
   * ramp that ends at the specified time.
   *
   * A convenience setter method called
   * {{#crossLink "Connection/onPlaySet:method"}}{{/crossLink}} exists for this
   * array and should be used unless it does not allow enough freedom for your
   * use-case.
   *
   * @example
   *     // at play time: connection.node.gain.setValueAtTime(0.1, 1)
   *     {
   *       key: 'gain',
   *       value: 0.1,
   *       startTime: 1
   *     }
   *     // the same thing can be accomplished like:
   *     connection.onPlaySet('gain').to(0.1).at(1)
   *
   * @public
   * @property setValuesAtTime
   * @type {Ember.MutableArray}
   * @default Ember.A() via _initArrays
   */
  setValuesAtTime: null,

  /**
   * Items in this array are set immediately at play-time on the `node`.
   *
   * A convenience setter method called
   * {{#crossLink "Connection/onPlaySet:method"}}{{/crossLink}} exists for this
   * array and should be used unless it does not allow enough freedom for your
   * use-case.
   *
   * @example
   *     // at play time: connection.node.gain.setValueAtTime(0.1, audioContext.currentTime)
   *     {
   *       key: 'gain',
   *       value: 0.1
   *     }
   *     // the same thing can be accomplished like:
   *     connection.onPlaySet('gain').to(0.1)
   *
   * @public
   * @property startingValues
   * @type {Ember.MutableArray}
   * @default Ember.A() via _initArrays
   */
  startingValues: null,

  /**
   * This is the main attraction here in connection-land. All the other
   * properties in the Connection class exist to create or mutate this property.
   * Houses an AudioNode instance that will be used by an instance of the Sound
   * class.
   *
   * If this property is set directly, all of the other properties on this class
   * (except `name`) are rendered useless.
   *
   * @public
   * @property node
   * @type {AudioNode}
   */
  node: null,

  /**
   * If this is true, the AudioNode will be created every time the consuming
   * Sound instance is played.
   *
   * @public
   * @property createdOnPlay
   * @type {boolean}
   * @default false
   */
  createdOnPlay: false,

  /**
   * Allows an AudioNode's values to be set at a specific time
   * relative to the moment that it is played, every time it is played.
   *
   * Especially useful for creating/shaping an "envelope" (think "ADSR").
   *
   * @example
   *     // results in an oscillator that starts at 150Hz and quickly drops
   *     // down to 0.01Hz each time it's played
   *     const kick = audio.createOscillator({ name: 'kick' });
   *     const osc = kick.getConnection('audioSource');
   *
   *     osc.onPlaySet('frequency').to(150).at(0);
   *     osc.onPlaySet('frequency').to(0.01).at(0.1);
   *
   * @public
   * @method onPlaySet
   * @todo document 'exponential' and 'linear' options
   */
  onPlaySet(key) {
    const startingValues = this.get('startingValues');
    const exponentialValues = this.get('exponentialRampToValuesAtTime');
    const linearValues = this.get('linearRampToValuesAtTime');
    const valuesAtTime = this.get('setValuesAtTime');

    return {
      to(value) {
        const startValue = { key, value };

        startingValues.pushObject(startValue);

        return {
          at(startTime) {
            startingValues.removeObject(startValue);
            valuesAtTime.pushObject({ key, value, startTime });
          },
          endingAt(endTime, type='exponential') {
            startingValues.removeObject(startValue);

            switch (type) {
              case 'exponential':
                exponentialValues.pushObject({ key, value, endTime });
                break;
              case 'linear':
                linearValues.pushObject({ key, value, endTime });
                break;
            }
          }
        };
      }
    };
  },

  /**
   * Convenience method that uses
   * {{#crossLink "Connection/onPlaySet:method"}}{{/crossLink}} twice to set an
   * initial value, and a ramped value in succession.
   *
   * Especially useful for creating/shaping an "envelope" (think "ADSR").
   *
   * @example
   *     // results in an oscillator that starts at 150Hz and quickly drops
   *     // down to 0.01Hz each time it's played
   *     const kick = audio.createOscillator({ name: 'kick' });
   *     const osc = kick.getConnection('audioSource');
   *
   *     osc.onPlayRamp('frequency').from(150).to(0.01).in(0.1);
   *
   * @public
   * @method onPlaySet
   */
  onPlayRamp(key) {
    const onPlaySet = this.onPlaySet.bind(this);

    return {
      from(startValue) {
        return {
          to(endValue) {
            return {
              in(endTime) {
                onPlaySet(key).to(startValue);
                onPlaySet(key).to(endValue).endingAt(endTime);
              }
            };
          }
        };
      }
    };
  },

  /**
   * If any of the array types are null on init, set them to an
   * Ember.MutableArray
   *
   * @private
   * @method _initArrays
   */
  _initArrays: on('init', function() {
    const arrays = [
      'onPlaySetAttrsOnNode',
      'exponentialRampToValuesAtTime',
      'linearRampToValuesAtTime',
      'setValuesAtTime',
      'startingValues'
    ];

    arrays.map((name) => {
      if (!this.get(name)) {
        this.set(name, A());
      }
    });
  })
});

export default Connection;