GLaDOS Vol 7 · The Body
ch 68 / 99
Chapter 68

Speaker and Amplifier

Everything she has said so far went into a laptop

Chapter 3 turned text into int16 samples at 22050 Hz and wrote them into a WAV file with a header describing exactly that. Chapter 7 replaced the stock voice with hers. Every sentence since then has come out of whatever speaker happened to be built into the machine you were sitting at, which was fine while she was software. The body on the bench has no built-in anything. It has a 4 ohm driver glued into a printed baffle, a board the size of a postage stamp between that driver and the Pi, and no sound at all until you write the last hop.

That hop is short and it fails quietly. Hand the playback library an array of the wrong dtype and you get static. Declare a sample rate that does not match the array and a two-second line plays in one second, a fifth higher, sounding like someone else. Point it at the wrong device and the program runs to completion, prints nothing unusual, exits zero, and the room stays silent. None of those raise. You are debugging by ear from the first minute.

Loudness fails just as quietly, and in the opposite direction. There are three separate multiplications between a number in memory and a moving cone: the sample value itself, the software volume you apply before playback, and the amplifier's gain, which is a fixed voltage multiplier chosen with a resistor and never visible to your code. So the rule for this chapter: keep every sample inside ±1.0 and check the headroom in software, because the amplifier will happily multiply a signal past what its supply can produce, and the first thing that gets destroyed when it does is not volume but intelligibility.

◆ Note — the bench for this chapter, and the pins the amplifier takes

A Raspberry Pi 4B, a MAX98357A breakout, and a 3 watt 4 ohm speaker. The MAX98357A is a digital-to-analog converter and a class-D amplifier on one chip: it reads samples off the I2S pins (BCLK on GPIO 18, LRCLK on GPIO 19, DIN on GPIO 21) and drives the speaker directly, so no analog signal exists anywhere on the Pi to pick up noise. Enable it with dtoverlay=hifiberry-dac in /boot/firmware/config.txt and reboot; the card then appears to ALSA as snd_rpi_hifiberry_dac. One consequence arrives with it: the Pi's own headphone jack is gone, because its analog audio is generated on the same PWM hardware those pins belong to. Nothing else on the body wants them, since the eye servos sit on GPIO 12 and 13 and the light ring is clocked over SPI on GPIO 10. The amplifier takes its 5 V on its own pair of wires back to the supply terminals with a 470 µF capacitor across them at the board, not by tapping the wire that feeds the servos: servo current spikes travelling down a shared wire arrive at the speaker as a buzz. Bench readings below came off this one build. Yours will differ.

A sound is an array long before anything hears it

▣ Build · stage 1 — synthesize a tone and read its numbers
# labs/audio_out.py
"""Sound out: tone synthesis, level checks, and WAV playback for her voice."""
import numpy as np

SAMPLE_RATE = 44_100     # Hz — the tone's own rate; a WAV file brings its own
TONE_FREQ = 440.0        # Hz — concert A, easy to hear and easy to check
TONE_DURATION = 1.0      # seconds
FADE_SECONDS = 0.010     # seconds of ramp at each end


def make_sine_tone(freq: float = TONE_FREQ,
                   duration: float = TONE_DURATION,
                   sample_rate: int = SAMPLE_RATE) -> np.ndarray:
    """A mono sine wave as float32 in [-1.0, 1.0]. Touches no hardware."""
    t = np.linspace(0.0, duration, int(sample_rate * duration), endpoint=False)
    return np.sin(2.0 * np.pi * freq * t).astype(np.float32)


if __name__ == "__main__":
    tone = make_sine_tone()
    print(f"samples: {tone.shape[0]}  dtype: {tone.dtype}  ndim: {tone.ndim}")
    print(f"range: {tone.min():.3f} to {tone.max():.3f}")
    print(f"{tone.shape[0]} samples at {SAMPLE_RATE} Hz = {tone.shape[0] / SAMPLE_RATE:.2f} s")
