Skip to content

Lab 10: Bit Depth, Headroom and Clipping

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

How much detail is in a number?

Echo waving welcome Last lab was about how often we look at the signal. This one is about how precisely we write down what we saw — and what happens when a sound is too loud to write down at all. Let's tune in.

What You'll Build

Three experiments on one captured sound: measure your headroom, watch detail vanish as you throw away bits, and deliberately clip audio until three-quarters of it slams into the wall.

Learning Objectives

  • Calculate dynamic range from bit depth
  • Measure headroom before clipping
  • Demonstrate how fewer bits raises the noise floor
  • Explain what clipping does to a waveform and why it invents new frequencies
  • Relate the 6 dB-per-bit rule to what you measure

Concepts Introduced

ID Concept
291 Dynamic Range
292 Full Scale Value
293 Headroom
294 Clipping
295 Clipping Distortion
296 Quantization Error
297 Noise Floor
298 Amplitude Normalization
299 Integer Overflow

Background

Bit depth is grid spacing

Sampling puts your measurements on a grid. Bit depth sets how fine that grid is.

Bits Distinct levels Used by
8 256 old game consoles
12 4,096 many microcontroller ADCs
16 65,536 CD audio
24 16,777,216 your INMP441

Every real value gets rounded to the nearest grid line. That rounding is quantization error, and it behaves exactly like added noise.

The 6 dB rule

Each extra bit doubles the number of levels, halving the error — worth about 6 dB of dynamic range:

1
dynamic range ≈ 6.02 × bits

For 24 bits that's about 144 dB, which is enormous: from a whisper to a jet engine inside one number.

Full scale and headroom

FULL_SCALE = 8388608 — that's 2²³, the largest a signed 24-bit sample can hold. Headroom is how much louder your signal could get before hitting it.

Clipping doesn't just get loud — it lies

When a sample exceeds full scale it can't. It stops at the maximum. Every sample in the peak becomes the same value, so the rounded top of the wave flattens into a plateau:

1
2
3
4
   before                after clipping
     ╱‾╲                  ┌───┐
    ╱   ╲                 │   │
───╱─────╲───          ───┘   └───

That flat-topped shape isn't the original sound any more. Squared-off waves contain extra harmonics — frequencies that were never in the room. In Lab 21 you'll see them appear as spurious spikes in a spectrum.

Clipping is not recoverable

Echo warning Like aliasing, it destroys information rather than degrading it. Once a thousand samples all read 8388608, nothing can tell you what they were. Turning the volume down afterwards just gives you a quieter flat top.

Procedure

Step 1 — Run all three experiments

Open 10-bit-depth.py and run it. Make some noise during the "Capturing..." message:

  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
# Lab 10: Bit Depth, Headroom and Clipping
#
# Three experiments on the same captured sound:
#
#   1. HEADROOM  -- how much louder could it get before it breaks?
#   2. BIT DEPTH -- throw away low bits and watch detail disappear
#   3. CLIPPING  -- turn it up too far and watch the peaks flatten
#
# All three matter for the FFT later: clipping invents frequencies that were
# never in the room, and too few bits buries quiet ones in noise.

import config
import math
import struct
import time

SAMPLES = 512
FULL_SCALE = 8388608          # 2^23
BITS = 24


