Skip to content

Lab 22: Windowing and Spectral Leakage

Time: ~50 minutes | Prerequisites: Lab 21 | Hardware: Pico 2 (microphone optional)

Why your whistle sometimes smears

Echo waving welcome In Lab 21 a steady whistle sometimes made one crisp spike and sometimes splattered across half the display. That wasn't the microphone misbehaving — it's a real property of the DFT, and it has a genuinely elegant fix. Let's tune in.

What You'll Build

A side-by-side demonstration of spectral leakage, then four window functions measured against each other so you can see exactly what each one costs and buys.

Learning Objectives

  • Explain why the DFT assumes your window repeats forever
  • Identify the discontinuity that causes leakage
  • Apply a Hanning window and measure the improvement
  • Compare rectangular, Hanning, Hamming and Blackman windows
  • Describe the sidelobe/resolution tradeoff
  • Account for the amplitude a window costs you

Concepts Introduced

ID Concept
411 Spectral Leakage Effect
412 Rectangular Window
413 Hanning Window
414 Hamming Window
415 Blackman Window
416 Main Lobe Width
417 Side Lobe Level
418 Window Tradeoff
419 Coherent Gain
420 Edge Discontinuity
421 Window Table

Background

The DFT thinks your signal loops

A DFT sees N samples and assumes they repeat forever, end joined to beginning. If the wave fits a whole number of cycles in the window, the loop is seamless. If it doesn't, there's a jump at the seam — and a jump is a sharp edge, full of frequencies that were never in the sound.

That's spectral leakage: energy from one true frequency spilling into neighbouring bins.

It is not a small effect

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
--- 800 Hz -- lands exactly on bin 8 ---
   8     800 Hz ##############################################

--- 850 Hz -- falls BETWEEN bins ---
   4     400 Hz ######
   5     500 Hz #######
   6     600 Hz ##########
   7     700 Hz ################
   8     800 Hz ##############################################
   9     900 Hz ###########################################
  10    1000 Hz #############

Same pure tone, same amplitude. Only the frequency moved — by half a bin. One is a clean spike; the other contaminates ten bins.

Real sounds are never bin-exact

Echo thinking A bin is 50 Hz wide in Lab 21. Your whistle doesn't politely land on a multiple of 50. So leakage isn't an edge case you occasionally hit — it's the normal situation, and the clean spike is the rare accident.

The fix: fade the edges

A window function multiplies your samples by a curve that starts at zero, rises to one in the middle, and falls back to zero. Now the ends do meet, and there's no seam.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Hanning window shape:
   0
   2 **
   6 *********
  10 ****************
  14 *********************
  16 ***********************
  20 ********************
  26 ********
  30 *

Procedure

Step 1 — See the problem

Open 22-windowing.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
# Lab 22: Windowing and Spectral Leakage
#
# In Lab 21 a steady whistle sometimes made one clean spike and sometimes
# smeared across several bars. That was not the microphone being flaky. It
# is a real and unavoidable property of the DFT, and it has a fix.
#
# The cause: the DFT assumes your window of samples repeats forever. If the
# wave does not complete a whole number of cycles inside the window, the
# repeat has a JUMP in it -- and a jump contains lots of frequencies that
# were never in the original sound.
#
# The fix: fade the window in and out at the edges so there is no jump.
# That is a WINDOW FUNCTION.

import config
import math
from fftlab import FFT

N = 128
RATE = config.SAMPLE_RATE
BIN_HZ = RATE / N

fft = FFT(N)


def tone(freq, n=N):
    return [math.sin(2 * math.pi * freq * (i / RATE)) for i in range(n)]


def spectrum(signal, window=None):
    re, im = fft.buffers()
    for i in range(N):
        re[i] = signal[i] * (window[i] if window else 1.0)
        im[i] = 0.0
    fft.run(re, im)
    return fft.magnitudes(re, im)


def show(mags, label, lo=0, hi=20):
    peak = max(mags) or 1.0
    print()
    print("--- %s ---" % label)
    for k in range(lo, hi):
        bar = "#" * int(mags[k] / peak * 46)
        print("%4d %7.0f Hz %s" % (k, k * BIN_HZ, bar))


