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

Microphone Array

Two questions a laptop microphone never asked

Everything she has heard so far arrived through a laptop. Chapter 4 recorded from whatever device the operating system nominated as the default, and that was the right amount of machinery at the time: you were sitting in front of the microphone, in a quiet room, and the default was correct. Her ears are now a USB array bolted inside a printed shell, and that arrangement raises two things the laptop never made you deal with.

The first is identity. A Raspberry Pi has no microphone at all; its onboard audio only goes outward. So you add one, and your program immediately has to answer a question that comes with a tempting wrong answer attached: of the seven audio devices the kernel reports, which is the one to record from. The tempting answer is to write down the index that worked while you were testing. It keeps working until a second USB device is plugged in, or the machine reboots and walks the bus in a slightly different order. Now device 2 is a speaker, the capture is three seconds of nothing, and Whisper transcribes the nothing without complaint.

The second is distance. The silence gate from chapter 13 carries a threshold of 0.005, and you settled on that number by speaking into a laptop microphone from about thirty centimetres away in a quiet room. Volume 6 finished by writing down what the software could not answer, and one of those three entries was whether the microphones hear you from the kitchen. That is a question about a number, and the number is measurable this afternoon.

So the rule for her hearing from here on: resolve the capture device by name every time the program starts, and re-measure every level threshold at the distance you will actually be standing. The first half keeps her pointed at real hardware. The second half keeps the thresholds honest about the room she has to live in.

◆ Note — the bench for this chapter, and how to read its numbers

A Raspberry Pi 4B, a ReSpeaker 4-Mic Array on USB, mounted in the shell from chapter 63 with the array facing out at roughly desk height. The room is a 3.6 by 4.5 metre living space that opens onto a kitchen; the kitchen doorway measures 4.2 metres from where she sits. Every level in this chapter came off that one bench, in that one room, with one voice at a conversational volume. Yours will land somewhere else, and the method for finding out where is the point. The Pi also needs PortAudio itself: sudo apt install libportaudio2 before uv add sounddevice soundfile is any use.

The device table is ordinary data

▣ Build · stage 1 — find the microphone by name, with no microphone present
# labs/mic_setup.py

def find_input_device(devices: list[dict], name_substring: str) -> int | None:
    """Index of the first INPUT device whose name contains the substring, else None."""
    target = name_substring.lower()
    for idx, dev in enumerate(devices):
        if dev["max_input_channels"] > 0 and target in dev["name"].lower():
            return idx
    return None


if __name__ == "__main__":
    # The exact structure sd.query_devices() returns: a sequence of dicts.
    table = [
        {"name": "bcm2835 Headphones", "max_input_channels": 0},
        {"name": "ReSpeaker Playback", "max_input_channels": 0},
        {"name": "ReSpeaker 4 Mic Array (UAC1.0)", "max_input_channels": 6},
        {"name": "USB PnP Sound Device", "max_input_channels": 1},
    ]
    for query in ("respeaker", "usb pnp", "bluetooth"):
        print(f"{query!r:12} -> {find_input_device(table, query)}")
$ uv run python -m labs.mic_setup
'respeaker'  -> 2
'usb pnp'    -> 3
'bluetooth'  -> None

Two decisions are doing all the work here, and neither needed hardware to make. The max_input_channels > 0 test is what stops the search at index 2 instead of index 1, even though both names contain "respeaker": the playback endpoint of the same physical device advertises zero input channels, so it can never be selected by accident. And a miss returns None, an answer the caller can act on, in place of the two alternatives people reach for. Returning 0 hands back a working index that points at the wrong device. Raising immediately takes the decision away from the caller, who may have a fallback in mind.

▣ Build · stage 2 — print the real table, and return it
# labs/mic_setup.py — added above find_input_device
import sounddevice as sd


def list_input_devices() -> list[dict]:
    """Print every input device with index, name, channels and native rate."""
    devices = sd.query_devices()
    print(f"{'Idx':>3}  {'Name':<40}  {'In':>2}  {'Rate':>6}")
    print("-" * 58)
    for idx, dev in enumerate(devices):
        if dev["max_input_channels"] > 0:
            print(f"{idx:>3}  {dev['name']:<40.40}  "
                  f"{dev['max_input_channels']:>2}  "
                  f"{int(dev['default_samplerate']):>6}")
    return devices
$ uv run python -m labs.mic_setup --list   # on the Pi — your table will differ
Idx  Name                                      In    Rate
----------------------------------------------------------
  1  ReSpeaker 4 Mic Array (UAC1.0): USB Audi   6   16000
  3  sysdefault                                128   44100
  5  default                                   128   44100

