Skip to content

Lab 23: Peak Detection — Build a Tuner

Time: ~50 minutes | Prerequisites: Lab 22 | Hardware: Pico 2, INMP441, OLED

A real instrument, from a coarse spectrum

Echo waving welcome Your bins are 25 Hz wide. The gap between A4 and A#4 is 26 Hz — barely one bin. So how can this possibly tune a guitar? Because a bin isn't a fence. Read the neighbours and you can see between them. Let's tune in — literally.

What You'll Build

A working chromatic tuner: note name, octave, cents-off, and a needle display that tells you sharp or flat.

Learning Objectives

  • Explain why the loudest bin alone is too coarse for tuning
  • Apply parabolic interpolation to locate a peak between bins
  • Measure the accuracy gain from interpolation
  • Demonstrate that windowing is required for interpolation to work
  • Convert a frequency to a note name and cents deviation

Concepts Introduced

ID Concept
422 Argmax Search
423 Peak Bin
424 Bin To Frequency
425 Frequency Resolution Limit
426 Parabolic Interpolation
427 Sub Bin Accuracy
428 Local Maximum
429 Threshold Rejection
430 Pitch
431 Musical Note Mapping
432 Octave

Background

A bin is not a fence

When a tone falls between two bins, both light up — and the ratio between them says where in the gap the true frequency sits. Fit a parabola through the peak and its two neighbours, and the apex gives you the answer:

1
2
delta = 0.5 * (y1 - y3) / (y1 - 2*y2 + y3)
true_bin = k + delta

Three magnitudes, four arithmetic operations, and your resolution improves by roughly an order of magnitude. You didn't change the FFT at all — you just read its output more carefully.

Notes are logarithmic

Every octave doubles the frequency, and each octave is 12 equal semitones:

1
semitones from A4 = 12 · log₂(freq / 440)

A cent is 1/100 of a semitone. Trained musicians hear about 5 cents, so ±5 counts as in tune.

Lab 22 earns its keep here

Echo thinking Parabolic interpolation assumes the peak is shaped like a parabola. An unwindowed peak isn't — and the refinement barely helps. Measured on this hardware: without a window, 5.7 Hz error; with a Hanning window, 1.3 Hz. The window isn't decoration, it's what makes this technique work.

Procedure

Step 1 — Measure the improvement

Open 23-tuner.py and run it:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
# Lab 23: Peak Detection -- Build a Tuner
#
# Lab 21 reported the loudest BIN. With 50 Hz bins that is far too coarse to
# tune an instrument: the whole of A4 (440 Hz) to A#4 (466 Hz) fits inside
# half a bin.
#
# But a bin is not really a fence. When a tone sits between two bins, BOTH
# light up -- and the ratio between them tells you where inside the gap the
# true frequency lies. Fitting a parabola through the peak and its two
# neighbours recovers the frequency to a fraction of a bin.
#
# That is how you build a tuner out of a coarse spectrum.

import config
import math
import struct
import time
from fftlab import FFT

N = 512                       # bigger N = finer bins; worth it for tuning
RATE = config.SAMPLE_RATE
BIN_HZ = RATE / N             # 25 Hz per bin

MIN_BIN = 4
PEAK_RATIO = 3.0

NOTE_NAMES = ["A", "A#", "B", "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#"]
A4 = 440.0

fft = FFT(N)

# Lab 22's Hanning window, precomputed once.
#
# This is not decoration. Parabolic interpolation below assumes the peak is
# shaped like a parabola -- and an UNWINDOWED peak is not. Without a window
# the refinement barely helps; with one it is accurate to a fraction of a
# hertz. Lab 22 earned its keep here.
WINDOW = [0.5 - 0.5 * math.cos(2 * math.pi * i / (N - 1)) for i in range(N)]


def note_of(freq):
    """Return (name, octave, cents_off) for a frequency."""
    semitones = 12 * math.log(freq / A4, 2)
    nearest = int(round(semitones))
    cents = (semitones - nearest) * 100
    name = NOTE_NAMES[nearest % 12]
    octave = 4 + (nearest + 9) // 12
    return name, octave, cents


def refine_peak(mags, k):
    """Parabolic interpolation: find the true peak between the bins.

    Fit a parabola through (k-1, k, k+1) and return the offset of its apex
    from bin k. The result is between -0.5 and +0.5 bins.
    """
    if k <= 0 or k >= len(mags) - 1:
        return 0.0
    y1 = mags[k - 1]
    y2 = mags[k]
    y3 = mags[k + 1]
    denom = y1 - 2 * y2 + y3
    if denom == 0:
        return 0.0
    return 0.5 * (y1 - y3) / denom


