Lab 24: Real-Time Spectrum Analyzer
Time: ~45 minutes | Prerequisites: Lab 23 | Hardware: Pico 2, INMP441, OLED
Where is the time actually going?
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
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 | |
1 2 3 4 5 6 7 8 9 10 | |
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
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 |
magnitudes → fast_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
- 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?
- Amdahl in practice. Suppose Module 7 makes the FFT 100× faster. Using these numbers, what frame rate would you get? Is that real time?
- Budget your own. Pick a target of 30 fps. Which stages must change, and by how much?
Check Your Understanding
- Why is capture only 1% when it's the stage touching real hardware?
- What fraction of the frame is the FFT, and why does that justify Modules 6–7?
- If the FFT became instant, would the analyzer be real time? Show your working.
- State Amdahl's law in your own words, using this table as the example.
- Why measure per stage rather than just total time?
Module 5 complete — and you know exactly what to fix
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