Ears
Everything downstream inherits the capture
Every voice feature she will ever have starts at the microphone. Before anything can transcribe a word, raw audio has to exist in memory: a block of samples at a known rate. And the whole downstream pipeline inherits whatever you capture here. If the microphone was muted, or the wrong device, or recorded at the wrong rate, no amount of clever transcription in chapter 5 will save it. Garbage in stays in.
The naive script "just records and saves," and that is where people lose an afternoon,
because none of the ways capture fails throw an error. The recording call returns
before the buffer is full, so saving too early writes five seconds of zeros. Headsets
expose both a real input and a .monitor loopback that records your
speakers instead of your voice. A muted mic records perfect, uncomplaining
silence. Every one of these produces a valid WAV file with nothing in it.
So this chapter's rule, which the whole book keeps: capture is a measured operation. Record, wait for the buffer, measure the signal level, and only then believe the microphone was listening. Four steps, and the third one is the difference between an assistant and a very quiet box.
Chapter 3 played at the rate the voice model was trained at. Capture targets 16,000 samples per second because of a theorem and a training set: sampling at 16 kHz faithfully captures frequencies up to 8 kHz (the Nyquist limit, half the rate), which covers the intelligible range of human speech, and Whisper, next chapter's ears, was trained on 16 kHz audio. Capturing there means no resample step and no wasted bytes storing ultrasound your voice does not contain. Different jobs, different rates, and the numbers always live in one config, never scattered.
Capture, wait, measure, save
# labs/record_audio.py
import sounddevice as sd
SAMPLE_RATE = 16000
CHANNELS = 1
DURATION = 5
print(f"Recording for {DURATION} seconds... Speak now.")
audio = sd.rec(
int(DURATION * SAMPLE_RATE),
samplerate=SAMPLE_RATE,
channels=CHANNELS,
dtype="float32",
)
sd.wait()
print(f"Captured array: shape={audio.shape}, dtype={audio.dtype}")
$ uv run python labs/record_audio.py
Recording for 5 seconds... Speak now.
Captured array: shape=(80000, 1), dtype=float32
uv add soundfile first (sounddevice and numpy arrived in chapter 3).
Two things to notice. sd.rec() returns immediately, handing you
an array the audio engine will fill over the next five seconds on a background
thread; sd.wait() is what blocks until it is actually full. And the
reported dimensions are your first verification: 80,000 frames is exactly
DURATION * SAMPLE_RATE, one column because mono. When the arithmetic and
the report agree, the capture path is wired the way you think it is.
import soundfile as sf
OUTPUT = "glados/data/captured_audio.wav"
sf.write(OUTPUT, audio, SAMPLE_RATE)
data, samplerate = sf.read(OUTPUT)
print(f"Saved {OUTPUT}: {len(data)} frames at {samplerate} Hz")
$ uv run python labs/record_audio.py
Recording for 5 seconds... Speak now.
Saved glados/data/captured_audio.wav: 80000 frames at 16000 Hz
Last chapter you built a WAV header by hand so you would know what lives in one;
from now on soundfile writes it, taking the array and the rate and
handling the format. The immediate read-back is a cheap round-trip test: 80,000
frames out means the file holds what the buffer held. Cheap tests you always run
beat thorough tests you sometimes run.
import numpy as np
SILENCE_THRESHOLD = 0.01
rms = float(np.sqrt(np.mean(audio ** 2)))
print(f"Signal level (RMS): {rms:.4f}")
if rms < SILENCE_THRESHOLD:
print("WARNING: near silence -- is this the right input device?")
$ uv run python labs/record_audio.py # mic muted on purpose
Recording for 5 seconds... Speak now.
Signal level (RMS): 0.0060
WARNING: near silence -- is this the right input device?
Saved to glados/data/captured_audio.wav
RMS, root-mean-square, is the average energy of the signal: square every sample, average, take the root. One number that answers "did the microphone hear anything?" Silence sits near zero; normal speech lands well above 0.01. The run shown was captured with the mic muted on purpose, so the guard fired; speak normally and you should see something in the 0.02–0.2 region, with the exact figure depending on your microphone and your room. This single check catches the two most common capture failures, wrong device and muted input, before they poison everything downstream.
# labs/record_audio.py — full file
import numpy as np
import sounddevice as sd
import soundfile as sf
SAMPLE_RATE = 16000
CHANNELS = 1
DURATION = 5
OUTPUT = "glados/data/captured_audio.wav"
SILENCE_THRESHOLD = 0.01
def record(duration: int) -> np.ndarray:
print(f"Recording for {duration} seconds... Speak now.")
audio = sd.rec(
int(duration * SAMPLE_RATE),
samplerate=SAMPLE_RATE,
channels=CHANNELS,
dtype="float32",
)
sd.wait()
return audio
def rms_level(audio: np.ndarray) -> float:
return float(np.sqrt(np.mean(audio ** 2)))
def main() -> None:
audio = record(DURATION)
rms = rms_level(audio)
print(f"Signal level (RMS): {rms:.4f}")
if rms < SILENCE_THRESHOLD:
print("WARNING: near silence -- is this the right input device?")
sf.write(OUTPUT, audio, SAMPLE_RATE)
print(f"Saved to {OUTPUT}")
print("Playing back...")
data, samplerate = sf.read(OUTPUT)
sd.play(data, samplerate)
sd.wait()
print("Done.")
if __name__ == "__main__":
main()
$ uv run python labs/record_audio.py
Recording for 5 seconds... Speak now.
Signal level (RMS): 0.0731
Saved to glados/data/captured_audio.wav
Playing back...
Done.
The chapter-2 conventions, applied under pressure: record returns the
buffer, rms_level returns the number, main owns every
print and the playback. Chapter 5 imports record; chapter 10 imports
both. The playback at the end is the human check on top of the numeric one — you
hear your own voice, or you find out now that something upstream lied.
Why this works: two threads and a barrier
sounddevice records on a background audio thread driven by PortAudio, the
cross-platform audio engine underneath. sd.rec() allocates the NumPy
buffer, hands it to that thread, and returns the still-empty array at once, so your
Python code and the audio engine run in parallel from that moment. Samples stream into
the buffer in real time, five seconds of wall clock for five seconds of audio, while
your code is free to do other work.
sd.wait() is the synchronization barrier: it blocks until the background
thread has filled every frame, so everything after it is guaranteed to see the complete
recording. This is the same returns-immediately pattern you will meet again with
streaming language models in chapter 8, and the same barrier idea that
sd.wait() served in chapter 3's playback. Concurrency arrives in this book
the way it arrives in real projects: not as a topic, but as a bug you now know how to
prevent.
The most common capture bug is no crash at all: a script that runs cleanly and saves a silent file. It happens when the barrier goes missing:
audio = sd.rec(int(DURATION * SAMPLE_RATE), samplerate=SAMPLE_RATE,
channels=CHANNELS, dtype="float32")
sf.write(OUTPUT, audio, SAMPLE_RATE) # BUG: no sd.wait() before saving
$ uv run python labs/record_audio.py
Recording for 5 seconds... Speak now.
Signal level (RMS): 0.0000
WARNING: near silence -- is this the right input device?
Saved to glados/data/captured_audio.wav
No traceback, no warning from the library, and the script finishes in a blink instead
of taking five seconds. That timing difference is itself a clue. sf.write
ran microseconds after sd.rec returned, serializing a buffer the audio
thread had barely begun to fill, so the file is 80,000 zeros with a correct header.
Note who caught it: the RMS guard from stage 3, reading 0.0000 on a buffer of zeros.
Guards you write in ten seconds catch bugs that would otherwise surface three
chapters later as "Whisper transcribes my speech as empty text," with nothing
pointing back here.
Checkpoint, and a loop half-closed
- I can explain why
sd.rec()returning instantly is a design, what fills the buffer while my code runs, and which call is the barrier. - I can compute the expected array size for any duration and rate, and check a capture against it.
- I can define RMS in one sentence and give the rough values for silence and for speech.
- I know why speech capture targets 16 kHz: the Nyquist limit against the range of the human voice, and what the transcription model was trained on.
- Handed a silent WAV, I have an ordered list of suspects: missing barrier, wrong device, muted input — and a level check that convicts all three.
Exercise 1 — find your devices. Run
uv run python -c "import sounddevice as sd; print(sd.query_devices())"
and identify your real microphone, your default input (marked >),
and any .monitor devices. Which would record your speakers by
mistake?
The .monitor entries are loopbacks of your outputs: select one as
the input and you record what the machine plays, not what you say. If your
default input is a monitor, that is tomorrow's mystery bug today; pass
device=<index> to sd.rec to pin the real mic, and
note the index for chapter 2's VoiceConfig.device_index field.
Exercise 2 — calibrate your own threshold. Record three buffers: silence, normal speech, and speech from across the room. Print all three RMS values. Is 0.01 the right threshold for your microphone?
Typical results land near 0.001–0.005 for room silence, 0.05–0.15 for close speech, and somewhere between for distance; your figures will differ with mic gain. The threshold wants to sit clearly above your silence and clearly below your quietest real use. If your numbers crowd together, raise the input gain before you blame the code — a rule that returns with force when the microphone array arrives in volume 7.
Exercise 3 — the wrong-rate experiment. Capture at 16 kHz
but save with sf.write(OUTPUT, audio, 32000). Predict what playback
sounds like before you listen, using chapter 3's version of this same mistake.
Double speed, octave up: the chipmunk again, from the other direction. Chapter 3 misdeclared the rate on synthesized audio, this time on captured audio, and the lesson is identical because the mechanism is: the header is an instruction to the player, and the samples never changed. You now recognize this failure from either end of the pipeline.
She talks; the microphone listens and can prove it. Between them sits the step that turns your captured samples into text a language model can reason about. Chapter 5 runs Whisper on this chapter's WAV, entirely on your machine, and closes the listening half of the loop.