$ uv run python -m labs.audio_out
samples: 44100  dtype: float32  ndim: 1
range: -1.000 to 1.000
44100 samples at 44100 Hz = 1.00 s

Four properties of that array are the entire contract with the output device, and all four are visible here with no hardware attached. The dtype says how each number is encoded, and float32 in the range ±1.0 is what the playback layer expects; the int16 samples chapter 3 wrote into the WAV header are the same waveform in a different currency, and the file reader will convert on the way in. The count of samples means nothing on its own: 44100 of them is one second at 44100 Hz and half a second at 88200 Hz, so duration and pitch both live in a rate that travels beside the array and is never stored in it. And ndim is 1, which is what mono means. Print these three lines before you connect a speaker and the silent failures lose most of their hiding places.

endpoint=False is the small correctness detail. With the endpoint included, linspace would fit 44100 samples across the closed interval from 0 to 1 second, spacing them very slightly wide and duplicating the phase of the first sample at the end. The tone would still sound like an A. It would click at the seam if you ever looped it.

▣ Build · stage 2 — ramp the edges, and cap the volume at the door
# labs/audio_out.py — added below make_sine_tone


def apply_fades(wave: np.ndarray,
                sample_rate: int = SAMPLE_RATE,
                seconds: float = FADE_SECONDS) -> np.ndarray:
    """Ramp the first and last `seconds` out of and back into silence."""
    n = min(int(sample_rate * seconds), wave.shape[0] // 2)
    wave[:n] *= np.linspace(0.0, 1.0, n, dtype=np.float32)
    wave[-n:] *= np.linspace(1.0, 0.0, n, dtype=np.float32)
    return wave


def scale_volume(wave: np.ndarray, volume: float) -> np.ndarray:
    """Multiply by a volume the caller cannot push past full scale."""
    return wave * np.float32(min(max(volume, 0.0), 1.0))


if __name__ == "__main__":
    n = int(SAMPLE_RATE * FADE_SECONDS)
    faded = scale_volume(apply_fades(make_sine_tone()), 0.7)
    print(f"fade window: {n} samples ({FADE_SECONDS * 1000:.1f} ms at {SAMPLE_RATE} Hz)")
    print(f"sample     0: {faded[0]:.5f}   silence")
    print(f"sample   220: {faded[220]:.5f}   halfway up the ramp")
    print(f"peak at volume 0.70: {float(np.abs(faded).max()):.3f}")
    hot = scale_volume(apply_fades(make_sine_tone()), 5.0)
    print(f"peak at volume 5.00: {float(np.abs(hot).max()):.3f}   volume clamped to 1.0")
$ uv run python -m labs.audio_out
fade window: 441 samples (10.0 ms at 44100 Hz)
sample     0: 0.00000   silence
sample   220: 0.32932   halfway up the ramp
peak at volume 0.70: 0.700
peak at volume 5.00: 1.000   volume clamped to 1.0

A waveform that begins at full amplitude asks the cone to jump from rest to its excursion limit between one sample and the next, in 23 microseconds. That step is not part of the tone; it is a burst of everything, and it arrives as a click at the start of every line she speaks. Ten milliseconds of ramp spreads the arrival across 441 samples and the click disappears. The same ramp at the end covers the amplifier's own switch-off, which has its own thump.

The clamp lives inside scale_volume for the reason chapter 65 put the clamp inside the one private method the eye controller used: the callers multiply, and one of them will eventually be a config file, an over-eager slider, or a language model that decided 5.0 was a reasonable volume. Clamping the multiplier rather than the output is the deliberate part. Clamping the samples afterwards would let a volume of 5.0 through and then flatten every peak it produced, which is precisely the distortion the rest of this chapter is about avoiding.

Measure the headroom before you ask for more

▣ Build · stage 3 — how much louder this signal can legally get
# labs/audio_out.py — added below scale_volume


def peak_headroom_db(wave: np.ndarray) -> float:
    """Decibels between this signal's loudest sample and full scale."""
    peak = float(np.abs(wave).max())
    if peak == 0.0:
        return float("inf")
    return float(20.0 * np.log10(1.0 / peak))


def count_clipped(wave: np.ndarray, limit: float = 0.999) -> int:
    """Samples at or past full scale: the ones the converter cannot render."""
    return int(np.count_nonzero(np.abs(wave) >= limit))


def report(name: str, wave: np.ndarray) -> None:
    print(f"{name:<22} peak {float(np.abs(wave).max()):.3f}  "
          f"headroom {peak_headroom_db(wave):5.2f} dB  clipped {count_clipped(wave)}")


if __name__ == "__main__":
    report("tone, volume 0.70", scale_volume(apply_fades(make_sine_tone()), 0.7))
    report("tone, volume 1.00", scale_volume(apply_fades(make_sine_tone()), 5.0))
    report("tone, driven 1.4x", np.clip(make_sine_tone() * np.float32(1.4), -1.0, 1.0))
$ uv run python -m labs.audio_out
tone, volume 0.70      peak 0.700  headroom  3.10 dB  clipped 0
tone, volume 1.00      peak 1.000  headroom  0.00 dB  clipped 1216
tone, driven 1.4x      peak 1.000  headroom  0.00 dB  clipped 21800

Decibels are the units everyone else in audio already speaks, and the conversion is one line: a level ratio in decibels is twenty times the base-10 logarithm of the ratio. A peak of 0.7 therefore sits 3.10 dB below full scale, and that number is a budget. Add 3 dB of gain anywhere downstream and the peaks land exactly on the ceiling; add 4 and they go through it.

The bottom two rows have identical peaks and nothing else in common. A clean sine touching full scale reports 1216 samples inside the top thousandth of the range, about 2.8 percent, because a sine spends a real fraction of its cycle near the crest. The same tone multiplied by 1.4 and then limited reports 21800, nearly half of every sample in the file, because the tops have been sawn off flat. The count is a gauge, not a verdict: what you watch is the jump. A voice line that normally reports zero and suddenly reports thousands has been pushed into a ceiling somewhere upstream.

The chain from a float sample to a moving cone, and what the supply rail does to a signal asked for too much A left-to-right chain of four stages: a float32 sample between minus one and plus one, three I2S wires carrying it as bits, a converter and amplifier applying a fixed voltage gain, and a four ohm speaker coil. Below the chain, two waveforms are drawn between the same pair of supply rail lines. The left waveform at nine decibels of gain swings clear of both rails and keeps its rounded crests. The right waveform at fifteen decibels of gain reaches the rails and its crests are flattened into plateaus. FOUR STAGES, ONE HARD CEILING float32 sample -1.0 to +1.0 I2S: 3 wires bits, exact, no noise DAC + amp gain set by a resistor 4 ohm coil volts and amps, heat + rail - rail 9 dB gain crests intact, words intact 15 dB gain crests flattened, consonants buried
Figure 68.1 — The digital half of the chain carries exact numbers and adds nothing. The analog half multiplies by a fixed amount and stops at the supply rail, so asking for more gain than the rail can deliver does not make the loud parts louder; it replaces their crests with plateaus.
▣ Build · stage 4 — her voice, out of a real speaker
# labs/audio_out.py — added below report
import sounddevice as sd
import soundfile as sf

OUTPUT_MATCH = "hifiberry"   # a substring of the device name, never an index


def find_output(match: str = OUTPUT_MATCH) -> int:
    """Resolve the amplifier by name, fresh on every run."""
    for index, dev in enumerate(sd.query_devices()):
        if dev["max_output_channels"] > 0 and match.lower() in dev["name"].lower():
            print(f"output device {index}: {dev['name']}")
            return index
    raise RuntimeError(f"no output device whose name contains {match!r}")


def play(wave: np.ndarray, sample_rate: int, device: int | None = None) -> None:
    """Hand the array over, then wait for the buffer to drain."""
    sd.play(wave, samplerate=sample_rate, device=device)
    sd.wait()


def play_wav(path: str, volume: float = 0.7, device: int | None = None) -> None:
    """Play one of her lines, reporting the levels before any sound comes out."""
    data, rate = sf.read(path, dtype="float32")
    channels = 1 if data.ndim == 1 else data.shape[1]
    scaled = scale_volume(data, volume)
    print(f"{path}: {rate} Hz  {channels} ch  {data.shape[0] / rate:.2f} s")
    print(f"  source peak {float(np.abs(data).max()):.3f} "
          f"(headroom {peak_headroom_db(data):.2f} dB)   "
          f"after volume {volume:.2f}: peak {float(np.abs(scaled).max()):.3f}, "
          f"clipped {count_clipped(scaled)}")
    play(scaled, rate, device)
    print("playback complete.")


if __name__ == "__main__":
    device = find_output()
    play(scale_volume(apply_fades(make_sine_tone()), 0.7), SAMPLE_RATE, device)
    play_wav("glados/data/voice/line.wav", volume=0.7, device=device)
$ uv run python -m labs.audio_out   # measured on the bench — yours will vary
output device 2: snd_rpi_hifiberry_dac: - (hw:2,0)
glados/data/voice/line.wav: 22050 Hz  1 ch  2.71 s
  source peak 0.951 (headroom 0.44 dB)   after volume 0.70: peak 0.666, clipped 0
playback complete.

Two things here are inherited rather than invented. The device is looked up by a substring of its name for the reason chapter 67 gave when it did the same to the microphone: an index is the order the kernel noticed things in, and adding one USB device renumbers it. The rate comes back from sf.read beside the samples and is passed straight through to sd.play, so the file's own 22050 Hz follows its own array and never gets confused with the tone's 44100.

The ndim branch is not defensive padding. soundfile returns a flat 1-D array for a mono file and a 2-D array of frames by channels for a stereo one, so data.shape[1] reads the channel count correctly on a stereo music file and raises IndexError: tuple index out of range on every one of her lines, because Piper writes mono. Asking ndim first costs one comparison and makes both paths work. Note also what sd.wait is for: sd.play hands the buffer to PortAudio and returns immediately, so without the wait a short script exits while the line is still playing and you hear the first syllable and nothing else.

Why this works: three multiplications and a ceiling

Follow one sample from memory to air. It starts as a float32 number, say 0.951, meaning 95.1 percent of the largest excursion this signal is allowed. Your software volume multiplies it to 0.666, and in float32 that multiply is exact enough to be free: no resolution is lost, unlike scaling int16 samples, where halving the volume throws away the bottom bit of every sample. The I2S wires carry the result as bits, and bits do not degrade over ten centimetres of jumper wire. Then the converter turns that number into a voltage, and the amplifier multiplies that voltage by a fixed factor: 9 dB is a factor of about 2.8, 15 dB about 5.6. Everything up to the converter was arithmetic. From there on it is electricity, and electricity has limits.

The limit is the supply rail. The amplifier cannot put out more voltage than its 5 V supply, and when the multiplied signal asks for more, the output simply stays at the rail until the signal comes back down. The crest of the waveform becomes a plateau. A plateau is not a quiet version of a crest; it is a different waveform, containing frequencies that were never in her voice, and it is generated in the loudest moments only, which is why clipping sounds like a rasp that appears on some syllables and not others.

The speaker turns that voltage into motion, and its impedance decides how much current comes along. Power is voltage squared divided by impedance, so the same amplifier driving 8 ohms instead of 4 delivers roughly half the power and lands about 3 dB quieter. Go the other way, to a 2 ohm driver, and the amplifier is asked for current it does not have; its protection circuit shuts it down and restarts it, and the sound stutters. Loudness is not impedance alone either: a driver's sensitivity, quoted as dB at one watt and one metre, varies by ten dB or more between cheap drivers, which is a bigger difference than doubling your power buys you.

That last point is the one to keep. Doubling the power buys 3 dB, a change most people describe as slightly louder. Intelligibility does not live on that axis at all. The consonants that separate "shut" from "shot" are brief, quiet, high-frequency bursts sitting far below the vowels around them, and both clipping distortion and a cone driven past its excursion smear energy right across that band. Push the gain until the amplifier clips and you get a machine that is measurably louder and harder to understand, in the same move.

⚠ Worked failure — turning her up until nobody could understand her

At volume 0.70 with the amplifier's gain pin left floating, which selects its default 9 dB, she is clear and slightly polite for a machine that insults you. Across a room with a fan running, she is also too quiet. The obvious fix is more of everything: software volume to 1.0, and the gain pin resistored to 15 dB, the loudest setting the part offers. The report line before playback:

$ uv run python -m labs.audio_out   # volume 1.0, gain pin at 15 dB — bench capture
output device 2: snd_rpi_hifiberry_dac: - (hw:2,0)
glados/data/voice/line.wav: 22050 Hz  1 ch  2.71 s
  source peak 0.951 (headroom 0.44 dB)   after volume 1.00: peak 0.951, clipped 0
playback complete.

Zero clipped samples, 0.44 dB of headroom, a legal array by every measure the code knows. What comes out of the speaker is louder and worse. Sustained vowels acquire a hard edge, the sibilants turn into a rasp, and the ends of her sentences are the first part you stop being able to make out. The bench supply reads 0.18 A average during the line at the old settings and 0.52 A at the new ones, and after a dozen plays the amplifier board is warm enough to notice.

Reason from where the numbers stop. The array is measured in software and it is clean, so nothing digital is at fault; the distortion has to be introduced after the last thing the code can see. What sits there is a fixed voltage gain and a 5 V rail. A source peaking at 0.951 through a gain of 5.6 asks for more than five times the converter's full output swing, and the rail refuses, so the loudest 40 percent of every vowel is delivered flat-topped. The current reading corroborates it: a clipped waveform spends much more of its time at full output, which is exactly what triples the average draw and heats the board. The cure was to put the gain resistor back to 9 dB, run software volume at 0.8, and get the extra loudness from a driver with higher sensitivity in the same printed baffle. Louder is bought with efficiency. Gain past the rail is not louder; it is only more broken.

Checkpoint, and an arm that needs the same discipline

✓ Checkpoint — what you can now do
  • I can name the four properties of an array that decide whether it plays at all, and say which of them is not stored in the array.
  • I can convert a peak amplitude into decibels of headroom and predict how much downstream gain the signal will survive.
  • Handed a mono clip that crashes on shape[1], I can say what soundfile returned and fix it without a try block.
  • I can explain why the 10 ms ramps exist and what the speaker does in their absence.
  • Given a voice that is loud and hard to understand, I can decide from the reported headroom whether the fault is in the array or after the converter.
  • I know what changing the driver from 4 ohms to 8 does to power and to loudness, and why sensitivity matters more than either.
⚡ Exercises — try first, then reveal
Exercise 1 — set the volume in decibels. Write db_to_scale(db) so callers can ask for -6 dB instead of guessing a multiplier, and print the table for 0, -3, -6, -12 and -20 dB.

Decibels are logarithmic, so the linear multiplier is ten raised to the level over twenty:

# labs/audio_db.py
def db_to_scale(db: float) -> float:
    """Convert a level in decibels (0 dB = full scale) to a linear multiplier."""
    return float(10.0 ** (db / 20.0))


if __name__ == "__main__":
    for db in (0.0, -3.0, -6.0, -12.0, -20.0):
        print(f"{db:>6.1f} dB -> x{db_to_scale(db):.4f}")
$ uv run python -m labs.audio_db
   0.0 dB -> x1.0000
  -3.0 dB -> x0.7079
  -6.0 dB -> x0.5012
 -12.0 dB -> x0.2512
 -20.0 dB -> x0.1000

Note that -6 dB is half the amplitude, not half the power, and it is the setting most people reach for when they want "a bit quieter". Feed the result to scale_volume, whose clamp still refuses anything above 0 dB.

Exercise 2 — spend the headroom on purpose. Write preview_gain(wave, gain_db) that reports the peak a given amount of extra gain would produce and how many samples it would pin at full scale, then run it across 0, 3, 4 and 6 dB on the faded tone.
# labs/audio_gain.py
import numpy as np

from labs.audio_out import (apply_fades, count_clipped, make_sine_tone,
                            peak_headroom_db, scale_volume)


def preview_gain(wave: np.ndarray, gain_db: float) -> tuple[float, int]:
    """What this signal would look like with `gain_db` more gain applied to it."""
    louder = wave * np.float32(10.0 ** (gain_db / 20.0))
    return float(np.abs(louder).max()), count_clipped(np.clip(louder, -1.0, 1.0))


if __name__ == "__main__":
    voice = scale_volume(apply_fades(make_sine_tone()), 0.7)
    print(f"headroom now: {peak_headroom_db(voice):.2f} dB")
    for gain in (0.0, 3.0, 4.0, 6.0):
        peak, clipped = preview_gain(voice, gain)
        share = 100.0 * clipped / voice.shape[0]
        print(f"+{gain:4.1f} dB -> peak {peak:5.3f}  clipped {clipped:6d}  ({share:4.1f}% of samples)")
$ uv run python -m labs.audio_gain
headroom now: 3.10 dB
+ 0.0 dB -> peak 0.700  clipped      0  ( 0.0% of samples)
+ 3.0 dB -> peak 0.989  clipped      0  ( 0.0% of samples)
+ 4.0 dB -> peak 1.109  clipped  12404  (28.1% of samples)
+ 6.0 dB -> peak 1.397  clipped  21376  (48.5% of samples)

The cliff is the lesson. Three decibels of the 3.10 dB budget costs nothing at all, and the next single decibel pins 28 percent of the file. Run the same function against one of her actual lines before you touch the gain resistor: the answer tells you how much of the loudness you wanted is available for free.

Exercise 3 — duck her voice when the microphone opens. Write a 10 ms ramp from full volume down to 0.2 so she drops out of her own microphone's way without a click, and print the first and last few multipliers.
# labs/audio_duck.py
import numpy as np


def duck_ramp(sample_rate: int, level: float = 0.2,
              seconds: float = 0.010) -> np.ndarray:
    """A per-sample multiplier that walks from 1.0 down to `level`."""
    return np.linspace(1.0, level, int(sample_rate * seconds), dtype=np.float32)


if __name__ == "__main__":
    ramp = duck_ramp(22050)
    print(f"ramp: {len(ramp)} samples over 10.0 ms")
    print("first four:", np.array2string(ramp[:4], precision=4))
    print("last four: ", np.array2string(ramp[-4:], precision=4))
$ uv run python -m labs.audio_duck
ramp: 220 samples over 10.0 ms
first four: [1.     0.9963 0.9927 0.989 ]
last four:  [0.211  0.2073 0.2037 0.2   ]

Switching the multiplier from 1.0 to 0.2 in one step would produce the same click the fades exist to prevent, so ducking reuses the ramp. Multiply the next 220 samples of the playback buffer by this array, hold at 0.2 while the microphone is open, and reverse the ramp to come back. On the real machine the trigger is the microphone level check from chapter 67, and the point is keeping her voice out of her own transcript.

She now speaks into the room from her own body, at a level you chose deliberately and can defend with a number. The eye tracks, the ring breathes, the speaker talks. What is still missing is the part of her that reaches out and touches something: four servos, three joints and a gripper, each with its own travel limits and its own way of tearing a gear if you command it past them. The arm needs one clamped conversion the way the voice needed one clamped volume, and it needs a homing order that folds the light parts in before the heavy ones swing.