Field notes / Audio signal processing on the web
A tuner too sensitive to be useful
A per-frame blend weight is not a time constant. Notes from damping a tuner needle so a student can tell in tune from jitter.
// every hop where YIN does not accept a peak:
smoothFreq *= 0.82; // 1200 * log2(0.82) = -343 cents
if (smoothFreq < 20) smoothFreq = 0;Virtuner is the free browser tuner I keep on the Virtunity site for band students. On September 3 I filed a one-line report against my own product: it was way too sensitive to be useful.
That matters more for a beginner than for a professional. A needle that never sits still teaches a student nothing, because they cannot tell the difference between a note that is in tune and a display that is jittering. Worse, when the student stops playing, the needle went, in the words of the report, crazy. A tool that punishes silence is not a practice tool.
These are notes on the investigation, the six defects it found, and the single smoothing stage that replaced them. Nothing here is exotic. It is the difference between a filter that is tuned in frames and a filter that is tuned in milliseconds.
Alpha is a blend weight, not a time constant. The same alpha is twice as twitchy on a 120 Hz laptop as on a 60 Hz monitor.
The signal path before
Microphone into a Web Audio analyser with no smoothing of its own, a 4096-sample window, echo cancellation and noise suppression off. There is no audio worklet. The YIN pitch detector runs on the main thread inside the animation loop and polls every other animation frame.
That last detail is the root of most of what follows. The analysis hop is two animation frames, so it rides the display refresh. On a 60 Hz screen that is about 33 ms. On a 120 Hz screen it is about 17 ms. The hop was never measured; it was whatever the browser happened to deliver.
Two smoothers sat in series. The engine blended frequency in Hertz once per hop with a fixed alpha per damping mode: slow 0.12, normal 0.28, fast 0.55. The display then eased the cents value toward the engine’s number on every animation frame with its own weight of 0.22, and when the engine reported no tone, the display eased toward zero.
| Mode | Alpha | Tau at 60 Hz | Tau at 120 Hz | 95% settle at 60 Hz |
|---|---|---|---|---|
| slow | 0.12 | 261 ms | 131 ms | 780 ms |
| normal | 0.28 | 101 ms | 51 ms | 300 ms |
| fast | 0.55 | 42 ms | 21 ms | 125 ms |
Good strobe and chromatic tuners sit somewhere around 150 to 400 ms of needle time constant. Even the slow setting here was at the fast end of that band on a 60 Hz screen, and twice as fast on a 120 Hz one. The normal setting was a blur.
Why it read as twitchy
The alpha table was the small problem. The behavior around a missed detector frame was the large one.
When the tuner was listening but YIN did not accept a peak, the engine did not hold its last estimate. It multiplied the smoothed frequency by 0.82, every hop, until the value dropped below 20 Hz and snapped to zero. An 18 percent drop in Hertz is 343 cents, more than three semitones, in a single 33 ms hop. The next good frame then blended that wrecked value back toward the real pitch at alpha 0.28.
One dropout is all it takes: a quiet attack, a breath, a page turn, a burst of room noise. The needle is yanked across the face and then crawls back. That is the “too sensitive” report, and the detector was not the cause. The recovery logic was.
Silence was the other ugly path. Decaying Hertz toward zero walks the estimate through every note between the last pitch and nothing, so the cents readout wraps plus and minus fifty over and over while the note name cascades downward. That is the “going crazy when I stop” report.
All six suspected defects were real
- Per-frame alpha at an unmeasured frame rate. The same setting behaved differently on every machine.
- No hold on gated frames. Loudness and clarity already decided whether the detector ran, but a miss still drove the estimate through the decay. Noise frames moved the needle.
- No dead band. A few cents of detector jitter rendered one to one. An in-tune note never sat still.
- No note-name hysteresis. The label flipped the instant the estimate crossed fifty cents, so A and B flat chattered on a flat A.
- Octave errors blended in Hertz. A one-frame octave jump from the detector was averaged into the frequency. In Hertz that is a factor of two, and the average is musically meaningless until it bleeds out.
- Two filters fighting. The engine smoothed Hertz, the display smoothed cents, and on a miss the display eased toward zero. The student saw the beat between the two rates, not one damped needle.
How a good tuner behaves
I wrote down what the tuners I trust actually do before changing anything, so the fix had a target instead of a feeling.
- Needle time constants of about 150 to 400 ms, independent of frame rate.
- A median or trimmed-mean prefilter over three to seven frames, so a single outlier never reaches the low-pass stage.
- Confidence and loudness gating. Below threshold, hold the last value, then fade.
- A dead band of one to two cents so an in-tune note is visually still.
- Fifty to sixty cents of hysteresis on note identity, held for a few hops, so the name does not chatter.
- Fast attack on a true new note, meaning a jump of more than a semitone, and a slow settle within a note.
- Hold on silence, then fade. Never slew the frequency to zero.
The fix: one smoother, in cents, in the engine
The display no longer smooths anything meaningful. It eases toward the engine value with a short fixed constant of about 24 ms so motion is not quantized to hops, and when there is no tone it holds the last needle angle and fades opacity instead of chasing zero.
The engine does all the real work in one stage, on cents from A4 rather than on Hertz, so that a semitone is the same distance everywhere on the scale and an octave error is a clean 1200 rather than a doubling.
| Piece | Value |
|---|---|
| Median window | 5 frames |
| Time constants | slow 400 ms, normal 250 ms, fast 120 ms |
| Alpha per hop | 1 - exp(-hop / tau), from the measured hop |
| Gate | detector miss, clarity below 0.65, or loudness below the floor: no update |
| Silence | hold last value, fade after 600 ms |
| Note hysteresis | switch only after 50 cents off the locked centre for 80 ms |
| Dead band | within 1.5 cents, display exactly 0 |
| Fast attack | a jump over 100 cents resets the filter and the locked note |
The line that matters is the alpha. alpha = 1 - exp(-hop / tau), computed from the hop the engine actually measured, means the slow setting feels the same on a 60 Hz monitor and a 120 Hz laptop. The user-facing chips are still slow, normal, and fast. Nothing about the interface changed. Everything about what the chips mean did.
What it forced
Parameters that describe motion must be in units of time. Any filter constant expressed per frame is a bug waiting for a different monitor.
Measured before and after
The tests drive the smoother with a synthetic stream at a 10 ms hop and measure the 63 percent rise time on a 40 cent step, which is below the fast-attack threshold, after the median window has flushed. The design time constant is the target; the measured rise is what the code does.
| Mode | Design tau | Measured | Error | Before |
|---|---|---|---|---|
| slow | 400 ms | 400 ms | 0% | 78 ms |
| normal | 250 ms | 250 ms | 0% | 30 ms |
| fast | 120 ms | 130 ms | 8.3% | 13 ms |
Fast lands one hop past the continuous-time mark because the discrete check fires on the hop that crosses. Still inside the 10 percent band. The before column is the old per-frame alpha at the same 10 ms hop; at the real 33 ms hop those were 261, 101, and 42 ms, and every missed frame then subtracted 343 cents on top.

