GLaDOS Vol 9 · Sharper Senses
ch 87 / 99
Chapter 87

The Speaker Decides When to Stop

A recording that ends on a stopwatch

Chapter 13 gave her a doorbell. Say her name, the wake word fires, and the loop starts recording your command. What it records is five seconds. Not five seconds if you need them: five seconds, every time, for every sentence anyone will ever say to her. That number was picked by you, in advance, about someone else's speech, and it is wrong in both directions at once. Ask "what time is it" and four of those seconds are refrigerator. Read her a grocery list and she stops listening somewhere around the yogurt.

Trailing silence is not a harmless surplus, either. Whisper was trained on speech with subtitles attached, so an audio clip that contains nothing still gets transcribed into something: "Thank you." and "Thanks for watching." are the two famous ones, learned from thousands of videos that faded out on an empty room. You get a phantom sentence appended to a real one, and the model downstream answers it. You also pay for those seconds. Transcription costs the same per second of audio whether the second holds a word or a fan.

The tempting repair is the gate you already own. Chapter 13's is_silence takes the root-mean-square of a chunk and calls anything under 0.005 quiet, so why not keep recording while RMS stays high and stop when it falls? Because RMS measures loudness, and a fan is loud. A furnace is loud. A dishwasher on its rinse cycle is loud for forty minutes. RMS answers "how much energy is in these samples," and the question you actually have is "is a person talking," and those two questions agree only in a silent room. Raise the threshold until the fan sits under it and you have put quiet speech under it too. One number cannot separate two things that happen to share it.

So this chapter changes the instrument. A voice activity detector reads a very short slice of audio, looks at how the energy is spread across the frequency bands where a human voice lives, and returns one bit: speech, or not speech. It has no idea what a word is. It cannot tell you what was said, who said it, or whether the sentence finished. It answers the one question RMS cannot, thirty milliseconds at a time, and that is enough to build the rule this chapter is after: recording starts when a classifier hears voice and ends when the speaker has been quiet long enough to mean it, so the length of a turn belongs to the person talking.

⚒ Tool — webrtcvad

webrtcvad is the voice detector from Google's WebRTC stack, the one that decides when your browser is worth transmitting during a video call, wrapped as a small Python module. There is no model to download and nothing to train: it is a few hundred lines of signal processing that run in microseconds per frame. Install it beside the audio libraries you already have:

uv add webrtcvad

The whole interface is two calls. webrtcvad.Vad(aggressiveness) makes a detector, and vad.is_speech(frame, rate) returns a bool. It takes raw 16-bit PCM bytes, not a NumPy array of floats, and it is strict about size: exactly 10, 20 or 30 milliseconds of audio at a rate it recognizes, which includes 8, 16 and 32 kHz. Hand it anything else and it raises. That strictness is the first thing to design around.

Frames, and one bit each

▣ Build · stage 1 — pin the frame geometry to two constants
# labs/vad_listen.py
SAMPLE_RATE = 16000    # Hz, the capture rate the whole pipeline uses
FRAME_MS = 30          # ms per frame; webrtcvad accepts 10, 20 or 30
FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS // 1000
FRAME_BYTES = FRAME_SAMPLES * 2        # int16 audio: two bytes per sample

if __name__ == "__main__":
    print(f"samples per frame: {FRAME_SAMPLES}")
    print(f"bytes per frame:   {FRAME_BYTES}")
    print(f"frames per second: {1000 / FRAME_MS:.1f}")
$ uv run python labs/vad_listen.py
samples per frame: 480
bytes per frame:   960
frames per second: 33.3

Follow the arithmetic once and you will never be confused by a frame again. One second of this audio is 16,000 samples, two bytes each, so 32,000 bytes a second. Thirty milliseconds is three hundredths of that: 480 samples, 960 bytes, and a shade over 33 of them per second. Every one of those numbers is derived, not typed. Change FRAME_MS to 20 and the samples become 320, the bytes 640, and the timeout you meet in a moment recounts itself. Two constants at the top, and no stray 480 buried in a stream call waiting to disagree with a stray 960 in a slice.