# =========================================================================
# PART 1 -- a tone that fits, and one that does not
# =========================================================================
print("=== PART 1: the problem ===")
print("bin width = %.0f Hz" % BIN_HZ)

on_bin = 8 * BIN_HZ                 # exactly bin 8
off_bin = 8.5 * BIN_HZ              # right between bins 8 and 9

show(spectrum(tone(on_bin)), "%.0f Hz -- lands exactly on bin 8" % on_bin, 4, 14)
show(spectrum(tone(off_bin)), "%.0f Hz -- falls BETWEEN bins" % off_bin, 4, 14)

print()
print("The first is one clean spike. The second smears across many bins.")
print("Same purity of tone, same amplitude -- only the frequency changed.")

# =========================================================================
# PART 2 -- why: look at the edges
# =========================================================================
print()
print("=== PART 2: why it happens ===")
print()
print("The DFT assumes your window repeats forever. Check the seam:")
for label, f in (("on-bin  ", on_bin), ("off-bin ", off_bin)):
    s = tone(f)
    jump = abs(s[0] - s[N - 1])
    print("  %s first=%+.3f last=%+.3f  jump at the seam = %.3f"
          % (label, s[0], s[N - 1], jump))
print()
print("The on-bin tone joins up smoothly. The off-bin one has a step in it,")
print("and a step is a sharp edge -- full of frequencies that were never")
print("in the sound. That is SPECTRAL LEAKAGE.")


# =========================================================================
# PART 3 -- window functions
# =========================================================================
def rectangular(n):
    return [1.0] * n


def hanning(n):
    return [0.5 - 0.5 * math.cos(2 * math.pi * i / (n - 1)) for i in range(n)]


def hamming(n):
    return [0.54 - 0.46 * math.cos(2 * math.pi * i / (n - 1)) for i in range(n)]


def blackman(n):
    return [0.42 - 0.5 * math.cos(2 * math.pi * i / (n - 1))
            + 0.08 * math.cos(4 * math.pi * i / (n - 1)) for i in range(n)]


print()
print("=== PART 3: the fix ===")
print()
print("A window fades the samples in and out, so the ends meet at zero and")
print("there is no seam. Here is the Hanning window's shape:")
w = hanning(32)
for i in range(0, 32, 2):
    print("  %2d %s" % (i, "*" * int(w[i] * 40)))

show(spectrum(tone(off_bin), hanning(N)),
     "%.0f Hz WITH a Hanning window" % off_bin, 4, 14)
print()
print("Still wider than a bin-exact peak -- windows cannot work miracles --")
print("but the smear is dramatically reduced.")

# =========================================================================
# PART 4 -- comparing windows
# =========================================================================
print()
print("=== PART 4: which window? ===")
print()
print("%-14s %10s %14s %12s" % ("window", "peak", "spread", "worst sidelobe"))