The function prints and also returns, which looks like two jobs until you notice they have different audiences. The printed table is for you: it is how you discover that "respeaker" is a substring nothing else on this machine claims. The returned list is for the program, and returning the same list you printed means name resolution runs against exactly the table you just looked at, with no second call to query_devices() that might disagree.

Read the last two rows before moving on. sysdefault and default both report 128 input channels at 44,100 Hz, and neither is a microphone. They are ALSA plugin devices that forward to something else, and their advertised numbers are placeholders for "whatever the target turns out to support". Recording from those is how you end up resampling audio you never needed to resample. The array's own row says 6 channels at 16,000 Hz, which is the rate the capture path has wanted since chapter 4.

Capture, then a number for how loud it was

▣ Build · stage 3 — two levels, checkable against arithmetic
# labs/mic_setup.py — added below find_input_device
import numpy as np

SAMPLE_RATE = 16_000
CHANNELS = 1
DURATION = 3


def signal_report(audio: np.ndarray) -> tuple[float, float]:
    """Peak and RMS level of a buffer. The float64 cast is load-bearing."""
    wide = audio.astype(np.float64)
    peak = float(np.abs(wide).max())
    rms = float(np.sqrt(np.mean(wide ** 2)))
    return peak, rms


if __name__ == "__main__":
    # A 220 Hz sine at amplitude 0.1 has a known RMS: 0.1 / sqrt(2).
    seconds = np.linspace(0, 3, 3 * SAMPLE_RATE, endpoint=False)
    tone = (0.1 * np.sin(2 * np.pi * 220 * seconds)).astype(np.float32)
    peak, rms = signal_report(tone)
    print(f"peak {peak:.4f}  rms {rms:.4f}  expected rms {0.1 / np.sqrt(2):.4f}")
$ uv run python -m labs.mic_setup
peak 0.1000  rms 0.0707  expected rms 0.0707

Peak and RMS answer different questions and the pipeline needs both. Peak is the largest single sample, so it catches a click, a chair scrape, or a signal already clipping at 1.0 with distortion baked in. RMS squares every sample, averages, and takes the root, so it reports sustained energy, and sustained energy is what "is somebody talking" actually means. The synthetic tone is here because a level meter you cannot check is not a measurement. A sine of amplitude A has an RMS of A divided by the square root of two, always, so an implementation that prints 0.0707 for this input is arithmetically correct before a microphone is ever involved.

▣ Build · stage 4 — record from the resolved device, and refuse to be quiet about silence
# labs/mic_setup.py — the capture path
import argparse
import sys

import soundfile as sf

OUTPUT_FILE = "glados/data/mic_test.wav"
DEAD_MIC_RMS = 0.0005     # below this, the buffer is not a quiet room, it is nothing


def record(device_index: int, seconds: int = DURATION) -> np.ndarray:
    """Capture mono audio at SAMPLE_RATE from one device index."""
    audio = sd.rec(int(seconds * SAMPLE_RATE), samplerate=SAMPLE_RATE,
                   channels=CHANNELS, dtype="float32", device=device_index)
    sd.wait()
    return audio


def resolve(devices: list[dict], name: str) -> int:
    """Name substring to index, or exit with the table already printed above."""
    idx = find_input_device(devices, name)
    if idx is None:
        print(f"No input device matching {name!r}. Pick a substring from the table.")
        sys.exit(1)
    return idx


def main() -> None:
    parser = argparse.ArgumentParser(description="GLaDOS microphone setup")
    parser.add_argument("--list", action="store_true", help="print the input table and stop")
    parser.add_argument("--name", default="respeaker", help="device name substring")
    args = parser.parse_args()

    devices = list_input_devices()
    if args.list:
        return

    idx = resolve(devices, args.name)
    print(f"\nRecording {DURATION}s from index {idx} at {SAMPLE_RATE} Hz mono. Speak now.")
    audio = record(idx)
    sf.write(OUTPUT_FILE, audio, SAMPLE_RATE)
    peak, rms = signal_report(audio)
    verdict = "dead or muted" if rms < DEAD_MIC_RMS else "hearing something"
    print(f"Saved {OUTPUT_FILE}  peak {peak:.4f}  rms {rms:.4f}  ({verdict})")


if __name__ == "__main__":
    main()
