Skip to content

Lab 24: Real-Time Spectrum Analyzer

Time: ~45 minutes | Prerequisites: Lab 23 | Hardware: Pico 2, INMP441, OLED

Where is the time actually going?

Echo waving welcome You have a working analyzer, and it's too slow. Before optimizing anything, find out what is slow — because almost everyone guesses wrong. Put a stopwatch on every stage and let the numbers decide. Let's tune in.

What You'll Build

The complete pipeline running continuously, with a stopwatch on each stage: capture, window, FFT, magnitudes, draw. Plus a live verdict on whether you're keeping up with the audio.

Learning Objectives

  • Assemble the complete real-time pipeline
  • Instrument each stage separately with ticks_us()
  • Calculate frame rate and compare against the audio deadline
  • Identify the bottleneck from measurement rather than intuition
  • Justify where optimization effort should go

Concepts Introduced

ID Concept
433 Frame Rate
434 Stage Profiling
435 Capture Time
436 Compute Time
437 Draw Time
438 Overlap Processing
439 Hop Size
440 Buffer Swapping
441 Processing Latency
442 Bottleneck Identification

Background

Two different questions

  • "How fast is my program?" → total time per frame → frame rate
  • "What should I fix?" → time per stage → the bottleneck

Only the second is actionable. A program that runs at 11 fps tells you there's a problem; a breakdown tells you where.

The deadline, again

At 12,800 Hz, 256 samples represent 20 ms of sound. If the pipeline takes longer than that, audio arrives faster than you can process it.

Predict before you look

Echo thinking Write down your guess for the five stages, in order, before running this. Most people put "capture" near the top — reading from hardware sounds slow. Hold on to your prediction; the measurement is about to disagree with you.

Procedure

Step 1 — Predict

Rank these fastest to slowest: capture, window+DC, FFT, magnitudes, draw.

Step 2 — Measure

Open 24-realtime-analyzer.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
# Lab 24: Real-Time Spectrum Analyzer
#
# The full pipeline, running continuously, with a stopwatch on every stage:
#
#     capture  ->  window  ->  FFT  ->  magnitudes  ->  draw
#
# Timing the whole loop tells you the frame rate. Timing each STAGE tells
# you what to fix. Those are different questions, and only the second one
# is actionable.
#
# Most people assume the FFT dominates. Measure before you believe it.

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

N = 256
RATE = config.SAMPLE_RATE
BIN_HZ = RATE / N
FRAME_MS = N / RATE * 1000        # how much sound one frame represents

BARS = 32
BAR_W = config.WIDTH // BARS
TOP = 14
BAR_H = config.HEIGHT - TOP - 10

fft = FFT(N)
WINDOW = [0.5 - 0.5 * math.cos(2 * math.pi * i / (N - 1)) for i in range(N)]

oled = config.init_display()
mic = config.init_microphone()
raw = bytearray(N * 4)
re, im = fft.buffers()

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

# Accumulators, in microseconds.
t_capture = 0
t_window = 0
t_fft = 0
t_mag = 0
t_draw = 0
frames = 0

REPORT_EVERY = 20

print("=== Real-time spectrum analyzer ===")
print("N = %d, bin width = %.0f Hz" % (N, BIN_HZ))
print("one frame of audio = %.1f ms" % FRAME_MS)
print()
print("Timing every stage. Ctrl-C to stop.")
print()

