Skip to content

Lab 8: Sound Levels — RMS and a VU Meter

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

512 numbers, one answer

Echo waving welcome Last lab gave you a pile of wobbling numbers. Useful, but not exactly readable at a glance. Today we squeeze each pile down to a single number — how loud — and put it on screen as a bar that dances when you talk. Let's tune in.

What You'll Build

A live sound level meter: a bar graph on the OLED with a peak-hold marker, a decibel readout, and a graph in Thonny's plotter that follows your voice.

Learning Objectives

  • Explain why the average of a sound wave is useless and RMS is not
  • Compute RMS from a buffer of samples
  • Convert a level to decibels relative to full scale
  • Smooth a noisy reading with a moving average
  • Use Thonny's plotter as a live graph
  • Draw a bar meter with peak hold on the OLED

Concepts Introduced

ID Concept
270 Root Mean Square
271 Sound Level
272 Loudness Perception
273 Thonny Plotter
274 Moving Average
275 Exponential Smoothing
276 Sensor Auto Calibration
277 Bar Graph Display
278 Decibel Scale
279 Level Meter

Background

Why not just average?

A sound wave spends as much time below zero as above it. Add up a loud sine wave and you get… roughly zero. Add up a quiet one: also roughly zero. Useless.

RMS — Root Mean Square — fixes this in three steps:

  1. Square every sample (negatives become positive)
  2. Take the mean of those squares
  3. Take the square root to get back to sensible units
1
rms = math.sqrt(sum(v*v for v in samples) / len(samples))

Read the name backwards and it's the recipe.

Decibels, because ears are logarithmic

Your ear doesn't hear loudness linearly. Doubling a sound's power isn't "twice as loud" — it's one small step. So we use a logarithmic scale:

1
db = 20 * math.log10(rms / FULL_SCALE)

This is dBFS — decibels relative to full scale. 0 dBFS is the loudest the hardware can represent, so real sounds are always negative. A quiet room measures around −70 dBFS. Speech lands nearer −40.

Every 6 dB is a doubling

Echo thinking Add 6 dB and the amplitude doubles. So −40 dB is about thirty times bigger than −70 dB, even though the numbers look close. That compression is exactly why dB is readable where raw numbers aren't — it squeezes a range of a million into a range of about 120.

Smoothing without lying

Raw readings twitch. A moving average — keep the last N readings and average them — steadies the display. Bigger N means smoother but slower to react. That's a genuine tradeoff, and you'll meet it again as "averaging" in Lab 26.

Procedure

Step 1 — Run the meter

Open 08-sound-levels.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
# Lab 8: Sound Levels -- RMS and a VU Meter
#
# Turns a buffer of wobbling numbers into ONE number that means "how loud".
#
# Why RMS and not just the average? Because a sound wave spends as much time
# below zero as above it, so its plain average is roughly zero no matter how
# loud it is. Squaring makes everything positive, then we take the square
# root to get back to the original units. Root-Mean-Square.
#
# Watch the Shell: Thonny's plotter draws any bare number you print, so this
# doubles as a live oscilloscope. (View -> Plotter to open it.)

import config
import math
import struct
import time

SAMPLES = 512
FULL_SCALE = 8388608          # 2^23, the largest a 24-bit sample can be
SMOOTHING = 4                 # how many readings to average together

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

# The mic needs a moment after power-up before its output means anything.
for _ in range(5):
    mic.readinto(raw)
    time.sleep_ms(50)