for name, fn in (("rectangular", rectangular), ("hanning", hanning),
                 ("hamming", hamming), ("blackman", blackman)):
    mags = spectrum(tone(off_bin), fn(N))
    peak = max(mags)

    # SPREAD: how many bins this one pure tone contaminates above 1% of its
    # own peak. This is the practically useful number -- it says how much of
    # your spectrum a single tone ruins.
    #
    # (Textbooks usually quote "main lobe width" measured to the first null.
    # That is a cleaner theoretical quantity but it is fragile to measure on
    # real data, where the tail has no crisp null. We measure the thing we
    # actually care about instead, and label it honestly.)
    wide = sum(1 for m in mags[:N // 2] if m > 0.01 * peak)

    # worst sidelobe: the largest value well away from the peak
    pk = mags.index(peak)
    side = 0.0
    for k in range(N // 2):
        if abs(k - pk) > 4 and mags[k] > side:
            side = mags[k]
    db = 20 * math.log10(side / peak) if side > 0 else -99

    print("%-14s %10.1f %10d bins %10.1f dB" % (name, peak, wide, db))

print()
print("Read that table as a TRADEOFF, not a ranking:")
print("  rectangular : biggest peak, but it contaminates the whole spectrum")
print("  hanning     : good all-round compromise")
print("  blackman    : cleanest spectrum, smallest peak")
print()
print("Low sidelobes  = spot a quiet tone sitting next to a loud one.")
print("Narrow lobe    = tell two CLOSE tones apart.")
print("Windows buy the first by giving up a little of the second, and they")
print("all cost you peak height. Choose based on what you are hunting.")

# =========================================================================
# PART 5 -- what a window costs you
# =========================================================================
print()
print("=== PART 5: windows lose amplitude ===")
clean = max(spectrum(tone(on_bin)))
for name, fn in (("rectangular", rectangular), ("hanning", hanning),
                 ("blackman", blackman)):
    p = max(spectrum(tone(on_bin), fn(N)))
    print("  %-12s peak %8.1f   (%.2f of unwindowed)" % (name, p, p / clean))
print()
print("A window multiplies most samples by less than 1, so the total energy")
print("drops. That factor is called COHERENT GAIN -- divide by it if you")
print("need true amplitudes rather than just a nice-looking picture.")

Part 1 shows the on-bin and off-bin spectra back to back.

Step 2 — Find the seam

1
2
  on-bin   first=+0.000 last=-0.383  jump at the seam = 0.383
  off-bin  first=+0.000 last=+0.924  jump at the seam = 0.924

The off-bin tone's discontinuity is more than twice as large. That step is the leakage.

Step 3 — Apply a window

Part 3 windows the same off-bin tone. The smear collapses dramatically — though not to a single bin. Windows reduce leakage; they don't abolish it.

Step 4 — Compare four windows

1
2
3
4
5
window               peak         spread worst sidelobe
rectangular          41.9         34 bins      -17.8 dB
hanning              27.0          6 bins      -46.8 dB
hamming              28.2         10 bins      -37.7 dB
blackman             23.5          6 bins      -57.9 dB

"Spread" is how many bins one pure tone contaminates above 1% of its own peak. Rectangular (i.e. no window) ruins 34 bins; Hanning ruins 6.

The sidelobe column is the headline: from −17.8 dB to −57.9 dB is a 40 dB improvement — a factor of 100 in amplitude.

There is no 'best' window

Echo offering a tip Low sidelobes let you spot a quiet tone beside a loud one. A narrow main lobe lets you separate two close tones. Windows buy the first by giving up a little of the second, and they all cost peak height. Hanning is the sensible default; pick another when you know which problem you have.

Step 5 — Pay the bill

1
2
3
  rectangular  peak     64.0   (1.00 of unwindowed)
  hanning      peak     31.7   (0.50 of unwindowed)
  blackman     peak     26.7   (0.42 of unwindowed)

A window multiplies most samples by less than one, so total energy drops. Hanning keeps about half. That factor is coherent gain — divide by it if you need true amplitudes rather than a nice-looking picture.

Step 6 — Predict, then measure

Prediction: add a Hanning window to your Lab 21 spectrum analyzer. What happens to the bars while you whistle?

Try it. Precompute the window table once at startup — recomputing a cosine per sample per frame is exactly the mistake Lab 18 taught you to avoid.

Expected Output

The leakage comparison in Part 1, the window shape in Part 3, and the two tables above.

Troubleshooting

Symptom Likely cause Fix
No difference from windowing Test tone is bin-exact Use a frequency between bins
Everything got quieter Working as designed That's coherent gain — see Part 5
Peak moved bins Shouldn't happen for a strong tone Check the window is applied elementwise
Leakage worse with a window Window applied twice Reset the buffer each frame

Challenges

  1. Window your analyzer. Add Hanning to Lab 21 with a precomputed table. Does the whistle peak look tighter?
  2. Two close tones. Generate 800 Hz and 900 Hz together. Which window separates them best? Now try 800 Hz loud and 1,500 Hz very quiet — does the answer change?
  3. Correct the amplitude. Divide by the coherent gain and confirm the windowed peak matches the unwindowed one for a bin-exact tone.

Check Your Understanding

  1. Why does the DFT behave as if your samples repeat forever?
  2. What exactly causes leakage for an off-bin tone?
  3. Reading the table: which window would you pick to find a quiet tone next to a loud one?
  4. What is coherent gain, and when must you correct for it?
  5. Why is leakage the normal case rather than the exception with real sounds?

Your spectra just got honest

Echo celebrating You know why peaks smear and how to tame them. Next lab we squeeze real precision out of those peaks — enough to build a working instrument tuner.


Next: Lab 23: Peak Detection — Build a Tuner | Previous: Lab 21