It is 2008. You have a microphone, a laptop, and a job: tell a scream from a siren. No GPUs, no pretrained nets, no AudioSet. Everything you feed the classifier, you must design by hand — and it works better than you think. This is the era every modern audio model was built on top of.
A city has bolted a microphone to a lamppost and handed you the audio stream. Your job: raise an alarm when the microphone hears a scream, stay silent when it hears a siren, a bus, a jackhammer, or a pigeon. You have a laptop, a few hundred labelled clips, and no internet connection to a model zoo — because in 2008 there isn’t one.
There is no AudioSet (2017), no pretrained audio transformer, no GPU sitting idle in your desk drawer. Deep learning for audio is three or four years away from being practical. Whatever the classifier sees, you have to design and compute. That constraint is what makes this era worth studying: every number in the pipeline was chosen by a human who could explain why it was there.
And here is the thing people forget — it worked. Speech recognition shipped on this stack for twenty years. Music genre classifiers, cough detectors, machine-fault monitors, bird surveys, gunshot locators: all built from a handful of hand-designed numbers per frame and a statistical classifier on top. Learning this pipeline is not archaeology. It is learning what the deep net had to beat, and why the things it does are the things it does.
Your first instinct is template matching: record a scream, store its samples, and when a new clip arrives, measure how far the new sample vector is from the stored one. Close means scream, far means not-scream. This is nearest-neighbour classification applied directly to the waveform, and it is completely reasonable to try. It also fails so badly that watching it fail teaches you what a feature is for.
Let us kill it with numbers rather than with adjectives. Take the simplest possible sound: a pure tone, sampled at exactly eight samples per cycle. One cycle is the eight-number vector
Now record the same tone a quarter of a cycle later — the microphone was switched on two samples afterwards. Nothing about the sound changed. No human, no dog, no spectrum analyser could tell the two recordings apart. But the numbers are shifted:
Euclidean distance between two vectors is the square root of the sum of squared differences. Subtract element by element:
Square each one: 1.000, 0.000, 1.000, 2.000, 1.000, 0.000, 1.000, 2.000. Add them: 1 + 0 + 1 + 2 + 1 + 0 + 1 + 2 = 8.000. Take the square root: √8 = 2.828. So two recordings of the identical tone sit 2.828 apart.
Is 2.828 a big number? We need something to compare against. Try the distance from our tone to total silence — the all-zeros vector, which is about as different from a tone as a sound can be. The differences are just the samples themselves, so the sum of squares is 0 + 0.5 + 1 + 0.5 + 0 + 0.5 + 1 + 0.5 = 4.000, and the distance is √4 = 2.000.
The failure has a name: raw samples encode phase, and phase is the one thing our ears almost entirely ignore. A microphone that starts a hair earlier produces a completely different vector for the same physical event. Every classifier built on top inherits that nonsense.
1. Length. A classifier needs a fixed-size input, but clips are not fixed size. A one-second clip at 16 kHz is a 16,000-dimensional vector; a 1.2-second clip is 19,200-dimensional. You cannot even subtract them. Every real pipeline must contain a step that turns “however long this was” into “exactly this many numbers.”
2. Dimensionality. Suppose you fixed the length at 16,000. You have 300 labelled clips. Fitting anything in 16,000 dimensions from 300 examples is hopeless — the space is so vast that every point is equally far from every other, and there is no way to tell a meaningful neighbour from an accidental one. Features are, among other things, a dimensionality budget: get from 16,000 numbers down to about 40 per frame and then to about 80 per clip.
3. Gain. Move the microphone twice as far away and every sample halves. Multiply our tone by 0.5 and the squared differences become 0.25 × 4.000 = 1.000, so the distance is 1.000 — closer than the identical-but-shifted copy. Distance in raw sample space is dominated by loudness and alignment, the two properties least related to what the sound is.
Two recordings of the same tone, offset by a shift you control. Top: the reference (teal) and the shifted copy (orange). Bottom: raw-sample distance (orange bar) versus a distance computed from two simple hand features, energy and zero-crossing rate (teal bar). Slide the shift and watch the raw distance thrash while the feature distance barely moves. The dashed line marks the distance from the reference to complete silence — whenever the orange bar crosses it, the classifier thinks the same sound is less like itself than like nothing.
The shifted-tone distance has a closed form, and deriving it takes three lines. Write the shift as a phase angle φ = 2πs/8 for a shift of s samples. Then
The first two sums are each 4.000 (a full cycle of a unit sine sampled eight times). For the cross term, use sin(a + φ) = sin a cos φ + cos a sin φ. Summed over a full cycle, the ∑sin·cos part is exactly zero, and the rest is cos φ × 4.000. So the cross term is 4 cos φ and
Now plug in shifts. At 16 kHz each sample is 0.0625 ms, so these are all sub-millisecond delays:
| Shift (samples) | φ | 8(1 − cos φ) | Distance | Verdict |
|---|---|---|---|---|
| 0 | 0° | 0.000 | 0.000 | identical vectors |
| 1 | 45° | 8(1 − 0.707) = 2.343 | 1.531 | still closer than silence |
| 1.33 | 60° | 8(1 − 0.500) = 4.000 | 2.000 | tied with silence |
| 2 | 90° | 8(1 − 0.000) = 8.000 | 2.828 | worse than silence |
| 3 | 135° | 8(1 + 0.707) = 13.657 | 3.696 | much worse |
| 4 | 180° | 8(1 + 1.000) = 16.000 | 4.000 | maximally far (inverted) |
| — | — | — | 2.000 | reference vs. silence |
The crossover is at 1.33 samples. At 16 kHz that is 0.083 milliseconds. A person standing three centimetres further from the microphone produces that delay from the speed of sound alone. There is no recording discipline that saves template matching on raw samples; the method is broken at the level of physics, not engineering.
…we kept raw samples but first normalized each clip to unit energy? Think for a second before reading on.
It fixes exactly one of our three problems. Gain is gone — the 0.5× copy now lands on top of the original. But the shift catastrophe is untouched (normalizing does not align anything), and the length problem is untouched (a longer clip is still a longer vector). Two out of three failures survive. This is worth internalizing: normalization is a preprocessing step, never a substitute for a representation.
This is not a hypothetical pipeline. In 2002, Tzanetakis and Cook classified music into ten genres using timbral features (spectral centroid, rolloff, flux, zero-crossing rate, and MFCCs), rhythmic features, and pitch features, pooled over the clip and fed to statistical classifiers, reaching roughly 61% accuracy — against about 70% for human listeners given the same short excerpts. That paper, and the GTZAN dataset it produced, defined music information retrieval for a decade.
In speech, the GMM-HMM system — MFCC features, a Gaussian mixture per phonetic state, hidden Markov models over the sequence — was the entire commercial industry from the late 1980s until about 2012. Dictation software, phone menus, in-car voice control: all of it was the pipeline in this lesson. When deep learning arrived, it arrived first as a replacement for one block (the GMM), keeping the HMM around for years afterwards.
In environmental sound, the DCASE challenge (Detection and Classification of Acoustic Scenes and Events) launched in 2013 with an MFCC-plus-GMM baseline; ESC-50, the 50-class environmental sound dataset that still appears in every modern paper’s results table, arrived in 2015 with hand-feature baselines we will look at closely in Chapter 9. Every “we achieve 95.6% on ESC-50” headline you read today is measured against the ruler this chapter is about to build.
The failures tell us the specification. A usable audio feature must be:
| Requirement | Why the waveform fails it | How we will fix it |
|---|---|---|
| Fixed length | Clips have different durations | Chop into frames, then pool statistics over frames (Ch 1, Ch 9) |
| Shift invariant | A 0.1 ms delay changes every number | Use magnitudes, not phase: energy, crossing counts, spectral magnitudes (Ch 2, Ch 3) |
| Gain robust | Distance halves when the mic moves back | Normalize energy, or standardize features across the dataset (Ch 5) |
| Low dimensional | 16,000 numbers from 300 clips | ~13 MFCCs per frame (Ch 4) |
| Discriminative | Loudness dominates everything | Design features that track brightness, noisiness, harmonicity (Ch 2–4) |
Notice how much of this is engineering judgement rather than mathematics. Nothing forces you to use zero-crossing rate. Somebody tried it, found it separated fricatives from vowels, and it stuck. That is the texture of this whole era: a vocabulary of features accumulated by people listening, plotting, and arguing.
Every classical audio classifier ever shipped has the same five stages. We will build each one, by hand, in order, and then wire them together in a live playground in Chapter 10.
(64000,) floats. Framing at 25 ms / 10 ms hop gives (399, 400) —
399 frames of 400 samples. Feature extraction gives (399, 39). Statistics pooling gives
(78,). The classifier sees 78 numbers and emits one label. Every arrow in that chain throws
information away on purpose, and Chapter 11 is about which throw-away eventually cost us the field.
python # The entire classical pipeline, in the shape we will build it. import numpy as np def classify_clip(x, sr, model): frames = frame_signal(x, win=400, hop=160) # (n_frames, 400) Ch 1 feats = np.stack([frame_features(f, sr) for f in frames]) # (n_frames, 39) Ch 2-4 pooled = np.concatenate([feats.mean(0), feats.std(0)]) # (78,) Ch 9 z = (pooled - model.mu) / model.sd # standardize Ch 5 return model.predict(z) # kNN/GMM/SVM Ch 5-7
Five lines. Every one of them is a chapter. By the end you will be able to write each function from memory, defend every constant in it, and say precisely what a convolutional network replaced.
Chapter 0 left us needing a fixed-length, phase-insensitive description of a sound. Before we can compute any such description, we have to answer a question that sounds trivial and is not: over what stretch of time?
Compute the average brightness of a whole four-second clip and you get one number describing a sentence, a siren sweep, and the bus that drove past — all averaged together into mush. Compute it over one sample and it is meaningless (a single number has no spectrum). The answer is in between, and the reason a specific answer exists is physical.
A vocal tract is a tube of flesh. Its shape changes when you move your tongue and jaw, and flesh has mass, so it cannot change quickly — the articulators move at roughly ten significant changes per second. A guitar string’s resonance, a car engine’s cycle, a room’s reverberation: all change on a scale of tens of milliseconds or slower.
This gives us quasi-stationarity: over a window of about 20 to 40 milliseconds, the statistical character of a sound — its spectrum — is approximately constant. Not the samples (those oscillate thousands of times per second), the spectrum. That is the assumption the whole classical pipeline stands on, and it is a good one for speech, most music, and most machine sounds.
So we chop. We cut the signal into overlapping frames, treat each frame as one still photograph of the sound, and compute features from it. A four-second clip becomes a few hundred photographs. This step is called framing or blocking, and it converts a one-dimensional stream into a two-dimensional array of shape (frames, samples-per-frame).
Framing has exactly three parameters, and every audio toolkit exposes them under slightly different names:
| Parameter | Typical speech value | What it controls |
|---|---|---|
| Frame length (window, n_fft) | 25 ms = 400 samples at 16 kHz | Time vs. frequency resolution — the shutter speed |
| Hop length (stride, shift) | 10 ms = 160 samples | How many frames per second (here, 100) — the frame rate |
| Window function | Hann (or Hamming) | Tapering the frame edges to suppress leakage |
The 25 ms / 10 ms pair is so standard that you will see it in Whisper’s front end, in Kaldi, in every ASR paper from 1985 to now. Environmental sound systems often use longer frames (40–50 ms) because the events are less rapid than phonemes; music systems sometimes use 100 ms or more.
Take an absurdly small signal so every index is visible. Eight samples:
Choose a frame length of 4 samples and a hop of 2 samples (that is 50% overlap). Frame i starts at sample index i × hop and covers hop-length onward:
Enumerate them:
| i | start = i·h | slice | samples |
|---|---|---|---|
| 0 | 0×2 = 0 | x[0:4] | [ 0.1, 0.6, −0.4, −0.8 ] |
| 1 | 1×2 = 2 | x[2:6] | [ −0.4, −0.8, 0.2, 0.9 ] |
| 2 | 2×2 = 4 | x[4:8] | [ 0.2, 0.9, −0.1, −0.5 ] |
| 3 | 3×2 = 6 | x[6:10] | runs off the end — stop |
Three frames. Notice that samples x2 and x3 appear in frames 0 and 1: with 50% overlap, every interior sample is used twice. That is deliberate and we will justify it in a moment.
How many frames does a signal of N samples give? The last valid frame must start at an index s with s + L ≤ N, that is s ≤ N − L. Starts are multiples of h, so the largest usable one is h × ⌊(N − L)/h⌋, and counting from i = 0 gives
Check against our toy: 1 + ⌊(8 − 4)/2⌋ = 1 + ⌊2⌋ = 3. Correct.
Now the real case. A 4-second clip at 16 kHz is N = 64,000 samples; L = 400, h = 160:
So the array shape after framing is (398, 400) — 159,200 numbers, more than the 64,000
we started with, because of the overlap. Framing expands the data; the features that come next
are what shrink it, from 400 numbers per frame down to about 39.
The frame rate is sr / h = 16000 / 160 = 100 frames per second. Every classical speech system runs at exactly this rate, which is why “frames” and “centiseconds” are used interchangeably in that literature.
The obvious objection: overlapping frames duplicate samples and cost 2.5× the computation. Why not hop by a full frame?
Two reasons, and the second is the real one. First, boundary events. A plosive consonant lasts about 5 ms. With non-overlapping 25 ms frames, a plosive that straddles a boundary is split into two halves, and neither half looks like a plosive. With 50–60% overlap, some frame always contains it whole.
Second, windowing throws away the edges. The window function we are about to introduce multiplies the first and last samples of every frame by zero. Without overlap, those samples contribute nothing to any frame — you have literally deleted parts of the signal. With 50% overlap, a sample that sits at the tapered edge of one frame sits near the centre of the next, so nothing is lost.
Cutting a frame out of a signal with a hard edge is itself a violent operation. The Fourier transform assumes the frame repeats forever; if the last sample is +0.9 and the first is −0.5, the implied repetition has a jump discontinuity every 400 samples. A jump is a click, and a click is broadband — it splatters energy across every frequency bin. This artefact is called spectral leakage, and it makes a clean tone look like a smeared hill.
The fix is to taper the frame to zero at both ends before transforming, by multiplying element-wise with a window function. The most common is the Hann window:
With L = 8, the argument is 2πn/7. Compute each cosine and each weight:
| n | angle 2πn/7 | cos | w = 0.5 − 0.5 cos |
|---|---|---|---|
| 0 | 0° | 1.0000 | 0.5 − 0.5000 = 0.0000 |
| 1 | 51.43° | 0.6235 | 0.5 − 0.3117 = 0.1883 |
| 2 | 102.86° | −0.2225 | 0.5 + 0.1113 = 0.6113 |
| 3 | 154.29° | −0.9010 | 0.5 + 0.4505 = 0.9505 |
| 4 | 205.71° | −0.9010 | 0.9505 |
| 5 | 257.14° | −0.2225 | 0.6113 |
| 6 | 308.57° | 0.6235 | 0.1883 |
| 7 | 360° | 1.0000 | 0.0000 |
Symmetric, zero at both ends, one at the centre. Now apply it to our 8-sample frame, multiplying element-wise:
The energy of the windowed frame is 0.0000 + 0.0128 + 0.0598 + 0.5782 + 0.0361 + 0.3026 + 0.0004 + 0.0000 = 0.990, versus 2.280 for the raw frame. Windowing removed 57% of the energy. That is expected and harmless as long as you are consistent — but it is exactly why you must never compare a windowed energy feature against an unwindowed one, and why libraries expose a normalization option.
Once you fix L you have fixed how finely you can resolve frequency. An L-sample frame at sample rate sr spans L/sr seconds, and the Fourier transform of that frame has bins spaced
For our speech setting: 16000 / 400 = 40 Hz per bin. Two tones 20 Hz apart cannot be separated in a 25 ms frame no matter what you do afterwards — not by zero-padding, not by a bigger FFT. Zero padding to 512 points makes the bin spacing 31.25 Hz, which interpolates the same underlying 40 Hz resolution more smoothly; it does not create information.
| Frame length | Samples @16 kHz | Δf | Good for | Failure mode |
|---|---|---|---|---|
| 5 ms | 80 | 200 Hz | very fast transients | cannot resolve pitch or formants at all |
| 25 ms | 400 | 40 Hz | speech, general purpose | — the standard compromise |
| 50 ms | 800 | 20 Hz | environmental sound, music timbre | consonants smear together |
| 500 ms | 8000 | 2 Hz | steady drones only | an entire syllable inside one “still” frame |
A one-second signal (teal) that changes character halfway through. The shaded blocks are the frames; their height shows the Hann taper actually applied to each. Change the frame length and the hop and watch the frame count, the overlap, and the frequency resolution respond. Push the frame length past 200 ms and the frames start swallowing the change point — that is smearing, visible.
python # FORM 1 - the loop you would write on a whiteboard. Explicit, obviously correct. import numpy as np def frame_signal(x, L, h): n_frames = 1 + (len(x) - L) // h # the formula we derived out = np.zeros((n_frames, L)) for i in range(n_frames): out[i] = x[i*h : i*h + L] # slice, copy return out # FORM 2 - the same thing with zero copying, using stride tricks. # Each row is a VIEW into x, offset by h samples. Memory: O(len(x)), not O(frames*L). def frame_strided(x, L, h): n_frames = 1 + (len(x) - L) // h s = x.strides[0] return np.lib.stride_tricks.as_strided(x, shape=(n_frames, L), strides=(h*s, s)) # Apply the window to every frame at once - broadcasting, one line. w = np.hanning(400) # (400,) frames = frame_signal(x, 400, 160) * w # (398, 400) * (400,) -> (398, 400) # FORM 3 - the library one-liner. Same array, decades of edge cases handled. import librosa frames = librosa.util.frame(x, frame_length=400, hop_length=160).T * w
Run form 1 and form 3 on the same signal and subtract: the maximum absolute difference is zero to floating-point precision. The library is not doing anything mysterious. It is doing the loop, faster, with the boundary conventions written down.
We have a frame: 400 windowed samples, one still photograph of the sound. Now we describe it with a few numbers. The first two cost no Fourier transform at all — a sum and a comparison — which is exactly why they were the first features anybody used, on hardware that could not have afforded an FFT per frame.
Do not dismiss them as historical. Short-time energy and zero-crossing rate still sit in production voice-activity detectors today, because they are two orders of magnitude cheaper than anything spectral and they answer the two coarsest questions about a frame: is there anything here? and is it buzzy or smooth?
The short-time energy of a frame is just the sum of squared samples:
Squaring does two jobs. It makes every contribution positive (a pressure dip is as much sound as a pressure peak, and we must not let them cancel), and it weights loud excursions more than quiet ones, which matches the physics: acoustic power really does go with amplitude squared.
Energy grows with frame length, so we usually report the root-mean-square instead, which does not:
Frame x = [ 0.1, 0.6, −0.4, −0.8, 0.2, 0.9, −0.1, −0.5 ]. Square each sample:
| n | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| xn | 0.1 | 0.6 | −0.4 | −0.8 | 0.2 | 0.9 | −0.1 | −0.5 |
| xn2 | 0.01 | 0.36 | 0.16 | 0.64 | 0.04 | 0.81 | 0.01 | 0.25 |
Add them left to right so you can check every partial sum: 0.01, then 0.37, then 0.53, then 1.17, then 1.21, then 2.02, then 2.03, then 2.28. So E = 2.28.
Mean square = 2.28 / 8 = 0.285. RMS = √0.285. Since 0.532 = 0.2809 and 0.542 = 0.2916, the answer is between them; refine: 0.5342 = 0.28516, slightly high, so RMS = 0.5339.
Audio dynamic range is enormous: a whisper and a jackhammer differ by a factor of about a million in amplitude, a trillion in energy. Feed raw energy into a distance-based classifier and the loud classes occupy a region a million times wider than the quiet ones. Every classical pipeline therefore uses log energy, usually in decibels:
For our frame: E/L = 0.285. Now, log10(0.285) = log10(2.85) − 1. Since log10(2.85) ≈ 0.4548, we get −0.5452. Multiply by 10: EdB = −5.45 dB relative to a full-scale sine.
The log also converts a multiplicative nuisance into an additive one. Halve the microphone gain and every sample halves, so E drops by a factor of 4 — but EdB drops by exactly 6.02 dB regardless of what the sound was. A constant offset is something a classifier can be made blind to (subtract the per-clip mean); a multiplicative factor is not. This trick, turn multiplication into addition so it becomes removable, is going to reappear as the entire justification for the log in MFCCs.
The zero-crossing rate (ZCR) counts how often the waveform changes sign inside the frame. A signal that oscillates rapidly crosses zero often; a slow, smooth signal crosses rarely. It is a frequency measurement made without any frequency mathematics.
You will also meet the equivalent textbook form using the absolute difference of signs, which counts each crossing as 2 and divides by 2L:
Write out the signs: + + − − + + − −
Now walk the seven adjacent pairs and mark the changes:
| pair | (0,1) | (1,2) | (2,3) | (3,4) | (4,5) | (5,6) | (6,7) |
|---|---|---|---|---|---|---|---|
| signs | + + | + − | − − | − + | + + | + − | − − |
| crossing? | no | yes | no | yes | no | yes | no |
Three crossings out of seven pairs, so ZCR = 3/7 = 0.4286 crossings per sample pair. In the alternative convention: each of the 3 crossings contributes |+1 − (−1)| = 2, so the sum is 6, and 6 / (2×8) = 0.375. Same information, different bookkeeping — always check which convention a library uses before comparing numbers across papers.
ZCR is only interesting once you convert it to a physical rate. Seven sample intervals at 16 kHz span 7 / 16000 = 0.4375 ms. Three crossings in that time is
A pure sine crosses zero twice per cycle, so the implied dominant frequency is 6857 / 2 ≈ 3429 Hz. That is a bright, hissy frame — consistent with a fricative or noise, not with a vowel. Our eight-sample toy is barely enough to estimate anything, but the arithmetic is exactly what runs on a 400-sample frame.
Individually each is weak. Together they carve up the acoustic world surprisingly well, which is why the pair has survived since the 1970s:
| Frame type | Energy | ZCR | Example |
|---|---|---|---|
| Silence / background | very low | high and erratic | room tone — low-level noise crosses constantly |
| Voiced | high | low | a vowel; the vocal folds impose a slow periodicity |
| Unvoiced / fricative | medium | very high | “s”, “f”, cymbal, rain, static |
| Transient / impact | high, brief | medium | door slam, drum hit, gunshot onset |
The classic endpoint detector of Rabiner and Sambur (1975) is nothing more than thresholds on these two numbers: estimate energy and ZCR statistics from the first 100 ms (assumed to be background), then mark speech as starting where energy exceeds a threshold, and back the boundary up to include preceding high-ZCR low-energy frames — because a word starting with “s” has no energy onset to find. That backing-up rule is the entire reason ZCR was invented, and it is a beautiful piece of engineering: a feature designed to catch the specific failure of another feature.
Crest factor = peak / RMS tells you how spiky a frame is. For our frame: peak amplitude 0.9, RMS 0.5339, so crest = 0.9 / 0.5339 = 1.686. A pure sine has crest √2 = 1.414; Gaussian noise sits around 3–4; a click can exceed 10. Impact sounds and percussive events are exactly the high-crest ones.
Autocorrelation peak gives real pitch, unlike ZCR. Correlate the frame with a delayed copy of itself at lag τ; the lag with the largest peak (excluding τ = 0) is the period, and the pitch is sr / τ. Its height relative to the τ = 0 value measures periodicity, which is the honest version of the question ZCR was pretending to answer.
Top: a frame you control, mixed between a pure tone and white noise, with every zero crossing marked as an orange tick. Bottom: the same frame plotted as a point in the (ZCR, log-energy) plane, over the three class regions from the table above. Drag towards noise and watch the point march right into the fricative region; drag the level down and watch it drop into silence. This two-dimensional picture is the whole of classical voice activity detection.
Push the DC-offset slider up with the mix at pure tone: the waveform lifts off the axis and the crossing ticks vanish one by one until the ZCR reads zero for a signal that has not changed audibly at all. That is the failure mode from the callout above, live.
A single frame’s energy is a number; the sequence of frame energies is a contour, and the contour is where events live. Suppose five consecutive frames have RMS values
The first difference — frame-to-frame change, the discrete derivative — is [ +0.01, +0.38, −0.03, −0.26 ]. The huge positive jump between frames 1 and 2 is an onset: something started. The slow decay afterwards is a release. A classifier fed only the five raw numbers in some order could not tell an onset from an offset; fed the differences, it can.
This is the first appearance of delta features, and they will come back formally in Chapter 4. The idea is always the same: append the rate of change of each feature to the feature vector, because how a sound evolves is often more diagnostic than what it is at any instant. A door slam and a bass note can have identical average spectra and completely different envelopes.
python # FORM 1 - the arithmetic exactly as we did it by hand. def energy_zcr_manual(frame): e = 0.0 for s in frame: e += s * s # sum of squares rms = (e / len(frame)) ** 0.5 crossings = 0 for n in range(1, len(frame)): if (frame[n] >= 0) != (frame[n-1] >= 0): crossings += 1 return rms, crossings / (len(frame) - 1) # FORM 2 - vectorised over ALL frames at once. Shape (n_frames, L) -> (n_frames,) import numpy as np def energy_zcr(frames): frames = frames - frames.mean(axis=1, keepdims=True) # kill DC first! rms = np.sqrt((frames ** 2).mean(axis=1)) sgn = np.signbit(frames) # True where negative zcr = (sgn[:, 1:] != sgn[:, :-1]).mean(axis=1) # fraction of pairs return 20 * np.log10(rms + 1e-10), zcr # +eps: log(0) is -inf # FORM 3 - the library. Note librosa returns shape (1, n_frames). import librosa rms = librosa.feature.rms(y=x, frame_length=400, hop_length=160) zcr = librosa.feature.zero_crossing_rate(x, frame_length=400, hop_length=160)
+ 1e-10 in form 2. A frame of digital silence
has RMS exactly 0, and log(0) is −∞, which propagates as NaN through every mean, every
standardization, and every distance in the rest of the pipeline — and NaN comparisons are all false,
so your classifier silently predicts class 0 forever. That epsilon is not sloppiness; it is the difference
between a system that ships and a bug report you will spend a day on. Every production feature extractor
has one.
Energy told us how much. ZCR told us how fast, badly. Now we take the Fourier transform of the windowed frame and describe the resulting spectrum with a handful of numbers — and this is where the features start to sound like the words a person would use: bright, thin, noisy, changing.
Concretely: our 400-sample windowed frame goes into an FFT and comes out as 201 complex numbers. Throw away the phase (Chapter 0 taught us why) and keep the magnitudes mk, one per frequency bin k = 0…200, with bin k sitting at frequency fk = k · sr / L = k × 40 Hz. Every feature in this chapter is a one-line summary of that list of 201 numbers.
201 numbers is too many to do on paper, so shrink it to five bins spaced 2 kHz apart. This is a real spectrum shape — energy concentrated low-mid, tailing off — just coarsely sampled:
| bin k | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| frequency fk (Hz) | 0 | 2000 | 4000 | 6000 | 8000 |
| magnitude mk | 1.0 | 3.0 | 4.0 | 1.5 | 0.5 |
The total magnitude is 1.0 + 3.0 + 4.0 + 1.5 + 0.5 = 10.0. Keep that number; almost every feature below divides by it.
The spectral centroid is the centre of mass of the spectrum: treat each magnitude as a weight sitting at its frequency and find the balance point.
Work the numerator term by term, so nothing is hidden:
| k | fk | mk | fk · mk |
|---|---|---|---|
| 0 | 0 | 1.0 | 0 × 1.0 = 0 |
| 1 | 2000 | 3.0 | 2000 × 3.0 = 6000 |
| 2 | 4000 | 4.0 | 4000 × 4.0 = 16000 |
| 3 | 6000 | 1.5 | 6000 × 1.5 = 9000 |
| 4 | 8000 | 0.5 | 8000 × 0.5 = 4000 |
Numerator = 0 + 6000 + 16000 + 9000 + 4000 = 35000. Divide by the total magnitude 10.0:
Sanity check the number against intuition: most of the weight sits at 2 and 4 kHz, and the small tail at 6–8 kHz drags the balance point up a little. 3500 Hz — just above the weighted middle — is exactly right.
Perceptually, centroid tracks brightness. A muted trumpet has a lower centroid than an open one; a dull thud lower than a hi-hat; the vowel “oo” lower than “ee”. Of all the hand-designed features, this is the one that correlates best with a word non-engineers actually use.
Centroid is a mean, so the natural companion is a standard deviation: how far the energy is spread around the centre of mass.
With centroid = 3500, the deviations are −3500, −1500, +500, +2500, +4500. Square them:
| k | fk − c | (fk − c)2 | mk × that |
|---|---|---|---|
| 0 | −3500 | 12,250,000 | 1.0 × 12,250,000 = 12,250,000 |
| 1 | −1500 | 2,250,000 | 3.0 × 2,250,000 = 6,750,000 |
| 2 | +500 | 250,000 | 4.0 × 250,000 = 1,000,000 |
| 3 | +2500 | 6,250,000 | 1.5 × 6,250,000 = 9,375,000 |
| 4 | +4500 | 20,250,000 | 0.5 × 20,250,000 = 10,125,000 |
Sum = 39,500,000. Divide by 10.0 → 3,950,000. Square root: 19872 = 3,948,169 and 19882 = 3,952,144, so
Interpretation: this frame’s energy is centred at 3.5 kHz and typically lands within about 2 kHz of it. A pure tone would have spread near zero; white noise across 0–8 kHz would have spread around 2.3 kHz. Ours is nearly as spread out as noise, which tells you the toy spectrum is fairly broadband.
Rolloff answers: below which frequency does a given fraction (usually 85%) of the total magnitude live? It is a percentile of the spectrum, and it captures skew — how much high-frequency tail there is — more robustly than the maximum frequency, which is dominated by the noise floor.
Target = 0.85 × 10.0 = 8.5. Walk the cumulative sum:
| k | mk | cumulative | ≥ 8.5? |
|---|---|---|---|
| 0 | 1.0 | 1.0 | no |
| 1 | 3.0 | 4.0 | no |
| 2 | 4.0 | 8.0 | no — so close |
| 3 | 1.5 | 9.5 | yes |
| 4 | 0.5 | 10.0 | — |
Notice how sharply this can move: at bin 2 the cumulative was 8.0, only 0.5 short. Nudge m2 from 4.0 to 4.6 and the rolloff jumps from 6000 Hz down to 4000 Hz — a 33% change in the feature from a 6% change in one bin. Rolloff is a quantized feature; treat single-frame values with suspicion and rely on its average over many frames.
Every feature so far describes one frame in isolation. Spectral flux describes the change between consecutive frames — the speed at which the sound is transforming:
Let the next frame’s spectrum be m′ = [1.0, 2.0, 3.0, 3.0, 2.0] — energy has shifted upward in frequency. Difference bin by bin:
| k | mk | m′k | d = m′ − m | d2 | max(0, d) |
|---|---|---|---|---|---|
| 0 | 1.0 | 1.0 | 0.0 | 0.00 | 0.0 |
| 1 | 3.0 | 2.0 | −1.0 | 1.00 | 0.0 |
| 2 | 4.0 | 3.0 | −1.0 | 1.00 | 0.0 |
| 3 | 1.5 | 3.0 | +1.5 | 2.25 | 1.5 |
| 4 | 0.5 | 2.0 | +1.5 | 2.25 | 1.5 |
Sum of squares = 0 + 1.00 + 1.00 + 2.25 + 2.25 = 6.50, so flux = √6.50 = 2.550. The half-wave-rectified version keeps only the increases: 0 + 0 + 0 + 1.5 + 1.5 = 3.0.
Flatness (also called Wiener entropy) is the ratio of the geometric mean to the arithmetic mean of the magnitudes. It answers “tonal or noisy?” on a scale from 0 to 1.
Product of our five magnitudes: 1.0 × 3.0 = 3.0; × 4.0 = 12.0; × 1.5 = 18.0; × 0.5 = 9.0. Fifth root: 91/5 = e(ln 9)/5 = e2.1972/5 = e0.4394 = 1.552. Arithmetic mean = 10.0 / 5 = 2.0. So
Check the extremes to calibrate. Pure tone, spectrum [0, 0, 10, 0, 0]: the product contains a zero, so the geometric mean is 0 and flatness = 0 — maximally tonal. White noise, spectrum [2, 2, 2, 2, 2]: geometric mean = 2, arithmetic mean = 2, flatness = 1 — maximally flat. Our 0.776 says “quite noisy, with some structure.”
A 24-bin spectrum you shape with the sliders: brightness slides the energy peak up in frequency, noisiness fills in the valleys between harmonics. The teal line is the centroid, the purple line the 85% rolloff, the shaded band is ±1 spread. The faint grey bars are the previous frame — the flux readout is the distance between them. Move brightness fast and flux spikes; that is an onset detector.
| Feature | Question it answers | Separates | Fooled by |
|---|---|---|---|
| centroid | how bright? | hi-hat vs. bass drum, /s/ vs. /o/ | a noise floor that adds constant high energy |
| spread | how wide? | tone vs. broadband noise | two distant peaks (spread says “wide”, ears say “two notes”) |
| rolloff | where is the top? | speech (low) vs. cymbal (high) | quantization — jumps a whole bin at a time |
| flux | how fast changing? | steady drone vs. rapid speech, onsets | amplitude changes alone, unless you normalize each frame |
| flatness | tonal or noisy? | violin vs. applause, voiced vs. fricative | zero bins, and any spectral gating you applied earlier |
python # FORM 1 - one frame, exactly the arithmetic we just did on paper. import numpy as np def spectral_feats_manual(m, freqs, m_prev=None, roll=0.85): total = m.sum() + 1e-10 centroid = (freqs * m).sum() / total spread = np.sqrt((m * (freqs - centroid) ** 2).sum() / total) cum = np.cumsum(m) rolloff = freqs[np.searchsorted(cum, roll * total)] # first bin past 85% gm = np.exp(np.log(m + 1e-10).mean()) # geometric mean, safely flatness = gm / (m.mean() + 1e-10) flux = 0.0 if m_prev is None else np.sqrt(((m - m_prev) ** 2).sum()) return centroid, spread, rolloff, flatness, flux # Verify against the hand computation: m = np.array([1.0, 3.0, 4.0, 1.5, 0.5]) f = np.array([0, 2000, 4000, 6000, 8000], dtype=float) print(spectral_feats_manual(m, f)) # -> (3500.0, 1987.46, 6000.0, 0.7759, 0.0) exactly our paper numbers # FORM 2 - the whole clip at once. frames: (T, L) already windowed. S = np.abs(np.fft.rfft(frames, axis=1)) # (T, L//2+1) magnitudes freqs = np.fft.rfftfreq(L, 1/sr) # (L//2+1,) in Hz tot = S.sum(1, keepdims=True) + 1e-10 centroid = (S * freqs).sum(1) / tot[:, 0] # (T,) flux = np.sqrt((np.diff(S, axis=0) ** 2).sum(1)) # (T-1,) # FORM 3 - library one-liners. Each returns (1, T). import librosa centroid = librosa.feature.spectral_centroid(y=x, sr=sr, n_fft=400, hop_length=160) rolloff = librosa.feature.spectral_rolloff(y=x, sr=sr, roll_percent=0.85) flatness = librosa.feature.spectral_flatness(y=x) onset = librosa.onset.onset_strength(y=x, sr=sr) # rectified flux, mel-scaled
Chapter 3 gave us seven summary statistics of the spectrum. They cannot distinguish “ah” from “eh”, because both are voiced, similarly bright, similarly noisy — they differ in the shape of the spectral envelope, in where the resonant bumps sit. To capture shape you need a handful of numbers that describe a curve, not a curve’s average.
The answer, from Davis and Mermelstein in 1980, is the Mel-Frequency Cepstral Coefficient vector. Thirteen numbers per frame. It became so dominant that for thirty years, “the features” in a speech paper meant MFCCs without further comment. If you learn one thing from this lesson, learn why each of its stages exists — because every stage is a decision, and every decision has a cost that Chapter 11 will collect.
The source-filter model: a voiced sound is made by the vocal folds producing a buzzy pulse train (the source, which sets the pitch), and that buzz then passes through the throat, mouth and nose, which act as a resonant tube (the filter, which sets the vowel). In the frequency domain, filtering is multiplication:
E(f) is a comb of sharp harmonics spaced at the pitch (say every 120 Hz for a male voice). H(f) is a smooth curve with a few broad bumps — the formants — and it is H(f) that determines which vowel. The observed spectrum is the comb with the smooth curve as its envelope.
Almost everything you want for classification is in H. The pitch in E is a nuisance: the same word spoken by a child and an adult has utterly different combs and nearly the same envelope. So the task is to separate a product into its factors, given only the product. That sounds impossible until you take a logarithm.
The transform of a log spectrum is called the cepstrum (“spectrum” with the first syllable reversed — Bogert, Healy and Tukey, 1963, who also gave us quefrency for its axis and liftering for filtering along it). Low quefrency = slowly varying = the envelope we want. High quefrency = the pitch comb we want to drop. Keeping the first ~13 coefficients is exactly “keep the envelope, discard the pitch.”
We built this in Audio Representations (and it is derived in detail in EE269-13: Cepstrum & MFCC), so here is just the arithmetic you need. The mel scale warps hertz to match how finely we can hear differences:
Check a few values with a calculator: m(1000) = 2595 × log10(1 + 1.4286) = 2595 × log10(2.4286) = 2595 × 0.3854 = 1000.0 mel — the constant 2595 is chosen so that 1000 Hz maps to 1000 mel. Then m(2000) = 1521, m(4000) = 2146, m(8000) = 2840.
Now compare two stretches of the frequency axis that are worlds apart in width. The first 100 Hz — from 0 to 100 Hz — spans m(100) = 150 mel. The stretch from 4000 to 8000 Hz is 4000 Hz wide, forty times bigger, and spans only 2840 − 2146 = 694 mel, under five times more perceptual distance. Per octave the mel cost is nearly constant (1→2 kHz gains 521 mel, 2→4 kHz gains 625, 4→8 kHz gains 694) even though the hertz cost doubles every time.
Spacing the filterbank evenly in mel therefore gives narrow, closely packed triangles down low and wide, sparse triangles up high — resolution spent where the ear has it, and 201 FFT bins collapsed into 40 band energies along the way.
Forty bands is too many for paper; use four. Suppose the mel filterbank outputs the band energies
Stage 3, take natural logs: ln 2 = 0.693, ln 8 = 2.079, ln 4 = 1.386, ln 1 = 0.000. So the log-mel vector is
Stage 4, the DCT-II. Its definition, for N values:
With N = 4, the cosine table is small enough to write out completely. The angles are πk(n+0.5)/4 in radians, shown here in degrees:
| cos(·) | n = 0 (22.5k°) | n = 1 (67.5k°) | n = 2 (112.5k°) | n = 3 (157.5k°) |
|---|---|---|---|---|
| k = 0 | 1.0000 | 1.0000 | 1.0000 | 1.0000 |
| k = 1 | 0.9239 | 0.3827 | −0.3827 | −0.9239 |
| k = 2 | 0.7071 | −0.7071 | −0.7071 | 0.7071 |
| k = 3 | 0.3827 | −0.9239 | 0.9239 | −0.3827 |
Now multiply the row by s and add. c0 is the easy one — all cosines are 1, so it is just the sum:
c1, term by term: 0.693 × 0.9239 = 0.640; 2.079 × 0.3827 = 0.796; 1.386 × (−0.3827) = −0.530; 0.000 × (−0.9239) = 0.000.
c2: every cosine is ±0.7071, so factor it out: 0.7071 × (0.693 − 2.079 − 1.386 + 0.000) = 0.7071 × (−2.772) =
c3: 0.693 × 0.3827 = 0.265; 2.079 × (−0.9239) = −1.921; 1.386 × 0.9239 = 1.281; 0.000 × (−0.3827) = 0.000.
So the four-band MFCC vector is [4.158, 0.906, −1.960, −0.375]. (If you check this in
SciPy you will get [8.318, 1.811, −3.921, −0.750] — exactly twice ours, because
scipy.fftpack.dct(type=2, norm=None) includes a factor of 2. Conventions differ by constant
factors everywhere in this corner of DSP; what matters is that you use one consistently.)
Each ck is the amount of a particular cosine ripple present in the log-spectrum shape:
| coefficient | the cosine it measures | meaning | our value |
|---|---|---|---|
| c0 | flat | overall log-loudness of the frame | 4.158 — a fairly loud frame |
| c1 | half a cycle: high on the left, low on the right | spectral tilt: positive = more energy low than high | +0.906 — tilted towards low frequencies |
| c2 | one full cycle: edges up, middle down | curvature: negative = a bump in the middle | −1.960 — strong mid bump (bands 2 and 3 dominate) |
| c3 | faster ripple | finer envelope wiggle | −0.375 — mild |
Confirm against the data: our band energies were [2, 8, 4, 1] — a peak at band 1, decreasing to the right. That is exactly “tilted low” (c1 > 0) with “a middle bump” (c2 < 0). The coefficients are not a black box; you can read a rough picture of the spectrum straight off them.
The perceptual story explains the mel scale and the log. The DCT’s justification is different and purely statistical, and it is the most under-explained step in the whole pipeline.
Neighbouring mel bands are strongly correlated: a broad formant lights up four or five adjacent triangles together. The classifier we are heading for (Chapter 6) is a Gaussian mixture, and a Gaussian over correlated dimensions needs a full covariance matrix. For a 39-dimensional feature that is 39 × 40 / 2 = 780 parameters per component, and you need to estimate every one of them from your data. With a diagonal covariance you need 39. That is a twentyfold reduction — the difference between a model you can fit with an hour of speech and one you cannot.
The DCT is (approximately) the eigenbasis of the covariance of log-mel vectors, so after applying it the dimensions are nearly uncorrelated and the diagonal assumption becomes defensible. In other words: MFCCs exist so that a diagonal-covariance GMM is legitimate. When the classifier changed — when neural nets arrived and correlated inputs stopped being a problem — the DCT immediately became unnecessary, which is why every modern audio model eats log-mel, not MFCC. The feature was co-designed with its classifier, and it retired with it.
A static MFCC vector says nothing about motion, and Chapter 2 already showed motion is diagnostic. The standard fix is to append the first and second time derivatives, estimated by a regression over a window of ±M frames:
With M = 2 the denominator is 2(1 + 4) = 10, so Δct = [ (ct+1 − ct−1) + 2(ct+2 − ct−2) ] / 10. Apply it to the c1 sequence [0.4, 0.6, 0.9, 1.3, 1.6] at the centre frame: (1.3 − 0.6) + 2(1.6 − 0.4) = 0.7 + 2.4 = 3.1, divided by 10 gives 0.31 per frame. Applying the same formula to the delta stream gives the delta-delta. Result: 13 static + 13 delta + 13 delta-delta = 39 dimensions per frame, the number stamped on twenty years of speech systems.
Record the same voice on a cheap phone and on a studio microphone. The channel is a fixed filter G(f), so the observed spectrum is S(f)·G(f) — another multiplication. In the log domain it is an additive constant, identical in every frame. In the cepstral domain, a DCT of a constant is still a constant. Therefore: subtract the per-clip mean of each coefficient and the channel is gone.
This one line of code — cepstral mean normalization, CMN — was worth several percent absolute in every ASR system of the era, and it is worth understanding why: it works only because the log turned a convolution into an addition. Every stage of MFCC is paying off some other stage. That is what a well-designed hand pipeline looks like.
Left: the log-mel band energies of a synthetic voiced frame (teal bars) — a smooth formant envelope (the warm curve) multiplied by a pitch comb. Right: the DCT coefficients, with the kept ones (c0– c12 region) in warm and the discarded high-quefrency ones dimmed. Move pitch: the comb spacing changes and only the far-right coefficients react — the kept ones barely move. Move formant: the envelope shifts and the low coefficients swing hard. That is the separation the cepstrum performs, visible in one picture.
python # FORM 1 - the DCT exactly as computed on paper. O(N^2), obviously correct. import numpy as np def dct2_manual(s): N = len(s) c = np.zeros(N) for k in range(N): for n in range(N): c[k] += s[n] * np.cos(np.pi * k * (n + 0.5) / N) return c print(dct2_manual(np.log([2., 8., 4., 1.]))) # -> [ 4.159 0.906 -1.961 -0.375 ] the paper answer # FORM 2 - full MFCC front end from scratch, shapes annotated. def mfcc_from_scratch(frames, sr, n_mels=40, n_mfcc=13): S = np.abs(np.fft.rfft(frames, axis=1)) ** 2 # (T, 201) power fb = mel_filterbank(sr, frames.shape[1], n_mels) # (40, 201) triangles mel = S @ fb.T # (T, 40) band energies logm = np.log(mel + 1e-10) # (T, 40) product -> sum c = dct2_manual_batch(logm)[:, :n_mfcc] # (T, 13) keep envelope c -= c.mean(axis=0, keepdims=True) # CMN: channel removed d1 = delta(c); d2 = delta(d1) return np.hstack([c, d1, d2]) # (T, 39) # FORM 3 - the one-liner. Identical maths, three decades of tuning. import librosa m = librosa.feature.mfcc(y=x, sr=16000, n_mfcc=13, n_fft=400, hop_length=160, n_mels=40) feats = np.vstack([m, librosa.feature.delta(m), librosa.feature.delta(m, order=2)]) # (39, T)
Every clip in the training set is now a point: a fixed-length vector of pooled features with a label attached. A new clip arrives and becomes another point. The simplest possible thing you could do is look at which labelled points it landed near.
That is k-nearest-neighbour classification in full. Store everything. To classify, compute the distance to every stored point, take the k closest, and let them vote. There is no training step, no parameters to fit, no optimizer. It is the right first classifier for any new problem because it tells you, immediately, whether your features are any good — if k-NN cannot separate the classes, the geometry is not there and no fancier model will conjure it.
Use two features so we can draw and compute everything: zero-crossing rate and spectral centroid in kHz, each averaged over the clip. Four labelled training clips:
| clip | ZCR | centroid (kHz) | label |
|---|---|---|---|
| A | 0.10 | 1.5 | speech |
| B | 0.14 | 2.0 | speech |
| C | 0.05 | 3.0 | music |
| D | 0.07 | 3.6 | music |
| Q | 0.09 | 2.8 | ? |
Euclidean distance is √((ΔZCR)2 + (Δcentroid)2). Compute each, showing both squared terms so you can see which one matters:
| to | ΔZCR | (ΔZCR)2 | Δcent | (Δcent)2 | sum | distance |
|---|---|---|---|---|---|---|
| A | +0.01 | 0.0001 | −1.30 | 1.6900 | 1.6901 | 1.300 |
| B | −0.05 | 0.0025 | −0.80 | 0.6400 | 0.6425 | 0.802 |
| C | +0.04 | 0.0016 | +0.20 | 0.0400 | 0.0416 | 0.204 |
| D | +0.02 | 0.0004 | +0.80 | 0.6400 | 0.6404 | 0.800 |
With k = 1 the answer is C, so we predict music. With k = 3 the three nearest are C (0.204), D (0.800) and B (0.802): two music, one speech, so again music. Fine — but look at the columns.
The (ΔZCR)2 column runs from 0.0001 to 0.0025. The (Δcent)2 column runs from 0.04 to 1.69. The centroid contributes between 16 and 17,000 times more to every distance. The ZCR feature is, arithmetically, not being used at all. We computed a two-dimensional distance and got a one-dimensional answer.
Give every feature the same voice by converting it to “standard deviations away from the training mean”:
ZCR statistics. Mean: (0.10 + 0.14 + 0.05 + 0.07)/4 = 0.36/4 = 0.090. Deviations: +0.01, +0.05, −0.04, −0.02. Squares: 0.0001, 0.0025, 0.0016, 0.0004; sum 0.0046; divide by 4 to get variance 0.00115; square root σ = 0.0339.
Centroid statistics. Mean: (1.5 + 2.0 + 3.0 + 3.6)/4 = 10.1/4 = 2.525. Deviations: −1.025, −0.525, +0.475, +1.075. Squares: 1.0506, 0.2756, 0.2256, 1.1556; sum 2.7075; variance 0.6769; σ = 0.8227.
Now transform every point. For A: z1 = (0.10 − 0.090)/0.0339 = +0.295; z2 = (1.5 − 2.525)/0.8227 = −1.246. The rest, the same way:
| point | z(ZCR) | z(centroid) |
|---|---|---|
| A (speech) | +0.295 | −1.246 |
| B (speech) | +1.474 | −0.638 |
| C (music) | −1.180 | +0.577 |
| D (music) | −0.590 | +1.307 |
| Q | 0.000 | +0.334 |
Recompute the four distances in this new space. To A: Δ = (0.295 − 0, −1.246 − 0.334) = (0.295, −1.580); squares 0.0870 and 2.4964; sum 2.5834; √ = 1.607. The full set:
| to | raw distance | rank | standardized distance | rank |
|---|---|---|---|---|
| A (speech) | 1.300 | 4 | 1.607 | 3 |
| B (speech) | 0.802 | 3 | 1.766 | 4 |
| C (music) | 0.204 | 1 | 1.204 | 2 |
| D (music) | 0.800 | 2 | 1.137 | 1 |
The nearest neighbour changed, from C to D. The predicted label happens to survive here (both are music), but the ranking is different, and on a harder query it would flip the answer. Same data, same algorithm, different result — purely because we chose units. Euclidean distance is only meaningful when the axes are commensurate, and making them commensurate is your job, not the classifier’s.
scaler.fit_transform(X_all) (wrong) and
scaler.fit(X_train); scaler.transform(X_test) (right). It is the single most common
methodological error in student audio projects.
k = 1 gives a decision boundary that wraps tightly around every training point, including mislabelled and freak ones: zero training error, high variance. Large k averages over a wide neighbourhood: smooth, stable, but it will smear away genuinely small classes. In the limit k = N, every query gets the majority label of the whole dataset.
| k | Boundary | Behaviour with a mislabelled point | Typical use |
|---|---|---|---|
| 1 | jagged, exact | creates an island of the wrong class around it | clean, dense data |
| 3–9 | moderately smooth | outvoted by its correct neighbours | the usual choice |
| > 30 | very smooth | ignored entirely | noisy labels, large N |
Pick k by cross-validation, and prefer odd k with two classes so votes cannot tie. A refinement worth knowing: distance weighting, where neighbour i votes with weight 1/di, so a neighbour at distance 0.2 outweighs one at 0.8 by four to one. That makes k less critical and is usually a free improvement.
k-NN degrades as dimensionality grows, and the reason is worth seeing numerically rather than as a slogan. Scatter 2000 random points uniformly in a d-dimensional unit cube, pick a query, and look at how far away the nearest and farthest points are:
| dimensions | nearest | mean | farthest | (far − near) / near |
|---|---|---|---|---|
| 2 | 0.003 | 0.392 | 0.765 | 267× |
| 78 | 2.744 | 3.619 | 4.375 | 0.59× |
In two dimensions the nearest neighbour is meaningfully nearer than everything else: the notion of “close” has teeth. In 78 dimensions — the size of our pooled feature vector — the farthest point is only about 1.6× the distance of the nearest. Everything is roughly the same distance from everything, and the word “neighbour” loses most of its meaning. That is why classical systems worked so hard to keep feature vectors small (13 MFCCs, not 40), and why k-NN is usually the worst of the three classifiers in this lesson on real audio benchmarks.
Three classes of clips in the (ZCR, centroid) plane. Drag the white-ringed query point anywhere, or tap. Lines connect it to its k nearest neighbours; the banner shows the vote. Turn standardization off and watch the neighbour set change dramatically — because with raw units the centroid axis dominates and the classifier effectively decides on one feature. Raise k and watch small pockets of a class get outvoted.
| metric | formula | when it is the right choice |
|---|---|---|
| Euclidean | √∑(a−b)2 | default, after standardizing |
| Cosine | 1 − a·b / (‖a‖‖b‖) | when overall loudness should be ignored — direction, not magnitude |
| Mahalanobis | √((a−b)T Σ−1 (a−b)) | when features are correlated; it whitens the space first |
| Manhattan | ∑|a−b| | high dimensions; less dominated by any single large deviation |
Note that standardizing and then using Euclidean distance is exactly Mahalanobis with a diagonal Σ. The two ideas are the same idea; full Mahalanobis additionally undoes correlations between features, which is what the DCT in Chapter 4 was already doing for us.
Return to the raw-distance ranking: C at 0.204 (music), D at 0.800 (music), B at 0.802 (speech). A plain k = 3 vote gives music 2, speech 1 — a 67% “confidence” that ignores how much nearer C is than the other two. Weight each vote by 1/d instead:
| neighbour | label | d | weight 1/d |
|---|---|---|---|
| C | music | 0.204 | 1 / 0.204 = 4.902 |
| D | music | 0.800 | 1 / 0.800 = 1.250 |
| B | speech | 0.802 | 1 / 0.802 = 1.247 |
Music scores 4.902 + 1.250 = 6.152; speech scores 1.247. Normalizing, music takes
6.152 / 7.399 = 83% of the vote rather than 67%. The weighting also makes the result far less
sensitive to k: adding a fourth neighbour at distance 1.5 would contribute only 0.67, barely moving the
total. This is why weights='distance' is usually a free improvement, and why it is the first
knob to turn when k-NN results look unstable.
Brute-force k-NN costs O(N · D) per query. With N = 2000 training clips and D = 86 features, that is 172,000 multiply-adds — microseconds. Perfectly fine. Now consider using it per frame: a hundred hours of audio is 36 million frames, so each query costs 36 million × 39 operations and you have 100 queries per second of test audio. Hopeless.
Two classical mitigations are worth knowing. KD-trees and ball trees partition the space so a query examines only a fraction of the points; they help enormously in low dimensions and, thanks to the concentration effect above, help almost not at all beyond about 20 dimensions. Condensed nearest neighbour instead prunes the training set itself, keeping only the points near class boundaries — typically 10–20% of the data, with nearly identical accuracy. That is a striking foreshadowing of the next chapter but one: an SVM does the same thing, but chooses which points to keep by optimization rather than by heuristic.
python # FORM 1 - the loop, exactly the table we computed by hand. import numpy as np from collections import Counter def knn_manual(X_train, y_train, q, k=3): dists = [] for i, p in enumerate(X_train): d = 0.0 for j in range(len(q)): d += (p[j] - q[j]) ** 2 # sum of squared differences dists.append((d ** 0.5, y_train[i])) dists.sort() # nearest first return Counter(l for _, l in dists[:k]).most_common(1)[0][0] # FORM 2 - vectorised, all queries at once. No Python loop at all. def knn_batch(X_train, y_train, Q, k=3): # ||a-b||^2 = ||a||^2 + ||b||^2 - 2 a.b -- the expansion that makes this fast d2 = (Q**2).sum(1)[:, None] + (X_train**2).sum(1)[None, :] - 2 * Q @ X_train.T idx = np.argsort(d2, axis=1)[:, :k] # (n_query, k) votes = y_train[idx] # (n_query, k) return np.array([np.bincount(v).argmax() for v in votes]) # FORM 3 - the library, with the scaler fitted on TRAIN ONLY. from sklearn.preprocessing import StandardScaler from sklearn.neighbors import KNeighborsClassifier from sklearn.pipeline import make_pipeline clf = make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=5, weights='distance')) clf.fit(X_train, y_train) # the pipeline fits the scaler on train only - no leakage print(clf.score(X_test, y_test))
k-NN memorised the training set. What we want instead is a compact description of each class: a probability density over feature space that says “dog barks live around here, with this much spread.” Then classifying is scoring — ask each class density how likely it thinks the new clip is, and take the winner.
This was the dominant approach of the classical era, and not by a small margin. Every speech recognizer from 1990 to 2012 was a bank of Gaussian mixtures. Speaker verification, language identification, the DCASE 2013 acoustic-scene baseline: Gaussian mixtures. It is worth doing the arithmetic yourself once, which is what this chapter is.
In one dimension, a Gaussian says “values cluster around μ with a typical wander of σ”:
Two parameters, both trivially estimated: μ is the sample mean, σ2 the sample variance. For a D-dimensional feature vector with a diagonal covariance you simply do this per dimension and multiply the results — which is precisely the independence assumption the DCT in Chapter 4 bought us.
But one Gaussian is a single blob, and a sound class is rarely one blob. “Dog” contains yaps and deep woofs. The phoneme /t/ has a silent closure followed by a burst. A single Gaussian fitted to a two-lump cloud puts its mean in the empty valley between the lumps and assigns high probability to a region where no data ever occurs. That is not a small inaccuracy; it is a density that is confidently wrong exactly where it matters.
So use several, and weight them:
The generative story is what makes the maths tractable: to produce a data point, first roll a die weighted by π to pick a component k, then draw from that component’s Gaussian. If we could see the die roll for each training point, fitting would be trivial — sort the points by component and compute means and variances within each group.
We cannot see it. The component identity is a latent variable. And that is the entire difficulty, dissolved by one idea: if we cannot observe the die roll, we will estimate the probability of each possible roll, and use those probabilities as soft group memberships.
The E-step is just Bayes’ rule. The probability that component k produced point x is the probability of picking k times the probability k would emit x, normalized over components:
The M-step is the ordinary mean/variance formulas with each point counted rnk times instead of once:
Four data points — think of them as a one-dimensional feature (say c1) from four frames:
Initialize deliberately badly, so there is something to fix: μ1 = 4, μ2 = 7, σ12 = σ22 = 4, π1 = π2 = 0.5.
Because the weights and variances are equal, the normalizing constants and the πs cancel in the ratio, and the responsibility depends only on the exponents. With 2σ2 = 8:
Point x = 2. Squared distances: (2−4)2 = 4 and (2−7)2 = 25. Exponents: −4/8 = −0.500 and −25/8 = −3.125. Exponentials: e−0.500 = 0.60653, e−3.125 = 0.04394. Their sum is 0.65047. So
Point x = 3. (3−4)2 = 1, (3−7)2 = 16. Exponents −0.125 and −2.000. Exponentials 0.88250 and 0.13534, sum 1.01784.
Point x = 8. (8−4)2 = 16, (8−7)2 = 1 — the mirror image of x = 3, so r1(8) = 0.1330, r2(8) = 0.8670.
Point x = 9. Mirror of x = 2: r1(9) = 0.0675, r2(9) = 0.9325.
| x | (x−μ1)2 | e−d/8 | (x−μ2)2 | e−d/8 | r1 | r2 |
|---|---|---|---|---|---|---|
| 2 | 4 | 0.60653 | 25 | 0.04394 | 0.9325 | 0.0675 |
| 3 | 1 | 0.88250 | 16 | 0.13534 | 0.8670 | 0.1330 |
| 8 | 16 | 0.13534 | 1 | 0.88250 | 0.1330 | 0.8670 |
| 9 | 25 | 0.04394 | 4 | 0.60653 | 0.0675 | 0.9325 |
Every point belongs partly to both components. Nothing has been assigned; everything has been weighted. That softness is the whole reason EM converges smoothly where hard assignment (k-means) jumps.
Effective counts. N1 = 0.9325 + 0.8670 + 0.1330 + 0.0675 = 2.000. By symmetry N2 = 2.000. Total 4 — as it must be, since each point’s responsibilities sum to 1.
New means. Weight each point by its responsibility:
Term by term: 0.9325 × 2 = 1.8649; 0.8670 × 3 = 2.6011; 0.1330 × 8 = 1.0637; 0.0675 × 9 = 0.6079. Sum = 6.1376. Divide by 2.000:
The means moved from (4, 7) to (3.07, 7.93) — outward, towards the real clusters at (2.5, 8.5). Check the symmetry: 3.069 + 7.931 = 11.0, and the data is symmetric about 5.5, so the means must stay symmetric about 5.5 too. They do.
New variances. Using the updated μ1 = 3.069:
| x | x − μ1 | (x − μ1)2 | r1 | product |
|---|---|---|---|---|
| 2 | −1.069 | 1.1423 | 0.9325 | 1.0651 |
| 3 | −0.069 | 0.0047 | 0.8670 | 0.0041 |
| 8 | 4.931 | 24.3167 | 0.1330 | 3.2333 |
| 9 | 5.931 | 35.1791 | 0.0675 | 2.3764 |
Sum = 1.0651 + 0.0041 + 3.2333 + 2.3764 = 6.6789. Divide by N1 = 2.000:
New weights. π1 = 2.000 / 4 = 0.5, π2 = 0.5 — unchanged, because the data is perfectly balanced.
EM promises the log-likelihood never decreases. Verify it. The mixture density at a point is π1N(x|μ1,σ12) + π2N(x|μ2,σ22).
Before. The normalizing constant is 1/√(2π×4) = 1/5.0133 = 0.19947. At x = 2: 0.5 × 0.19947 × (0.60653 + 0.04394) = 0.099735 × 0.65047 = 0.06487, whose natural log is −2.7355. At x = 3: 0.099735 × 1.01784 = 0.10151, log −2.2876. Points 8 and 9 mirror them, so
After. Now σ2 = 3.3394, so the constant is 1/√(2π×3.3394) = 1/4.5806 = 0.21831. At x = 2: distances 1.1423 and 35.179; exponents −1.1423/6.6788 = −0.17103 and −5.2672; exponentials 0.84280 and 0.00517; sum 0.84797; density 0.5 × 0.21831 × 0.84797 = 0.09256, log −2.3796. At x = 3: exponentials 0.99929 and 0.02626, sum 1.02555, density 0.11194, log −2.1899. So
It rose by 0.907 nats in one iteration. Run it to convergence and the means walk out to roughly 2.5 and 8.5 with small variances, exactly where a human would put them.
The four data points from the worked example (or a larger random cloud) with two Gaussians. Press E-step to recompute responsibilities — each point is coloured by its mix of warm and teal. Press M-step to move the curves. The log-likelihood trace at the bottom must never go down. Drag the initial-mean slider to a bad starting point and press Run: sometimes both components chase the same cluster and get stuck — that is the local optimum the callout warned about, and it is why real systems initialize with k-means.
Everything above holds in D dimensions with the scalar variance replaced by a diagonal covariance vector. The bookkeeping for a speech system:
| quantity | value | note |
|---|---|---|
| feature dimension D | 39 | 13 MFCC + delta + delta-delta |
| components K | 32–256 per class or state | more data allows more components |
| parameters per component | 39 means + 39 variances + 1 weight = 79 | diagonal covariance |
| parameters, K = 64 | 64 × 79 = 5056 | vs 64 × (39 + 780 + 1) = 52,480 for full covariance |
| scoring a clip | ∑t log p(xt) | frames assumed independent — the “bag of frames” assumption |
Classification is then Bayes: pick the class c maximizing log p(X | class c) + log P(class c). Because the log-likelihood is a sum over frames, a longer clip produces a more negative score — so if clip lengths differ, divide by the frame count before comparing, or your classifier will develop opinions about duration.
python # FORM 1 - EM from scratch, 1-D, exactly the arithmetic above. import numpy as np def em_1d(x, mu, var, pi, n_iter=20, var_floor=1e-3): for it in range(n_iter): # E-step: (N, K) responsibilities comp = pi * np.exp(-(x[:, None] - mu)**2 / (2*var)) / np.sqrt(2*np.pi*var) ll = np.log(comp.sum(1) + 1e-300).sum() # total log-likelihood r = comp / comp.sum(1, keepdims=True) # M-step Nk = r.sum(0) # (K,) effective counts mu = (r * x[:, None]).sum(0) / Nk var = (r * (x[:, None] - mu)**2).sum(0) / Nk var = np.maximum(var, var_floor) # the floor that prevents collapse pi = Nk / len(x) print(it, ll.round(4), mu.round(4), var.round(4)) return mu, var, pi em_1d(np.array([2., 3., 8., 9.]), np.array([4., 7.]), np.array([4., 4.]), np.array([.5, .5])) # iteration 0 prints ll = -10.0457, then mu = [3.0688 7.9312], var = [3.3394 3.3394] # FORM 2 - one GMM per class on real pooled features, then Bayes classification. from sklearn.mixture import GaussianMixture models = {} for c in classes: models[c] = GaussianMixture(n_components=32, covariance_type='diag', reg_covar=1e-4, n_init=3).fit(X_train[y_train == c]) # reg_covar IS the variance floor; n_init=3 restarts to escape bad local optima scores = np.stack([models[c].score_samples(X_test) for c in classes]) # (C, n_test) pred = np.array(classes)[scores.argmax(0)]
A GMM describes what each class looks like: it models the density, then compares. That is a generative approach, and it spends its capacity describing regions of feature space you may never need to distinguish. If all you want is the answer, model the boundary.
That is the discriminative view, and its champion in this era is the support vector machine. On the pooled clip-level vectors we built in Chapter 5, an SVM with an RBF kernel was — and honestly still is — a strong baseline you should beat before claiming anything.
Take two classes that are linearly separable in feature space. There are infinitely many straight lines that split them, and the training error of every one of them is zero. Training error cannot choose. So choose by a different principle: pick the line with the widest empty corridor around it.
Why is width the right criterion? Because your features are noisy. Centroid estimates wobble by tens of hertz between recordings of the same event; pooled statistics shift with clip length. A boundary that skims a training point will misclassify that same sound on a slightly different day. A boundary with a 2-unit corridor tolerates 1 unit of feature noise on either side. Margin is a robustness budget, purchased in advance.
Reduce to the smallest problem with an answer. One positive point at (3, 3) and one negative at (1, 1). The classifier is
The SVM convention scales w and b so the closest points sit exactly at f = ±1 (this is a choice of units, and it costs nothing since scaling w and b together does not move the boundary). So we need
By symmetry w must point along (1,1) — the direction from the negative point to the positive one — so write w = (a, a). Substitute:
Subtract the second equation from the first: (6a + b) − (2a + b) = 1 − (−1), so 4a = 2 and a = 0.5. Substitute back into 6a + b = 1: 3 + b = 1, so b = −2. Therefore
Check both constraints: f(3,3) = 1.5 + 1.5 − 2 = +1 ✓. f(1,1) = 0.5 + 0.5 − 2 = −1 ✓. The boundary passes through (2,2), the midpoint. Exactly what a human would draw.
The distance from a point to the hyperplane is |f(x)| / ‖w‖. Our support points have |f| = 1, so each sits 1/‖w‖ from the boundary and the full corridor is
Here ‖w‖ = √(0.25 + 0.25) = √0.5 = 0.7071, so the margin is 2 / 0.7071 = 2.828. Sanity check: the two points are √((3−1)2 + (3−1)2) = √8 = 2.828 apart, and with only two points the widest corridor is obviously the whole gap between them. The formula agrees with the picture.
Maximizing 2/‖w‖ is the same as minimizing ‖w‖, which is the same as minimizing ½‖w‖2 (nicer derivative). Hence the canonical statement:
Read it as English: make the weight vector as small as possible while still pushing every training point at least one unit onto its own side. Small w means a gentle slope means a wide corridor.
Real audio classes overlap — a shout and a scream share feature space. With the hard constraint above there is no solution. So allow violations and pay for them, using the hinge loss
Compute a few hinge values to feel it. A point with y = +1 and f = 2.5 is correct and beyond the margin: loss max(0, 1 − 2.5) = 0 — it contributes nothing and could be deleted. A point with f = 0.4 is on the right side but inside the corridor: loss max(0, 1 − 0.4) = 0.6. A point with f = −0.3 is misclassified: loss max(0, 1 + 0.3) = 1.3.
C is the exchange rate between corridor width and violations. Small C: a wide corridor, many points allowed inside, a smooth boundary that ignores individuals — high bias. Large C: violations are expensive, so the boundary contorts to get every training point right — high variance, and with noisy audio features that means memorizing the noise. C is chosen by cross-validation, always, typically over a log grid from 0.01 to 1000.
Audio classes are not linearly separable in a pooled feature space. The classical fix is to map the features into a higher-dimensional space where they are separable, and draw a straight line there — which is a curve back in the original space.
Doing that literally is expensive: a degree-2 map of a 78-dimensional vector has 3081 dimensions; degree 3 has over 90,000. The kernel trick avoids building the space at all. The key observation is that the SVM solution can be written so the data appears only inside dot products:
So if some cheap function K(u, v) happens to equal φ(u) · φ(v) for a rich map φ, we can substitute K for the dot product and get the rich space for free.
Take the degree-2 polynomial kernel K(u, v) = (u · v)2 and the two vectors u = (1, 2), v = (3, 1).
The cheap way. u · v = 1×3 + 2×1 = 3 + 2 = 5. Square it: K(u, v) = 52 = 25. Two multiplications, one addition, one squaring.
The expensive way. Expand the map explicitly. For two dimensions,
Compute both images: φ(u) = (12, √2×1×2, 22) = (1, 2.8284, 4) and φ(v) = (32, √2×3×1, 12) = (9, 4.2426, 1). Their dot product, term by term:
| term | φ(u) | φ(v) | product |
|---|---|---|---|
| x12 | 1 | 9 | 9.0000 |
| √2 x1x2 | 2.8284 | 4.2426 | 12.0000 |
| x22 | 4 | 1 | 4.0000 |
Identical. We obtained the dot product in a three-dimensional expanded space without ever building the three-dimensional vectors. In 78 dimensions the saving is between 3081 numbers and one. The √2 in the map is exactly what makes the cross-term appear twice in the expansion of (u·v)2 — the kernel dictates the map, not the other way round.
For our vectors with γ = 0.5: ‖u − v‖2 = (1−3)2 + (2−1)2 = 4 + 1 = 5, so K = e−2.5 = 0.0821. Two identical points give K = 1; distant points give K → 0. The RBF kernel is a similarity that decays with distance, and its implicit feature space is infinite-dimensional.
Read the classifier f(x) = ∑αiyiK(xi, x) + b and you will see something familiar: it is a weighted vote of the support vectors, with weights that fall off with distance. An RBF SVM is a k-NN that learned which neighbours matter and how much.
| γ | reach of one support vector | boundary | risk |
|---|---|---|---|
| very small (0.001) | enormous — every point influences everything | almost linear | underfits |
| moderate (1/D, the default) | comparable to typical inter-point distance | smooth curves | usually right |
| very large (100) | tiny bubble around each support vector | islands around individual points | memorizes; test accuracy collapses |
Two classes of clips in a standardized 2-D feature space, with a soft-margin SVM trained live in your browser (simplified SMO on the dual). Shaded background = decision regions, and the pale strip between them is the ±1 margin corridor; ringed points are the support vectors. Switch the kernel to RBF and push γ up: watch the boundary shatter into islands and the support-vector count climb towards “every point” — memorization made visible. Lower C and watch the corridor widen and swallow outliers.
An SVM is binary. ESC-50 has fifty classes. Two standard reductions:
| scheme | number of SVMs | each trained on | prediction |
|---|---|---|---|
| one-vs-rest | C = 50 | all data, one class vs. the other 49 | largest decision value |
| one-vs-one | C(C−1)/2 = 1225 | only the two classes involved — small and fast | majority vote over 1225 duels |
LIBSVM, the library behind almost every classical result you will read, uses one-vs-one. It sounds absurd — 1225 classifiers — but each sees only 2/50 of the data, and training cost grows faster than linearly with dataset size, so the total is usually cheaper than 50 full-data problems.
This is the structural fact that explains the whole era’s architecture. Kernel SVM training solves a quadratic program over an n × n kernel matrix: memory grows as n2, time as roughly n2 to n3. With n = 2000 pooled clip vectors, the kernel matrix is 4 million entries — trivial. With n = 10 million frames (a hundred hours of speech), it is 1014 entries — impossible.
GMM training, by contrast, is linear in the number of points and streams beautifully. So the division of labour was forced by computation: GMMs for frame-level acoustic modelling, where the data is enormous, and SVMs for clip-level classification, where the data is small and the boundary is what you want. Not fashion — arithmetic.
python # FORM 1 - the kernel trick, verified against the hand computation. import numpy as np u, v = np.array([1., 2.]), np.array([3., 1.]) K_cheap = (u @ v) ** 2 # 25.0 phi = lambda x: np.array([x[0]**2, np.sqrt(2)*x[0]*x[1], x[1]**2]) K_explicit = phi(u) @ phi(v) # 25.000000000000004 print(K_cheap, K_explicit) # the trick, demonstrated # The full RBF kernel matrix, vectorised (no loops): def rbf(X, Y, gamma): d2 = (X**2).sum(1)[:, None] + (Y**2).sum(1)[None, :] - 2 * X @ Y.T return np.exp(-gamma * d2) # (n, m) # FORM 2 - the practical recipe: standardize, grid-search, cross-validate. from sklearn.svm import SVC from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler from sklearn.model_selection import GridSearchCV, StratifiedKFold pipe = make_pipeline(StandardScaler(), SVC(kernel='rbf')) grid = {'svc__C': [0.1, 1, 10, 100], 'svc__gamma': [1e-3, 1e-2, 1e-1, 1]} cv = GridSearchCV(pipe, grid, cv=StratifiedKFold(5, shuffle=True, random_state=0)) cv.fit(X_train, y_train) print(cv.best_params_, cv.score(X_test, y_test)) print(len(cv.best_estimator_[-1].support_), 'support vectors of', len(X_train))
Classify each frame independently with your best GMM and watch the output: speech, speech, music, speech, speech, music, music… It flickers. No physical sound alternates between categories at 100 Hz. The classifier is treating consecutive frames as unrelated events when they are anything but.
Two things are missing. First, a prior on continuity: whatever was happening 10 ms ago is overwhelmingly likely to still be happening. Second, the ability to score and decode a whole sequence rather than a bag of frames. The hidden Markov model supplies both, and it is the single most important model of the classical era.
An HMM says: there is a hidden state that evolves over time, and each state emits an observation. We see the observations, never the states — hence “hidden.” It is specified by three things:
| symbol | name | meaning |
|---|---|---|
| πi | initial distribution | probability the sequence starts in state i |
| aij | transition matrix | probability of moving from state i to state j at the next frame |
| bi(o) | emission model | probability that state i produces observation o |
In a real system bi is a Gaussian mixture over the 39-dimensional MFCC vector — the GMM from Chapter 6, slotted in as the emission model. That composite is the GMM-HMM, and it was the entire field of speech recognition for two decades. Here we use two discrete symbols so the arithmetic fits on a page.
Two hidden states, S (speech) and M (music). Two possible observations per frame: H (high ZCR) and L (low ZCR). The parameters:
| π | → S | → M | emits H | emits L | |
|---|---|---|---|---|---|
| S | 0.6 | 0.7 | 0.3 | 0.8 | 0.2 |
| M | 0.4 | 0.4 | 0.6 | 0.3 | 0.7 |
Read the transitions: speech stays speech 70% of the time, so the model expects runs, not flicker. Read the emissions: speech usually shows high ZCR (consonant-rich), music usually low. Observed sequence:
The question — how likely is this sequence under this model? — is what lets us compare a “speech/music” HMM against a “traffic” HMM and pick a winner.
The sequence could have been produced by any state path. There are 23 = 8 paths here, and we could compute the probability of each and add. Let us actually do two of them so the structure is concrete.
Path S→S→S: start in S (0.6) and emit H (0.8); stay in S (0.7) and emit L (0.2); stay in S (0.7) and emit L (0.2).
Path S→M→M: start in S (0.6), emit H (0.8); move to M (0.3), emit L (0.7); stay in M (0.6), emit L (0.7).
All eight, for completeness:
| path | probability | path | probability |
|---|---|---|---|
| S S S | 0.009408 | M S S | 0.001344 |
| S S M | 0.014112 | M S M | 0.002016 |
| S M S | 0.008064 | M M S | 0.004032 |
| S M M | 0.042336 | M M M | 0.021168 |
Their sum is 0.10248. That is the answer — and the method is useless, because the number of paths is NT. For a modest 3-state model over a one-second clip (T = 100 frames) that is 3100 ≈ 5 × 1047 paths. You cannot enumerate them, ever.
Look at the eight paths again. Paths S→S→S and S→S→M share their first two steps and recompute them independently. That waste is the whole problem, and the fix is to compute each shared prefix once.
Define the forward variable
It bundles every path that reaches state i at time t into a single number. The recursion follows from the Markov property — given the current state, the past is irrelevant to the future:
In words: to be in state j now, you must have been in some state before (sum over i), moved here (aij), and then emitted what we saw (bj). The final answer is P(O) = ∑i αT(i).
t = 1, observation H.
t = 2, observation L. Incoming to S: from S with 0.7, from M with 0.4.
Incoming to M: from S with 0.3, from M with 0.6.
Already interesting: at t = 1 speech was four times more likely than music; after one low-ZCR frame, music has overtaken it. The evidence flipped the belief, and the transition prior slowed the flip down — that is the smoothing we wanted.
t = 3, observation L.
Exactly the brute-force sum — but computed with 8 multiply-adds instead of 8 full path expansions, and the gap explodes with T. The cost is O(T · N2) instead of O(NT): for the 3-state, 100-frame case, 900 operations instead of 5 × 1047.
Replace the sum with a max and remember where the max came from, and the same trellis gives you the single most likely state sequence — the Viterbi algorithm:
For our example the best path is S→M→M with probability 0.042336, which you can read straight off the eight-path table. Note that its probability, 0.042, is far below the total 0.102: the single best path accounts for only 41% of the sequence probability. That difference is exactly why the forward algorithm (sum) and Viterbi (max) are different algorithms answering different questions — “how likely is this sound at all” versus “what was happening when.”
The trellis for our two-state model. Each column is a time step, each node a state, and the number inside is αt(i). Press Step to advance one frame — the incoming edges light up with their contributions (α × a) before the emission is applied. Change the self-transition slider to make states stickier and watch the belief stop reacting to single frames; change the observations slider to feed a different sequence.
The 1990s architecture, in numbers, so the scale is concrete:
| level | construction | typical count |
|---|---|---|
| phone | 3-state left-to-right HMM (begin / middle / end) | ~40 phones × 3 = 120 states |
| context-dependent | triphones: each phone conditioned on its neighbours | 403 = 64,000 possible, most unseen |
| tied states (senones) | decision-tree clustering of triphone states | ~2000–10,000 shared states |
| emissions | one diagonal GMM per tied state, 16–32 components | millions of parameters |
| words | HMMs chained per pronunciation dictionary | 60k-word vocabulary |
| language model | n-gram probabilities on the word transitions | millions of n-grams |
Recognition is then a single enormous Viterbi search through this composite graph, with beam pruning to keep it tractable. Training uses the Baum-Welch algorithm, which is exactly EM from Chapter 6 with the responsibilities computed by a forward pass and a matching backward pass. Everything in this lesson composes.
α is a product of probabilities. After 1000 frames of a real system, values around 0.1 per frame give α ≈ 10−1000 — which is exactly 0.0 in double precision, whose smallest normal value is about 10−308. Every implementation must either work in logs (replacing multiplication with addition and the sum with a log-sum-exp) or rescale α to sum to 1 at each step and accumulate the log of the scale factors. This is not an optimization; without it the algorithm returns zero and your classifier compares zero to zero.
python # FORM 1 - the recursion exactly as computed by hand. import numpy as np pi = np.array([0.6, 0.4]) A = np.array([[0.7, 0.3], [0.4, 0.6]]) B = {'H': np.array([0.8, 0.3]), 'L': np.array([0.2, 0.7])} def forward(obs): a = pi * B[obs[0]] # alpha_1 -> [0.48, 0.12] for o in obs[1:]: a = (a @ A) * B[o] # sum over i, then emit return a.sum() print(forward(['H', 'L', 'L'])) # 0.10248 - the paper answer # FORM 2 - log domain, the version you must ship. No underflow, ever. from scipy.special import logsumexp def forward_log(obs, logpi, logA, logB): a = logpi + logB[obs[0]] for o in obs[1:]: a = logsumexp(a[:, None] + logA, axis=0) + logB[o] return logsumexp(a) # log P(O) # FORM 3 - Viterbi: same trellis, max instead of sum, plus backpointers. def viterbi(obs): d = pi * B[obs[0]]; back = [] for o in obs[1:]: scores = d[:, None] * A # (from, to) back.append(scores.argmax(0)) d = scores.max(0) * B[o] path = [int(d.argmax())] for bp in reversed(back): path.append(int(bp[path[-1]])) return path[::-1], d.max() print(viterbi(['H', 'L', 'L'])) # ([0, 1, 1], 0.042336) = S M M
Everything is built. Now we bolt it together, run it on the datasets people actually competed on, and put honest numbers next to it — including the numbers that show where it stopped.
After Chapter 4 a clip is an array of shape (T, 39), and T depends on how long the recording was. The classifiers of Chapters 5–7 need a single fixed-length vector. The classical answer is statistics pooling: summarize each feature dimension over time.
The mean says where this clip typically sits; the standard deviation says how much it moves. A steady drone and a rapid alternation can have the same mean and very different spread — so the second statistic is doing real work, not decoration.
Take the ZCR values of four consecutive frames:
Mean. 0.42 + 0.30 = 0.72; + 0.55 = 1.27; + 0.33 = 1.60. Divide by 4:
Deviations. 0.42 − 0.40 = +0.02; 0.30 − 0.40 = −0.10; 0.55 − 0.40 = +0.15; 0.33 − 0.40 = −0.07. (Check: they must sum to zero. +0.02 − 0.10 + 0.15 − 0.07 = 0.00 ✓.)
Squares. 0.0004, 0.0100, 0.0225, 0.0049. Sum = 0.0378.
Variance and sd. Population variance divides by n: 0.0378 / 4 = 0.00945, so sd = √0.00945 = 0.0972. Sample variance divides by n − 1: 0.0378 / 3 = 0.0126, so sd = 0.1122. Both conventions appear in the literature; with T in the hundreds the difference is under half a percent, but with short clips it is not, and mixing conventions between training and test is a real (if embarrassing) source of degradation.
One 4-second clip, all the way through, with the transformation and the loss at each step:
| stage | shape | numbers | what is discarded here |
|---|---|---|---|
| waveform, 16 kHz | (64000,) | 64,000 | — everything is still present |
| framed, 25 ms / 10 ms | (398, 400) | 159,200 | nothing (data expands via overlap) |
| windowed & |FFT|2 | (398, 201) | 80,000 | phase — irrecoverably |
| mel filterbank | (398, 40) | 15,920 | fine frequency detail, especially high up |
| log + DCT, keep 13 | (398, 13) | 5,174 | pitch and harmonic structure |
| + deltas | (398, 39) | 15,522 | nothing (adds derived motion) |
| mean/sd pooling | (78,) | 78 | time order, entirely |
| classifier | (1,) | 1 | — |
64,000 numbers to 78: a compression of 820×. Each throw-away was defensible when it was made. Read the third column downward and you are reading, in advance, the list of things a convolutional network would later be praised for keeping.
Top: a per-frame feature trajectory for the selected sound (spectral centroid over time). Bottom: the pooled vector — mean and standard deviation bars. Press Shuffle frames: the trajectory is scrambled beyond recognition, the sound would be unrecognisable to your ear, and the pooled bars do not move by a single pixel. That is what the classifier receives.
The protocol matters as much as the model, and this era learned that the hard way.
Stratified k-fold cross-validation. Split the clips into k folds preserving class proportions, train on k−1, test on the held-out one, rotate, average. With only 40 clips per class you cannot afford a single train/test split — the variance of the estimate would swamp the differences you are trying to measure.
Group by source recording. ESC-50’s 2000 clips are cut from a smaller number of original Freesound recordings, and the dataset ships five predefined folds constructed so that clips from the same source file never straddle a fold boundary. Ignore them, shuffle randomly, and your model can recognise the background hum of a particular recording rather than the sound class. Reported accuracies jump by double digits, and the system fails completely in the field. This is the single most important methodological rule in audio classification, and it is violated constantly.
Report the confusion matrix, not just accuracy. With 50 classes, an accuracy number hides which pairs are being confused — and the confusions are diagnostic: helicopter/chainsaw confusions mean your features cannot separate broadband periodic textures, which is a feature problem, not a classifier problem.
Suppose a three-class detector is tested on 100 clips: 80 of class A (background), 15 of class B (alarm), 5 of class C (glass break). The confusion matrix comes back as
| true ↓ / predicted → | A | B | C | total |
|---|---|---|---|---|
| A | 72 | 6 | 2 | 80 |
| B | 11 | 3 | 1 | 15 |
| C | 4 | 1 | 0 | 5 |
Plain accuracy counts the diagonal: (72 + 3 + 0) / 100 = 75%. That sounds like a working system. Now compute the per-class recalls: A = 72/80 = 0.900, B = 3/15 = 0.200, C = 0/5 = 0.000. The balanced accuracy is their mean:
The same system is 75% or 37% depending on which number you report, and the second one is the truth about what it does: it never detects glass breaking, which is the only class anybody cared about. Always report per-class recall, and prefer balanced accuracy (or macro-F1) whenever the classes are unbalanced. ESC-50 is perfectly balanced — 40 clips per class — so plain accuracy is honest there, which is part of why it became the standard benchmark.
| pooling | dimensions from 39 | captures | cost |
|---|---|---|---|
| mean only | 39 | typical value | blind to variability |
| mean + sd | 78 | typical value and spread | the standard |
| + min, max | 156 | extremes — a single loud event in a quiet clip | very outlier-sensitive |
| percentiles (10/50/90) | 117 | extremes, robustly | usually better than min/max |
| + mean/sd of Δ | 156 | how fast features change on average | partial order information — a genuine improvement |
| histogram per dimension | 39 × bins | the whole marginal distribution | large; needs more data |
Every one of these is still permutation-invariant. Note the fifth row carefully: pooling the deltas smuggles a little temporal information past the pooling stage, because the deltas were computed before order was destroyed. That trick — compute time-sensitive quantities early, pool them late — is the best the bag-of-frames framework can do, and it is worth several points in practice.
ESC-50 (Piczak, 2015): 2000 five-second clips, 50 environmental classes, 40 clips each, five predefined folds. The paper’s own baselines use MFCC and zero-crossing-rate statistics pooled per clip — exactly the pipeline of this lesson:
| system | ESC-50 | ESC-10 (easier 10-class subset) |
|---|---|---|
| k-NN on pooled MFCC + ZCR | ~32.2% | ~66.7% |
| SVM on the same features | ~39.6% | ~67.5% |
| random forest on the same features | ~44.3% | ~72.7% |
| human listeners | ~81.3% | ~95.7% |
| convolutional net on log-mel (Piczak, 2015) | ~64.5% | ~80% |
| pretrained audio net (PANNs, 2020) | ~94.7% | — |
| audio spectrogram transformer (2021) | ~95.6% | — |
Sit with the first four rows. The best hand-feature classifier reached about 44% where humans reach about 81% — a gap of nearly a factor of two, on a task humans find easy. That gap is the honest summary of the classical era on general environmental sound.
DCASE acoustic scene classification. The 2013 challenge shipped an MFCC-plus-GMM baseline that scored around 55% on ten scene classes, with the best submitted systems reaching roughly 76%. By the 2016 edition (15 classes) the MFCC-GMM baseline was around 72–77% and the winning entries approached 90%, using i-vectors and early neural fusion. The baseline barely moved in three years; the winners moved a lot, and what changed in the winners was the representation.
Speech. Here the classical pipeline was not a weak baseline — it was the state of the art for twenty years, and GMM-HMM systems delivered usable dictation. The 2012 turn came when deep networks replaced the GMM emission model while keeping the HMM: across several groups and benchmarks, the reported word-error-rate reductions were on the order of a third relative. It is worth noticing which block was replaced first. Not the features, not the sequence model — the density estimator.
python # A complete, runnable classical audio classifier. Every function is a chapter of this lesson. import numpy as np, librosa, glob, os from sklearn.svm import SVC from sklearn.preprocessing import StandardScaler from sklearn.pipeline import make_pipeline from sklearn.model_selection import GroupKFold, cross_val_score SR, N_FFT, HOP = 16000, 400, 160 # Ch 1: 25 ms frames, 10 ms hop def clip_features(path): x, _ = librosa.load(path, sr=SR, mono=True) # (n_samples,) # --- Ch 2-4: per-frame features ------------------------------------ mf = librosa.feature.mfcc(y=x, sr=SR, n_mfcc=13, n_fft=N_FFT, hop_length=HOP) mf = mf - mf.mean(axis=1, keepdims=True) # CMN: remove the channel d1 = librosa.feature.delta(mf) d2 = librosa.feature.delta(mf, order=2) zcr = librosa.feature.zero_crossing_rate(x, frame_length=N_FFT, hop_length=HOP) cen = librosa.feature.spectral_centroid(y=x, sr=SR, n_fft=N_FFT, hop_length=HOP) rol = librosa.feature.spectral_rolloff(y=x, sr=SR, n_fft=N_FFT, hop_length=HOP) flt = librosa.feature.spectral_flatness(y=x, n_fft=N_FFT, hop_length=HOP) F = np.vstack([mf, d1, d2, zcr, cen / SR, rol / SR, flt]) # (43, T) # --- Ch 9: statistics pooling -------------------------------------- return np.concatenate([F.mean(axis=1), F.std(axis=1)]) # (86,) files = sorted(glob.glob('ESC-50/audio/*.wav')) X = np.stack([clip_features(f) for f in files]) # (2000, 86) y = np.array([int(os.path.basename(f).split('-')[3][:-4]) for f in files]) folds = np.array([int(os.path.basename(f).split('-')[0]) for f in files]) # official folds! # Ch 5-7: standardize INSIDE the pipeline, evaluate on the official folds only. clf = make_pipeline(StandardScaler(), SVC(kernel='rbf', C=10, gamma='scale')) scores = cross_val_score(clf, X, y, groups=folds, cv=GroupKFold(5)) print(scores.mean().round(3)) # expect the high 0.3s / low 0.4s - the era's ceiling
That script is about forty lines and it reproduces, to within a couple of points, the numbers people published. There is no hidden trick and no missing ingredient. The ceiling is structural.
One detail in it repays attention: groups=folds. That single keyword is the difference
between a reproducible 40% and a fraudulent 60%. Everything else in the script — the features, the
kernel, the pooling — is a modelling choice you can argue about. The grouping is not a choice; it is
the definition of the question being asked.
Here is the whole lesson, running. A sound is generated, framed, described by features frame by frame, pooled, and classified — live, with every intermediate stage on screen. Nothing in this simulation is faked: the features are the arithmetic from Chapters 2 and 3 applied to synthetic frames, the decision regions are the actual classifier evaluated on a grid, and the verdict is what that classifier says.
| panel | shows | corresponds to |
|---|---|---|
| top strip | the waveform with the current 25 ms frame highlighted, sliding left to right | Chapter 1 — framing |
| main plot | feature space: zero-crossing rate horizontally, spectral centroid vertically. Faint clouds are the labelled training clips; the shaded background is the classifier’s decision region; the small dots are this clip’s per-frame features as they are extracted; the large ringed dot is the running pooled mean | Chapters 2–3 (features), 5–7 (classifier), 9 (pooling) |
| bottom bars | the score for each class and the current verdict | Chapter 6 — likelihood scoring |
Press Play to run the clip frame by frame and watch the pooled point walk into (or out of) the right region. Change the source sound, add noise, switch the classifier between nearest-neighbour and Gaussian, and watch the decision regions redraw. Everything responds immediately.
Why these two features and not the other five? Because zero-crossing rate and spectral centroid are almost independent: ZCR responds to noisiness, centroid to brightness, and a sound can be any combination of the two. Plotting two correlated features would give a diagonal smear in which every class sits on one line, which teaches nothing. Choosing axes that vary independently is the same instinct the DCT formalized in Chapter 4, applied here to a picture instead of a model.
A simulation you only watch teaches nothing. Do these in order; each one demonstrates a claim made earlier in the lesson.
Experiment 1 — the trajectory matters, the pooled point does not know it. Select siren and press Play. Watch the small frame dots sweep upward across the plot as the siren rises in pitch: that is genuine temporal structure, plainly visible. Now look at the big pooled dot: it sits placidly in the middle of the sweep and never moves along it. Everything the classifier receives is that one dot. This is the Chapter 9 blindness, in the one place where you can see both things at once.
Experiment 2 — noise is a translation, not just a blur. Set the sound to speech and raise the noise slider from 0.1 to 0.7. The frame dots do not merely scatter — they migrate right and up, because added broadband noise raises both the zero-crossing rate and the spectral centroid. The pooled point walks out of the speech region and into the rain region. Your classifier has not been confused by randomness; it has been given features that genuinely moved. That distinction matters when you debug: this failure is fixed by a better feature (or noise-robust normalization), not by a bigger classifier.
Experiment 3 — how little data a Gaussian needs. Set the classifier to Gaussian and pull the training-clips slider down to 3 per class. The decision regions stay broadly sensible: a Gaussian estimates two numbers per dimension and can do that from three points. Now switch to nearest neighbour with the same three points and press “Resample training set” a few times: the regions lurch wildly between samples. That is the bias-variance trade-off, made visual. A parametric model imposes a shape and is stable when data is scarce; a memory-based model imposes nothing and is at the mercy of which points it happened to see.
Experiment 4 — the boundary between the two hardest classes. Select rain and then scream, watching where each lands. Rain is high-ZCR and high-centroid; a scream is loud and high-centroid but far more periodic, so its ZCR is lower. In this two-feature space they are adjacent, and with noise at 0.5 they overlap. In a real system you would fix this by adding a feature that separates them structurally — spectral flatness, which is near 1 for rain and near 0 for a scream’s harmonic stack. Note what “fixing it” meant: a human thought about the physics and designed a number. That is the era’s method and its limit.
Experiment 5 — break it on purpose. Set noise to 1.0. Every class collapses towards the same corner of the plot, and the verdict becomes essentially arbitrary while the score bars stay confidently tall. A generative classifier reports the relative likelihood of classes; nothing in it says “this clip is unlike everything I was trained on.” To get that, you would threshold the absolute log-likelihood — the open-set rejection from Chapter 6 — and this experiment is why you would bother.
The bars at the bottom of the simulation are diagonal-Gaussian log-likelihoods. Let us compute two of them by hand so nothing is mysterious. Suppose the pooled point for the current clip is
and two of the class models are
| class | μ(ZCR) | σ(ZCR) | μ(centroid) | σ(centroid) |
|---|---|---|---|---|
| speech | 0.16 | 0.05 | 2.0 | 0.6 |
| rain | 0.35 | 0.06 | 4.2 | 0.9 |
With a diagonal covariance the log-likelihood is a sum over dimensions:
Speech, dimension 1. The standardized distance is (0.30 − 0.16)/0.05 = 2.800, so the quadratic term is 2.8002/2 = 3.920. The constant term is −½ log(2π × 0.0025) = −½ log(0.015708) = −½(−4.1533) = +2.077. Contribution: 2.077 − 3.920 = −1.843.
Speech, dimension 2. (3.4 − 2.0)/0.6 = 2.333; quadratic term 2.722; constant −½ log(2π × 0.36) = −½(0.8163) = −0.408. Contribution: −0.408 − 2.722 = −3.130.
Rain, dimension 1. (0.30 − 0.35)/0.06 = −0.833; quadratic 0.347; constant −½ log(2π × 0.0036) = +1.895. Contribution: +1.547. Dimension 2. (3.4 − 4.2)/0.9 = −0.889; quadratic 0.395; constant −0.814. Contribution: −1.209.
Rain wins by 0.339 − (−4.974) = 5.312 nats. With equal class priors, that is a posterior odds ratio of e5.312 ≈ 203 to 1. Note that a log-likelihood can be positive: this is a density, not a probability, and a tight Gaussian (small σ) can exceed 1 at its peak. If you ever see a “probability” above 1 in an audio classifier, this is why, and it is not a bug.
javascript // The classifier behind the shaded regions - the same maths as the hand computation. function logGauss(x, y, m) { // m = {mx, my, sx, sy} const a = -0.5 * Math.log(2 * Math.PI * m.sx * m.sx) - (x - m.mx) ** 2 / (2 * m.sx * m.sx); const b = -0.5 * Math.log(2 * Math.PI * m.sy * m.sy) - (y - m.my) ** 2 / (2 * m.sy * m.sy); return a + b; // diagonal covariance = sum over dims } // Fit: the M-step of a one-component GMM is just the sample mean and variance. function fitClass(points) { const n = points.length; const mx = points.reduce((s, p) => s + p[0], 0) / n; const my = points.reduce((s, p) => s + p[1], 0) / n; const vx = points.reduce((s, p) => s + (p[0] - mx) ** 2, 0) / n; const vy = points.reduce((s, p) => s + (p[1] - my) ** 2, 0) / n; // variance floor - Chapter 6's singularity guard, needed here too with 3 points/class return { mx, my, sx: Math.sqrt(Math.max(vx, 1e-4)), sy: Math.sqrt(Math.max(vy, 1e-3)) }; } // The decision region is just argmax evaluated on a coarse grid, one colour per winner. for (let gx = 0; gx < GRID; gx++) for (let gy = 0; gy < GRID; gy++) region[gx][gy] = argmax(models.map(m => logGauss(xOf(gx), yOf(gy), m)));
Twenty lines for a working classifier, because a diagonal Gaussian has a closed-form fit — no gradient descent, no iterations, no learning rate. That closed form is a large part of why this family dominated when compute was scarce: you could fit a model faster than you could load the data.
The same budget argument governs inference. Per frame, the playground computes a few hundred arithmetic operations for the features and five Gaussian evaluations for the scores — call it a thousand operations at 100 frames per second, so 100,000 operations per second of audio. A 2008 laptop ran roughly a billion operations per second, so the pipeline used about one ten-thousandth of the machine and could comfortably run on dozens of live microphone streams at once. That is not a historical curiosity: it is why energy-and-ZCR voice detection still runs on the always-listening core of your phone while the neural model stays asleep.
Finally, notice what the playground does not need: a training loop, a validation curve, an early stopping criterion, a learning-rate schedule, or a GPU. Press “Resample training set” and the model is refitted instantly, from scratch, in front of you. Whatever else it lacked, this era had a development loop measured in seconds.
The playground uses two features and one classifier for legibility. A competitive 2010 DCASE entry differed in scale, not in kind:
| this playground | a real submission | why |
|---|---|---|
| 2 features | 60–200 pooled statistics | 13 MFCC + deltas + spectral shape + rhythm features, mean/sd/percentiles |
| one Gaussian per class | 32–256-component GMM, or an RBF SVM | classes are multi-modal (Chapter 6’s opening argument) |
| fixed hyperparameters | grid search over C, γ, K under cross-validation | every one of them changes accuracy by several points |
| synthetic clips | real recordings, official folds | source-level leakage otherwise (Chapter 9) |
| one model | fusion of 3–8 models by score averaging | the standard last 2–4 points of every challenge |
None of those additions changes the shape of the pipeline. They are all “more of the same, tuned harder,” which is exactly the profile of a mature technology approaching its ceiling. The next chapter is about what that ceiling was made of.
If you build the real system, these are the moves that change the number, roughly in order of how much they change it. The magnitudes below are typical of what you will see on a small environmental-sound task; treat them as expectations to test, not constants.
| change | typical effect | why |
|---|---|---|
| forget to standardize before an RBF SVM | large loss | the kernel distance is dominated by whichever feature has the biggest units (Ch 5, Ch 7) |
| random split instead of source-grouped folds | large spurious gain | the model recognises the recording, not the class |
| drop sd pooling, keep mean only | several points lost | variability is genuinely discriminative |
| add delta and delta-delta statistics | several points gained | the only temporal information that survives pooling |
| tune C and γ by grid search | several points gained | defaults are rarely near-optimal |
| 13 → 20 MFCCs on non-speech | small gain | lets some pitch information back in, which helps outside speech |
| 32 → 128 GMM components with 40 clips/class | loss | not enough data per component; variances collapse |
When a classical audio classifier underperforms, the cause is almost always in this list, and almost never in the classifier:
| symptom | first suspect | how to check |
|---|---|---|
| NaNs in the feature matrix | log(0) on a silent frame, or divide-by-∑m in silence | np.isnan(X).any(axis=0) — find the offending column |
| train accuracy 100%, test at chance | leakage or memorization (C or γ far too large) | check fold construction first, hyperparameters second |
| one feature dominates the model | no standardization | print the per-feature std of X |
| works in the lab, fails in the field | channel/gain mismatch | apply CMN; test on a recording from a different device |
| accuracy high, target class never detected | class imbalance | report per-class recall, not accuracy |
| results change every run | GMM local optima, unseeded splits | fix the seed, raise n_init, report a mean over folds |
This pipeline was refined for a quarter of a century by thousands of very good engineers. Then, between roughly 2012 and 2016, it was overtaken across every audio task at once. It is worth being precise about why, because “deep learning is better” is not an explanation — it is a restatement of the observation.
There are four structural reasons, and each of them is a specific decision we made earlier in this lesson, coming due.
In the classical stack, exactly one block adapts to your task: the classifier at the end. The framing, the filterbank, the log, the DCT, the truncation, the pooling — all fixed before any data is seen. If your task needs a feature nobody thought of, there is no mechanism by which the system can discover it. The only path is a human having an idea.
In a convolutional network on log-mel input, the first layer is a learned filterbank, the middle layers are learned pattern detectors, the pooling is learned, and the classifier is learned — and the error signal from a misclassified clip flows back through all of them. The difference is not that nets are “more powerful” in the abstract; it is that gradient reaches the representation.
Every discard in the MFCC pipeline is correct for speech recognition:
| discarded | right for speech because… | wrong for general audio because… |
|---|---|---|
| phase | the ear is largely phase-deaf for steady sounds | fine timing/transient structure distinguishes impacts |
| pitch (high quefrency) | a word means the same at any pitch | pitch is the label for birds, alarms, engines, music |
| fine high-frequency detail | speech energy lives below 4 kHz | bird calls, keys, glass, insects live above it |
| temporal order (pooling) | the HMM put it back | clip-level classifiers never put it back |
A feature set is a statement about what does not matter. MFCCs are an excellent statement about speech and a poor one about sirens, and the field used them for both for fifteen years because they were what existed.
This is the deepest one, and the simulation below is built to show it. Look at a spectrogram and the diagnostic evidence is often a two-dimensional shape: a rising diagonal streak (a chirp), a vertical line followed by horizontal bands (a struck note), a checkerboard of harmonics moving together (a voice). These are patterns in time and frequency jointly.
Per-frame features collapse each column to a few numbers before any of that shape can be measured, and statistics pooling then discards the column order. There is no stage in the classical pipeline at which a 2-D pattern could be detected even in principle. A convolutional kernel sliding over the spectrogram does nothing else.
Left: a spectrogram you choose — a rising chirp, a falling chirp, or their average. Right: the pooled hand features (mean and standard deviation of the spectral centroid, and mean energy) as bars, next to the response of a small learned 2-D filter tuned for diagonal structure. Switch between rising and falling: the hand-feature bars do not move at all — they are literally identical — while the 2-D filter response flips sign. Everything that distinguishes these two sounds is invisible to the classical pipeline and trivially visible to one convolution.
Give a GMM-plus-MFCC system ten times more training data and it improves a little: the parameter count is fixed by your choice of K, and beyond a few hours the estimates are already converged. Give a neural network ten times more data and you can also make it ten times larger, and it improves a lot. Once datasets like AudioSet (about two million clips, 2017) existed, the classical stack had no way to use them.
That is the honest ordering of events. The deep models did not merely have better inductive biases; they arrived at the same time as datasets and hardware that rewarded scale, and the classical pipeline had no scale dimension to grow along.
| stage | classical | modern | verdict |
|---|---|---|---|
| framing | 25 ms / 10 ms Hann | 25 ms / 10 ms Hann | kept verbatim — Whisper uses exactly this |
| spectrum | |FFT|2 | |FFT|2 | kept |
| mel filterbank | 40 bands | 64–128 bands | kept, widened — less aggressive compression |
| log | log energy | log-mel | kept |
| DCT / truncation | 13 MFCCs | — | dropped — nets do not need decorrelated inputs |
| deltas | +Δ, +ΔΔ | — | dropped — a conv kernel over time computes them if useful |
| pooling | mean/sd statistics | learned (attention, GeM, or a final conv) | replaced |
| classifier | GMM / SVM | softmax head | replaced |
| sequence model | HMM + Viterbi | CTC, attention encoder-decoder | replaced |
Read the “kept” rows: everything up to and including the log survived unchanged. The front end of a 2024 audio model is the front end of a 1990 speech recognizer. What was replaced is precisely everything that was lossy and hand-decided — and everything that was lossless and perceptually motivated stayed. That is a remarkably clean verdict on twenty-five years of design work: the physics was right, the summarization was wrong.
| year | event | what it changed |
|---|---|---|
| 1980 | Davis & Mermelstein publish MFCCs | the feature vector is settled for thirty years |
| 1989 | Rabiner’s HMM tutorial | the sequence model is settled; GMM-HMM becomes the standard |
| 2002 | Tzanetakis & Cook, GTZAN | the same recipe crosses into music |
| 2012 | deep networks replace the GMM emission model | the first block falls; the HMM stays for years |
| 2013 | DCASE launches with an MFCC-GMM baseline | environmental sound gets a shared ruler |
| 2015 | ESC-50 published, with hand-feature baselines and a CNN | the ~44% vs ~64% gap is measured in a single paper |
| 2016 | CTC and attention models drop the HMM entirely | the sequence model falls |
| 2017 | AudioSet: ~2M labelled clips | scale becomes available; the classical stack cannot use it |
| 2020–21 | PANNs, then AST, on ESC-50 | the human baseline is passed on this benchmark |
Notice the order in which the blocks fell: emission model, then sequence model, then the features. The front end — framing, FFT, mel, log — never fell at all. Technologies are not replaced whole; they are dismantled from whichever end is most obviously arbitrary.
It would be tidy to say the classical pipeline was simply worse. It was not, and pretending otherwise makes you a worse engineer.
For most of the 2010s, on tasks with a few hundred labelled clips, a well-tuned MFCC-plus-SVM system beat a from-scratch convolutional network, because the net had nowhere near enough data and the hand features encoded decades of correct prior knowledge for free. What changed the verdict was not architecture alone; it was transfer — the ability to pretrain on AudioSet or on unlabelled audio and arrive at your small task with a representation already formed. Before pretraining was routine, hand-crafted priors were competitive with learned ones, exactly as theory predicts when data is scarce.
The lasting lesson is therefore not “never design features.” It is: a hand-designed feature is a prior, priors dominate when data is scarce, and data stopped being scarce.
| where | what survives | why it wins there |
|---|---|---|
| voice activity detection on a phone | energy + ZCR thresholds | must run continuously on microwatts |
| microcontroller keyword spotting | MFCC front end (then a tiny net) | 13 numbers per frame fits in the RAM budget |
| forced alignment | GMM-HMM (Kaldi-lineage aligners) | robust, fast, needs no GPU, and the HMM is the alignment |
| speaker verification, low-resource | GMM-UBM with MAP adaptation | calibrated likelihood ratios from minutes of enrolment audio |
| any new task, day one | MFCC statistics + SVM | a two-hour baseline that tells you if the task is even learnable |
That last row is the practical reason this lesson is not history. Before fine-tuning a pretrained audio transformer on your 400 labelled clips, spend an afternoon on the classical pipeline. If it gets 70%, your problem is easy and you have a deployable model already. If it gets chance, your labels or your recordings are broken, and you have learned that for the price of an afternoon instead of a week of GPU time.
| k-NN | GMM | SVM | |
|---|---|---|---|
| type | non-parametric memory | generative, parametric | discriminative, sparse |
| training cost | zero | linear in points per EM pass | quadratic to cubic in points |
| inference cost | O(N·D) — grows with the dataset | O(K·D) | O(#SV · D) |
| gives a likelihood | no | yes — enables rejection of unknown sounds | no (needs Platt scaling for probabilities) |
| data hunger | needs dense coverage — suffers most from dimensionality | modest; two numbers per dimension per component | modest; boundary-focused |
| scaling sensitivity | extreme | low (variances absorb scale) | extreme with an RBF kernel |
| where it was used | quick baselines, small datasets | frame-level acoustic models, speaker ID | clip-level classification, challenge entries |
| typical ESC-50 result | ~32% | — (used per frame, not per clip) | ~40% |
If you remember one row, remember the likelihood row. A system that can say “I have never heard anything like this” behaves completely differently in deployment from one that must pick a label, and that capability came from modelling the density rather than the boundary. Most modern classifiers gave it up, which is why open-set audio recognition is a live research problem again.
You will actually face this choice, so here it is as a procedure rather than an opinion. Work down the list and stop at the first row that matches your situation.
| if… | then… | because |
|---|---|---|
| you have fewer than ~50 labelled clips per class | MFCC statistics + SVM, or a pretrained embedding + linear probe | a from-scratch net cannot be fitted; hand priors or borrowed priors are all you have |
| the model must run on a microcontroller or an always-on core | classical features, small classifier | a few thousand operations per frame versus millions |
| you must explain each decision to a regulator or a clinician | classical features, and report which ones moved | “the centroid was 3.5 kHz and the flatness 0.8” is an explanation; a saliency map is a picture |
| you have thousands of clips and a GPU | fine-tune a pretrained audio model | this is where learned representations dominate outright |
| you do not yet know whether the task is learnable at all | classical baseline first, for one afternoon | it costs almost nothing and it detects broken labels and broken recordings |
| your classes differ in a property you can name (brightness, noisiness, tempo) | compute that property directly and check it first | if one hand feature separates the classes, ship it and stop |
The last row is the one people skip. If you can describe in words what makes your two classes different, there is often a five-line feature that captures it, and that feature will be more robust, more debuggable and a thousand times cheaper than anything you could train. The classical era’s real bequest is not MFCCs; it is the habit of listening to the data first and asking what actually differs.
| quantity | formula | meaning |
|---|---|---|
| frame count | 1 + ⌊(N − L)/h⌋ | how many frames a clip yields |
| frequency resolution | Δf = sr / L | set entirely by frame length |
| Hann window | wn = 0.5 − 0.5 cos(2πn/(L−1)) | tapers frame edges, kills leakage |
| short-time energy | E = ∑ xn2 | loudness; use 10 log10(E/L) in dB |
| zero-crossing rate | #sign changes / (L−1) | noisiness; remove DC first |
| spectral centroid | ∑fkmk / ∑mk | brightness (centre of mass) |
| spectral spread | √(∑mk(fk−c)2 / ∑mk) | bandwidth around the centroid |
| rolloff | smallest f with cumulative ≥ 0.85 ∑m | where the top of the spectrum is |
| flux | √∑(mk(t) − mk(t−1))2 | rate of change; rectify for onsets |
| flatness | geometric mean / arithmetic mean | 0 = tonal, 1 = white noise |
| mel scale | m = 2595 log10(1 + f/700) | perceptual frequency warp |
| DCT-II | ck = ∑sn cos(πk(n+0.5)/N) | decorrelates the log-mel vector |
| delta | ∑τ(ct+τ−ct−τ) / 2∑τ2 | smoothed time derivative |
| Gaussian | N(x) = exp(−(x−μ)2/2σ2) / √(2πσ2) | one blob in feature space |
| EM E-step | rnk = πkNk(xn) / ∑jπjNj(xn) | soft membership |
| EM M-step | μk = ∑rnkxn / ∑rnk | responsibility-weighted refit |
| SVM margin | 2 / ‖w‖ | width of the empty corridor |
| hinge loss | max(0, 1 − y f(x)) | zero beyond the margin, linear inside |
| RBF kernel | exp(−γ‖u−v‖2) | similarity that decays with distance |
| forward recursion | αt(j) = [∑iαt−1(i)aij] bj(ot) | P(observations so far, in state j) |
| Viterbi | same with max instead of sum | most likely state path |
| pooling | [meant(f), sdt(f)] | fixed-length clip vector; order-blind |
One last calibration before you leave. Everything in this lesson — twelve chapters, seven features, three classifiers, a sequence model — adds up to a system that reaches roughly 44% where humans reach 81% on general environmental sound. That is not a failure. It is a measurement of exactly how much of hearing is signal processing and how much is learned experience, taken by a generation of engineers who had no way to supply the second half. When you read a modern paper reporting 95%, you are reading what happened when the second half arrived.
| if you want… | go to |
|---|---|
| the front end in full detail — sampling, FFT, STFT, mel | Audio Representations |
| the cepstrum derived properly, with the source-filter algebra | EE269-13: Cepstrum & MFCC |
| the DFT itself, from first principles | EE269-06: The DFT |
| HMMs on their own terms — Baum-Welch, the dishonest casino | Hidden Markov Models |
| the same recursion in continuous state space | The Bayes Filter |
| the exact moment the GMM was replaced by a net, block by block | DNN-GMM-HMM Hybrids |
| the deep successor to this whole lesson, on the same task | Acoustic Scene Classification |
| the CNN that ate the benchmarks | PANNs · AST |
| learned features with no labels at all | Self-Supervised Speech · BEATs |
| classification with no fixed label set | CLAP |
| speech recognition after the HMM | Whisper |
“Far better an approximate answer to the right question, which is often vague, than an exact answer
to the wrong question, which can always be made precise.”
— John Tukey, who also gave us the FFT, the cepstrum’s name, and most of this lesson’s
vocabulary