Skip to content

Instantly share code, notes, and snippets.

@ivmos
Created April 26, 2025 11:33
Show Gist options
  • Save ivmos/e3fabb08f53bc6fecde637cf762de8c0 to your computer and use it in GitHub Desktop.
Save ivmos/e3fabb08f53bc6fecde637cf762de8c0 to your computer and use it in GitHub Desktop.
Note detector
/*
* Copyright (c) 2020 Alexander Pankratov, <[email protected]>
* https://swapped.ch/note-detector
*/
/*
* Distributed under the terms of the 2-clause BSD license.
* https://www.opensource.org/licenses/bsd-license.php
*/
const OnePi = 1 * Math.PI;
const TwoPi = 2 * Math.PI;
const FourPi = 4 * Math.PI;
function sinc(x) { return x ? Math.sin(OnePi * x) / (OnePi * x) : 1; }
/*
*
*/
const tapers =
{
'raw' : null,
'hann' : function(x) { return 1/2 - 1/2 * Math.cos(TwoPi * x); },
'hamming' : function(x) { return 25/46 - 21/46 * Math.cos(TwoPi * x); },
'blackman' : function(x) { return 0.42 - 0.50 * Math.cos(TwoPi * x) + 0.08 * Math.cos(FourPi * x); },
'lanczos' : function(x) { return sinc(2 * x - 1); }
}
function applyWindow(arr, out, func)
{
if (arr.length != out.length)
throw 'Wrong in/out lengths';
if (! func)
for (let i=0, n=arr.length; i<n; i++)
out[i] = arr[i];
else
for (let i=0, n=arr.length; i<n; i++)
out[i] = arr[i] * func( i/(n-1) );
}
/*
*
*/
function getVolume(buf)
{
var sum = 0;
for (var i=0; i < buf.length; i++)
sum += buf[i] * buf[i];
return Math.sqrt(sum / buf.length);
}
function getQuadraticPeak(data, pos)
{
if (pos == 0 || pos == data.length-1 || data.length < 3)
return { x: pos, y: data[pos] };
var A = data[pos-1];
var B = data[pos ];
var C = data[pos+1];
var D = A - 2*B + C;
return { x: pos - (C-A) / (2*D), y: B - (C-A)*(C-A) / (8*D) };
}
function findPeaks(data, threshold)
{
var peaks = [];
let pos = 0;
// skip while above zero
while (pos < data.length && data[pos] > 0) pos++;
// skip until above zero again
while (pos < data.length && data[pos] <= 0) pos++;
while (pos < data.length)
{
let pos_max = -1;
// while above zero
while (pos < data.length && data[pos] > 0)
{
if (pos_max < 0 || data[pos] > data[pos_max])
pos_max = pos;
pos++;
}
if (pos_max != -1 && data[pos_max] >= threshold)
peaks.push(pos_max);
// while below zero or zero
while (pos < data.length && data[pos] <= 0)
pos++;
}
return peaks;
}
function findMcLeodPeak(data, threshold, cutoff)
{
// as per "A Smarter Way to Find Pitch", see below
var peaks_x;
var peaks_q;
var peak_max;
var cutoff;
var i;
// find peak positions
peaks_x = findPeaks(data, threshold);
if (! peaks_x.length)
return -1;
// refine them
peaks_q = [];
peak_max = -1;
for (i = 0; i < peaks_x.length; i++)
{
let peak;
peak = getQuadraticPeak(data, peaks_x[i]);
peaks_q.push(peak);
peak_max = Math.max(peak_max, peak.y);
}
// find first large-enough peak
cutoff = peak_max * cutoff;
for (i = 0; i < peaks_q.length; i++)
if (peaks_q[i].y >= cutoff)
break;
// i < peaks_q.length
return peaks_q[i].x;
}
/*
* https://github.com/aubio/aubio/blob/master/src/pitch/pitchyin.c
* + minor changes
*/
function Detector_yin(dataSize, sampleRate)
{
this.conf =
{
threshold: 0.20
};
this.sampleRate = sampleRate;
this.tmp = new Float32Array(dataSize/2);
this.process = function(buf)
{
if (this.tmp.length != buf.length/2)
throw 'Wrong buf.length';
var yin = this.tmp;
var sum = 0;
var peak_pos = -1;
var min_pos = 0;
yin[0] = 1.0;
for (let tau = 1; tau < yin.length; tau++)
{
yin[tau] = 0;
for (var j = 0; j < yin.length; j++)
{
var diff = buf[j] - buf[j + tau];
yin[tau] += diff * diff;
}
sum += yin[tau];
if (sum) yin[tau] *= tau / sum;
else yin[tau] = 1.;
if (yin[tau] < yin[min_pos])
min_pos = tau;
var period = tau - 3;
if (tau > 4 &&
yin[period] < this.conf.threshold &&
yin[period] < yin[period + 1])
{
peak_pos = period;
break;
}
}
if (peak_pos == -1)
{
peak_pos = min_pos;
if (yin[peak_pos] >= this.conf.threshold)
return -1;
}
var t0 = getQuadraticPeak(yin, peak_pos).x;
var hz = t0 ? this.sampleRate / t0 : -1;
return hz;
}
}
/*
* http://www.cs.otago.ac.nz/tartini/papers/A_Smarter_Way_to_Find_Pitch.pdf
*/
function Detector_mpm(dataSize, sampleRate)
{
this.conf =
{
peak_ignore: 0.25, // ignore peaks smaller than this fraction of max
peak_cutoff: 0.93, // pick first peak that's larger than this fraction of max
pitch_min: 80 // if we arrive at hz less than this, then fail detection
};
this.sampleRate = sampleRate;
this.tmp = new Float32Array(dataSize);
this.process = function(buf)
{
if (this.tmp.length != buf.length)
throw 'Wrong buf.length';
var nsdf = this.tmp;
var peak;
var hz;
nsdf.fill(0);
for (let tau = 0; tau < buf.length/2; tau++)
{
let acf = 0;
let div = 0;
for (let i = 0; i+tau < buf.length; i++)
{
acf += buf[i] * buf[i+tau];
div += buf[i] * buf[i] + buf[i + tau] * buf[i + tau];
}
nsdf[tau] = div ? 2 * acf / div : 0;
}
peak = findMcLeodPeak(nsdf, this.conf.peak_ignore, this.conf.peak_cutoff);
hz = (peak > 0) ? this.sampleRate / peak : -1;
// fail if an estimate is too low
return (hz < this.conf.pitch_min) ? -1 : hz;
}
};
/*
* Basic auto-correlation with MPM peak detection
*/
function Detector_acx(dataSize, sampleRate) // acf2+
{
this.conf =
{
volume_min: 0.005,
peak_ignore: 0.00, // ignore peaks smaller than this fraction of max
peak_cutoff: 0.93, // pick first peak that's larger than this fraction of max
};
this.sampleRate = sampleRate;
this.tmp = new Float32Array(dataSize);
this.process = function(buf)
{
if (this.tmp.length != buf.length)
throw 'Wrong buf.length';
var acfv = this.tmp;
var peak;
var hz;
acfv.fill(0);
for (var tau = 0; tau < buf.length/2; tau++)
{
let acf = 0;
let div = buf.length - tau;
for (var i=0; i+tau < buf.length; i++)
acf += buf[i] * buf[i+tau];
acfv[tau] = acf / div;
if (tau == 0)
{
let vol = Math.sqrt(acfv[0]);
if (vol < this.conf.volume_min)
return -1;
}
}
peak = findMcLeodPeak(acfv, this.conf.peak_ignore, this.conf.peak_cutoff);
hz = (peak > 0) ? this.sampleRate / peak : -1;
return hz;
}
}
/*
*
*/
function NoteDetector(dataSize, sampleRate, windowType)
{
this.conf =
{
close_threshold : 0.05, // 5%
track_lone_ms : 100, // X ms of sustained lone estimate
track_cons_ms : 50, // X ms of sustained consensus estimate
detrack_min_volume : 0.005,
detrack_est_none_ms : 500, // X ms of no estimates
detrack_est_some_ms : 250, // X ms of some estimates with no consensus
stable_note_ms : 50,
}
//
this.trace = function(x) { }; // diagnostic trace
this.taper = tapers[windowType];
this.candidate = null;
this.tracking = null;
this.detectors =
[
new Detector_acx(dataSize, sampleRate),
new Detector_yin(dataSize, sampleRate),
new Detector_mpm(dataSize, sampleRate)
];
this.buf = new Float32Array(dataSize);
this.est = new Float32Array( this.detectors.length );
//
this.update = function(data)
{
applyWindow(data, this.buf, this.taper);
var est = this.est;
for (let i=0; i<this.detectors.length; i++)
est[i] = this.detectors[i].process(this.buf);
let res = this.getConsensus_(est);
let freq = (res.cons <= 0) ? res.lone : res.cons;
let lone = (res.cons <= 0);
if (this.tracking)
{
if (this.isClose_(this.tracking.freq, freq))
return;
if (res.cons <= 0)
{
for (let i=0; i<est.length; i++)
if (this.isClose_(est[i], this.tracking.freq))
{
this.tracking.missed = 0;
return;
}
let vol = getVolume(data);
if (vol < this.conf.detrack_min_volume)
{
this.trace('** TOO QUIET @ ' + vol.toFixed(5));
}
else
{
if (! this.tracking.missed)
{
this.tracking.missed = performance.now();
return;
}
let ms = performance.now() - this.tracking.missed;
if (res.lone != 0 && ms < this.conf.detrack_est_some_ms || // 1+ estimates
res.lone == 0 && ms < this.conf.detrack_est_none_ms) // no estimates
return;
this.trace('** GONE STALE in ' + ms.toFixed(0) + ' ms ');
}
}
this.stopTracking_();
}
if (! this.tracking)
{
if (res.cons <= 0 && res.lone <= 0)
{
this.candidate = null;
return;
}
if (! this.candidate ||
! this.isClose_(this.candidate.freq, freq))
{
this.candidate = { freq: freq, lone: lone, start: performance.now() }
return;
}
this.candidate.freq = (this.candidate.freq + freq)/2;
this.candidate.lone = lone;
let ms = performance.now() - this.candidate.start;
if (ms > this.conf.track_cons_ms && ! this.candidate.lone)
{
this.trace('** TRACKING by consensus');
this.startTracking_(this.candidate.freq, this.candidate.start);
return;
}
if (ms > this.conf.track_lone_ms && this.candidate.lone)
{
this.trace('** TRACKING by lone estimate');
this.startTracking_(this.candidate.freq, this.candidate.start);
return;
}
// still priming
}
}
this.getNote = function()
{
if (! this.tracking)
return null;
let ms = performance.now() - this.tracking.start;
return { freq: this.tracking.freq, stable: ms >= this.conf.stable_note_ms };
}
/*
* internals
*/
this.isClose_ = function(a, b)
{
return Math.abs(a-b) < Math.abs(a+b) * 0.5 * this.conf.close_threshold;
}
this.getConsensus_ = function(est)
{
let res = { cons: 0, lone: 0 };
let num = 0;
for (let i=0; i+1 < est.length; i++)
{
if (est[i] <= 0)
continue;
if (res.lone == 0) res.lone = est[i];
else res.lone = -1;
for (let j=i+1; i+j < est.length; j++)
{
if (est[j] <= 0)
continue;
if (this.isClose_(est[i], est[j]))
{
res.cons += (est[i] + est[j])/2;
num++;
}
}
}
if (num)
res.cons /= num;
/*
* if there's a consensus (2 out of 3 or 3 out of 3) -> ( res.cons > 0 && res.lone < 0 )
* if there's no consensus and more than one estimate -> ( res.cons = 0 && res.lone < 0 )
* if there's no consensus and exactly one estimate -> ( res.cons = 0 && res.lone > 0 )
* if there are no estimates at all -> ( res.cons = 0 && res.lone = 0 )
*/
if (res.cons || res.lone)
{
function v6(v) { return v.toFixed(0).toString().padStart(6); }
let x = '';
for (let i=0; i<est.length; i++) x += v6(est[i]);
this.trace( 'est[' + x + '], consensus: ' + v6(res.cons) + ', lone_est: ' + v6(res.lone));
}
return res;
}
this.startTracking_ = function(hz, start)
{
this.candidate = null;
this.tracking = { freq: hz, start: start, missed: 0 };
// increase sensitivity of the detectors
this.detectors[0].volume_min /= 2; // acx
this.detectors[1].threshold *= 2; // yin
this.detectors[2].peak_ignore /= 2; // mpm
}
this.stopTracking_ = function()
{
this.tracking = null;
this.detectors[0].volume_min *= 2; // acx
this.detectors[1].threshold /= 2; // yin
this.detectors[2].peak_ignore *= 2; // mpm
}
}
module.exports = NoteDetector;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment