Steady Pitch / notes
My pitch detector read every note sharp, and the cause was arithmetic
It was sharp on every frequency I knew the answer to. Never flat, always sharp, which is the shape of a bug rather than the shape of noise.
The measurement
I had been testing this thing with my own voice for weeks, which turns out to be close to useless. A test that uses your voice cannot separate your error from the tool's error. So I generated tones at frequencies I already knew and asked it what it heard.
frequency reported error 82.41 Hz 29.8 cents sharp 98 Hz 15.5 cents sharp 130.81 Hz 9.1 cents sharp 196 Hz 6.8 cents sharp 261.63 Hz 6.2 cents sharp 440 Hz 2.6 cents sharp 880 Hz 2.1 cents sharp
Thirty cents is about a third of a semitone. On a low E my own tool was lying to anyone who checked it against a tuning fork, which is exactly what a sceptical user does first.
Three things needed explaining. The sign never changed. The error grew as the note got lower. And a pure sine was far worse than a voice, five to six times worse at the same pitch.
The cause
It is a YIN detector. YIN builds a difference function over lag
tau.
d(tau) = sum over j of ( x[j] - x[j+tau] )^2
Expanded, that is the power of the head window plus the power of the tail
window minus twice the autocorrelation at that lag. The usual fast
implementation computes the autocorrelation with an FFT and then takes a
shortcut, assuming both power terms equal r0, the autocorrelation
at lag zero.
d(tau) = 2*r0 - 2*acf[tau]
That shortcut is the bug, and it has nothing to do with sound.
FFT autocorrelation is biased. At lag tau
only N - tau sample pairs actually overlap, but the result is
still divided by N. So acf[tau] slopes downward as
the lag grows, purely as an artefact of counting. Feed it a perfectly periodic
signal and it still decays.
Put that decaying autocorrelation into the shortcut and the difference is inflated more and more at long lags. The minimum slides toward shorter lags. A shorter lag means a higher frequency. Every reading comes out sharp.
All three symptoms follow from that one cause. The bias only pushes one way, so the sign is constant. A low note means a long period means a large lag, where the taper is worst, so the error grows in the bass. And a pure sine gives a wide shallow minimum with nothing to anchor it, while a harmonically rich voice gives a sharp narrow one that resists being dragged, which is why my worst case was a sine and not a singer.
The fix
Do not try to correct the autocorrelation. Recompute the difference honestly, over a window of constant length, on a handful of lags around the first estimate.
const largeur = n >> 1; // same count for every lag
for (let tau = tauMin; tau <= tauMax; tau++) {
let somme = 0;
for (let j = 0; j < largeur; j++) {
const ecart = buf[j] - buf[j + tau];
somme += ecart * ecart;
}
d[tau - tauMin] = somme;
}
Constant length is the whole point. Every lag is judged on exactly the same number of terms, so the counting artefact cannot exist. Take the local minimum and interpolate parabolically. It costs one narrow pass over half the buffer, which is nothing next to the FFT you have already run.
Same table afterwards, every case at 0.0 cents, on pure sines and on harmonically rich tones alike, at both 44 100 and 48 000 Hz.
I got the fix wrong first, and that part is worth more
My first version searched three samples either side of the first estimate. It changed nothing at 82 Hz. A gain of exactly zero, on the one case it was written for.
A thirty cent error is about nine samples on a low E and less than one sample on a high A. So a fixed search width in samples missed precisely the cases that needed it and helped only the cases that did not. The width had to be a fraction of the period.
const rayon = Math.max(3, Math.round(tauCentre * 0.04));
When the size of a defect scales with something, the fix has to scale with the same thing. A constant put up against a proportional defect works at exactly one point on the scale.
One more thing saved me. The search returns the original estimate untouched if the minimum lands on the edge of the bracket, rather than interpolating against a missing neighbour. That guard is why the failure showed up as no change instead of as a plausible wrong number. A guard that hands back its input when it is unsure is worth more than one that always produces an answer.
Two things to check in any pitch detector
Feed it a frequency you already know. Not your voice. Sixteen synthetic tones found a bug that weeks of singing at it did not.
Feed it a pure sine in the bass. It is the least forgiving input for autocorrelation methods, and it is also the first thing a sceptical user reaches for.
And keep two kinds of test apart. I have a bench that compares this port to its reference implementation to within a ten thousandth of a cent, and it stayed green through all of this, because both sides were wrong in the same way. A fidelity test tells you that you copied correctly. Only a known frequency tells you that you are right.
The tool this came from. A vocal pitch monitor that runs in a browser tab. Press a button, sing, and it names the note and shows how many cents sharp or flat you are. No account, nothing to install, and nothing you sing leaves your machine.
Open it on the front page, or go straight to the trainer.