$ uv run python -m labs.mic_setup --name respeaker   # measured on the bench — yours will vary
Idx  Name                                      In    Rate
----------------------------------------------------------
  1  ReSpeaker 4 Mic Array (UAC1.0): USB Audi   6   16000
  3  sysdefault                                128   44100
  5  default                                   128   44100

Recording 3s from index 1 at 16000 Hz mono. Speak now.
Saved glados/data/mic_test.wav  peak 0.3120  rms 0.0912  (hearing something)

Notice how low DEAD_MIC_RMS is set. It is not asking "was that loud enough to transcribe", it is asking "did any electrical signal arrive at all", and those are separate questions that want separate numbers. A muted array, an unplugged array, and a capture from an ALSA plugin pointing at nothing all return buffers within a whisker of exact zero, while a quiet room still carries a floor of a couple of thousandths. Setting the dead-mic test at 0.0005 puts it below any real room and above nothing at all, so it fires on a broken cable and stays silent about a quiet one.

The verdict is printed next to the peak and the RMS on purpose. A line that only says "hearing something" is a claim you have to take on faith; the same line carrying 0.3120 and 0.0912 lets you disagree with it. Every boundary between the physical world and her code gets that treatment: report the measurement beside the judgement, and a wrong judgement has nowhere to hide.

Four distances, one afternoon

▣ Build · stage 5 — the measurement volume 6 could not make
# labs/mic_distance.py
import math

import numpy as np

from labs.mic_setup import (SAMPLE_RATE, find_input_device, list_input_devices,
                            record, signal_report)

SILENCE_GATE = 0.005     # the wake-word gate, calibrated at arm's length in a quiet room
REF_METRES = 0.3         # the distance the gate was calibrated at
CAPTURE_SECONDS = 4

POSITIONS = [
    (0.3, "leaning over her"),
    (1.2, "seated at the desk"),
    (2.5, "standing across the room"),
    (4.2, "kitchen doorway"),
]


def free_field_rms(ref_rms: float, metres: float) -> float:
    """Level predicted by distance alone: pressure falls as 1/r in open air."""
    return ref_rms * REF_METRES / metres


def snr_db(signal_rms: float, floor_rms: float) -> float:
    """How far the voice sits above the room, in decibels."""
    return 20.0 * math.log10(signal_rms / floor_rms)


def measure(device_index: int, prompt: str) -> float:
    input(f"{prompt} — press Enter, then read one sentence aloud.")
    _, rms = signal_report(record(device_index, CAPTURE_SECONDS))
    return rms


def main() -> None:
    devices = list_input_devices()
    idx = find_input_device(devices, "respeaker")
    floor = measure(idx, "\nStay silent for the room floor")
    print(f"Room floor, no one speaking: rms {floor:.4f}\n")

    print(f"{'m':>4}  {'position':<26}  {'rms':>7}  {'free-field':>10}  {'SNR dB':>7}  gate")
    ref_rms = None
    for metres, label in POSITIONS:
        rms = measure(idx, f"\nStand at {metres} m ({label})")
        ref_rms = rms if ref_rms is None else ref_rms
        print(f"{metres:>4.1f}  {label:<26}  {rms:>7.4f}  "
              f"{free_field_rms(ref_rms, metres):>10.4f}  "
              f"{snr_db(rms, floor):>7.1f}  {'open' if rms >= SILENCE_GATE else 'SHUT'}")


if __name__ == "__main__":
    main()
$ uv run python -m labs.mic_distance   # measured on the bench — yours will vary
Room floor, no one speaking: rms 0.0021

   m  position                        rms  free-field   SNR dB  gate
 0.3  leaning over her             0.0912      0.0912     32.8  open
 1.2  seated at the desk           0.0310      0.0228     23.4  open
 2.5  standing across the room     0.0122      0.0109     15.3  open
 4.2  kitchen doorway              0.0061      0.0065      9.3  open

So the answer volume 6 wrote down as unanswerable is yes, barely, in a quiet house. From the kitchen doorway a normal speaking voice lands at 0.0061 against a gate of 0.005: it opens, with 22 percent of margin. That is not comfort, it is a measurement sitting one small change away from failing, and the honest reading is that her hearing ends at about that doorway.

The free-field column is the reason to trust the rest. It predicts each level from the first one using nothing but distance, and the two middle rows come in above the prediction because a room is not open air: the walls return energy the free-field model assumes is gone forever. The doorway row falls slightly below prediction instead, because at 4.2 metres you are also off to the side of an array that points at the desk. When measurements and a simple model disagree in a direction you can explain, both are probably telling the truth.

