Source: env/media/webMicLevels.js


/**
 * MicLevelsAnalysis is a plugin/module for generating "microphone input levels changed events" for
 * ASR (speech recognition) modules based on Web Audio Input (i.e. analyze audio from getUserMedia)
 *
 * The plugin triggers events <code>miclevelchanged</code> on listeners registered to the MediaManager.
 *
 * In addition, if the mic-levels-audio plugin starts its own audio-stream, an <code>webaudioinputstarted</code>
 * event is trigger, when the plugin starts.
 *
 * @example
 *
 * ////////////////////////////////  within media plugin: load analyzer //////////////////////////////////
 * //within audio-input plugin that uses Web Audio: load mic-levels-analyzer plugin
 *
 * //first: check, if the analyzer plugin is already loaded (should be loaded only once)
 * if(!mediaManager.micLevelsAnalysis){
 *
 * 	//set marker so that other plugins may know that the analyzer will be loaded:
 * 	mediaManager.micLevelsAnalysis = true;
 *
 * 	//load the analyzer
 * 	mediaManager.loadPlugin(micLevelsImplFile, function success(){
 *
 * 		//... finish the audio-plugin initialization, e.g. invoke initializer-callback
 *
 * 	}, function error(err){
 *
 *	  	// ... in case the analyzer could not be loaded:
 *		// do some error handling ...
 *
 *		//... and supply a stub-implementation for the analyzer module:
 *	  	mediaManager.micLevelsAnalysis = {
 *	  		_active: false,
 *	  		start: function(){
 *	  			console.info('STUB::micLevelsAnalysis.start()');
 *	  		},
 *	  		stop: function(){
 *	  			console.info('STUB::micLevelsAnalysis.stop()');
 *	  		},
 *	  		enable: function(enable){
 *	  			console.info('STUB::micLevelsAnalysis.enable('+(typeof enable === 'undefined'? '': enable)+') -> false');
 *	  			return false;//<- the stub can never be enabled
 *	  		},
 *	  		active: function(active){
 *	  			this._active = typeof active === 'undefined'? this._active: active;
 *	  			console.info('STUB::micLevelsAnalysis.active('+(typeof active === 'undefined'? '': active)+') -> ' + this._active);
 *	  			return active;//<- must always return the input-argument's value
 *	  		}
 *	  	};
 *
 *	  	//... finish the audio-plugin initialization without the mic-levels-analyzer, e.g. invoke initializer-callback
 *
 *	  });
 * } else {
 *
 * 	//if analyzer is already loaded/loading: just finish the audio-plugin initialization,
 * 	//										 e.g. invoke initializer-callback
 *
 * }
 *
 *
 * ////////////////////////////////  use of mic-levels-analysis events //////////////////////////////////
 * //in application code: listen for mic-level-changes
 *
 * mmir.media.on('miclevelchange', function(micValue){
 *
 * });
 *
 * @class
 * @public
 * @name MicLevelsAnalysis
 * @memberOf mmir.env.media
 * @hideconstructor
 *
 * @see {@link mmir.env.media.WebspeechAudioInput} for an example on integrating the mic-levels-analysis plugin into an audio-input plugin
 *
 * @requires HTML5 AudioContext
 * @requires HTML5 getUserMedia (audio)
 */