▣ Build · stage 2 — one frame in, one bool out
import math
import struct

import webrtcvad

AGGRESSIVENESS = 2     # 0 passes almost everything, 3 discards hardest

vad = webrtcvad.Vad(AGGRESSIVENESS)

silence = b"\x00\x00" * FRAME_SAMPLES
tone = b"".join(
    struct.pack("<h", int(20000 * math.sin(2 * math.pi * 440 * n / SAMPLE_RATE)))
    for n in range(FRAME_SAMPLES)
)

for name, frame in (("silence", silence), ("440 Hz tone", tone)):
    print(f"{name:12s} {len(frame)} bytes  speech={vad.is_speech(frame, SAMPLE_RATE)}")
$ uv run python labs/vad_listen.py
silence      960 bytes  speech=False
440 Hz tone  960 bytes  speech=True

Two synthetic frames, built by hand so this stage runs on a machine with no microphone attached. The silent one is 480 zero samples and comes back False, as it should. The second is a pure 440 Hz tone, a tuning-fork A, and it comes back True, which is the most useful wrong answer in this chapter. Nobody sang that. A voice detector detects voice-like signal in the bands where speech has energy, and a strong steady tone sitting in the middle of those bands passes for one. Take the lesson now, before it costs you: this classifier goes behind the wake word from chapter 13, never in front of it. It tells you a person may be talking. Only Whisper can tell you they were talking to her.

AGGRESSIVENESS runs 0 to 3 and tunes how eager the detector is to throw a frame away. At 0 it keeps nearly everything, so a noisy room reads as one long utterance. At 3 it is severe enough to drop the quiet leading consonant of a word, and you get recordings that begin at "-emind me". Level 2 is a reasonable place to start in a normal room, and you will tune it in this chapter's exercises against the room you actually live in.

▣ Build · stage 3 — watch the two instruments disagree
import numpy as np
import sounddevice as sd

SILENCE_THRESHOLD = 0.005    # the RMS gate from chapter 13, unchanged

def frame_rms(frame: bytes) -> float:
    samples = np.frombuffer(frame, dtype=np.int16).astype(np.float32) / 32768.0
    return float(np.sqrt(np.mean(samples ** 2)))

def trace(seconds: float = 3.0) -> None:
    vad = webrtcvad.Vad(AGGRESSIVENESS)
    with sd.RawInputStream(samplerate=SAMPLE_RATE, blocksize=FRAME_SAMPLES,
                           dtype="int16", channels=1) as stream:
        for i in range(int(seconds * 1000 / FRAME_MS)):
            frame = bytes(stream.read(FRAME_SAMPLES)[0])
            rms = frame_rms(frame)
            gate = "open" if rms >= SILENCE_THRESHOLD else "closed"
            speech = vad.is_speech(frame, SAMPLE_RATE)
            print(f"{i:3d}  rms={rms:.4f}  gate={gate:6s} vad={speech}")
$ uv run python labs/vad_listen.py   # fan running, then a short sentence; your numbers will vary
  0  rms=0.0121  gate=open   vad=False
  1  rms=0.0118  gate=open   vad=False
  2  rms=0.0134  gate=open   vad=False
  3  rms=0.0402  gate=open   vad=True
  4  rms=0.0761  gate=open   vad=True
  5  rms=0.0693  gate=open   vad=True
  6  rms=0.0044  gate=closed vad=True
  7  rms=0.0038  gate=closed vad=True
  8  rms=0.0126  gate=open   vad=False
  9  rms=0.0119  gate=open   vad=False

There is the whole argument, printed. Your figures will not match these, because they are a capture of one room with one fan and one microphone gain, but the pattern will reproduce anywhere. Frames 0 to 2 are an empty room with a fan in it: the RMS gate calls them audio and opens, the detector calls them noise and stays shut. Frames 6 and 7 are the tail of the last word, a fricative quiet enough to drop under 0.005, so the gate slams while the detector still hears a person. The loudness meter is wrong in both directions on the same three seconds, and no threshold you pick moves both errors the way you want.

