GLaDOS Vol 10 · Reaching Out
ch 97 / 99
Chapter 97

Hearing Tone, Not Just Words

Two channels, one buffer

Whisper hands back a sentence. I'm fine. Two words and a full stop, and since chapter 5 the pipeline has treated that string as everything the microphone delivered. It never was. The same buffer carries a second reading in the samples themselves: how loud the sentence was, how high, how much the pitch moved while it went by, and how much of the clip was talking instead of pausing. Delivered one way those two words end the conversation. Delivered another way they are an invitation to ask again.

This chapter measures that second reading. Four numbers come off the recording her voice detector already made, three of them computed with arithmetic you can check on paper, and together they say how the sentence was delivered without knowing a word of it. Then it does something more important than measuring, because the temptation with a number is to act on it, and these four numbers do not deserve that. They are a weak signal. They separate two people far better than they separate one person's two moods, they shift when you are tired, and they shift when you have a cold.

So the rule stands before the code does. Prosody moves the dial on a mood she already has. It never picks the mood, never starts an action, and never gets said back to you. Everything built below is written to make that rule cheap to keep and awkward to break.

◆ Note — what to install, and how to read the numbers here

NumPy and webrtcvad are already in the workspace. uv add librosa adds the one library this chapter borrows, and it is a heavy install: scipy, numba and an LLVM toolchain come with it. Only the pitch tracker in stage 3 needs it, so on a board where that install fights you, the hand-written estimators in stage 2 keep the rest of the chapter running. Every microphone figure below was captured on the bench in one room with one USB microphone, and yours will vary in every digit. The arithmetic, the frame counts and the error text are exact.

Loudness and pitch, checked by hand

∑ Math Interlude — averaging a wave, and comparing it with itself

A sound wave spends about half its time below zero, so the plain average of its samples is close to zero no matter how loud it is. Squaring first fixes that: a square is never negative, and it grows with distance from silence in either direction. Average the squares, then take the square root to get back to the units you started in. That is the root mean square, read backwards.

Loudness then gets one more step, into decibels: twenty times the base-ten logarithm of the RMS, with zero decibels at the loudest sample the format can hold. Turning up the microphone gain multiplies every sample by the same factor, and a logarithm turns a multiply into an add, so gain moves the whole scale up or down without changing the gaps between measurements. Gaps are all this chapter ever compares.

Pitch comes from a different trick. A voiced sound repeats: the vocal folds close, the pressure pulse travels out, and the pattern happens again a few milliseconds later. Delay a copy of the clip by some number of samples, multiply it against the original point by point, and add the products up. When the delay matches the repeat, peaks land on peaks and the sum is large. The delay that scores highest is the period, and the sample rate divided by that period is the frequency.

sampleone measurement of air pressure, 16,000 of them a second here, scaled to sit between -1.0 and 1.0
RMSroot mean square: square every sample, average, take the root; a single number for loudness
dBFSthat loudness in decibels, counting down from 0 at full scale, so every real recording is negative
laghow many samples a copy of the signal is delayed by before it is compared with the original
fundamental frequencyhow many times a second the pattern repeats: the pitch you hear, written f0 and measured in hertz
semitonea musical step; twelve of them double the frequency, so equal steps mean equal ratios
▣ Build · stage 1 — loudness, in two units
# glados/prosody.py
"""How something was said: four numbers off the buffer Whisper reads the words from."""
import math

import numpy as np

SAMPLE_RATE = 16000                              # the capture rate since chapter 4
FRAME_MS = 30
FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS // 1000   # 480
FRAME_BYTES = FRAME_SAMPLES * 2                  # int16 audio: two bytes a sample
FULL_SCALE = 32768.0


def to_float(pcm: bytes) -> np.ndarray:
    """int16 bytes from the recorder, as floats between -1.0 and 1.0."""
    return np.frombuffer(pcm, dtype=np.int16).astype(np.float64) / FULL_SCALE


def rms(samples: np.ndarray) -> float:
    """Square every sample, take the mean, take the root."""
    if samples.size == 0:
        return 0.0
    return float(np.sqrt(np.mean(samples ** 2)))