Measured speech level at four distances against the silence gate Four horizontal bars, one per distance, drawn on a level axis from zero to 0.10 RMS. At 0.3 metres the bar reaches 0.0912; at 1.2 metres, 0.0310; at 2.5 metres, 0.0122; at 4.2 metres, 0.0061. A vertical marker at 0.005 shows the silence gate, and the room noise floor of 0.0021 sits well to its left. Only the 4.2 metre bar comes close to the marker. SPEECH RMS BY DISTANCE · GATE AT 0.005 0.3 m 0.0912 1.2 m 0.0310 2.5 m 0.0122 4.2 m 0.0061 gate 0.005 0 0.10 rms
Figure 67.1 — The first three distances clear the gate by margins nothing in a normal room will erase. The fourth clears it by a sliver, which is what "she hears you from the kitchen" turns out to mean on this bench.
◆ Note — what happens when the dishwasher runs

Repeat the floor measurement with the dishwasher going and it reads 0.0021 no longer. On this bench it came back at 0.0068, above the 0.005 gate on its own, so the gate opens continuously and every two-second chunk of appliance noise goes to Whisper. Your voice from the doorway measured 0.0061 in the same room, which puts the speech about a decibel below the noise. Lowering the gate cannot help, because the problem is not that the gate is too high; it is that the two signals are now the same size. The available moves are physical or acoustic: move her closer, aim the array, or take the wake-word decision away from raw energy and give it to something that knows what a word sounds like.

Why this works: identity, and six decibels per doubling

Your recording call travels down four layers. sounddevice is a thin Python wrapper over PortAudio, PortAudio is a C library that presents one interface across operating systems, ALSA is the Linux kernel's sound layer, and under ALSA sits the driver for the actual USB or I2S hardware. PortAudio hands each device it finds a plain integer, assigned in the order the enumeration walked the buses. That integer is discovery order, and discovery order is a fact about one boot, not a fact about the device. The name comes from the hardware's own descriptor, so it survives a reboot, a different port, and a second USB device appearing ahead of it in the list. Any time a system offers you both a position and a name for the same thing, the name is the part that means something.

The same distinction explains the sample rate argument. Ask PortAudio for a rate the device does not natively run and it will not refuse; it inserts a software resampler and keeps going, quietly spending CPU and adding a little distortion to audio that Whisper then has to interpret. The array runs natively at 16 kHz and Whisper was trained at 16 kHz, so requesting 16 kHz means nothing in the path resamples anything.

The distance numbers come from a rule that carries into every room she is ever installed in. In open air, sound pressure from a point source falls as one over the distance, so doubling the distance halves the pressure, and halving a pressure is a drop of six decibels. From 0.3 metres to 4.2 metres is a factor of fourteen, close to four doublings, so the rule predicts around 23 dB of loss and the table lost 23.5. This is why turning up the input gain does not rescue the doorway. Gain multiplies everything the array picked up, voice and refrigerator alike, so the level rises and the ratio between them does not move at all. The number that decides whether she can hear you is the gap between your voice and the room, and the only ways to widen that gap are to get closer, point the microphone better, or make the room quieter.

⚠ Worked failure — an RMS of 98 from a microphone that was shouting

Some capture paths hand back 16-bit integers instead of floats: an I2S microphone read through a driver that does no conversion, or a WAV file written by another tool. The level check looks like it should not care, since RMS is square, average, root, and those work on any numbers. Written against the raw buffer it comes out like this:

import numpy as np

# Four loud samples from an int16 capture. Full scale for int16 is 32767.
audio = np.array([12000, -15000, 22000, -18000], dtype=np.int16)
print("RMS:", np.sqrt(np.mean(audio ** 2)))
$ uv run python -m labs.int16_rms
RMS: 98.71170143402453

No exception, no warning, and a number with fourteen digits of apparent precision. Work back from the symptom. Ninety-eight out of a full scale of 32,767 is 0.3 percent, which would be an almost silent room, and yet the samples in the array reach two thirds of full scale. The reported level and the visible data cannot both be right, so the arithmetic between them is where to look. NumPy keeps the dtype through the operation: audio ** 2 on an int16 array produces an int16 array, and 12000 squared is 144,000,000, which does not fit in a slot that stops at 32,767. It wraps. The mean is then computed over four wrapped values, and the root of a garbage mean is garbage that looks like a level.