◆ Note — bytes here, floats there

Chapter 4 captured with sd.rec, which hands back a NumPy array of float32 samples between -1 and 1. This chapter opens a RawInputStream with dtype="int16" and reads bytes, because that is the only thing is_speech accepts. Neither format is more correct. They are two views of the same samples, and converting between them costs a multiply and a cast, which frame_rms above does in one line so the old measurement still works on the new buffers. Pick the format the strictest consumer demands, and convert for the flexible ones.

Two thresholds that disagree on purpose

A per-frame bool is not yet a recorder. Something has to decide when the utterance begins and when it is over, and the naive version of that decision fails immediately: start on the first voiced frame, stop on the first quiet one. Try it and you get a recorder that starts when you set a mug down and stops in the middle of your second word. Both halves are broken for the same reason. Thirty milliseconds is short. A single frame of speech can be a keyboard, and a single frame of silence is a normal part of talking: say "a pocket" and the p is a genuine gap, the vocal tract closed, nothing coming out, roughly one frame of true quiet in the middle of a word you did not pause during.

The fix is to make the two decisions ask for different amounts of evidence. Starting requires five voiced frames inside the last ten. Stopping requires fifty quiet frames in a row, which is a second and a half. Between those two conditions sits a wide band where neither fires, and that band is what keeps the recorder from flapping. Engineers call the gap hysteresis, and any voiced frame at all resets the silence counter to zero, so only uninterrupted quiet counts toward the end of a turn.

Forty frames of one utterance, with the trigger point, the ring buffer and the silence counter Each cell is one 30 millisecond frame. The first ten are room noise. Frames 10 to 17 are the first word, and the fifth voiced frame among them fires the trigger. The ten frames already held in the ring buffer are prepended, so the recording keeps the leading edge. A three-frame pause between words pushes the silence counter to 3 before a voiced frame resets it to zero. The final quiet run starts counting again and stops the recording when it reaches fifty. EACH CELL IS ONE 30 MS FRAME · GOLD = THE DETECTOR SAID SPEECH START: 5 voiced frames among the last 10 STOP: 50 quiet frames in a row, which is 1.5 seconds the quiet run below reaches 11 here; the recorder waits for 50 trigger ring buffer, prepended 1 2 3 1 2 3 4 5 6 7 8 9 10 11 silence counter: every voiced frame puts it back to zero
Figure 87.1 — The pause between the two words reaches a count of 3 and is wiped out by the next voiced frame. Only the run at the end is allowed to grow, and it has to reach 50 before the recording closes. The ring buffer is the reason the first syllable survives: those ten frames were already in hand when the trigger fired.
▣ Build · stage 4 — the recorder, as a state machine
import collections

RING_FRAMES = 10                                     # 300 ms of pre-roll and vote window
MIN_SPEECH_FRAMES = 5                                # 5 of those 10 must be voiced to start
SILENCE_TIMEOUT_MS = 1500
SILENCE_TIMEOUT_FRAMES = SILENCE_TIMEOUT_MS // FRAME_MS

def record_utterance() -> bytes:
    vad = webrtcvad.Vad(AGGRESSIVENESS)
    ring: collections.deque[tuple[bytes, bool]] = collections.deque(maxlen=RING_FRAMES)
    voiced: list[bytes] = []
    triggered = False
    silent_run = 0

    print("Listening...")
    with sd.RawInputStream(samplerate=SAMPLE_RATE, blocksize=FRAME_SAMPLES,
                           dtype="int16", channels=1) as stream:
        while True:
            frame = bytes(stream.read(FRAME_SAMPLES)[0])
            speech = vad.is_speech(frame, SAMPLE_RATE)

            if not triggered:
                ring.append((frame, speech))
                if sum(1 for _, s in ring if s) >= MIN_SPEECH_FRAMES:
                    triggered = True
                    voiced.extend(f for f, _ in ring)
                    ring.clear()
                    print("Speech detected, recording...")
                continue

            voiced.append(frame)
            if speech:
                silent_run = 0
            else:
                silent_run += 1
                if silent_run >= SILENCE_TIMEOUT_FRAMES:
                    print(f"Quiet for {SILENCE_TIMEOUT_MS} ms, stopping.")
                    break

    return b"".join(voiced)

