/*global PitchAnalyzer:true, Float32Array:false, FFT:false */
/*jshint undef:true node:true browser:true */

PitchAnalyzer = this.PitchAnalyzer = (function () {

var	pi	= Math.PI,
	pi2	= pi * 2,
	cos	= Math.cos,
	pow	= Math.pow,
	log	= Math.log,
	max	= Math.max,
	min	= Math.min,
	abs	= Math.abs,
	LN10	= Math.LN10,
	sqrt	= Math.sqrt,
	atan2	= Math.atan2,
	round	= Math.round,
	inf	= 1/0,
	FFT_P	= 10,
	FFT_N	= 1 << FFT_P,
	BUF_N	= FFT_N * 2;

function remainder (val, div) {
	return val - round(val/div) * div;
}

function extend (obj) {
	var	args	= arguments,
		l	= args.length,
		i, n;


	for (i=1; i<l; i++){
		for (n in args[i]){
			if (args[i].hasOwnProperty(n)){
				obj[n] = args[i][n];
			}
		}
	}

	return obj;
}

/**
 * A class for tones.
 *
 * @class
 * @static PitchAnalyzer
 * @param default:0.0 min:0.0 type:Number freq The frequency of the tone.
 * @param default:-Infinity max:0.0 type:Number db The volume of the tone.
 * @param default:-Infinity max:0.0 type:Number stabledb An average of the volume of the tone.
 * @param default:0 min:0 type:Integer age How many times the tone has been detected in a row.
*/
function Tone () {
	this.harmonics = new Float32Array(Tone.MAX_HARM);
}

Tone.prototype = {
	freq: 0.0,
	db: -inf,
	stabledb: -inf,
	age: 0,

	toString: function () {
		return '{freq: ' + this.freq + ', db: ' + this.db + ', stabledb: ' + this.stabledb + ', age: ' + this.age + '}';
	},

/**
 * Return an approximation of whether the tone has the same frequency as provided.
 *
 * @method Tone
 * @private
 * @arg {Number} freq The frequency to compare to.
 * @return {Boolean} Whether it was a match.
*/
	matches: function (freq) {
		return abs(this.freq / freq - 1.0) < 0.05;
	},

	harmonics: null
};

Tone.MIN_AGE = 2;
Tone.MAX_HARM = 48;

/**
 * An internal class to manage the peak frequencies detected.
 *
 * @private
 * @class
 * @static PitchAnalyzer
 * @arg default:0.0 min:0.0 type:Number !freq The frequency of the peak.
 * @arg default:-Infinity max:0.0 type:Number !db The volume of the peak.
*/
function Peak (freq, db) {
	this.freq = typeof freq === 'undefined' ? this.freq : freq;
	this.db = typeof db === 'undefined' ? this.db : db;

	this.harm = new Array(Tone.MAX_HARM);
}

Peak.prototype = {
	harm: null,

	freq: 0.0,
	db: -inf,

/**
 * Resets the peak to default values.
 *
 * @method Peak
 * @private
*/
	clear: function () {
		this.freq	= Peak.prototype.freq;
		this.db		= Peak.prototype.db;
	}
};

/**
 * Finds the best matching peak from a certain point in the array of peaks.
 *
 * @name match
 * @static Peak
 * @private
 * @arg {Array} peaks The peaks to search from.
 * @arg {Integer} pos The position to find the match for.
 * @return {Peak} The best matching peak.
*/
Peak.match = function (peaks, pos) {
	var best = pos;

	if (peaks[pos - 1].db > peaks[best].db) best = pos - 1;
	if (peaks[pos + 1].db > peaks[best].db) best = pos + 1;

	return peaks[best];
};

/**
 * A class to analyze pitch from input data.
 *
 * @class PitchAnalyzer
 * @arg {Object} !options Options to override default values.
*/
function Analyzer (options) {
	options = extend(this, options);

	this.data = new Float32Array(FFT_N);
	this.buffer = new Float32Array(BUF_N);
	this.fftLastPhase = new Float32Array(BUF_N);
	this.tones = [];

	if (this.wnd === null) this.wnd = Analyzer.calculateWindow();
	this.setupFFT();
}

Analyzer.prototype = {
	wnd: null,
	data: null,
	fft: null,
	tones: null,
	fftLastPhase: null,
	buffer: null,

	offset: 0,
	bufRead: 0,
	bufWrite: 0,

	MIN_FREQ: 45,
	MAX_FREQ: 5000,

	sampleRate: 44100,
	step: 200,
	oldFreq: 0.0,

	peak: 0.0,

/**
 * Gets the current peak level in dB (negative value, 0.0 = clipping).
 *
 * @method PitchAnalyzer
 * @return {Number} The current peak level (db).
*/
	getPeak: function () {
		return 10.0 * log(this.peak) / LN10;
	},

	findTone: function (minFreq, maxFreq) {
		if (!this.tones.length) {
			this.oldFreq = 0.0;
			return null;
		}

		minFreq = typeof minFreq === 'undefined' ? 65.0 : minFreq;
		maxFreq = typeof maxFreq === 'undefined' ? 1000.0 : maxFreq;

		var db = max.apply(null, this.tones.map(Analyzer.mapdb));
		var best = null;
		var bestscore = 0;

		for (var i=0; i<this.tones.length; i++) {
			if (this.tones[i].db < db - 20.0 || this.tones[i].freq < minFreq || this.tones[i].age < Tone.MIN_AGE) continue;
			if (this.tones[i].freq > maxFreq) break;

			var score = this.tones[i].db - max(180.0, abs(this.tones[i].freq - 300)) / 10.0;

			if (this.oldFreq !== 0.0 && abs(this.tones[i].freq / this.oldFreq - 1.0) < 0.05) score += 10.0;
			if (best && bestscore > score) break;

			best = this.tones[i];
			bestscore = score;
		}

		this.oldFreq = (best ? best.freq : 0.0);
		return best;
	},

/**
 * Copies data to the internal buffers for processing and calculates peak.
 * Note that if the buffer overflows, unprocessed data gets discarded.
 *
 * @method PitchAnalyzer
 * @arg {Float32Array} data The input data.
*/
	input: function (data) {
		var buf = this.buffer;
		var r = this.bufRead;
		var w = this.bufWrite;

		var overflow = false;

		for (var i=0; i<data.length; i++) {
			var s = data[i];
			var p = s * s;

			if (p > this.peak) this.peak = p; else this.peak *= 0.999;

			buf[w] = s;

			w = (w + 1) % BUF_N;

			if (w === r) overflow = true;
		}

		this.bufWrite = w;
		if (overflow) this.bufRead = (w + 1) % BUF_N;
	},

/**
 * Processes available data and calculates tones.
 *
 * @method PitchAnalyzer
*/
	process: function () {
		while (this.calcFFT()) this.calcTones();
	},

/**
 * Matches new tones against old ones, merging similar ones.
 *
 * @method PitchAnalyzer
 * @private
*/
	mergeWithOld: function (tones) {
		var i, n;

		tones.sort(function (a, b) { return a.freq < b.freq ? -1 : a.freq > b.freq ? 1 : 0; });

		for (i=0, n=0; i<this.tones.length; i++) {
			while (n < tones.length && tones[n].freq < this.tones[i].freq) n++;

			if (n < tones.length && tones[n].matches(this.tones[i].freq)) {
				tones[n].age = this.tones[i].age + 1;
				tones[n].stabledb = 0.8 * this.tones[i].stabledb + 0.2 * tones[n].db;
				tones[n].freq = 0.5 * (this.tones[i].freq + tones[n].freq);
			} else if (this.tones[i].db > -80.0) {
				tones.splice(n, 0, this.tones[i]);
				tones[n].db -= 5.0;
				tones[n].stabledb -= 0.1;
			}

		}
	},

/**
 * Calculates the tones from the FFT data.
 *
 * @method PitchAnalyzer
 * @private
*/
	calcTones: function () {
		var	freqPerBin	= this.sampleRate / FFT_N,
			phaseStep	= pi2 * this.step / FFT_N,
			normCoeff	= 1.0 / FFT_N,
			minMagnitude	= pow(10, -100.0 / 20.0) / normCoeff,
			kMin		= ~~max(1, this.MIN_FREQ / freqPerBin),
			kMax		= ~~min(FFT_N / 2, this.MAX_FREQ / freqPerBin),
			peaks		= [],
			tones		= [],
			k, k2, p, n, t, count, freq, magnitude, phase, delta, prevdb, db, bestDiv,
			bestScore, div, score;

		for (k=0; k <= kMax; k++) {
			peaks.push(new Peak());
		}

		for (k=1, k2=2; k<=kMax; k++, k2 += 2) {
			/* complex absolute */
			magnitude = sqrt(this.fft[k2] * this.fft[k2] + this.fft[k2+1] * this.fft[k2+1]);
			/* complex arguscosine */
			phase = atan2(this.fft[k2+1], this.fft[k2]);

			delta = phase - this.fftLastPhase[k];
			this.fftLastPhase[k] = phase;

			delta -= k * phaseStep;
			delta = remainder(delta, pi2);
			delta /= phaseStep;

			freq = (k + delta) * freqPerBin;

			if (freq > 1.0 && magnitude > minMagnitude) {
				peaks[k].freq = freq;
				peaks[k].db = 20.0 * log(normCoeff * magnitude) / LN10;
			}
		}

		prevdb = peaks[0].db;

		for (k=1; k<kMax; k++) {
			db = peaks[k].db;
			if (db > prevdb) peaks[k - 1].clear();
			if (db < prevdb) peaks[k].clear();
			prevdb = db;
		}

		for (k=kMax-1; k >= kMin; k--) {
			if (peaks[k].db < -70.0) continue;

			bestDiv = 1;
			bestScore = 0;

			for (div = 2; div <= Tone.MAX_HARM && k / div > 1; div++) {
				freq = peaks[k].freq / div;
				score = 0;

				for (n=1; n<div && n<8; n++) {
					p = Peak.match(peaks, ~~(k * n / div));
					score--;
					if (p.db < -90.0 || abs(p.freq / n / freq - 1.0) > 0.03) continue;
					if (n === 1) score += 4;
					score += 2;
				}

				if (score > bestScore) {
					bestScore = score;
					bestDiv = div;
				}
			}

			t = new Tone();

			count = 0;

			freq = peaks[k].freq / bestDiv;

			t.db = peaks[k].db;

			for (n=1; n<=bestDiv; n++) {
				p = Peak.match(peaks, ~~(k * n / bestDiv));

				if (abs(p.freq / n / freq - 1.0) > 0.03) continue;

				if (p.db > t.db - 10.0) {
					t.db = max(t.db, p.db);
					count++;
					t.freq += p.freq / n;
				}

				t.harmonics[n - 1] = p.db;
				p.clear();
			}

			t.freq /= count;

			if (t.db > -50.0 - 3.0 * count) {
				t.stabledb = t.db;
				tones.push(t);
			}
		}

		this.mergeWithOld(tones);

		this.tones = tones;
	},

/**
 * Calculates the FFT for the input signal, if enough is available.
 *
 * @method PitchAnalyzer
 * @private
 * @return {Boolean} Whether there was enough data to process.
*/
	calcFFT: function () {
		var r = this.bufRead;

		if ((BUF_N + this.bufWrite - r) % BUF_N <= FFT_N) return false;

		for (var i=0; i<FFT_N; i++) {
			this.data[i] = this.buffer[(r + i) % BUF_N];
		}

		this.bufRead = (r + this.step) % BUF_N;

		this.processFFT(this.data, this.wnd);

		return true;
	},

	setupFFT: function () {
		var RFFT = typeof FFT !== 'undefined' && FFT;

		if (!RFFT) {
			try {
				RFFT = require('fft');
			} catch (e) {
				throw Error("pitch.js requires fft.js");
			}
		}

		RFFT = RFFT.complex;

		this.rfft = new RFFT(FFT_N, false);
		this.fft = new Float32Array(FFT_N * 2);
		this.fftInput = new Float32Array(FFT_N);
	},

	processFFT: function (data, wnd) {
		var i;

		for (i=0; i<data.length; i++) {
			this.fftInput[i] = data[i] * wnd[i];
		}

		this.rfft.simple(this.fft, this.fftInput, 'real');
	}
};

Analyzer.mapdb = function (e) {
	return e.db;
};

Analyzer.Tone = Tone;

/**
 * Calculates a Hamming window for the size FFT_N, scaled up with FFT_N.
 *
 * @static PitchAnalyzer
 * @return {Float32Array} The hamming window.
*/
Analyzer.calculateWindow = function () {
	var	i,
		w = new Float32Array(FFT_N);

	for (i=0; i<FFT_N; i++) {
		w[i] = 0.53836 - 0.46164 * cos(pi2 * i / (FFT_N - 1));
	}

	return w;
};

return Analyzer;

}());

if (typeof module !== 'undefined') {
	module.exports = PitchAnalyzer;
}