Widening the type before the multiply is the whole fix, and it is what signal_report already does with audio.astype(np.float64). The same input then prints RMS: 17153.716798408444, a little over half of full scale, which agrees with the samples you can see. The general lesson outlives audio: when a calculation squares or sums many values, the type that was correct for storing them is often too narrow to hold the intermediate result, and integer types in NumPy wrap silently instead of raising.

Checkpoint, and the voice going the other way

✓ Checkpoint — what you can now do
  • I can explain why a device index that worked yesterday can point at a speaker today, and name the layer that assigned it.
  • I can say why max_input_channels > 0 is what keeps a name search from matching the playback half of the same device.
  • I can verify a level meter against a sine wave before trusting it on a voice, and state the RMS an amplitude of 0.1 must produce.
  • I can read a distance-versus-RMS table and say where in my own room the silence gate stops opening.
  • I know why raising the input gain does not rescue a far-away voice, in terms of the gap between voice and room.
  • Handed an RMS of 98 from a loud int16 buffer, I can trace it to a squared value that would not fit in its own dtype.
⚡ Exercises — try first, then reveal
Exercise 1 — make the resolver prefer the array over its own loopback. Build a device table where the playback endpoint appears before the capture endpoint and both names match, then prove which index comes back and why.
from labs.mic_setup import find_input_device

devices = [
    {"name": "ReSpeaker 4 Mic Array Playback", "max_input_channels": 0},
    {"name": "ReSpeaker 4 Mic Array Capture", "max_input_channels": 6},
]
idx = find_input_device(devices, "respeaker")
print("matched", idx, "->", devices[idx]["name"])
$ uv run python -m labs.resolver_check
matched 1 -> ReSpeaker 4 Mic Array Capture

Index 0 matches the substring and loses anyway, because the channel test runs first in the same condition. Deleting that half of the and gives you index 0, a capture that returns instantly with an empty array, and an afternoon of blaming Whisper.

Exercise 2 — find the distance where your own gate closes. Solve the free-field relation for the distance at which a measured close-range level falls to 0.005, then walk to that spot and check the prediction against a real capture.
from labs.mic_distance import REF_METRES, SILENCE_GATE

close_rms = 0.0912          # replace with your own measurement at 0.3 m
limit_m = close_rms * REF_METRES / SILENCE_GATE
print(f"close {close_rms:.4f} at {REF_METRES} m -> gate closes near {limit_m:.1f} m")
$ uv run python -m labs.gate_range
close 0.0912 at 0.3 m -> gate closes near 5.5 m

The free-field model puts the boundary at 5.5 metres, and the bench measured the doorway at 4.2 metres already down to 0.0061. Walking out to 5.5 metres and reading a sentence returned 0.0043 on this bench, under the gate: the model is optimistic past the doorway because it ignores the wall that is now between you and her. Trust it for an estimate and your legs for the answer.

Exercise 3 — watch the level move while you back away. Use sd.InputStream to print a text meter from each block of samples, then walk from her desk to the far wall and watch where the bar collapses.
import numpy as np
import sounddevice as sd

from labs.mic_setup import SAMPLE_RATE, find_input_device, list_input_devices

WIDTH = 40
FULL_SCALE_RMS = 0.10


def meter(seconds: int = 20) -> None:
    idx = find_input_device(list_input_devices(), "respeaker")

    def on_block(indata, frames, time_info, status) -> None:
        rms = float(np.sqrt(np.mean(indata.astype(np.float64) ** 2)))
        bars = min(WIDTH, int(rms / FULL_SCALE_RMS * WIDTH))
        print("\r[" + "#" * bars + " " * (WIDTH - bars) + f"] {rms:.4f}", end="")

    with sd.InputStream(device=idx, channels=1, samplerate=SAMPLE_RATE,
                        blocksize=1600, callback=on_block):
        sd.sleep(seconds * 1000)
    print()


if __name__ == "__main__":
    meter()

This one only means something with the array plugged in, so there is no captured output to compare against. A blocksize of 1600 at 16 kHz updates the bar ten times a second, fast enough to see individual words and slow enough to read. The callback runs on the audio thread, so keep it to arithmetic and a print; anything slower there drops blocks. Note that the same callback pattern is what continuous listening needs, since a wake word cannot be caught by a program that only records when asked.

Her ears now find themselves by name at every launch, report a level you can check against arithmetic, and come with a table saying how far across the room they reach. Sound going the other way is a different problem with the same trap underneath it: an output device also has an index that drifts, and an array of samples has a rate, a dtype and a column count that the speaker either accepts or answers with silence. Next comes the amplifier, the speaker, and the exact contract a buffer has to satisfy before her voice makes it into the room.