if __name__ == "__main__":
    pcm = record_utterance()
    frames = len(pcm) // FRAME_BYTES
    print(f"captured {frames} frames ({frames * FRAME_MS / 1000:.2f} s)")
$ uv run python labs/vad_listen.py   # a live microphone; your frame count will differ
Listening...
Speech detected, recording...
Quiet for 1500 ms, stopping.
captured 141 frames (4.23 s)

One deque does two jobs, and that is the design worth studying. Before the trigger it is a voting window: ten frames wide, and five of them voiced is the entry condition. At the instant it fires, those same ten frames are the audio that arrived while the detector was still making up its mind, so they get poured into voiced before the live frames start arriving. Without that, every recording would open on the second syllable, because proving speech has started takes five frames and those five frames are your first consonant.

After the trigger the ring is finished and the code takes the simpler path: append every frame, count consecutive quiet ones, reset on any voice. Note what is not in this function. No duration, no deadline, no stopwatch. It returns when you stop talking and not before, and the 141 frames in that capture are 4.23 seconds because the sentence was 4.23 seconds long, minus the fixed second and a half of proof at the end.

Handing a clean clip to the pipeline

▣ Build · stage 5 — trim the proof, write the WAV, splice it in
import wave

UTTERANCE = "glados/data/utterance.wav"

def trim_trailing_silence(pcm: bytes, keep_ms: int = 300) -> bytes:
    drop = (SILENCE_TIMEOUT_MS - keep_ms) // FRAME_MS * FRAME_BYTES
    return pcm[:-drop] if len(pcm) > drop else pcm

def save_wav(pcm: bytes, path: str = UTTERANCE) -> None:
    with wave.open(path, "wb") as wf:
        wf.setnchannels(1)
        wf.setsampwidth(2)             # 16-bit, matching the capture
        wf.setframerate(SAMPLE_RATE)
        wf.writeframes(pcm)