def capture(mic, raw):
    n = mic.readinto(raw)
    words = struct.unpack("<%di" % (n // 4), raw[:n])
    samples = [w >> 8 for w in words]
    dc = sum(samples) / len(samples)
    return [s - dc for s in samples]


def rms_of(values):
    total = 0.0
    for v in values:
        total += v * v
    return math.sqrt(total / len(values))


def db_of(value):
    return 20 * math.log10(value / FULL_SCALE) if value >= 1 else -140.0


mic = config.init_microphone()
raw = bytearray(SAMPLES * 4)
for _ in range(5):
    mic.readinto(raw)
    time.sleep_ms(50)

print("Capturing... make some noise now!")
time.sleep(1)
ac = capture(mic, raw)
mic.deinit()

peak = max(abs(v) for v in ac)
rms = rms_of(ac)

# --- 1. headroom -----------------------------------------------------------
print()
print("=== 1. Headroom ===")
print("full scale : %d  (2^%d)" % (FULL_SCALE, BITS - 1))
print("your peak  : %d" % peak)
print("peak level : %.1f dBFS" % db_of(peak))
print("rms level  : %.1f dBFS" % db_of(rms))
print("headroom   : %.1f dB before clipping" % (-db_of(peak)))
print()
print("Theoretical dynamic range of %d bits: %.0f dB" % (BITS, 6.02 * BITS))
print("(each extra bit doubles the range -- worth about 6 dB)")

# --- 2. bit depth ----------------------------------------------------------
# Masking off low bits is exactly what a cheaper converter would do.
print()
print("=== 2. What happens when you throw away bits ===")
print("%6s %14s %14s" % ("bits", "step size", "quant. noise dB"))
for bits in (24, 16, 12, 8, 6, 4):
    drop = BITS - bits
    step = 1 << drop
    if drop == 0:
        quantized = ac
    else:
        # Integer divide then multiply back: this is rounding to a coarser grid.
        quantized = [(int(v) >> drop) << drop for v in ac]
    error = [q - v for q, v in zip(quantized, ac)]
    noise = rms_of(error) if any(error) else 0.0
    print("%6d %14d %14.1f" % (bits, step, db_of(noise) if noise else -140))

print()
print("Fewer bits = a coarser grid = more error = a louder noise floor.")
print("Quiet sounds fall below that floor and simply vanish.")

# --- 3. clipping -----------------------------------------------------------
print()
print("=== 3. Turning it up too far ===")
print("%8s %10s %12s" % ("gain", "clipped", "peak"))
for gain in (1, 4, 16, 64, 256, 1024):
    loud = []
    clipped = 0
    for v in ac:
        x = v * gain
        if x > FULL_SCALE:
            x = FULL_SCALE
            clipped += 1
        elif x < -FULL_SCALE:
            x = -FULL_SCALE
            clipped += 1
        loud.append(x)
    pct = 100.0 * clipped / len(loud)
    print("%8d %9.1f%% %12.0f" % (gain, pct, max(abs(v) for v in loud)))

print()
print("Once samples hit the wall they all read the SAME value, so the peaks")
print("of the wave flatten into plateaus. That squared-off shape contains")
print("frequencies the room never produced -- you will see them appear as")
print("extra spikes once we have an FFT in Lab 21.")

Step 2 — Read your headroom

1
2
3
4
full scale : 8388608  (2^23)
your peak  : 41998
peak level : -46.0 dBFS
headroom   : 46.0 dB before clipping

46 dB of headroom means the sound could get about 200× louder before clipping. Microphones are usually configured with plenty of headroom, because clipping is unfixable and a slightly quiet recording is not.

Step 3 — Watch bits disappear

1
2
3
4
5
6
7
  bits      step size quant. noise dB
    24              1         -140.0
    16            256          -95.1
    12           4096          -71.0
     8          65536          -47.0
     6         262144          -33.7
     4        1048576          -21.2

Look at the noise column. Each row drops 4 bits and the noise rises about 24 dB — that's 6 dB per bit, exactly as the rule predicts. Theory, confirmed on your own desk.

Now compare against Lab 8: your quiet room measured around −75 dB. At 12 bits the noise floor is −71 dB, which is louder than your room. At 12 bits, silence would be buried in quantization noise. That's what bit depth buys you.

Step 4 — Break it

1
2
3
4
5
    gain    clipped         peak
       1       0.0%        41999
      64       0.0%      2687910
     256      14.3%      8388608
    1024      74.6%      8388608

At ×256, one sample in seven is pinned. At ×1024, three-quarters of the waveform is a flat plateau. Notice the peak column stops changing — it can't go higher, so extra gain only flattens more of the wave.

Step 5 — Predict, then measure

Prediction: if 16-bit audio is "CD quality", why would anyone use 24?

Write your answer, then look again at the headroom number. (Hint: what if you don't know in advance how loud the sound will be?)

Expected Output

See the tables above — your peak and noise numbers will differ with room loudness, but the 6 dB per bit pattern and the clipping progression should hold.

Troubleshooting

Symptom Likely cause Fix
Peak very small, huge headroom Quiet room Clap during the capture message
Quantization noise all −140 Signal too quiet to quantize Make more noise, then re-run
No clipping even at ×1024 Extremely quiet capture Raise the gains, or capture something louder
Peak already near full scale Very loud source Move the source back; you're near clipping already

Challenges

  1. Find the breaking point. Binary-search the gain that first produces exactly 1% clipping.
  2. Hear the difference. Quantize to 4 bits and print the ASCII waveform from Lab 7 for both versions. The staircase is visible.
  3. Do the arithmetic. Your room floor is about −75 dB. Using 6 dB per bit, what's the fewest bits that keeps quantization noise below it? Check against the table.

Check Your Understanding

  1. How many distinct levels does a 24-bit sample have?
  2. What is headroom, and why aim for plenty of it?
  3. Why does clipping create frequencies that weren't in the original sound?
  4. Roughly how much dynamic range does each extra bit buy?
  5. Aliasing and clipping are both unrecoverable. What do they have in common?

Module 2 complete — you speak audio now

Echo celebrating Capture, loudness, sample rate, bit depth. You can measure how loud and you know exactly how your instrument can deceive you. But you still can't tell a whistle from a rumble. That's next — and Module 3 is where you build the answer yourself, from scratch. Now that's going to be a superpower.


Next: Lab 11: Sine Waves | Previous: Lab 9