Source: media-element-audio-stream.js

'use strict';
var Readable = require('stream').Readable;
var util = require('util');

/**
 * Turns a MediaStream object (from getUserMedia) into a Node.js Readable stream and converts the audio to Buffers
 *
 * @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/getUserMedia
 *
 * @param {MediaStream|HTMLMediaElement} source - either https://developer.mozilla.org/en-US/docs/Web/API/MediaStream or https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement
 * @param {Object} [opts] options
 * @param {Number|null} [opts.bufferSize=null] https://developer.mozilla.org/en-US/docs/Web/API/AudioContext/createScriptProcessor
 * @param {Boolean} [opts.muteSource=false] - If true, the audio will not be sent back to the source
 *
 * // todo: add option for whether to keep or destroy the context
 *
 * @constructor
 */
function MediaElementAudioStream(source, opts) {

  opts = util._extend({
    // "It is recommended for authors to not specify this buffer size and allow the implementation to pick a good
    // buffer size to balance between latency and audio quality."
    // https://developer.mozilla.org/en-US/docs/Web/API/AudioContext/createScriptProcessor
    // Possible values: null, 256, 512, 1024, 2048, 4096, 8192, 16384
    // however, webkitAudioContext (safari) requires it to be set
    bufferSize: (typeof AudioContext != "undefined" ? null : 4096),
    muteSource: false,
    autoplay: true,
    crossOrigin: "anonymous", // required for cross-domain audio playback
    objectMode: true // true = emit AudioBuffers w/ audio + some metadata, false = emite node.js Buffers (with binary data only
  }, opts);

  // We can only emit one channel's worth of audio, so only one input. (Who has multiple microphones anyways?)
  var inputChannels = 1;

  // we shouldn't need any output channels (going back to the browser - that's what the gain node is for), but chrome is buggy and won't give us any audio without one
  var outputChannels = 1;

  Readable.call(this, opts);

  var self = this;
  var recording = true;

  // I can't find much documentation for this for <audio> elements, but it seems to be required for cross-domain usage (in addition to CORS headers)
  source.crossOrigin = opts.crossOrigin;

  /**
   * Convert and emit the raw audio data
   * @see https://developer.mozilla.org/en-US/docs/Web/API/ScriptProcessorNode/onaudioprocess
   * @param {AudioProcessingEvent} e https://developer.mozilla.org/en-US/docs/Web/API/AudioProcessingEvent
   */
  function processAudio(e) {
    // onaudioprocess can be called at least once after we've stopped
    if (recording) {
      // todo: interleave channels in binary mode
      self.push( opts.objectMode ? e.inputBuffer : new Buffer(e.inputBuffer.getChannelData(0)) );
    }
  }

  var AudioContext = window.AudioContext || window.webkitAudioContext;
  // cache the source node & context since it's not possible to recreate it later
  var context = source.context = source.context || new AudioContext();
  var audioInput = source.node  = source.node || context.createMediaElementSource(source);
  var scriptProcessor = context.createScriptProcessor(opts.bufferSize, inputChannels, outputChannels);

  scriptProcessor.onaudioprocess = processAudio;

  if (!opts.muteSource) {
    var gain = context.createGain();
    audioInput.connect(gain);
    gain.connect(context.destination);
  }

  /**
   * Setup script processor to extract audio and also re-connect it via a no-op gain node if desired
   *
   * Delayed to avoid processing the stream of silence received before the file begins playing
   *
   */
  function connect() {
    audioInput.connect(scriptProcessor);
    // other half of workaround for chrome bugs
    scriptProcessor.connect(context.destination);
    source.removeEventListener("playing", connect);
  }
  source.addEventListener("playing", connect);

  // https://developer.mozilla.org/en-US/docs/Web/Guide/Events/Media_events
  // https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/readyState
  function start() {
    source.play();
    source.removeEventListener("canplaythrough", start);
  }
  if (opts.autoplay) {
    // play immediately if we have enough data, otherwise wait for the canplaythrough event
    if(source.readyState === source.HAVE_ENOUGH_DATA) {
      source.play();
    } else {
      source.addEventListener("canplaythrough", start);
    }
  }

  function end() {
    recording = false;
    scriptProcessor.disconnect();
    audioInput.disconnect();
    //context.close(); // this prevents us from re-using the same audio element until the page is refreshed
    self.push(null);
    self.emit('close');
  }
  source.addEventListener("ended", end);

  this.stop = function() {
    source.pause();
    end();
  };

  source.addEventListener("error", this.emit.bind(this, 'error'));

  process.nextTick(function() {
    // this is more useful for binary mode than object mode, but it won't hurt either way
    self.emit('format', {
      channels: 1,
      bitDepth: 32,
      sampleRate: context.sampleRate,
      signed: true,
      float: true
    });
  });

}
util.inherits(MediaElementAudioStream, Readable);

MediaElementAudioStream.prototype._read = function(/* bytes */) {
  // no-op, (back-pressure flow-control doesn't really work on sound)
};

/**
 * Converts a Buffer back into the raw Float32Array format that browsers use.
 * Note: this is just a new DataView for the same underlying buffer -
 * the actual audio data is not copied or changed here.
 *
 * @param {Buffer} chunk node-style buffer of audio data from a 'data' event or read() call
 * @return {Float32Array} raw 32-bit float data view of audio data
 */
MediaElementAudioStream.toRaw = function toFloat32(chunk) {
  return new Float32Array(chunk.buffer);
};

module.exports = MediaElementAudioStream;