if __name__ == "__main__":                  # prove the writer with no microphone
    save_wav(b"\x00\x00" * (SAMPLE_RATE // 2), "glados/data/writer_check.wav")
    with wave.open("glados/data/writer_check.wav") as wf:
        print(f"channels={wf.getnchannels()} width={wf.getsampwidth()} "
              f"rate={wf.getframerate()} frames={wf.getnframes()}")
$ uv run python labs/vad_listen.py
channels=1 width=2 rate=16000 frames=8000

Half a second of zeros in, 8,000 frames back out at 16 kHz mono 16-bit: the writer is told the same three facts the capture used, so nothing is reinterpreted on the way to disk. Get any of the three wrong and the file plays at the wrong pitch or as static, the mistake chapter 4's last exercise had you produce on purpose.

trim_trailing_silence is the small repair that pays for section one's complaint. The recorder had to hear 1.5 seconds of quiet to know you were finished, and that proof is now sitting in the buffer as 50 frames of nothing, which is exactly the fuel Whisper hallucinates on. Dropping 40 of them leaves 300 milliseconds of natural tail, enough that the final consonant is not clipped and little enough that the model has nothing to invent from.

# labs/wake_word.py — the command window stops being a duration
from labs.vad_listen import (FRAME_BYTES, UTTERANCE, record_utterance,
                             save_wav, trim_trailing_silence)

if __name__ == "__main__":
    model = WhisperModel("base", device="cpu", compute_type="int8")
    print("Waiting for wake word. Say 'Hey GLaDOS'...")
    while True:
        woke, heard = detect_wake_word(model)
        if not woke:
            continue
        raw = record_utterance()
        pcm = trim_trailing_silence(raw)
        save_wav(pcm, UTTERANCE)
        print(f"{len(raw) // FRAME_BYTES} frames captured, "
              f"{len(pcm) // FRAME_BYTES} after trim")
        segments, _ = model.transcribe(UTTERANCE, language="en")
        print("You:", " ".join(s.text.strip() for s in segments))
        break
$ uv run python labs/wake_word.py   # live mic and model; your transcript will read differently
Waiting for wake word. Say 'Hey GLaDOS'...
Wake word detected in: 'hey glados'
Listening...
Speech detected, recording...
Quiet for 1500 ms, stopping.
187 frames captured, 147 after trim
You: remind me to move the laundry before the printer in the hall eats another envelope

187 frames is 5.61 seconds of talking, past the window this replaced, and she waited for all of it. The clip that reached Whisper was 147 frames, because the 40 frames of silence that proved the sentence had ended were never the sentence. The loop from chapter 13 changed in one place, where a fixed duration became a call. Everything else about the cascade holds: the wake word still guards the expensive stage, and the transcriber still receives a 16 kHz mono WAV it can read without resampling, since the detector demanded the same rate the transcriber was trained on.

Why this works: the gap between two thresholds

Hysteresis is older than this chapter and much older than voice detection. Your thermostat runs the furnace until the room is 71 and does not restart it until the room falls to 68, and that three-degree gap is the only thing standing between you and a furnace that cycles every ninety seconds. A Schmitt trigger in an electronics circuit does the same job in hardware, turning a slowly drifting voltage into a clean digital edge. Alerting systems fire after five failed checks and clear after ten good ones. The pattern is always identical: one threshold to enter a state, a different threshold to leave it, and a band in between where nothing happens at all.

The band is where noise lives. Any measurement of the physical world jitters around its true value, so a single threshold with a value sitting on it produces an endless run of transitions driven entirely by the jitter. In a thermostat you hear that as a furnace hammering itself to death. In this recorder you would hear it as a file that starts on a cough and ends inside a word. Choosing the two numbers is a real design decision, not a formality: five voiced frames out of ten is loose enough for a soft speaker and tight enough to ignore a keystroke, and fifty quiet frames is long enough to cover the pauses inside speech and short enough that she does not feel asleep.

The cascade from chapter 13 keeps its ordering and gains a better first stage. The detector costs microseconds per frame, so it can afford to run on every frame forever, and it is more accurate about the question being asked than the loudness gate it replaced. What reaches Whisper is now one clip per turn, cut to the turn, instead of a two-second chunk every two seconds all day. Filter cheaply first, compute expensively last, and when a cheap filter is also a more honest one, take the upgrade.

Now the part that stays broken, because you will meet it on the first day and should meet it here first. Somebody says "remind me to call" and stops to remember the name. They are thinking, not finished, but a thinking pause and a finished sentence are the same signal at the microphone: no voice. At 1.5 seconds the recorder decides, Whisper gets half a request, and she answers a fragment. No value of SILENCE_TIMEOUT_MS fixes this, because the recorder is not being asked a hard question, it is being asked an impossible one. What you can do is choose which mistake you would rather make, and put the recovery somewhere it can see more: a transcript that ends mid-clause is evidence the turn was not over, and a loop that notices can listen again instead of replying. She will still cut off a slow thinker sometimes. So do people.

⚠ Worked failure — is_speech rejects a frame it just measured

Twenty milliseconds feels twitchy and thirty feels slow, so you split the difference in the one place the file says to change it:

FRAME_MS = 25          # 25 ms: not one of the three legal values
$ uv run python labs/vad_listen.py
samples per frame: 400
bytes per frame:   800
frames per second: 40.0
Traceback (most recent call last):
  File "/home/you/GladOS/labs/vad_listen.py", line 31, in <module>
    print(f"{name:12s} {len(frame)} bytes  speech={vad.is_speech(frame, SAMPLE_RATE)}")
                                                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/you/GladOS/.venv/lib/python3.11/site-packages/webrtcvad.py", line 27, in is_speech
    return _webrtcvad.process(self._vad, sample_rate, buf, length)
webrtcvad.Error: Error while processing frame

Read the three lines above the traceback before you read the traceback. The arithmetic worked perfectly: 400 samples, 800 bytes, 40 frames a second, all correct, all consistent, all useless. Nothing in your code objected, because nothing in your code knows that this particular library is hardwired to 10, 20 and 30 milliseconds at three fixed rates. The frame was the right size for the stream, the right size for the RMS function, and the wrong size for the one consumer with an opinion.

The fix is to keep FRAME_MS at a legal value, and the durable fix is to say so where the value is set, since a constant with three allowed values is a constant that deserves a guard:

if FRAME_MS not in (10, 20, 30):
    raise ValueError(f"FRAME_MS must be 10, 20 or 30, got {FRAME_MS}")

Two lines, and the error now names the variable, the rule and the offending value at import time instead of surfacing as "Error while processing frame" from inside a C extension five calls away. Whenever an external library constrains one of your constants, that constraint belongs next to the constant, in code, not in a comment anyone can edit past.

Checkpoint, and a turn that ends when you do

✓ Checkpoint — what you can now do
  • I can compute the sample count and byte count of a frame from a rate and a duration, and say why 480 and 960 are never typed into this file.
  • I can name two situations where an RMS gate and a voice detector disagree, and say which one is right in each.
  • I can explain why a pure 440 Hz tone reads as speech, and what that tells me about where this classifier belongs in the cascade.
  • I can state both hysteresis thresholds in frames and in milliseconds, and say what breaks if either is set to one frame.
  • I know what the ring buffer holds at the moment the trigger fires, and what the recording would sound like without it.
  • I can describe the speaker this recorder cuts off, and where the repair for that has to live.
⚡ Exercises — try first, then reveal
Exercise 1 — audit your own room. Run trace() for ten seconds with every appliance you own switched on and nobody speaking. Count the frames where the gate opens and the detector says False.

In most kitchens the count is nearly every frame: a fridge compressor, a range hood, a laptop fan under load, all comfortably above 0.005 RMS and all of them non-speech. That count is the number of Whisper calls a loudness gate would have bought you for nothing. Then run the same trace with a television on, and watch the detector say True to every word of it. Both experiments are honest about what this classifier is: it separates voice from noise, and it has no opinion about whose voice.

Exercise 2 — replay a WAV through the detector. Read glados/data/utterance.wav in 960-byte slices and print the speech bit for each, so you can classify audio without a microphone.

Open it with wave, call wf.readframes(FRAME_SAMPLES) in a loop, and pass each slice straight to is_speech. The detector never knew where the bytes came from, so a file works exactly as a stream does, and now your experiments are repeatable: the same recording gives the same bits every run. Discard the final partial slice, since a short frame raises the error from this chapter's worked failure. This is also the fastest way to sweep AGGRESSIVENESS from 0 to 3 on one recording and count how many frames each level keeps.

Exercise 3 — find your own timeout. Set SILENCE_TIMEOUT_MS to 600, then 1500, then 2500, and speak ten real requests at each setting. Count how many were cut off and how many left you waiting.

At 600 ms almost every request with a mid-sentence comma gets truncated, and the transcripts read like telegrams. At 2500 ms nothing is ever cut, and every single turn feels like she is ignoring you for two and a half seconds. Most rooms and most speakers land between 1200 and 1800. The number you pick is the answer to "which annoyance do I prefer," and once you have measured it on yourself, write it into the config beside the wake word settings with a comment naming the day you measured it and the speed you talk.

Half the conversation now runs at your pace. The other half still runs at hers: she says nothing at all until the language model has finished producing every token of its reply, then synthesizes the whole thing, then plays it. Two slow operations, strictly one after the other, with a silence in the middle that you sit through. The next chapter cuts the reply at sentence boundaries and starts speaking the first one while the second is still being written.