Top to bottom: slow, normal, fast. Same synthetic stream in each panel.
Gray = detector input · Coral = old Hertz smoother · Green = new cents smoother
Left to right: hop-rate jitter, a single 30 cent outlier, a semitone step, a silence gap, a slow drift.
The plot is the whole argument in one picture. On the outlier, the old filter spikes and the new one does not move. On the step, both arrive, but the new one arrives on a schedule you can name. On the silence, the old filter falls off the bottom of the panel on its way to zero while the new one holds and then lets go.
What I would tell someone building one
- Measure the hop. If your analysis rate rides the display, your filter has no idea what it is doing. Derive alpha from tau and the measured hop, every hop.
- One filter. If two stages smooth the same signal at different rates, the user sees the interference between them. Put the smoother in the engine and let the display only ease.
- Gate, then hold. A frame the detector rejected carries no information. Do not let it move the estimate, and never decay toward zero. Hold, then fade.
- Smooth in cents. Hertz is the wrong domain for anything musical. An octave error in cents is a clean 1200 the fast-attack rule can catch; in Hertz it is a doubling that poisons the average.
- Test the time constant, not the alpha. The test that caught the fast mode landing one hop late is a rise-time test. An alpha test would have passed and told you nothing.
Honest status
A needle that never sits still is not sensitive. It is uninformative.
Figures in this piece are from the project’s own unit tests and the design note committed alongside the fix. The plot is generated by the test suite from the same synthetic streams.
This field note was drafted with AI assistance from the design note, the commit, the test suite, and the source. I reviewed the technical claims against those artifacts.