# =========================================================================
# PART 1 -- prove interpolation works, on tones we generate ourselves
# =========================================================================
print("=== PART 1: how accurate is interpolation? ===")
print("bin width = %.1f Hz" % BIN_HZ)
print()
print("%10s %12s %12s %10s %12s" %
      ("true Hz", "nearest bin", "bin only", "refined", "error"))

re, im = fft.buffers()
worst_raw = 0.0
worst_fine = 0.0

for true_hz in (440.0, 466.2, 493.9, 1000.0, 1234.5, 2093.0):
    for i in range(N):
        re[i] = math.sin(2 * math.pi * true_hz * (i / RATE)) * WINDOW[i]
        im[i] = 0.0
    fft.run(re, im)
    mags = fft.magnitudes(re, im)

    k = MIN_BIN
    best = 0.0
    for j in range(MIN_BIN, N // 2):
        if mags[j] > best:
            best = mags[j]
            k = j

    raw_hz = k * BIN_HZ
    fine_hz = (k + refine_peak(mags, k)) * BIN_HZ

    worst_raw = max(worst_raw, abs(raw_hz - true_hz))
    worst_fine = max(worst_fine, abs(fine_hz - true_hz))

    print("%10.1f %12d %12.1f %10.1f %+11.2f" %
          (true_hz, k, raw_hz, fine_hz, fine_hz - true_hz))

print()
print("worst error, bin only : %.2f Hz" % worst_raw)
print("worst error, refined  : %.2f Hz" % worst_fine)
print("improvement           : %.0fx" % (worst_raw / max(worst_fine, 0.01)))
print()
print()
print("A bin is %.0f Hz wide, yet we locate the tone far more precisely than" % BIN_HZ)
print("that. We did not change the FFT at all -- we just read its output")
print("more carefully, and applied the window from Lab 22 so the peak is")
print("actually parabola-shaped. Try deleting the window and re-running:")
print("the refinement stops helping almost entirely.")

# =========================================================================
# PART 2 -- frequencies to note names
# =========================================================================
print()
print("=== PART 2: naming the note ===")
print()
print("Musical pitch is logarithmic: every octave DOUBLES the frequency,")
print("and each octave is 12 equal semitones. So:")
print("    semitones from A4 = 12 * log2(freq / 440)")
print()
print("%10s %8s %10s" % ("frequency", "note", "cents off"))
for f in (440.0, 445.0, 466.2, 261.6, 329.6, 880.0):
    name, octave, cents = note_of(f)
    print("%10.1f %6s%-2d %+9.0f" % (f, name, octave, cents))
print()
print("A 'cent' is 1/100 of a semitone. Musicians can hear about 5 cents,")
print("so anything inside +/-5 counts as in tune.")

# =========================================================================
# PART 3 -- the live tuner
# =========================================================================
print()
print("=== PART 3: live tuner ===")
print("Play or sing a steady note. Ctrl-C to stop.")
print()

oled = config.init_display()
mic = config.init_microphone()
raw = bytearray(N * 4)

for _ in range(5):
    mic.readinto(raw)
    time.sleep_ms(50)


def capture():
    n = mic.readinto(raw)
    words = struct.unpack("<%di" % (n // 4), raw[:n])
    count = min(N, len(words))
    total = 0
    for i in range(count):
        total += words[i] >> 8
    dc = total / count
    for i in range(count):
        re[i] = ((words[i] >> 8) - dc) * WINDOW[i]
        im[i] = 0.0
    for i in range(count, N):
        re[i] = 0.0
        im[i] = 0.0


try:
    while True:
        capture()
        fft.run(re, im)
        mags = fft.magnitudes(re, im)

        top = N // 2
        total = 0.0
        for k in range(MIN_BIN, top):
            total += mags[k]
        average = total / (top - MIN_BIN)

        best_k = MIN_BIN
        best_v = 0.0
        for k in range(MIN_BIN, top):
            if mags[k] > best_v:
                best_v = mags[k]
                best_k = k

        oled.fill(config.BLACK)

        if best_v < PEAK_RATIO * average or best_k == MIN_BIN:
            oled.text("   listening", 0, 24, config.WHITE)
            oled.show()
            continue

        hz = (best_k + refine_peak(mags, best_k)) * BIN_HZ
        name, octave, cents = note_of(hz)

        oled.text("%s%d" % (name, octave), 4, 4, config.WHITE)
        oled.text("%.1f Hz" % hz, 52, 4, config.WHITE)
        oled.hline(0, 16, config.WIDTH, config.WHITE)

        # A needle: centre means in tune, left is flat, right is sharp.
        mid = config.WIDTH // 2
        oled.vline(mid, 22, 18, config.WHITE)
        pos = mid + int(max(-50, min(50, cents)) / 50 * (mid - 6))
        oled.fill_rect(pos - 2, 24, 5, 14, config.WHITE)

        if abs(cents) <= 5:
            oled.text("IN TUNE", 36, 46, config.WHITE)
        elif cents < 0:
            oled.text("flat  <<", 32, 46, config.WHITE)
        else:
            oled.text(">>  sharp", 28, 46, config.WHITE)

        oled.text("%+d cents" % int(cents), 30, 56, config.WHITE)
        oled.show()

        print("%7.1f Hz  %s%d  %+4d cents  %s" %
              (hz, name, octave, int(cents),
               "IN TUNE" if abs(cents) <= 5 else ("flat" if cents < 0 else "sharp")))

except KeyboardInterrupt:
    mic.deinit()
    oled.fill(config.BLACK)
    oled.text("Stopped.", 32, 28, config.WHITE)
    oled.show()
    print("Stopped.")
1
2
3
4
5
6
7
8
   true Hz  nearest bin     bin only    refined        error
     440.0           18        450.0      441.1       +1.07
     466.2           19        475.0      467.5       +1.26
    1234.5           49       1225.0     1233.3       -1.17

worst error, bin only : 10.00 Hz
worst error, refined  : 1.30 Hz
improvement           : 8x

Bins are 25 Hz wide, yet we locate tones to about 1.3 Hz.

Step 2 — Prove the window matters

Delete the * WINDOW[i] from the test loop and re-run. The refined column gets dramatically worse — around 5.7 Hz instead of 1.3.

That's Lab 22 paying a concrete dividend, not just producing prettier pictures.

Step 3 — Read the note table

1
2
3
4
5
 frequency     note  cents off
     440.0      A4         +0
     445.0      A4        +20
     466.2     A#4         +0
     261.6      C4         -0

440 Hz is A4 exactly. 445 Hz is still A4, but 20 cents sharp — clearly audible to a musician.

Step 4 — Tune something

Part 3 runs the live tuner. Sing, hum, whistle, or play an instrument. The display shows:

  • the note name and octave
  • the measured frequency
  • a needle: centre is in tune, left flat, right sharp
  • the cents deviation

Try singing a steady note and watch how much you drift. Most people are surprised.

1.3 Hz is about 5 cents at A4

Echo offering a tip Which puts this tuner right at the threshold of human hearing — genuinely usable, but not studio-grade. To do better you'd need a longer window (finer bins) or a different algorithm entirely. Knowing your instrument's limits is as important as building it.

Step 5 — Predict, then measure

Prediction: you doubled N from 256 to 512 for this lab. What did that do to the bin width, and what did it cost?

Check against Lab 24's timing.

Expected Output

The accuracy table, the note table, and the live tuner display.

Troubleshooting

Symptom Likely cause Fix
Interpolation barely helps Window missing Apply Hanning before the FFT
Note jumps an octave Harmonic louder than fundamental Common with some instruments; restrict the search range
Needle jitters Genuine pitch variation Average the last few readings
Always "listening" Too quiet, or PEAK_RATIO too high Get closer, or lower it
Wrong octave number Off-by-one in the octave formula Check against a known 440 Hz tone

Challenges

  1. Smooth the needle. Average the last five frequency estimates. Does it feel better or just slower?
  2. Guitar mode. Restrict detection to the six open-string frequencies (82, 110, 147, 196, 247, 330 Hz) and show which string you're nearest.
  3. Beat the interpolation. Compare parabolic interpolation against fitting on a log magnitude scale. Which is more accurate for a Hanning-windowed peak?

Check Your Understanding

  1. Why is the loudest bin alone insufficient for tuning?
  2. How does parabolic interpolation find a frequency between bins?
  3. Why does interpolation need a window to work well?
  4. What is a cent, and how many are in an octave?
  5. Our tuner is accurate to ~1.3 Hz. Is that good enough for a musician? Justify it.

You built a real instrument

Echo celebrating Not a demo — a tuner someone could actually use. Next lab puts a stopwatch on every stage and finds out what's really costing you time. The answer surprises most people.


Next: Lab 24: Real-Time Spectrum Analyzer | Previous: Lab 22