try:
    while True:
        # --- capture ------------------------------------------------------
        t0 = time.ticks_us()
        n = mic.readinto(raw)
        words = struct.unpack("<%di" % (n // 4), raw[:n])
        t1 = time.ticks_us()

        # --- remove DC and apply the window --------------------------------
        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
        t2 = time.ticks_us()

        # --- the transform -------------------------------------------------
        fft.run(re, im)
        t3 = time.ticks_us()

        # --- magnitudes ----------------------------------------------------
        mags = fft.fast_magnitudes(re, im)
        t4 = time.ticks_us()

        # --- draw ----------------------------------------------------------
        usable = N // 2
        per_bar = usable // BARS
        heights = []
        biggest = 1.0
        for b in range(BARS):
            s = 0.0
            for k in range(b * per_bar, (b + 1) * per_bar):
                if mags[k] > s:
                    s = mags[k]
            heights.append(s)
            if s > biggest:
                biggest = s

        oled.fill(config.BLACK)
        oled.text("spectrum", 0, 0, config.WHITE)
        oled.hline(0, 11, config.WIDTH, config.WHITE)
        for b, h in enumerate(heights):
            px = int(math.sqrt(h / biggest) * BAR_H)
            if px > 0:
                oled.fill_rect(b * BAR_W, TOP + BAR_H - px, BAR_W - 1, px,
                               config.WHITE)
        oled.show()
        t5 = time.ticks_us()

        t_capture += time.ticks_diff(t1, t0)
        t_window += time.ticks_diff(t2, t1)
        t_fft += time.ticks_diff(t3, t2)
        t_mag += time.ticks_diff(t4, t3)
        t_draw += time.ticks_diff(t5, t4)
        frames += 1

        if frames % REPORT_EVERY == 0:
            total_us = t_capture + t_window + t_fft + t_mag + t_draw
            per_frame = total_us / frames
            fps = 1e6 / per_frame

            print("--- after %d frames ---" % frames)
            print("%-12s %10s %8s" % ("stage", "us/frame", "share"))
            for label, acc in (("capture", t_capture), ("window+DC", t_window),
                               ("FFT", t_fft), ("magnitudes", t_mag),
                               ("draw", t_draw)):
                us = acc / frames
                print("%-12s %10.0f %7.0f%%" % (label, us, 100 * acc / total_us))
            print("%-12s %10.0f" % ("TOTAL", per_frame))
            print("frame rate  : %.1f fps" % fps)
            print("audio frame : %.1f ms   pipeline: %.1f ms" %
                  (FRAME_MS, per_frame / 1000))
            if per_frame / 1000 > FRAME_MS:
                print("VERDICT     : NOT real time -- over budget by %.1fx"
                      % (per_frame / 1000 / FRAME_MS))
            else:
                print("VERDICT     : real time, with %.1f ms to spare"
                      % (FRAME_MS - per_frame / 1000))
            print()

except KeyboardInterrupt:
    mic.deinit()
    oled.fill(config.BLACK)
    oled.text("Stopped.", 32, 28, config.WHITE)
    oled.show()

    if frames:
        total_us = t_capture + t_window + t_fft + t_mag + t_draw
        print()
        print("=== Final report over %d frames ===" % frames)
        stages = (("capture", t_capture), ("window+DC", t_window),
                  ("FFT", t_fft), ("magnitudes", t_mag), ("draw", t_draw))
        worst = max(stages, key=lambda s: s[1])
        for label, acc in stages:
            print("  %-12s %8.0f us  %5.1f%%"
                  % (label, acc / frames, 100 * acc / total_us))
        print()
        print("Biggest cost: %s at %.0f%% of the frame."
              % (worst[0], 100 * worst[1] / total_us))
        print()
        print("If you want this faster, that is where to spend your effort.")
        print("Optimising anything else is rearranging deck chairs.")
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
stage          us/frame    share
capture            1297       1%
window+DC          9617      11%
FFT               59212      66%
magnitudes         8488      10%
draw              10471      12%
TOTAL             89085
frame rate  : 11.2 fps
audio frame : 20.0 ms   pipeline: 89.1 ms
VERDICT     : NOT real time -- over budget by 4.5x

Step 3 — Read it properly

Capture is 1%. Reading from the microphone — the one stage that talks to physical hardware — is essentially free. The I²S peripheral fills a buffer in the background using dedicated silicon while the CPU does other things. readinto() just collects what's already waiting.

The FFT is 66%. Two-thirds of every frame. That validates the entire plan for Modules 6 and 7: optimizing the FFT is worth more than everything else combined.

Draw is 12%. More than capture, less than you'd fear for a screen update.

Optimizing the wrong stage is worse than doing nothing

Echo warning Suppose you spent a week making capture twice as fast. Total gain: 0.5%. You'd have burned a week, added complexity, and the analyzer would still run at 11 fps. Amdahl's law in one sentence: your speedup is capped by the fraction you're actually improving.

Step 4 — Do the arithmetic

If the FFT became instant — infinitely fast, zero time — the frame would drop from 89 ms to about 30 ms. Still over the 20 ms budget.

So the FFT is necessary but not sufficient. That's worth knowing before you start: even a perfect FFT leaves work to do on the other stages.

Step 5 — Try the knobs

Change Effect
N = 128 ~4× less FFT work, coarser bins
fewer bars less draw time, blockier display
magnitudesfast_magnitudes already using the fast one; try the sqrt version
draw every other frame halves draw cost, choppier display

Every one is a tradeoff. None of them is free.

Expected Output

The stage table every 20 frames, then a final report naming the biggest cost when you press Ctrl-C.

Troubleshooting

Symptom Likely cause Fix
Frame rate wildly variable Garbage collection Call gc.collect() outside the timed region
Capture time large Buffer larger than the FFT needs Read exactly N samples
Times don't sum to total Untimed work between stages Every line must fall inside a bracket
Draw dominates Too many bars, or drawing per pixel Use fill_rect, fewer bars

Challenges

  1. Overlap. Advance by N/2 samples instead of N so frames overlap 50% — the Cornell lab's approach. What does that do to the frame rate and the responsiveness?
  2. Amdahl in practice. Suppose Module 7 makes the FFT 100× faster. Using these numbers, what frame rate would you get? Is that real time?
  3. Budget your own. Pick a target of 30 fps. Which stages must change, and by how much?

Check Your Understanding

  1. Why is capture only 1% when it's the stage touching real hardware?
  2. What fraction of the frame is the FFT, and why does that justify Modules 6–7?
  3. If the FFT became instant, would the analyzer be real time? Show your working.
  4. State Amdahl's law in your own words, using this table as the example.
  5. Why measure per stage rather than just total time?

Module 5 complete — and you know exactly what to fix

Echo celebrating Live spectrum, windowing, a working tuner, and a profiled pipeline. You have a real instrument and hard evidence about its bottleneck. Module 6 is where we learn to measure honestly — and Module 7 is where we go get that 66% back.


Next: Lab 25: How Long Did That Take? | Previous: Lab 23