def dbfs(level: float) -> float:
    """That level in decibels, 0 dB at full scale. Digital silence floors at -100."""
    if level <= 0.0:
        return -100.0
    return 20.0 * math.log10(level)


if __name__ == "__main__":
    n = np.arange(FRAME_SAMPLES)
    silence = np.zeros(FRAME_SAMPLES)
    square = np.where((n // 40) % 2 == 0, 0.1, -0.1)             # 200 Hz, always ±0.1
    sine = 0.1 * np.sin(2 * np.pi * 200 * n / SAMPLE_RATE + 0.5)  # 200 Hz, peak 0.1
    for name, frame in (("silence", silence), ("square 0.1", square), ("sine 0.1", sine)):
        level = rms(frame)
        print(f"{name:11s} rms {level:.4f}   {dbfs(level):7.2f} dBFS")
$ uv run python -m glados.prosody
silence     rms 0.0000   -100.00 dBFS
square 0.1  rms 0.1000    -20.00 dBFS
sine 0.1    rms 0.0707    -23.01 dBFS

Every row is checkable without running anything. The square wave sits at plus or minus 0.1 for all 480 samples, so every square is 0.01, the mean of 480 copies of 0.01 is 0.01, and its root is 0.1. The sine touches 0.1 only at its peaks, and the mean of a squared sine over whole cycles is exactly one half, so its RMS is 0.1 divided by the square root of two. Same peak, less energy.

The decibel column says the same thing more usefully. Half the power is always 3.01 decibels down, whatever the signal, because ten times the logarithm of two is 3.0103. That fixed step is why loudness gets compared in decibels here and never in raw RMS: a person who leans back from the microphone and doubles their distance loses about six decibels, and six is a number you can hold in your head next to the numbers a mood produces. Stage 1 hands the rest of the chapter one honest measurement and no interpretation at all.

▣ Build · stage 2 — pitch, cheaply and then properly
FMIN, FMAX = 65.0, 400.0    # the floor and ceiling of a speaking voice, in Hz
CONFIDENCE = 0.3            # how much of the frame's own energy the repeat must recover


def zero_crossings(samples: np.ndarray) -> int:
    """How many times the waveform changes sign inside this frame."""
    if samples.size < 2:
        return 0
    return int(np.count_nonzero(np.diff(np.signbit(samples))))


def pitch_from_crossings(samples: np.ndarray, sample_rate: int = SAMPLE_RATE) -> float:
    """A tone crosses zero twice a cycle, so half the crossing rate is its frequency."""
    seconds = samples.size / sample_rate
    if seconds <= 0.0:
        return 0.0
    return zero_crossings(samples) / seconds / 2.0


def pitch_from_autocorr(samples: np.ndarray, sample_rate: int = SAMPLE_RATE) -> float:
    """Frequency from the lag at which the frame most resembles itself. 0.0 if none does."""
    x = samples - samples.mean()
    if not np.any(x):
        return 0.0
    corr = np.correlate(x, x, mode="full")[x.size - 1:]
    lo, hi = int(sample_rate / FMAX), min(int(sample_rate / FMIN), corr.size - 1)
    if hi <= lo:
        return 0.0
    lag = lo + int(np.argmax(corr[lo:hi]))
    if corr[lag] < CONFIDENCE * corr[0]:
        return 0.0
    return sample_rate / lag


if __name__ == "__main__":
    for name, frame in (("silence", silence), ("sine 200 Hz", sine)):
        print(f"{name:11s} crossings {zero_crossings(frame):3d}   "
              f"by crossings {pitch_from_crossings(frame):6.1f} Hz   "
              f"by autocorrelation {pitch_from_autocorr(frame):6.1f} Hz")
$ uv run python -m glados.prosody
silence     crossings   0   by crossings    0.0 Hz   by autocorrelation    0.0 Hz
sine 200 Hz crossings  12   by crossings  200.0 Hz   by autocorrelation  200.0 Hz

Two routes to 200.0, both arithmetic. A 200 hertz wave completes six cycles in 30 milliseconds and crosses zero twice a cycle, so twelve crossings in 0.03 seconds is 400 crossings a second, and half of that is the frequency. The autocorrelation takes the other road: at 16,000 samples a second one cycle spans 80 samples, the delayed copy lines up exactly there, and 16,000 divided by 80 is 200. The confidence test is what makes the second one able to abstain. Silence and hiss never recover 30 per cent of the frame's energy at any lag in range, so the function returns 0.0 to mean "no pitch here" instead of returning a number nobody should use.

Then point both at a person and watch them part company. The lab below slices one recorded sentence into 30 millisecond frames and prints two of them, a vowel and a fricative.

# labs/prosody_frames.py
"""The two pitch estimators, side by side, on frames of one recorded sentence."""
import wave

import numpy as np

from glados.prosody import (FRAME_SAMPLES, pitch_from_autocorr, pitch_from_crossings,
                            to_float, zero_crossings)

UTTERANCE = "glados/data/utterance.wav"


def frames(path: str = UTTERANCE) -> list[np.ndarray]:
    """The clip cut into whole frames; a trailing partial frame is dropped."""
    with wave.open(path) as wf:
        samples = to_float(wf.readframes(wf.getnframes()))
    whole = samples.size // FRAME_SAMPLES * FRAME_SAMPLES
    return list(samples[:whole].reshape(-1, FRAME_SAMPLES))


if __name__ == "__main__":
    picked = {31: 'vowel in "afternoon"', 58: 'the "s" in "sensor"'}
    for i, frame in enumerate(frames()):
        if i in picked:
            print(f"frame {i:3d}  {picked[i]:22s} crossings {zero_crossings(frame):4d}   "
                  f"by crossings {pitch_from_crossings(frame):7.1f} Hz   "
                  f"by autocorrelation {pitch_from_autocorr(frame):6.1f} Hz")
$ uv run python -m labs.prosody_frames   # measured on the bench: yours will vary
frame  31  vowel in "afternoon"   crossings   14   by crossings   233.3 Hz   by autocorrelation  110.3 Hz
frame  58  the "s" in "sensor"    crossings  214   by crossings  3566.7 Hz   by autocorrelation    0.0 Hz

The vowel is the interesting failure. Fourteen crossings in 30 milliseconds gives 233.3 hertz by the cheap method, and the speaker's pitch is nowhere near that: a vowel is a buzz at 110 hertz plus a stack of harmonics on top, and those harmonics drag the waveform across zero extra times inside every cycle. Autocorrelation is unmoved, because it asks when the whole pattern repeats and the whole pattern repeats at the fundamental, lag 145, which is 110.3 hertz. The fricative settles it. An "s" is filtered noise with no repeat in it at all; crossings report a confident 3,566.7 hertz, and the honest answer is the 0.0 the other estimator gives.

Four numbers over a whole utterance

▣ Build · stage 3 — a pitch track, and how far it wandered
import librosa

HOP = 256    # 16 ms between pitch estimates at 16 kHz


def pitch_track(samples: np.ndarray, sample_rate: int = SAMPLE_RATE) -> np.ndarray:
    """Pitch per frame across the whole clip, with the unvoiced frames removed."""
    f0, _flag, _prob = librosa.pyin(samples, fmin=FMIN, fmax=FMAX, sr=sample_rate,
                                    frame_length=1024, hop_length=HOP)
    return f0[~np.isnan(f0)]


def semitones_from(track: np.ndarray, reference: float) -> np.ndarray:
    """The track rewritten as musical distance from a reference pitch."""
    return 12.0 * np.log2(track / reference)


def pitch_spread(track: np.ndarray) -> float:
    """How far the pitch wandered, in semitones, so two different voices compare."""
    if track.size < 2:
        return 0.0
    return float(np.std(semitones_from(track, float(np.median(track)))))


if __name__ == "__main__":
    octave = np.array([100.0, 200.0])
    print(f"an octave apart: median {np.median(octave):.1f} Hz   "
          f"spread {pitch_spread(octave):.2f} semitones")
$ uv run python -m glados.prosody
an octave apart: median 150.0 Hz   spread 6.00 semitones

librosa.pyin runs a smarter version of stage 2's comparison over overlapping windows and returns three arrays; the first is the pitch track, and every frame it judges unvoiced holds NaN instead of a number. f0[~np.isnan(f0)] keeps the frames where a person was actually voicing. Leave that filter out and a single silent frame poisons every statistic downstream, which is this chapter's worked failure and a mistake that never announces itself.

Measuring the wander in semitones instead of hertz is the choice that makes this number portable between people. Two frames an octave apart give a spread of 6.00 semitones whatever the pitch they sit at, since the standard deviation of two values is half the gap between them and an octave is twelve semitones by definition. In hertz an octave is 100 wide starting from 100 and 200 wide starting from 200, so a spread measured in hertz mostly reports how deep a voice is. Equal semitone steps are equal frequency ratios, and ratios are what a listener hears.

▣ Build · stage 4 — the four numbers as one record
from dataclasses import dataclass

import webrtcvad

AGGRESSIVENESS = 2    # the setting the recorder from chapter 87 already listens with


def speech_frames(pcm: bytes, aggressiveness: int = AGGRESSIVENESS) -> tuple[int, int]:
    """(frames carrying voice, frames in the clip), counted by the same detector."""
    vad = webrtcvad.Vad(aggressiveness)
    total = len(pcm) // FRAME_BYTES
    voiced = sum(1 for i in range(total)
                 if vad.is_speech(pcm[i * FRAME_BYTES:(i + 1) * FRAME_BYTES], SAMPLE_RATE))
    return voiced, total


@dataclass(frozen=True)
class Prosody:
    """How an utterance was delivered. Nothing in here says what was said."""
    level_db: float     # loudness of the whole clip, dBFS
    pitch_hz: float     # median pitch of the voiced frames
    spread_st: float    # how far the pitch wandered, semitones
    density: float      # fraction of frames carrying voice
    frames: int         # length of the clip, in 30 ms frames


def measure(pcm: bytes) -> Prosody:
    """One pass over one recording. No decisions are made here."""
    samples = to_float(pcm)
    track = pitch_track(samples)
    voiced, total = speech_frames(pcm)
    return Prosody(
        level_db=round(dbfs(rms(samples)), 1),
        pitch_hz=round(float(np.median(track)), 1) if track.size else 0.0,
        spread_st=round(pitch_spread(track), 1),
        density=round(voiced / total, 2) if total else 0.0,
        frames=total,
    )


if __name__ == "__main__":
    from labs.vad_listen import record_utterance

    for delivery in ("flat", "ordinary", "agitated"):
        input(f"say the same sentence {delivery}, then press enter: ")
        p = measure(record_utterance())
        print(f"{delivery:9s} {p.level_db:7.1f} dB {p.pitch_hz:7.1f} Hz "
              f"{p.spread_st:5.1f} st {p.density:6.2f} {p.frames:5d} frames")
$ uv run python -m glados.prosody   # measured on the bench: yours will vary
flat        -31.2 dB   108.6 Hz   1.9 st   0.55   121 frames
ordinary    -27.4 dB   114.2 Hz   3.1 st   0.66   108 frames
agitated    -21.8 dB   139.7 Hz   4.4 st   0.87    96 frames

One sentence, three deliveries, and all four numbers move together in the direction you would guess. Density is the one that needs its definition stated carefully, since it is voiced frames divided by total frames and nothing more. It rises when somebody talks without pausing and it falls when they leave gaps, so it does not measure syllables a second: a fast talker who breathes between phrases and a slow one who never stops can land on the same 0.66. Call it how densely packed the clip is, write that in the docstring, and never let a later reader think it is a speaking rate.

Two practical details keep it comparable between clips. The recorder proves you have finished by waiting through a second and a half of quiet, and its trim leaves 300 milliseconds of that on the end, so every clip carries the same ten silent frames and the ratio is not skewed by a tail that varies. And the whole measurement runs on the buffer that already exists, so hearing tone costs one more pass over samples the pipeline was holding anyway.

What ordinary sounds like for you

Now the part that decides whether any of this is usable. Stage 4 ran a second time on the bench with a second person at the same microphone and the same distance, saying the same sentence the same three ways.

$ uv run python -m glados.prosody   # the same script, a different speaker: yours will vary
flat        -29.8 dB   188.4 Hz   2.6 st   0.58   118 frames
ordinary    -25.9 dB   196.3 Hz   3.6 st   0.69   104 frames
agitated    -20.6 dB   214.8 Hz   5.1 st   0.88    93 frames

Put the pitch columns of the two runs side by side and the whole idea of a shipped threshold falls over. Getting agitated moved the first speaker from 114.2 to 139.7 hertz, 25.5 hertz or about three and a half semitones. The gap between the two speakers while both were perfectly calm is 82.1 hertz, more than nine semitones, almost three times larger than the mood effect. Any fixed number you write into the code, 200 hertz say, is no kind of agitation detector: it labels the second speaker agitated during every sentence they ever speak, calm ones included, and it labels the first speaker calm while they shout. The feature separates people more strongly than it separates moods, so a constant in the source is mostly a speaker detector with a misleading name.

A better constant will not save it. The only workable move is to measure each person against their own middle, which means the reader records a baseline before anything here is switched on.

▣ Build · stage 5 — write down what ordinary means for you
# labs/prosody_calibrate.py
"""Record a handful of ordinary sentences and write down your own middle."""
import json
import statistics
from pathlib import Path

from glados.prosody import Prosody, measure
from labs.vad_listen import record_utterance

BASELINE_PATH = Path("glados/data/prosody_baseline.json")
FIELDS = ("level_db", "pitch_hz", "spread_st", "density")
CLIPS = 8


def mad(values: list[float]) -> float:
    """Median absolute deviation: the typical distance from the middle."""
    middle = statistics.median(values)
    return statistics.median([abs(v - middle) for v in values])


def build_baseline(clips: list[Prosody]) -> dict:
    """Middle and spread per feature, from your own voice on an ordinary day."""
    out: dict = {}
    for field in FIELDS:
        values = [getattr(c, field) for c in clips]
        out[field] = {"middle": round(statistics.median(values), 2),
                      "mad": round(mad(values), 2)}
    return out


def distance(value: float, stat: dict) -> float:
    """How far from your middle, counted in your own typical deviations."""
    if stat["mad"] <= 0.0:
        return 0.0
    return (value - stat["middle"]) / stat["mad"]


def load_baseline(path: Path = BASELINE_PATH) -> dict | None:
    """None means this reader has not calibrated, and prosody stays switched off."""
    if not path.exists():
        return None
    data = json.loads(path.read_text())
    return data if all(f in data for f in FIELDS) else None


if __name__ == "__main__":
    print("mad([1, 2, 3, 4, 100]) =", mad([1.0, 2.0, 3.0, 4.0, 100.0]))
    clips = []
    for i in range(CLIPS):
        input(f"clip {i + 1}/{CLIPS}: say an ordinary sentence, then press enter: ")
        clips.append(measure(record_utterance()))
        p = clips[-1]
        print(f"          {p.level_db:7.1f} dB {p.pitch_hz:7.1f} Hz "
              f"{p.spread_st:5.1f} st {p.density:6.2f}")
    baseline = build_baseline(clips)
    BASELINE_PATH.parent.mkdir(parents=True, exist_ok=True)
    BASELINE_PATH.write_text(json.dumps(baseline, indent=2))
    for field, stat in baseline.items():
        print(f"  {field:10s} middle {stat['middle']:8.2f}   mad {stat['mad']:5.2f}")
$ uv run python -m labs.prosody_calibrate   # measured on the bench: yours will vary
mad([1, 2, 3, 4, 100]) = 1.0
clip 1/8: say an ordinary sentence, then press enter:
            -27.9 dB   113.2 Hz   2.8 st   0.64
clip 2/8: say an ordinary sentence, then press enter:
            -26.1 dB   117.3 Hz   3.4 st   0.72
clip 3/8: say an ordinary sentence, then press enter:
            -30.7 dB   105.8 Hz   1.7 st   0.51
clip 4/8: say an ordinary sentence, then press enter:
            -27.3 dB   114.6 Hz   3.0 st   0.66
clip 5/8: say an ordinary sentence, then press enter:
            -25.2 dB   120.1 Hz   3.8 st   0.76
clip 6/8: say an ordinary sentence, then press enter:
            -29.4 dB   109.3 Hz   2.3 st   0.57
clip 7/8: say an ordinary sentence, then press enter:
            -26.4 dB   116.9 Hz   3.2 st   0.70
clip 8/8: say an ordinary sentence, then press enter:
            -28.5 dB   111.8 Hz   2.6 st   0.61
  level_db   middle   -27.60   mad  1.35
  pitch_hz   middle   113.90   mad  3.20
  spread_st  middle     2.90   mad  0.40
  density    middle     0.65   mad  0.06

The first line is the argument for the median absolute deviation, and you can verify it in your head. The middle of 1, 2, 3, 4, 100 is 3; the distances from it are 2, 1, 0, 1 and 97; and the middle of those is 1. One clip where the dog barked contributes a 97 that changes nothing. A standard deviation over the same five numbers is about 39, and every threshold built on it would be nonsense.

The summary at the bottom is checkable off the eight rows above it in the same way. Sort the level column and the fourth and fifth entries are -27.9 and -27.3, whose average is the -27.60 printed; take each row's distance from that middle and the fourth and fifth of those are 1.2 and 1.5, whose average is the 1.35. Eight clips of ordinary speech vary by five and a half decibels and fourteen hertz, which is the first useful thing calibration tells you: your own ordinary is a range, not a point.

distance is the whole payoff. It converts a measurement into a count of your own deviations: the agitated clip's -21.8 decibels is 4.3 of this reader's deviations above their middle, its 139.7 hertz is 8.1 above, its density is 3.7 above. Those three counts mean the same thing for the second speaker, whose middles are nowhere near these ones. And load_baseline returning None is a design decision rather than an error path: a reader who has not calibrated gets no prosody at all, and she behaves exactly as she did before this chapter.

▣ Build · stage 6 — into the dial, and no further
from labs.mood_state import STATE_PATH, MoodState

AROUSAL_FIELDS = ("level_db", "pitch_hz", "density")   # spread is its own axis
MAX_NUDGE = 0.15      # the most one utterance's delivery may move the dial
AROUSAL_FULL = 3.0    # deviations at which that maximum is reached
HINT_LEVEL = 1.5      # deviations below which she is told nothing at all


def arousal(p: Prosody, baseline: dict) -> float:
    """One score: how far this delivery sits from your own ordinary."""
    return statistics.mean(distance(getattr(p, f), baseline[f]) for f in AROUSAL_FIELDS)


def style_hint(score: float) -> str:
    """One advisory line for the system prompt. Advice about manner, never an instruction."""
    if score >= HINT_LEVEL:
        return "The speaker sounds more keyed up than is usual for them. Answer briefly."
    if score <= -HINT_LEVEL:
        return "The speaker sounds flatter than is usual for them. Do not press them."
    return ""


def apply_prosody(state: MoodState, p: Prosody, baseline: dict) -> tuple[float, str]:
    """Nudge the intensity, return the hint. The mood name is never touched here."""
    score = arousal(p, baseline)
    delta = MAX_NUDGE * max(-1.0, min(1.0, score / AROUSAL_FULL))
    state.nudge(delta)
    return score, style_hint(score)


if __name__ == "__main__":
    baseline = load_baseline()
    state = MoodState.load(STATE_PATH)
    for delivery in ("agitated", "ordinary", "flat"):
        input(f"say the same sentence {delivery}, then press enter: ")
        before = state.intensity
        score, hint = apply_prosody(state, measure(record_utterance()), baseline)
        print(f"{delivery:9s} arousal {score:+5.1f}   nudge {state.intensity - before:+.2f}   "
              f"{state.mood} {before:.2f} -> {state.intensity:.2f}")
        print(f"          hint: {hint!r}")
$ uv run python -m labs.prosody_calibrate   # measured on the bench: yours will vary
say the same sentence agitated, then press enter:
agitated  arousal  +5.3   nudge +0.15   neutral 0.50 -> 0.65
          hint: 'The speaker sounds more keyed up than is usual for them. Answer briefly.'
say the same sentence ordinary, then press enter:
ordinary  arousal  +0.1   nudge +0.01   neutral 0.65 -> 0.66
          hint: ''
say the same sentence flat, then press enter:
flat      arousal  -2.0   nudge -0.10   neutral 0.66 -> 0.56
          hint: 'The speaker sounds flatter than is usual for them. Do not press them.'

The mood column never changes, and that is the design, not a coincidence in the data. Chapter 47 split her mood into two axes: a name drawn from a fixed set, and a number between 0 and 1 saying how much of it she is feeling. Chapter 24's transition table owns the name, moving her between neutral and hostile and curious from the sentiment of the words. Prosody has no business there, because how loudly a sentence arrived says nothing about whether it was kind. It owns a slice of the other axis instead, capped at 0.15 a turn, so ten agitated sentences in a row can carry her from calm to fully felt and one cannot.

The clamp inside nudge keeps the result in range without any checking here, and the cap on delta bounds how wrong this can be. That matters because being wrong is the normal case. If the reading is nonsense, she is at most fifteen per cent more intense about a mood the words already justified. Note also what the hint is allowed to say: it describes manner and asks for brevity. It does not name an emotion, it does not reach the permission layer from volume 6, and it never gets repeated to the speaker, since being told by a machine that you sound stressed is unpleasant when it is right and infuriating when it is wrong.

Why this works: one number with many causes

The four measurements are real physics, and that is exactly why they cannot carry the weight people want to put on them. Arousal raises the air pressure under the vocal folds. Higher pressure means a louder sound and faster fold vibration, so louder and higher travel together, and the breathing pattern that comes with it packs speech more densely into the clip. The microphone records that faithfully.

The trouble is on the way back. Anger raises subglottal pressure. So does excitement, fear, a room with a fan in it, calling to somebody across a kitchen, laughing, and being three metres from the microphone instead of one. One measured axis, many causes: the map from state to sound is many-to-one, and no arithmetic inverts a many-to-one map. The reading is real and the interpretation is a guess.

Put the two speakers' runs next to that and the design conclusion writes itself. A signal whose between-person variation dwarfs its within-person variation can be used two ways. You can calibrate away the between-person part, which is stage 5, and you are left with a within-person signal that is honest but small. Or you can act on the raw number, and build a system that reliably classifies who is speaking while claiming to classify how they feel. The first is worth having. The second is the kind of confident wrongness that gets people misread by machines.

Even calibrated, the small signal has bad days. A head cold lowers pitch and drops level. Tiredness flattens the wander. An accent the calibration never covered, a second language, a stammer, a speech difference of any kind: each shifts the middles this code treats as fixed. Every item on that list is an argument for the cap on the dial and for the whole feature switching itself off when no baseline exists. Generalise the habit: when one number is the sum of many causes, let it weight a decision that other evidence already justifies, and never let it make one.

◆ Note — keep the numbers, drop the audio

The temptation once this runs is to keep every clip so the thresholds can be retuned later. Do not. Four floats and a timestamp per utterance are enough to re-derive a baseline, they weigh 40 bytes, and they cannot be played back. A directory of WAV files of everyone who has spoken in your kitchen is a different object entirely, and she is meant to be a machine you own rather than one that quietly accumulates recordings of your family. If you keep clips at all while tuning, keep the ones you made deliberately for calibration, and delete them when you are done.

⚠ Worked failure — the quietest clip that moved her the most

The pitch tracker returns NaN for every frame it judges unvoiced, and dropping those frames looks optional until the first real recording arrives with silence on the end of it. Skip the filter, take the median directly, and run the flat clip through the dial:

    track = librosa.pyin(samples, fmin=FMIN, fmax=FMAX, sr=SAMPLE_RATE)[0]
    pitch = float(np.median(track))          # BUG: unvoiced frames are still NaN
    p = Prosody(level_db=-31.2, pitch_hz=pitch, spread_st=1.9, density=0.55, frames=121)
    before = state.intensity
    score, hint = apply_prosody(state, p, baseline)
    print(f"pitch {pitch}   arousal {score}   hint {hint!r}")
    print(f"intensity {before:.2f} -> {state.intensity:.2f}")
$ uv run python -m labs.prosody_calibrate
pitch nan   arousal nan   hint ''
intensity 0.50 -> 0.65

Nothing is raised, nothing is printed in red, and the flattest, quietest clip of the three has just pushed her dial by the full 0.15 that a shouted sentence earns. Two silent behaviours stacked to produce it, and both are worth being able to recite.

Work back from the symptom. The pitch printed as nan, not as a wrong number, so the fault sits upstream in the statistic: a median over an array holding NaN is NaN, and the array holds NaN because librosa.pyin writes it in every frame with no pitch in it, the same message pitch_from_autocorr sends with 0.0. That NaN then flows through distance into arousal and out the other side, because arithmetic on NaN gives NaN. style_hint stayed quiet, since both of its comparisons against NaN are False. And the clamp let it through at maximum: min(1.0, nan) returns 1.0, because min keeps what it has when the comparison says nothing, so max(-1.0, min(1.0, nan)) is 1.0 and the delta is the largest one available.

The fix is stage 3's line, f0[~np.isnan(f0)], run before any statistic touches the track. The lesson underneath it is the one this chapter keeps arriving at from different directions. A missing measurement does not raise, it votes, and the cap that was meant to bound a wrong reading only bounds readings that are numbers. If you want the guard to hold, test for the missing value where it is produced: if not math.isfinite(score): return 0.0, "" at the top of apply_prosody costs one line and turns a silent maximum into a deliberate nothing.

Checkpoint, and numbers that vanish when the terminal closes

✓ Checkpoint — what you can now do
  • I can compute the RMS of a square wave and a sine of the same peak by hand, and say why the two sit 3.01 decibels apart.
  • I can explain why zero crossings called a 110 hertz vowel 233.3 hertz while autocorrelation did not, and which of the two to believe on a fricative.
  • I can say what librosa.pyin puts in an unvoiced frame, and trace one of those through a median, a threshold test and a clamp that was supposed to bound it.
  • I can defend measuring pitch wander in semitones when comparing two different voices.
  • I can quote the bench numbers showing that two calm speakers differ by more than one speaker's calm and agitated deliveries, and say what that does to any threshold written into the source.
  • I can explain why prosody nudges the intensity, never sets the mood name, and never becomes something she repeats back to the speaker.
⚡ Exercises — try first, then reveal
Exercise 1 — find out which feature works for you. Record five ordinary clips and five agitated ones, print the median of each feature for both sets, and express the gap between them in your own deviations.

Reuse mad on the ordinary set for the denominator, then divide the gap between the two medians by it. The single agitated clip measured earlier sat 4.3 deviations out on level, 8.1 on pitch and 3.7 on density; a median over five clips smooths those numbers but should keep that ordering, and all three stay wide enough to earn a place in the average. Your table will not look like that. If one feature's two medians land closer than about two deviations apart, it is contributing noise to arousal for your voice and your microphone, so drop it from AROUSAL_FIELDS and watch the score get steadier immediately. This is the exercise that turns the chapter from a demo into something tuned to one person.

Exercise 2 — break the loudness feature on purpose. Say the same flat sentence twice, once at 10 cm from the microphone and once at 60 cm, and print the level and the arousal score for each.

Sound level falls with distance, and six times the distance costs twenty times the logarithm of six, which is 15.6 decibels. Against this reader's deviation of 1.35 that is 11.5 deviations of pure posture, nearly three times what actual agitation produced. Leaning back in your chair outweighs your mood. There is no software repair for it, so pick one of two honest responses: keep the microphone geometry fixed, which is what mounting it on her body does, or lean on pitch and density, which barely move with distance. Print both scores side by side and the argument is on your screen in one run.

Exercise 3 — let the baseline drift. Replace the frozen JSON baseline with a rolling one over the last 50 measured utterances, and print how far the middles move across a week.

Keep a deque(maxlen=50) of Prosody records, rebuild the baseline from it after each utterance, and only start scoring once it holds at least 20. A head cold that drops your median pitch by four hertz and your level by two decibels costs about 1.5 deviations, half the distance to the keyed-up hint, and a fixed baseline would spend the week reporting you as flat. A rolling one follows you down and back up. The tradeoff is real and you should see it: a truly stressful week slides the middle toward stress and the system stops noticing it, which is one more argument for this signal never being the thing that decides anything.

She now hears the delivery as well as the words, and both of them evaporate when the process ends. The four floats from this utterance, the transcript that came with them, the mood they nudged: all of it lives in one Python object that the next power cut deletes. Chapter 98 gives her an append-only log of what was said and how, and a periodic pass that distils days of it into a paragraph small enough to sit in every prompt.