define(['mmirf/mediaManager'], function(mediaManager){

return {
	/**  @memberOf mmir.env.media.MicLevelsAnalysis.module# */
	initialize: function(callBack){//, ctxId, moduleConfig){//DISABLED this argument is currently un-used -> disabled

		/**
		 * @type getUserMedia()
		 * @memberOf mmir.env.media.MicLevelsAnalysis#
		 * @private
		 */
		var _getUserMedia;
		/**
		 * @type AudioContext
		 * @memberOf mmir.env.media.MicLevelsAnalysis#
		 * @private
		 */
		var _audioContext;
		/**  @memberOf mmir.env.media.MicLevelsAnalysis#
		 * @private
		 */
		var createAudioContext = function(){

			if(typeof AudioContext !== 'undefined'){
				_audioContext = new AudioContext;
			}
			else {//if(typeof webkitAudioContext !== 'undefined'){
				_audioContext = new webkitAudioContext;
			}
		};
		var nonFunctional = false;

		try {

			/**  @memberOf mmir.env.media.MicLevelsAnalysis.navigator# */
			_getUserMedia = (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) || navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia;

			if(_getUserMedia){
				nonFunctional = !((typeof AudioContext !== 'undefined') || (typeof webkitAudioContext !== 'undefined'));
				if(!nonFunctional){
					if(navigator.mediaDevices){
						// wrap navigator.mediaDevices.getUserMedia():
						_getUserMedia = function(constraints, onSuccess, onError){
							navigator.mediaDevices.getUserMedia(constraints).then(onSuccess).catch(onError);
						};
					} else {
						// wrap legacy impl. navigator.getUserMedia():
						navigator.__getUserMedia = _getUserMedia;
						_getUserMedia = function(constraints, onSuccess, onError){
							navigator.__getUserMedia.getUserMedia(constraints, onSuccess, onError);
						};
					}
				} else {
					mediaManager._log.error('MicLevelsAnalysis: No web audio support in this browser!');
				}

			} else {
				mediaManager._log.error('MicLevelsAnalysis: Could not access getUserMedia() API: no access to microphone available (may not be running in through secure HTTPS connection?)');
				nonFunctional = true;
			}
		}
		catch (e) {
			mediaManager._log.error('MicLevelsAnalysis: No web audio support in this browser! Error: '+(e.stack? e.stack : e));
			nonFunctional = true;
		}

		/**
		 * state-flag that indicates, if the process (e.g. ASR, recording)
		 * is actually active right now, i.e. if analysis calculations should be done or not.
		 *
		 * @memberOf mmir.env.media.MicLevelsAnalysis#
		 * @private
		 */
		var recording = false;
		/**
		 * Switch for generally disabling "microphone-level changed" calculations
		 * (otherwise calculation becomes active/inactive depending on whether or
		 *  not a listener is registered to event {@link #MIC_CHANGED_EVT_NAME})
		 *
		 * <p>
		 * TODO make this configurable?...
		 *
		 * @memberOf mmir.env.media.MicLevelsAnalysis#
		 * @private
		 */
		var isMicLevelsEnabled = true;
		/** MIC-LEVELS: the maximal value to occurs in the input data
		 * <p>
		 * FIXME verify / check if this is really the maximal possible value...
		 * @contant
		 * @memberOf mmir.env.media.MicLevelsAnalysis#
		 * @private
		 */
		var MIC_MAX_VAL = 2;//
		/** MIC-LEVELS: the maximal value for level changes (used for normalizing change-values)
		 * @constant
		 * @private
		 * @memberOf mmir.env.media.MicLevelsAnalysis# */
		var MIC_MAX_NORM_VAL = -90;// -90 dB ... ???

		/** MIC-LEVELS: normalization factor for values: adjust value, so that is
		 *              more similar to the results from the other input-modules
		 * @constant
		 * @private
		 * @memberOf mmir.env.media.MicLevelsAnalysis# */
		var MIC_NORMALIZATION_FACTOR = 3.5;//adjust value, so that is more similar to the results from the other input-modules
		/** MIC-LEVELS: time interval / pauses between calculating level changes
		 * @constant
		 * @private
		 * @memberOf mmir.env.media.MicLevelsAnalysis# */
		var MIC_QUERY_INTERVALL = 48;
		/** MIC-LEVELS: threshold for calculating level changes
		 * @constant
		 * @private
		 * @memberOf mmir.env.media.MicLevelsAnalysis# */
		var LEVEL_CHANGED_THRESHOLD = 1.05;
		/**
		 * MIC-LEVELS: Name for the event that is emitted, when the input-mircophone's level change.
		 *
		 * @private
		 * @constant
		 * @default "miclevelchanged"
		 * @memberOf mmir.env.media.MicLevelsAnalysis#
		 */
		var MIC_CHANGED_EVT_NAME = 'miclevelchanged';

		/**
		 * STREAM_STARTED: Name for the event that is emitted, when the audio input stream for analysis becomes available.
		 *
		 * @private
		 * @constant
		 * @default "webaudioinputstarted"
		 * @memberOf mmir.env.media.MicLevelsAnalysis#
		 */
		var STREAM_STARTED_EVT_NAME = 'webaudioinputstarted';

		/**
		 * HELPER normalize the levels-changed value to MIC_MAX_NORM_VAL
		 * @deprecated currently un-used
		 * @private
		 * @memberOf mmir.env.media.MicLevelsAnalysis#
		 */
		var normalize = function (v){
			return MIC_MAX_NORM_VAL * v / MIC_MAX_VAL;
		};
		/**
		 * HELPER calculate the RMS value for list of audio values
		 * @deprecated currently un-used
		 * @private
		 * @memberOf mmir.env.media.MicLevelsAnalysis#
		 */
		var getRms = function (buffer, size){
			if(!buffer || size === 0){
				return 0;
			}

			var sum = 0, i = 0;
			for(; i < size; ++i){
				sum += buffer[i];
			}
			var avg = sum / size;

			var meansq = 0;
			for(i=0; i < size; ++i){
				meansq += Math.pow(buffer[i] - avg, 2);
			}

			var avgMeansq = meansq / size;

			return Math.sqrt(avgMeansq);
		};
		/**
		 * HELPER calculate the dezible value for PCM value
		 * @deprecated currently un-used
		 * @private
		 * @memberOf mmir.env.media.MicLevelsAnalysis#
		 */
		var getDb = function (pcmData, upperLimit){
			return 20 * Math.log10(Math.abs(pcmData)/upperLimit);
		};
		/**
		 * HELPER determine if a value has change in comparison with a previous value
		 * 		  (taking the LEVEL_CHANGED_THRESHOLD into account)
		 * @memberOf mmir.env.media.MicLevelsAnalysis#
		 * @private
		 */
		var hasChanged = function(value, previousValue){
			var res = typeof previousValue === 'undefined' || Math.abs(value - previousValue) > LEVEL_CHANGED_THRESHOLD;
			return res;
		};
		/**
		 * @type LocalMediaStream
		 * @memberOf mmir.env.media.MicLevelsAnalysis#
		 * @private
		 * @see https://developer.mozilla.org/en-US/docs/Web/API/MediaStream_API#LocalMediaStream
		 */
		var _currentInputStream;
		/**
		 * @type AnalyserNode
		 * @memberOf mmir.env.media.MicLevelsAnalysis#
		 * @private
		 * @see https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode
		 */
		var _audioAnalyzer;

		var _ownsInputStream = true;

		//FIXME test
		// window.RAW_DATA = [];
		// window.DB_DATA = [];
		// window.RMS_DATA = [];

		/**
		 * HELPER callback for getUserMedia: creates the microphone-levels-changed "analyzer"
		 *        and fires mic-levels-changed events for registered listeners
		 * @param {LocalMediaStream} inputstream
		 * @private
		 * @memberOf mmir.env.media.MicLevelsAnalysis#
		 */
		function _startUserMedia(inputstream, foreignAudioData){
			mediaManager._log.info('MicLevelsAnalysis: start analysing audio input...');
			var buffer = 0;
			// var prevDb;
			var prevRms;

			//we only need one analysis: if there is one active from a previous start
			//  -> do stop it, before storing the new inputstream in _currentInputStream
			if(_currentInputStream){
				_stopAudioAnalysis();
			}

			_currentInputStream = inputstream;

			if(_isAnalysisCanceled === true){
				//ASR was stopped, before the audio-stream for the analysis became available:
				// -> stop analysis now, since ASR is not active (and close the audio stream without doing anything)
				_stopAudioAnalysis();
				return;//////////////// EARLY EXIT //////////////////////
			}

			var inputNode;
			if(!foreignAudioData){

				_ownsInputStream = true;

				if(!_audioContext){
					createAudioContext();
				}

				inputNode = _audioContext.createMediaStreamSource(_currentInputStream);

				//fire event STREAM_STARTED to inform listeners & allow them to use the audio stream
				mediaManager._emitEvent(STREAM_STARTED_EVT_NAME, inputNode, _audioContext);
			}
			else {

				_ownsInputStream = false;

				_currentInputStream = true;
				inputNode = foreignAudioData.inputSource;
				_audioContext = foreignAudioData.audioContext;
			}

			///////////////////// VIZ ///////////////////
	//		recorder = recorderInstance;

			_audioAnalyzer = _audioContext.createAnalyser();
			_audioAnalyzer.fftSize = 2048;
			_audioAnalyzer.minDecibels = -90;
			_audioAnalyzer.maxDecibels = 0;
			_audioAnalyzer.smoothingTimeConstant = 0.8;//NOTE: value 1 will smooth everything *completely* -> do not use 1
			inputNode.connect(_audioAnalyzer);

	//		audioRecorder = new Recorder( _currentInputStream );
	//		recorder = new Recorder(_currentInputStream, {workerPath: recorderWorkerPath});

	//		updateAnalysers();

			var updateAnalysis = function(){
				if(!_currentInputStream){
					return;
				}

				var size = _audioAnalyzer.fftSize;//.frequencyBinCount;//
				var data = new Uint8Array(size);//new Float32Array(size);//
				_audioAnalyzer.getByteTimeDomainData(data);//.getFloatFrequencyData(data);//.getByteFrequencyData(data);//.getFloatTimeDomainData(data);//

				// var view = new DataView(data.buffer);

				var MAX = 255;//32768;
				var MIN = 0;//-32768;

				var min = MAX;//32768;
				var max = MIN;//-32768;
				var total = 0;
				for(var i=0; i < size; ++i){
					var datum = Math.abs(data[i]);

					//FIXM TEST
					// mediaManager._log.d('data '+(20 * Math.log10(data[i]/MAX)));//+view.getInt16(i));
					// mediaManager._log.d('data '+view.getInt16(i));
					// mediaManager._log.d('data '+data[i]);
					// window.RAW_DATA.push(data[i]);
					// window.DB_DATA.push(20 * Math.log10(data[i]/MAX));
					// window.RMS_DATA.push('');

					if (datum < min)
						min = datum;
					if (datum > max)
						max = datum;

					total += datum;
				}
				var avg = total / size;
	//			mediaManager._log.debug('audio ['+min+', '+max+'], avg '+avg);

	//			var rms = getRms(data, size);
	//			var db = 20 * Math.log(rms);// / 0.0002);

	//			mediaManager._log.debug('audio rms '+rms+', db '+db);

				/* RMS stands for Root Mean Square, basically the root square of the
				 * average of the square of each value. */
				var rms = 0, val;
				for (var i = 0; i < data.length; i++) {
					val = data[i] - avg;
					rms += val * val;
				}
				rms /= data.length;
				rms = Math.sqrt(rms);

				// window.RMS_DATA[window.RMS_DATA.length-1] = rms;//FIXME TEST
				var db;// = 20 * Math.log10(Math.abs(max)/MAX);
				// var db = rms;
	//			mediaManager._log.debug('audio rms '+rms);
				// mediaManager._log.debug('audio rms changed: '+prevDb+' -> '+db);
				//actually fire the change-event on all registered listeners:
				if(hasChanged(rms, prevRms)){
					prevRms = rms;

					// //adjust value
					// db *= MIC_NORMALIZATION_FACTOR;
					db = 20 * Math.log10(Math.abs(max)/MAX);

					//mediaManager._log.debug('audio rms changed ('+db+'): '+prevRms+' -> '+rms);

					mediaManager._emitEvent(MIC_CHANGED_EVT_NAME, db, rms);
				}


				if(_isAnalysisActive && _currentInputStream){
					setTimeout(updateAnalysis, MIC_QUERY_INTERVALL);
				}
			};
			updateAnalysis();
			///////////////////// VIZ ///////////////////

		}


		/** internal flag: is/should mic-levels analysis be active?
		 * @memberOf mmir.env.media.MicLevelsAnalysis#
		 * @private
		 */
		var _isAnalysisActive = false;
		/** internal flag: is/should mic-levels analysis be active?
		 * @memberOf mmir.env.media.MicLevelsAnalysis#
		 * @private
		 */
		var _isAnalysisCanceled = false;
		/** HELPER start-up mic-levels analysis (and fire events for registered listeners)
		 * @memberOf mmir.env.media.MicLevelsAnalysis#
		 * @private
		 */
		function _startAudioAnalysis(audioInputData){
			if(_isAnalysisActive === true){
				return;
			}
			_isAnalysisCanceled = false;
			_isAnalysisActive = true;

			if(audioInputData){
				//use existing input stream for analysis:
				_startUserMedia(null, audioInputData);
			}
			else {

				//start analysis with own audio input stream:
				_getUserMedia({audio: true}, _startUserMedia, function(e) {
					mediaManager._log.warn("MicLevelsAnalysis: failed _startAudioAnalysis, unsuccessfully requested getUserMedia() ", e);
					_isAnalysisActive = false;
				});
			}
		}

		/** HELPER stop mic-levels analysis
		 * @memberOf mmir.env.media.MicLevelsAnalysis#
		 * @private
		 */
		function _stopAudioAnalysis(){

			if(_ownsInputStream){

				if(_currentInputStream){
					var stream = _currentInputStream;
					_currentInputStream = void(0);
					//DISABLED: MediaStream.stop() is deprecated -> instead: stop all tracks individually
//					stream.stop();
					try{
						if(stream.active){
							var list = stream.getTracks(), track;
							for(var i=list.length-1; i >= 0; --i){
								track = list[i];
								if(track.readyState !== 'ended'){
									track.stop();
								}
							}
						}
					} catch (err){
						mediaManager._log.error('MicLevelsAnalysis: a problem occured while stopping audio input analysis: '+(e.stack? e.stack : e));
					}
					_isAnalysisCanceled = false;
					_isAnalysisActive = false;

					mediaManager._log.info('MicLevelsAnalysis: stopped analysing audio input!');
				}
				else if(_isAnalysisActive === true){
					mediaManager._log.warn('MicLevelsAnalysis: stopped analysing audio input process, but no valid audio stream present!');
					_isAnalysisCanceled = true;
					_isAnalysisActive = false;
				}

			} else {//input stream is owned by external creator: just set internal flag for stopping analysis

				_currentInputStream = void(0);//remove foreign inputStream
				_audioContext = void(0);//remove foreign audioContext
				_isAnalysisCanceled = false;
				_isAnalysisActive = false;
			}
		}

		/** HELPER determine whether to start/stop audio-analysis based on
		 * 		   listeners getting added/removed on the MediaManager
		 * @memberOf mmir.env.media.MicLevelsAnalysis#
		 * @private
		 */
		function _updateMicLevelAnalysis(actionType, handler){

			//start analysis now, if necessary
			if(		actionType 			=== 'added' &&
					recording 			=== true &&
					_isAnalysisActive 	=== false &&
					isMicLevelsEnabled 	=== true
			){
				_startAudioAnalysis();
			}
			//stop analysis, if there is no listener anymore
			else if(actionType 			=== 'removed' &&
					_isAnalysisActive 	=== true &&
					mediaManager.hasListeners(MIC_CHANGED_EVT_NAME) === false
			){
				_stopAudioAnalysis();
			}
		}
		//observe changes on listener-list for mic-levels-changed-event
		mediaManager._addListenerObserver(MIC_CHANGED_EVT_NAME, _updateMicLevelAnalysis);

		callBack({micLevelsAnalysis: {
			/**
			 * Start the audio analysis for generating "microphone levels changed" events.
			 *
			 * This functions should be called, when ASR is starting / receiving the audio audio stream.
			 *
			 *
			 * When the analysis has started, listeners of the <code>MediaManager</code> for
			 * event <code>miclevelchanged</code> will get notified, when the mic-levels analysis detects
			 * changes in the microphone audio input levels.
			 *
			 * @param {AudioInputData} [audioInputData]
			 * 						If provided, the analysis will use these audio input objects instead
			 * 						of creating its own audio-input via <code>getUserMedia</code>.
			 * 						The AudioInputData object must have 2 properties:
			 * 	{
			 * 		inputSource: MediaStreamAudioSourceNode (HTML5 Web Audio API)
			 * 		audioContext: AudioContext (HTML5 Web Audio API)
			 * 	}
			 * 						If this argument is omitted, then the analysis will create its own
			 * 						audio input stream via <code>getUserMedia</code>
			 *
			 * @memberOf mmir.env.media.MicLevelsAnalysis.prototype
			 */
			start: function(audioInputData){

				if(isMicLevelsEnabled){//same as: this.enabled()
					if(!nonFunctional){
						_startAudioAnalysis(audioInputData);
					}
				}
			},
			/**
			 * Stops the audio analysis for "microphone levels changed" events.
			 *
			 * This functions should be called, when ASR has stopped / closed the audio input stream.
			 *
			 * @memberOf mmir.env.media.MicLevelsAnalysis.prototype
			 */
			stop: function(){
				if(!nonFunctional){
					_stopAudioAnalysis();
				}
			},
			/**
			 * Get/set the mic-level-analysis' enabled-state:
			 * If the analysis is disabled, then {@link #start} will not active the analysis (and currently running
			 * analysis will be stopped).
			 *
			 * This function is getter and setter: if an argument <code>enable</code> is provided, then the
			 * mic-level-analysis' enabled-state will be set, before returning the current value of the enabled-state
			 * (if omitted, just the enabled-state will be returned)
			 *
			 * @param {Boolean} [enable] OPTIONAL
			 * 				if <code>enable</code> is provided, then the mic-level-analysis' enabled-state
			 * 				is set to this value.
			 * @returns {Boolean}
			 * 				the mic-level-analysis' enabled-state
			 *
			 * @memberOf mmir.env.media.MicLevelsAnalysis.prototype
			 */
			enabled: function(enable){

				if(nonFunctional){
					return false;
				}

				if(typeof enable !== 'undefined'){

					if(!enable && (isMicLevelsEnabled != enable || _isAnalysisActive)){
						this.stop();
					}

					isMicLevelsEnabled = enable;
				}
				return isMicLevelsEnabled;
			},
			/**
			 * Getter/Setter for ASR-/recording-active state.
			 *
			 * This function should be called with <code>true</code> when ASR starts and
			 * with <code>false</code> when ASR stops.
			 *
			 *
			 * NOTE setting the <code>active</code> state allows the analyzer to start
			 * processing when a listener for <code>miclevelchanged</code> is added while
			 * ASR/recording is already active (otherwise the processing would not start
			 * immediately, but when the ASR/recording is started the next time).
			 *
			 *
			 * @param {Boolean} [active]
			 * 				if <code>active</code> is provided, then the mic-level-analysis' (recording) active-state
			 * 				is set to this value.
			 * @returns {Boolean}
			 * 				the mic-level-analysis' (recording) active-state.
			 * 				If argument <code>active</code> was supplied, then the return value will be the same
			 * 				as this input value.
			 *
			 * @memberOf mmir.env.media.MicLevelsAnalysis.prototype
			 */
			active: function(active){

				if(nonFunctional){
					return false;
				}

				if(typeof active !== 'undefined'){
					recording = active;
				}
				return recording;
			}
		}})
	}

};

});//END define