Steady Pitch / notes
An octave correction that could not tell a glitch from a soprano
Forty five unit tests green, then the first run in a real browser read a held A4 as an A2. The correction was not too weak. It was the wrong shape.
What the code had to do
I was building a vocal range test. You slide down to your lowest note, then up to your highest, and it names both ends. The hard part is not the pitch detection, it is deciding which of the several hundred readings in a sweep deserve to be called your lowest note.
Pitch detectors slip octaves. A sung low note often carries more energy in its second harmonic than in its fundamental, so a detector can lock onto the wrong lobe and report a note exactly twice the frequency, or half it. If you take the raw minimum of a downward sweep you will almost always be reporting one of those slips rather than a note the person sang. So the reading has to be corrected before anything is concluded from it.
The correction I wrote, which looks obviously right
Voices are continuous. Between two frames forty milliseconds apart, a singer cannot jump a fifth. So any gap larger than that is suspicious, and a gap of very nearly 1200 cents is the classic detector slip. Fold it back and carry on.
for each frame {
gap = cents(frame, previous)
if (|gap| > 700) {
if (|gap - 1200| <= 60) frame = frame / 2
if (|gap + 1200| <= 60) frame = frame * 2
}
previous = frame
}
Forty five checks over synthetic sweeps, all green. A single stray frame in the middle of a held note did not move the answer. A run of six frames an octave low, surrounded by the correct note, was folded back and the answer held. A breath in the middle did not break anything.
What the browser did on the first run
I drove the real page with a synthetic tone at 440 Hz, held for a little over two seconds, and asked for the highest note. It answered A2. Two octaves down, from a signal that never left 440 Hz.
The diagnostic line on the page said 58 octave slips corrected,
which is what gave it away. Fifty eight corrections on a signal that needed
none.
Here is the sequence. When the test source changed, one leftover frame from
the previous tone arrived first, at 110 Hz. That frame became previous.
The next frame, a perfectly good 440 Hz, sat 2400 cents above it, so it was
folded down to 110. And then the line that ruins everything.
previous = frame
The anchor was now the folded value. Every subsequent frame was measured against a number the correction had itself invented, found to be two octaves high, and folded. Fifty eight times. It was not a correction, it was a ratchet, and once engaged nothing in the loop could disengage it.
Why this was not a test artifact
The stray frame came from my bench, so the first honest question is whether this could happen to a real person. It can, and worse.
A singer reaching for the top of her range flips into head voice. That is a real jump of an octave, sung on purpose, and it is precisely the note the test exists to measure. My correction would have folded her entire upper register down, silently, and told her that her range stopped where her chest voice stopped. Nobody would have reported that as a bug. They would have concluded the tool does not work and closed the tab.
The thing that actually separates the two
I spent a while looking for a better threshold before noticing that no threshold can work. The detector slip and the sung octave are identical in size, they are identical in direction, and duration does not separate them either, since a slip can last half a second and a deliberate jump can be short.
What separates them is what happens afterwards.
When the detector slips, the reading comes back to where it was. The bad stretch is bracketed by the correct value on both sides. When the voice jumps, it stays. Nothing comes back.
That rule cannot be applied one frame at a time, because at the moment of the jump the evidence has not arrived yet. It needs to look ahead, which is fine here, because the analysis runs over a whole sweep rather than as a stream.
The rewrite
Two passes. The first cuts the sweep into plateaus, groups of neighbouring frames holding roughly the same pitch, breaking on silence and on jumps. The second walks the plateaus and folds one only when the plateau after it returns to the pitch of the plateau before it.
A plateau that follows silence is never folded at all. A breath is exactly what lets a singer change register on purpose, so an octave across a breath is taken at face value.
Three cases now sit next to each other in the test file, and they are the point of the whole exercise.
one stray frame, then A4 held 58 frames answer A4, nothing folded E4 held, then E5 held, both 20 frames answer E5, nothing folded E4, then E5, then E4 again answer E4, 20 frames folded
The second and third have the same jump, the same size, the same direction. Only the third has a return, and only the third is treated as a fault.
What I took away
A correction that takes its reference from its own last output is a ratchet. It has no way of discovering that its reference was wrong, because every new value confirms it. Tightening the threshold does not help. The fix is to give the correction a reference that does not depend on what it just corrected.
And the tests. Forty five checks did not find this, and they were not bad tests, they were tests of the failures I had thought of. The browser found it on the first run because it produced an input I would never have written down, a leftover frame at a source change. One run of the real thing was worth more than ten more synthetic cases.
The range test this came out of runs in a browser tab, with nothing to install and nothing uploaded. It is at vocal-range-test, and the detector behind it is measured in cents against known tones in my pitch detector read every note sharp.