nateparker.dev

← Writing

Getting a guitar tuner to read within a cent

I'm writing a YIN pitch detector in Swift for a guitar tuner. The spec asks for one cent. Getting there meant ignoring one of the spec's rules and one well-known speed trick.

6 min readswiftiosaudiodsp
A tuning fork standing on a small wooden block, with a guitar string running across the page and a row of fine tick marks below, the centre one in violet.

Part one of two about Lantern, a guitar tuner for the iPhone Duo. Part two is about the hardware.

Lantern is an experiment in what an app can do with the iPhone Duo’s fold poses, and the test case is a guitar tuner. None of the fold questions matter if the tuner itself is wrong, so the brief puts its requirement in one line: ±1 cent displayed, stable enough that the lamp colour doesn’t flicker. A cent is a hundredth of a semitone. Most people can’t hear five.

So the first two days went into the tuner. This post covers how it gets to a cent.

YIN, in one paragraph

YIN is a 2002 algorithm from de Cheveigné and Kawahara, and it’s still the default for monophonic pitch. Take a frame of audio. For each lag τ, compare the signal to itself shifted by τ and sum the squared differences. A periodic signal matches itself at its period, so the sum dips there. Normalise each value by the running mean so the dips are comparable across lags. Take the first dip under a threshold, walk down to its local minimum, and fit a parabola through it and its neighbours to get a fractional lag. The frequency is the sample rate divided by that lag.

Lantern’s version takes 4096-sample frames with a 1024-sample hop, covers 60 to 1200 Hz, and uses a threshold of 0.12. It runs on Accelerate’s vDSP. That’s all in YINDetector.swift. The textbook steps are the easy part. Two details took most of the work.

The speed trick I didn’t use

You can expand the squared difference:

d(τ) = Σ (x[j] − x[j+τ])²
     = Σ x[j]² + Σ x[j+τ]² − 2 Σ x[j]·x[j+τ]

The last term is an autocorrelation, and an FFT gives you all of those at once. Every fast YIN implementation does this.

The problem is where it loses precision. At the true period, d(τ) is close to zero, and the expanded form gets that near-zero by subtracting two large, nearly equal energies. In Float, the subtraction cancels out the low digits, and those are the digits the parabola fit needs. The dip loses its shape exactly where the measurement is taken.

So Lantern computes it directly, one lag at a time: vDSP_vsub to get the differences, then vDSP_svesq to sum their squares. The differences exist before anything is squared, so nothing large is subtracted. That’s about five million float operations per frame, around 4% of one core at 43 frames a second. If that ever becomes a problem, the fix is the FFT form in Double, not in Float.

The spec’s octave rule

Every guitar tuner has trouble with the low E string. The string is 82 Hz, but its second harmonic at 165 Hz is often louder than the fundamental. A strong second harmonic puts a deep dip at half the true period, YIN takes the first good dip, and the tuner reads the note an octave high.

My brief had a fix: prefer the lower candidate when a dip at ½ frequency is within ~0.05 of the chosen one.

Taken literally, that rule breaks every clean tone. A sine repeats at 2τ just as well as at τ, so its dip one octave down is always within 0.05 of the chosen one. The rule fires every time. A pure 440 Hz test tone came back as 220.

What I wrote instead requires the lower octave to be better in two ways. Its dip has to be under 0.6 of the current one, and lower by at least 0.005. The second condition is the important one. When the chosen dip is already almost perfect, comparing it with the octave below only compares rounding noise, and the absolute margin keeps the guard out of that. A real octave error leaves the chosen dip somewhere between 0.005 and 0.12, well clear of the margin, so the guard still catches it.

Two tests pin both cases. In one, a low E with an almost missing fundamental still reads 82 Hz. In the other, sines at 110, 220, 440 and 880 Hz read where they are. If anyone wants the literal rule back, those are the two tests that will fail.

Smoothing in cents

Single frames jitter, so the tracker smooths them: a median of the last five valid frames to discard outliers and octave flips, then an exponential moving average to settle the result.

Do the smoothing in cents, not hertz. One cent is about 0.05 Hz on the low E and about 0.19 Hz on the high E. Smoothing in hertz would make the low strings feel slow and the high strings twitchy. In cents from A4 the space is linear in pitch, so one set of constants works for every string.

The tracker also ignores the first 100 ms after each pluck. The attack of a plucked string is noise with a pitch somewhere inside it, and reporting it would show a wrong note for a moment on every pluck.

A lamp that doesn’t flicker

The tuner’s output is a colour as well as a number: red when more than 15 cents out, amber within 15, green when in tune. A colour switching on every vibrato wobble would be worse than no colour at all, so green has hysteresis:

  • Into green: within 3 cents for six frames in a row.
  • Out of green: beyond 5 cents for 400 ms.

Getting into green is strict and getting out is slow. A player who’s in tune and leans into a note stays green.

Three screenshots of the Lantern tuner side by side. Left: the low E string at plus 1 cent, needle and string button green. Middle: the A string at plus 14 cents, amber. Right: the A string at minus 56 cents, red.

In tune, close, far. The simulator has no microphone, so for these screenshots a synthesized pluck is fed through the real tracker and lock machine.

The state machine takes the frame duration as an argument instead of reading a clock. That made the 400 ms release testable without sleeping or timing-dependent tests: the test feeds it frames and counts.

What only running it found

  • The sample rate. The brief said 44.1 kHz. iPhones give you 48. The engine now reads the rate from the input node after the audio session is active. Before activation it reports 0 Hz.
  • Measurement mode. The session uses .measurement, which switches off automatic gain control and noise suppression. Both of them change pitch.
  • Analysis off the audio thread. The input tap copies samples into a buffer and wakes a serial queue. YIN would fit inside the 23 ms hop, but the tap can run on a real-time thread, and a tuner gains nothing by risking that.
  • Two start() calls on launch. The view’s .task and its scene-phase handler both started the engine. The first was still waiting on the microphone permission dialog, so the second got through and made its own permission request. iOS refuses a second request while one is pending. On first launch the app said “permission denied” with the permission dialog still on screen.
  • “Error 0.” The start error didn’t conform to LocalizedError, so the error message shown to the user was a number.

The numbers, and what they don’t cover

The package has 37 tests and runs on macOS in about a quarter of a second. That’s why LanternCore declares macOS support even though nothing ships there: when you’re adjusting a pitch detector, a quarter-second test loop matters more than anything else.

The YIN tests generate sines and harmonic-rich plucks (fourteen partials, each decaying faster than the one below) at every open string, dead on and at ±5 and ±20 cents. Against the ±1 cent target, the worst reading is 0.068 cents and the mean is 0.014.

That’s against synthetic signal. The Mac mini I built it on has no audio input at all, so the simulator had no microphone to pass through. The real microphone path can only be tested on a phone. The brief’s last test step is a real guitar next to a clip-on tuner. I haven’t done it yet.

The code is MIT licensed: github.com/0x4rch/Lantern. docs/decisions.md in the repo has the reasoning behind every place the code departs from the brief.