def read_level():
    """Return (rms, decibels) for one buffer of audio."""
    n = mic.readinto(raw)
    words = struct.unpack("<%di" % (n // 4), raw[:n])
    samples = [w >> 8 for w in words]

    # Subtract the DC offset. Without this the meter reads a big constant
    # number in dead silence, which is a very confusing bug to chase.
    dc = sum(samples) / len(samples)

    total = 0.0
    for s in samples:
        v = s - dc
        total += v * v
    rms = math.sqrt(total / len(samples))

    # Decibels relative to full scale. 0 dBFS is as loud as it gets;
    # everything real is negative. Ears work logarithmically, so dB matches
    # perception far better than raw numbers do.
    if rms < 1:
        db = -90.0
    else:
        db = 20 * math.log10(rms / FULL_SCALE)
    return rms, db


def draw_meter(db, peak_db):
    oled.fill(config.BLACK)
    oled.text("Sound Level", 0, 0, config.WHITE)
    oled.hline(0, 10, config.WIDTH, config.WHITE)

    # Map -80..0 dB onto the full width of the screen.
    width = int((db + 80) / 80 * config.WIDTH)
    width = max(0, min(config.WIDTH, width))
    oled.fill_rect(0, 18, width, 14, config.WHITE)
    oled.rect(0, 18, config.WIDTH, 14, config.WHITE)

    # A peak marker that falls back slowly -- like a real VU meter.
    pk = int((peak_db + 80) / 80 * config.WIDTH)
    pk = max(0, min(config.WIDTH - 1, pk))
    oled.vline(pk, 16, 18, config.WHITE)

    oled.text("%6.1f dB" % db, 0, 40, config.WHITE)
    oled.text("peak %5.1f" % peak_db, 0, 52, config.WHITE)
    oled.show()


print("Make some noise! Ctrl-C to stop.")
print("Tip: View -> Plotter to see this as a graph.")

history = [-90.0] * SMOOTHING
peak_db = -90.0

try:
    while True:
        rms, db = read_level()

        # Moving average: a simple low-pass filter that stops the meter
        # twitching. Bigger window = smoother but slower to react.
        history.pop(0)
        history.append(db)
        smooth = sum(history) / len(history)

        # Peak hold with slow decay.
        if smooth > peak_db:
            peak_db = smooth
        else:
            peak_db -= 0.5

        draw_meter(smooth, peak_db)

        # A bare number is what Thonny's plotter wants.
        print(smooth)

        time.sleep_ms(50)

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

Talk, clap, whistle. The bar on the OLED follows you, and the peak marker lingers then falls.

Step 2 — Open the plotter

In Thonny: View → Plotter. Because the program prints one bare number per reading, Thonny graphs it automatically. Now you have a rolling chart of the room's loudness.

Try:

  • speaking normally
  • clapping once (watch the peak marker hold, then decay)
  • staying silent (find your room's noise floor)

Find your noise floor

Echo offering a tip Be completely quiet and note the reading — probably around −75 to −80 dB. That is your room plus the microphone's own electrical noise, and nothing quieter than that will ever be measurable. Every sensor has one. Knowing yours tells you what's real and what's just the floor.

Step 3 — Tune the smoothing

Change SMOOTHING from 4 to 1, then to 20:

Value Behaviour
1 jumpy, instant response
4 balanced (default)
20 glassy smooth, noticeably laggy

There's no correct answer — it depends whether you're measuring a drum hit or room ambience.

Step 4 — Predict, then measure

Write your prediction down first:

Prediction: if you clap twice as loudly, how many dB does the reading rise?

Then test it. (Hint: doubling amplitude is +6 dB. Most people guess much higher.)

Expected Output

Shell, with the plotter drawing them as a graph:

1
2
3
4
5
6
7
8
Make some noise! Ctrl-C to stop.
Tip: View -> Plotter to see this as a graph.
-80.59595
-71.84481
-63.53012
-56.0706
-59.44247
-62.05345

On the OLED: a title, a filled bar with a peak marker, and the dB readouts.

Troubleshooting

Symptom Likely cause Fix
Bar pinned at maximum in silence DC offset not removed Subtract the mean before squaring
Reading never changes Mic not delivering data Re-check Lab 7 wiring
Bar always empty Range mismatch The meter maps −80…0 dB; a very quiet room may sit below −80
Plotter shows nothing Printing extra text Thonny plots bare numbers only — one per line
Meter twitches wildly Smoothing too low Raise SMOOTHING
Meter feels sluggish Smoothing too high Lower SMOOTHING

Challenges

  1. Auto-ranging. Track the quietest and loudest levels seen so far and stretch the bar between them. Now the meter adapts to any room.
  2. Clap detector. Trigger something when the level jumps more than 20 dB in one reading. Careful: what stops it firing repeatedly on one clap? (You solved this in Lab 5.)
  3. Exponential smoothing. Replace the moving average with smooth = 0.8 * smooth + 0.2 * new. Same steadying effect, one variable instead of a list. Which do you prefer, and why?

Check Your Understanding

  1. Why is the plain average of a sound wave close to zero regardless of loudness?
  2. Write the three steps of RMS in order.
  3. Why are dBFS values always negative?
  4. If a sound gets 6 dB louder, what happened to its amplitude?
  5. What is a noise floor, and why can't you measure below it?

One number, live, on a screen

Echo celebrating You now know how loud. But not what — a rumble and a whistle can read identically. Cracking that open is what Module 3 is for. First, though, two labs on how sampling can fool you.


Next: Lab 9: Sampling Rate and Aliasing | Previous: Lab 7