Audio & Speech

Classical Audio Classification

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.

Prerequisites: a spectrogram is time × frequency × energy + arithmetic. If the spectrogram part is fuzzy, read Audio Representations first.
12
Chapters
12
Simulations
0
Assumed Knowledge

Chapter 0: It’s 2008 — Tell a Scream From a Siren

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.

The first idea everybody has (and why it dies)

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

x = [ 0.000,  0.707,  1.000,  0.707,  0.000,  −0.707,  −1.000,  −0.707 ]

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:

y = [ 1.000,  0.707,  0.000,  −0.707,  −1.000,  −0.707,  0.000,  0.707 ]

Worked by hand: the distance that lies

Euclidean distance between two vectors is the square root of the sum of squared differences. Subtract element by element:

x − y = [ −1.000,  0.000,  1.000,  1.414,  1.000,  0.000,  −1.000,  −1.414 ]

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.

Read that again. The same sound, delayed by a quarter of a cycle, is 2.828 away. Complete silence is 2.000 away. By raw-sample distance, our tone is more similar to nothing at all than it is to itself. The metric is not slightly wrong; it is inverted. And the shift that caused it was 0.125 milliseconds at 16 kHz — far below any timing you could control in a real recording.

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.

Three more nails in the coffin

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.

Why raw waveform distance is meaningless

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.

shift (samples)2.00
gain of copy1.00

A distance table you can verify yourself

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

‖x − y‖2 = ∑xn2 + ∑yn2 − 2∑xnyn

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

‖x − y‖2 = 4 + 4 − 8 cos φ = 8 (1 − cos φ)

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 φ)DistanceVerdict
00.0000.000identical vectors
145°8(1 − 0.707) = 2.3431.531still closer than silence
1.3360°8(1 − 0.500) = 4.0002.000tied with silence
290°8(1 − 0.000) = 8.0002.828worse than silence
3135°8(1 + 0.707) = 13.6573.696much worse
4180°8(1 + 1.000) = 16.0004.000maximally far (inverted)
2.000reference 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.

The tempting patch, and why it does not work. “Fine — store every shift of every template, and take the minimum distance.” That is cross-correlation, and it does remove the phase problem for a single stored waveform. But a scream is not one waveform: it is a family of waveforms differing in pitch, duration, room reverb, and vocal effort, and the family has effectively infinite members. You would need a template per member. Features are the alternative: instead of enumerating the family, describe the properties all members share.

Inline check: what would happen if…

…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.

What people actually built in this era

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.

So what does a feature have to be?

The failures tell us the specification. A usable audio feature must be:

RequirementWhy the waveform fails itHow we will fix it
Fixed lengthClips have different durationsChop into frames, then pool statistics over frames (Ch 1, Ch 9)
Shift invariantA 0.1 ms delay changes every numberUse magnitudes, not phase: energy, crossing counts, spectral magnitudes (Ch 2, Ch 3)
Gain robustDistance halves when the mic moves backNormalize energy, or standardize features across the dataset (Ch 5)
Low dimensional16,000 numbers from 300 clips~13 MFCCs per frame (Ch 4)
DiscriminativeLoudness dominates everythingDesign 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.

The pipeline we are going to build

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.

1. Frame
chop the signal into 25 ms windows, hop 10 ms
2. Features
per frame: ZCR, energy, centroid, rolloff, flux, 13 MFCCs
3. Pool
mean and standard deviation over all frames → one fixed vector
4. Classify
k-NN, GMM, or SVM on that vector
5. (Sequences)
HMM over frames when order matters — speech, events
Concept → realization. Trace the shapes, because they are the whole story. A 4-second clip at 16 kHz is (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.

The code we are heading towards

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.

Where we are going next. Stage 1 is framing, and it is not a boring bookkeeping step — it encodes a physical assumption about sound (that it holds still for a few tens of milliseconds) that the entire rest of the pipeline leans on. Get the frame length wrong and every feature downstream is measuring a blur.
Two recordings of the identical tone, offset by a quarter cycle, were 2.828 apart, while silence was only 2.000 away. What does this show?

Chapter 1: Framing — Chopping Sound Into Stillness

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.

Sound holds still, briefly

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).

The analogy that carries the rest of the lesson. Framing is filming. A movie is not continuous motion; it is 24 still photographs per second, each one short enough that nothing blurs. Frame length is your shutter speed: too long and fast events smear, too short and each photograph is too dark (too few samples) to see anything. Hop is your frame rate: how often you take a photograph.

The three numbers

Framing has exactly three parameters, and every audio toolkit exposes them under slightly different names:

ParameterTypical speech valueWhat it controls
Frame length (window, n_fft)25 ms = 400 samples at 16 kHzTime vs. frequency resolution — the shutter speed
Hop length (stride, shift)10 ms = 160 samplesHow many frames per second (here, 100) — the frame rate
Window functionHann (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.

Worked by hand: framing an 8-sample signal

Take an absurdly small signal so every index is visible. Eight samples:

x = [ x0, x1, x2, x3, x4, x5, x6, x7 ] = [ 0.1, 0.6, −0.4, −0.8, 0.2, 0.9, −0.1, −0.5 ]

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:

framei = x[ i·h : i·h + L ]    with L = 4, h = 2

Enumerate them:

istart = i·hslicesamples
00×2 = 0x[0:4][ 0.1, 0.6, −0.4, −0.8 ]
11×2 = 2x[2:6][ −0.4, −0.8, 0.2, 0.9 ]
22×2 = 4x[4:8][ 0.2, 0.9, −0.1, −0.5 ]
33×2 = 6x[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.

The frame-count formula, derived

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

nframes = 1 + ⌊ (N − L) / h ⌋

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:

1 + ⌊ (64000 − 400) / 160 ⌋ = 1 + ⌊ 63600 / 160 ⌋ = 1 + ⌊397.5⌋ = 1 + 397 = 398 frames

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.

Why overlap, really

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.

Misconception: “more overlap is always better, so hop by one sample.” Adjacent frames would then share 399 of 400 samples and produce nearly identical feature vectors. You would multiply compute and storage by 160× to obtain a feature stream that is almost perfectly redundant — and worse, downstream statistics (means, variances) would be computed over samples that are strongly correlated, making your effective sample size far smaller than the frame count suggests. The 50% convention is where the marginal information stops being worth the cost.

The window function, and the discontinuity it fixes

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:

wn = 0.5 − 0.5 · cos( 2πn / (L − 1) ) ,   n = 0 … L−1

Worked by hand: an 8-point Hann window

With L = 8, the argument is 2πn/7. Compute each cosine and each weight:

nangle 2πn/7cosw = 0.5 − 0.5 cos
01.00000.5 − 0.5000 = 0.0000
151.43°0.62350.5 − 0.3117 = 0.1883
2102.86°−0.22250.5 + 0.1113 = 0.6113
3154.29°−0.90100.5 + 0.4505 = 0.9505
4205.71°−0.90100.9505
5257.14°−0.22250.6113
6308.57°0.62350.1883
7360°1.00000.0000

Symmetric, zero at both ends, one at the centre. Now apply it to our 8-sample frame, multiplying element-wise:

x ⊙ w = [ 0.1×0,  0.6×0.1883,  −0.4×0.6113,  −0.8×0.9505,  0.2×0.9505,  0.9×0.6113,  −0.1×0.1883,  −0.5×0 ]
= [ 0.0000,  0.1130,  −0.2445,  −0.7604,  0.1901,  0.5501,  −0.0188,  0.0000 ]

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.

Concept → realization. Two zeros at the ends of an eight-point window look like a rounding error; at L = 400 the taper is gentle and the two exact zeros are negligible. But the shape matters for resolution: the Hann window trades a slightly wider main lobe (blurrier frequency peaks) for dramatically lower side lobes (far less leakage between distant frequencies). Hamming trades differently. This is why you will see “window=hann” in every config file and almost never see anyone change it: the choice is a solved trade-off for speech-like signals.

Frequency resolution falls out of the frame length

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

Δf = sr / L

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 lengthSamples @16 kHzΔfGood forFailure mode
5 ms80200 Hzvery fast transientscannot resolve pitch or formants at all
25 ms40040 Hzspeech, general purpose— the standard compromise
50 ms80020 Hzenvironmental sound, music timbreconsonants smear together
500 ms80002 Hzsteady drones onlyan entire syllable inside one “still” frame
Framing explorer

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.

frame length (ms)25
hop (% of frame)40

Code: three forms

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.

What you can now do. Given any clip and any (L, h), you can state the output shape, the frame rate, and the frequency resolution without running code. That is the whole of stage 1. Everything from here operates on a single frame at a time — so from now on, when we say “the signal,” we mean 400 windowed samples.
A 3-second clip at 16 kHz is framed with L = 400 and hop = 160. How many frames, and what is the frequency resolution?

Chapter 2: Time-Domain Features — Energy and Zero Crossings

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?

Feature 1: short-time energy

The short-time energy of a frame is just the sum of squared samples:

E = ∑n=0L−1 xn2

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:

RMS = √( E / L )

Worked by hand: energy of our 8-sample frame

Frame x = [ 0.1, 0.6, −0.4, −0.8, 0.2, 0.9, −0.1, −0.5 ]. Square each sample:

n01234567
xn0.10.6−0.4−0.80.20.9−0.1−0.5
xn20.010.360.160.640.040.810.010.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.

Why we take the log

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:

EdB = 10 · log10( E / L )

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.

Feature 2: zero-crossing rate

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.

ZCR = ( 1 / (L−1) ) · #{ n : sign(xn) ≠ sign(xn−1) }

You will also meet the equivalent textbook form using the absolute difference of signs, which counts each crossing as 2 and divides by 2L:

ZCRalt = ( 1 / (2L) ) · ∑n=1L−1 | sign(xn) − sign(xn−1) |

Worked by hand: ZCR of the same frame

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?noyesnoyesnoyesno

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.

Turning ZCR into hertz

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

3 / 0.0004375 s = 6857 crossings per second

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.

The misconception that has broken real systems: ZCR is not a robust pitch detector. Take our frame and add a DC offset of 0.9 — a constant added by a badly coupled microphone preamplifier, completely inaudible, since a constant pressure is not sound. The samples become [1.0, 1.5, 0.5, 0.1, 1.1, 1.8, 0.8, 0.4]: every one positive. The measured ZCR is now exactly zero. The sound did not change; the feature collapsed. Always remove the mean from each frame before computing ZCR — and treat ZCR as a noisiness indicator rather than a pitch estimate, because for any signal with more than one frequency component the crossing count follows the high-frequency content, not the fundamental.

What the two features mean together

Individually each is weak. Together they carve up the acoustic world surprisingly well, which is why the pair has survived since the 1970s:

Frame typeEnergyZCRExample
Silence / backgroundvery lowhigh and erraticroom tone — low-level noise crosses constantly
Voicedhighlowa vowel; the vocal folds impose a slow periodicity
Unvoiced / fricativemediumvery high“s”, “f”, cymbal, rain, static
Transient / impacthigh, briefmediumdoor 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.

Two more cheap time-domain numbers

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.

Live time-domain features

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.

tone → noise0.15
level0.70
DC offset0.00

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.

From one frame to a contour

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

r = [ 0.02,  0.03,  0.41,  0.38,  0.12 ]

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.

Code: three forms

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)
Concept → realization. Look at the + 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.
Where we stand. Two features, roughly one CPU cycle per sample, and we can already separate silence from sound and buzz from smoothness. What we cannot do is tell a violin from a flute, or a siren from a scream — both are periodic, both are loud, both have moderate ZCR. For that we need to look at which frequencies, not just how fast the signal wiggles. That is Chapter 3.
A frame of quiet room tone typically has:

Chapter 3: Spectral Features — Centroid, Rolloff, Flux

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.

The toy spectrum we will compute by hand

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 k01234
frequency fk (Hz)02000400060008000
magnitude mk1.03.04.01.50.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.

Feature 3: spectral centroid

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.

centroid = ∑k fk mk  /  ∑k mk

Work the numerator term by term, so nothing is hidden:

kfkmkfk · mk
001.00 × 1.0 = 0
120003.02000 × 3.0 = 6000
240004.04000 × 4.0 = 16000
360001.56000 × 1.5 = 9000
480000.58000 × 0.5 = 4000

Numerator = 0 + 6000 + 16000 + 9000 + 4000 = 35000. Divide by the total magnitude 10.0:

centroid = 35000 / 10.0 = 3500 Hz

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.

Feature 4: spectral spread (bandwidth)

Centroid is a mean, so the natural companion is a standard deviation: how far the energy is spread around the centre of mass.

spread = √( ∑k mk (fk − centroid)2  /  ∑k mk )

With centroid = 3500, the deviations are −3500, −1500, +500, +2500, +4500. Square them:

kfk − c(fk − c)2mk × that
0−350012,250,0001.0 × 12,250,000 = 12,250,000
1−15002,250,0003.0 × 2,250,000 = 6,750,000
2+500250,0004.0 × 250,000 = 1,000,000
3+25006,250,0001.5 × 6,250,000 = 9,375,000
4+450020,250,0000.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

spread ≈ 1987 Hz

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.

Feature 5: spectral rolloff

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:

kmkcumulative≥ 8.5?
01.01.0no
13.04.0no
24.08.0no — so close
31.59.5yes
40.510.0
rolloff85 = f3 = 6000 Hz

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.

Feature 6: spectral flux

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:

fluxt = √( ∑k ( mk(t) − mk(t−1) )2 )

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:

kmkm′kd = m′ − md2max(0, d)
01.01.00.00.000.0
13.02.0−1.01.000.0
24.03.0−1.01.000.0
31.53.0+1.52.251.5
40.52.0+1.52.251.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.

Why rectify? An onset is energy appearing, not energy disappearing. If you use plain flux, the decay after a piano note produces a flux peak just as large as the strike, and your onset detector fires twice per note. Keeping only positive differences makes the feature answer the question you actually asked. This is a perfect miniature of the whole classical-features mindset: the mathematics is trivial, and all the intelligence is in deciding what to measure.

Feature 7: spectral flatness

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.

flatness = ( ∏k mk )1/K  /  ( (1/K) ∑k mk )

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

flatness = 1.552 / 2.0 = 0.776

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.”

The bug this feature always causes. Any bin that is exactly zero drives the geometric mean to zero, so a frame with one empty bin reports flatness 0 — “perfectly tonal” — even if it is noise. Real implementations compute it as exp(mean(log(m + ε))) / mean(m), and the choice of ε visibly changes the feature. Similarly, the centroid divides by ∑m, which is near zero in silent frames: the result is a wild number (or NaN) that then poisons the clip-level mean. Always gate spectral features on an energy threshold, and record which frames you dropped.
Spectral feature meter

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.

brightness0.30
noisiness0.20

What each feature buys you

FeatureQuestion it answersSeparatesFooled by
centroidhow bright?hi-hat vs. bass drum, /s/ vs. /o/a noise floor that adds constant high energy
spreadhow wide?tone vs. broadband noisetwo distant peaks (spread says “wide”, ears say “two notes”)
rolloffwhere is the top?speech (low) vs. cymbal (high)quantization — jumps a whole bin at a time
fluxhow fast changing?steady drone vs. rapid speech, onsetsamplitude changes alone, unless you normalize each frame
flatnesstonal or noisy?violin vs. applause, voiced vs. fricativezero bins, and any spectral gating you applied earlier

Code: three forms

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
Where we stand. Seven numbers per frame: energy, ZCR, centroid, spread, rolloff, flux, flatness. They are cheap, interpretable, and genuinely discriminative — a random forest on just these, pooled over a clip, is a real baseline. But they are all global summaries of the spectrum. They cannot tell you that there is a peak at 700 Hz and another at 1200 Hz, which is precisely the pattern that distinguishes one vowel from another. For shape, not summary, we need the next chapter.
The spectrum [1.0, 3.0, 4.0, 1.5, 0.5] at bins 0–8 kHz had centroid 3500 Hz and rolloff85 6000 Hz. If we add a small constant 0.3 to every bin (a noise floor), what happens?

Chapter 4: MFCCs — The Feature Vector That Ran the World

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.

Why the spectrum has two things mixed in it

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:

S(f) = E(f) · H(f)

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 trick, in one line. log S(f) = log E(f) + log H(f). Multiplication became addition. And the two addends have completely different shapes along the frequency axis: log E wiggles rapidly (a spike every 120 Hz), log H undulates slowly (bumps hundreds of hertz wide). Two additive signals that differ in rate of variation can be separated by … a Fourier transform. So we take the transform of the log spectrum, and the fast wiggles and slow undulations land in different places.

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.”

The five stages

1. |FFT|2
windowed frame (400) → power spectrum (201)
2. Mel filterbank
201 bins → 40 band energies, triangular, perceptually spaced
3. log
product → sum; compresses dynamic range
4. DCT-II
40 correlated values → 40 decorrelated coefficients
5. keep 13
envelope kept, pitch comb discarded

Stage 2 recap: the mel scale

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:

m(f) = 2595 · log10( 1 + f / 700 )

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.

Worked by hand: log-mel to MFCC on four bands

Forty bands is too many for paper; use four. Suppose the mel filterbank outputs the band energies

E = [ 2.0,  8.0,  4.0,  1.0 ]

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

s = [ 0.693,  2.079,  1.386,  0.000 ]

Stage 4, the DCT-II. Its definition, for N values:

ck = ∑n=0N−1 sn · cos( π k (n + 0.5) / N )

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 = 01.00001.00001.00001.0000
k = 10.92390.3827−0.3827−0.9239
k = 20.7071−0.7071−0.70710.7071
k = 30.3827−0.92390.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:

c0 = 0.693 + 2.079 + 1.386 + 0.000 = 4.158

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.

c1 = 0.640 + 0.796 − 0.530 + 0.000 = 0.906

c2: every cosine is ±0.7071, so factor it out: 0.7071 × (0.693 − 2.079 − 1.386 + 0.000) = 0.7071 × (−2.772) =

c2 = −1.960

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.

c3 = 0.265 − 1.921 + 1.281 + 0.000 = −0.375

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.)

Reading the coefficients

Each ck is the amount of a particular cosine ripple present in the log-spectrum shape:

coefficientthe cosine it measuresmeaningour value
c0flatoverall log-loudness of the frame4.158 — a fairly loud frame
c1half a cycle: high on the left, low on the rightspectral tilt: positive = more energy low than high+0.906 — tilted towards low frequencies
c2one full cycle: edges up, middle downcurvature: negative = a bump in the middle−1.960 — strong mid bump (bands 2 and 3 dominate)
c3faster ripplefiner 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.

Why the DCT, really: a parameter-count argument

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.

Misconception: “more coefficients must be better.” Keep all 40 and you have merely rotated the log-mel vector — you have lost nothing and gained nothing, but you have re-admitted the pitch comb in the high coefficients. For speaker-independent speech recognition that is actively harmful: the model can now latch onto the speaker’s pitch, which is irrelevant to what was said and will not generalize to the next speaker. Truncating at 13 is not compression for its own sake; it is a deliberate invariance being enforced. And for tasks where pitch matters — music, bird species, emotion — that same truncation is throwing away the answer, which is one concrete reason MFCC baselines underperform on ESC-50.

Deltas: 13 becomes 39

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:

Δct = ∑τ=1M τ ( ct+τ − ct−τ )  /  2 ∑τ=1M τ2

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.

Cepstral mean normalization, and why it works

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.

t,k = ct,k − (1/T) ∑t ct,k

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.

Log-mel → DCT → MFCC, live

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.

formant position0.35
pitch (comb spacing)3.0
coefficients kept13

Code: three forms

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)
Where we stand. Every frame is now 39 numbers that describe the spectral envelope, its motion, and nothing about pitch or channel. That is the input every classical classifier in the next three chapters consumes. The features are finished; from here on, the question is what to do with a cloud of points.
Why does the MFCC pipeline apply a DCT after the log-mel stage?

Chapter 5: k-Nearest Neighbours — The Laziest Classifier That Works

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.

The setup, with real audio features

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:

clipZCRcentroid (kHz)label
A0.101.5speech
B0.142.0speech
C0.053.0music
D0.073.6music
Q0.092.8?

Worked by hand: raw distances

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)2sumdistance
A+0.010.0001−1.301.69001.69011.300
B−0.050.0025−0.800.64000.64250.802
C+0.040.0016+0.200.04000.04160.204
D+0.020.0004+0.800.64000.64040.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.

This is the most common silent bug in classical audio pipelines. Nobody notices, because the classifier still returns plausible labels — it is just quietly ignoring most of your carefully designed features. Any feature measured in hertz (centroid 0–8000, rolloff 0–8000) will bury any feature measured as a fraction (ZCR 0–1, flatness 0–1) unless you rescale. The fix is one line, and it changes answers.

Worked by hand: standardization (z-scoring)

Give every feature the same voice by converting it to “standard deviations away from the training mean”:

z = ( value − μ ) / σ

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:

pointz(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
Q0.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:

toraw distancerankstandardized distancerank
A (speech)1.30041.6073
B (speech)0.80231.7664
C (music)0.20411.2042
D (music)0.80021.1371

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.

Concept → realization: fit on train, apply to test. The mean and σ above must be computed from the training set only and then reused verbatim on validation and test data. If you standardize using statistics computed over the whole dataset, information about the test clips has leaked into training, and your reported accuracy is optimistic — sometimes by several points. In code this is the difference between 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.

Choosing k

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.

kBoundaryBehaviour with a mislabelled pointTypical use
1jagged, exactcreates an island of the wrong class around itclean, dense data
3–9moderately smoothoutvoted by its correct neighboursthe usual choice
> 30very smoothignored entirelynoisy 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.

The curse of dimensionality, measured

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:

dimensionsnearestmeanfarthest(far − near) / near
20.0030.3920.765267×
782.7443.6194.3750.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.

Interactive k-NN in feature space

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.

k3
standardizeon

Other distances you will meet

metricformulawhen it is the right choice
Euclidean√∑(a−b)2default, after standardizing
Cosine1 − 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.

Worked by hand: distance-weighted voting

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:

neighbourlabeldweight 1/d
Cmusic0.2041 / 0.204 = 4.902
Dmusic0.8001 / 0.800 = 1.250
Bspeech0.8021 / 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.

The cost, and why k-NN stayed at the clip level

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.

Code: three forms

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))
Where we stand. k-NN is a memory, not a model. It gives no likelihood, no notion of “this sound is unlike anything I have heard”, and it costs a full pass over the training set per query. What we would like instead is a compact description of what each class looks like in feature space — a probability density we can score against. That is the next chapter, and it is where the real workhorse of the era lives.
Why did z-scoring change which training clip was the nearest neighbour?

Chapter 6: Gaussian Mixtures & One EM Iteration By Hand

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.

Start with one Gaussian

In one dimension, a Gaussian says “values cluster around μ with a typical wander of σ”:

N(x | μ, σ2) = ( 1 / √(2πσ2) ) · exp( −(x − μ)2 / (2σ2) )

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.

A mixture of Gaussians

So use several, and weight them:

p(x) = ∑k=1K πk · N(x | μk, σk2) ,   with   ∑k πk = 1

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 EM loop

Initialize
guess μ, σ2, π (usually from k-means)
E-step
for every point, compute the responsibility rnk = P(component k generated xn)
M-step
refit each component using every point, weighted by its responsibility
↻ until the log-likelihood stops rising

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:

rnk = πk N(xn | μk, σk2)  /  ∑j πj N(xn | μj, σj2)

The M-step is the ordinary mean/variance formulas with each point counted rnk times instead of once:

Nk = ∑n rnk     μk = ( ∑n rnk xn ) / Nk     σk2 = ( ∑n rnk (xn − μk)2 ) / Nk     πk = Nk / N

Worked by hand: the complete first iteration

Four data points — think of them as a one-dimensional feature (say c1) from four frames:

x = [ 2,  3,  8,  9 ]

Initialize deliberately badly, so there is something to fix: μ1 = 4, μ2 = 7, σ12 = σ22 = 4, π1 = π2 = 0.5.

E-step

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

r1(2) = 0.60653 / 0.65047 = 0.9325    r2(2) = 0.04394 / 0.65047 = 0.0675

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.

r1(3) = 0.88250 / 1.01784 = 0.8670    r2(3) = 0.1330

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)2e−d/8(x−μ2)2e−d/8r1r2
240.60653250.043940.93250.0675
310.88250160.135340.86700.1330
8160.1353410.882500.13300.8670
9250.0439440.606530.06750.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.

M-step

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:

μ1 = ( 0.9325×2 + 0.8670×3 + 0.1330×8 + 0.0675×9 ) / 2.000

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:

μ1 = 3.069     and by symmetry     μ2 = 7.931

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:

xx − μ1(x − μ1)2r1product
2−1.0691.14230.93251.0651
3−0.0690.00470.86700.0041
84.93124.31670.13303.2333
95.93135.17910.06752.3764

Sum = 1.0651 + 0.0041 + 3.2333 + 2.3764 = 6.6789. Divide by N1 = 2.000:

σ12 = 3.339  ( σ1 = 1.827 ) ,   and σ22 = 3.339

New weights. π1 = 2.000 / 4 = 0.5, π2 = 0.5 — unchanged, because the data is perfectly balanced.

Did it actually improve?

EM promises the log-likelihood never decreases. Verify it. The mixture density at a point is π1N(x|μ112) + π2N(x|μ222).

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

log Lbefore = 2(−2.7355) + 2(−2.2876) = −10.046

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

log Lafter = 2(−2.3796) + 2(−2.1899) = −9.139

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.

Why it cannot go down. EM does not maximize the log-likelihood directly — it maximizes a lower bound built from the current responsibilities. The E-step chooses the bound so that it touches the true log-likelihood at the current parameters (the gap, a KL divergence, becomes zero). The M-step then moves to the top of that bound. Since you start on the true curve and climb a function that never exceeds it, you must end at or above where you started. That is the entire convergence proof in three sentences, and it also tells you the catch: you climb to a local maximum, and which one depends on the initialization.
EM, one step at a time

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.

init mean gap3.0
datasettoy 4

From one dimension to a real audio system

Everything above holds in D dimensions with the scalar variance replaced by a diagonal covariance vector. The bookkeeping for a speech system:

quantityvaluenote
feature dimension D3913 MFCC + delta + delta-delta
components K32–256 per class or statemore data allows more components
parameters per component39 means + 39 variances + 1 weight = 79diagonal covariance
parameters, K = 6464 × 79 = 5056vs 64 × (39 + 780 + 1) = 52,480 for full covariance
scoring a clipt 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.

The failure that will bite you: variance collapse. If one component drifts onto a single data point, its variance shrinks towards zero, the density at that point shoots towards infinity, and the log-likelihood goes to +∞. EM will happily march there — it is a genuine singularity of the objective, not a bug. Real implementations impose a variance floor (never let σ2 fall below, say, 0.001 times the global variance) and restart or prune components whose weight collapses. If your GMM training ever reports an absurdly good likelihood, this is what happened.

Code: two forms

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)]
What a GMM gives you that k-NN does not. A number. Not just “this is the closest class” but “this clip has log-likelihood −41.2 under dog and −58.7 under siren”, which you can threshold to say none of the above — open-set rejection, impossible with a bare nearest-neighbour vote. That is why GMMs, not k-NN, were the production choice.
In the E-step we computed r1(2) = 0.9325. What exactly is that number?

Chapter 7: Support Vector Machines & the Kernel Trick

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.

Many boundaries separate; one is widest

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.

Worked by hand: the two-point SVM

Reduce to the smallest problem with an answer. One positive point at (3, 3) and one negative at (1, 1). The classifier is

f(x) = w · x + b ,   predict +1 if f(x) > 0

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

w · (3,3) + b = +1     and     w · (1,1) + b = −1

By symmetry w must point along (1,1) — the direction from the negative point to the positive one — so write w = (a, a). Substitute:

3a + 3a + b = 6a + b = 1      1a + 1a + b = 2a + b = −1

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

w = (0.5, 0.5) ,   b = −2 ,   boundary: 0.5x1 + 0.5x2 − 2 = 0 , i.e. x1 + x2 = 4

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 margin, and why the objective is what it is

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

margin = 2 / ‖w‖

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:

minimize   ½ ‖w‖2   subject to   yi ( w · xi + b ) ≥ 1   for all i

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.

Soft margins: real data overlaps

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

ℓ(x, y) = max( 0,  1 − y · f(x) )
minimize   ½‖w‖2 + C ∑i max(0, 1 − yi f(xi))

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.

Why “support vector”. Points with zero hinge loss that sit beyond the margin have no influence at all: delete them and the solution is identical. Only the points on or inside the corridor — typically a small minority — determine w and b. Those are the support vectors. It is a striking property: your 2000-clip training set may be summarized by 180 clips, and the other 1820 were only there to prove they were not needed.

Non-linear boundaries without non-linear maths

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:

f(x) = ∑i ∈ SV αi yi ( xi · x ) + b

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.

Worked by hand: the kernel trick is not magic

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,

φ(x) = ( x12,   √2 · x1x2,   x22 )

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
x12199.0000
√2 x1x22.82844.242612.0000
x22414.0000
9.0000 + 12.0000 + 4.0000 = 25

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.

The RBF kernel, and its one knob

K(u, v) = exp( −γ ‖u − v‖2 )

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 vectorboundaryrisk
very small (0.001)enormous — every point influences everythingalmost linearunderfits
moderate (1/D, the default)comparable to typical inter-point distancesmooth curvesusually right
very large (100)tiny bubble around each support vectorislands around individual pointsmemorizes; test accuracy collapses
Misconception: “the kernel finds structure in the data.” It does not. A kernel is a similarity function you assert, and the SVM then finds the best boundary under your assertion. If you use an RBF kernel on unstandardized features, the ‖u − v‖2 inside it is dominated by whichever feature has the biggest units — the exact bug from Chapter 5, now hidden one level deeper where it is much harder to notice. Standardize before kernelizing, every time.
Margin and kernel playground

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.

kernelRBF
gamma0.80
C3.0
class overlap0.35

Fifty classes from a two-class machine

An SVM is binary. ESC-50 has fifty classes. Two standard reductions:

schemenumber of SVMseach trained onprediction
one-vs-restC = 50all data, one class vs. the other 49largest decision value
one-vs-oneC(C−1)/2 = 1225only the two classes involved — small and fastmajority 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.

Why SVMs sat at the clip level and GMMs at the frame level

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.

Code: two forms

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))
Three classifiers, one blind spot. k-NN, GMM and SVM all take a single pooled vector per clip. None of them knows that frame 40 came after frame 39. For a siren that sweeps up, a siren that sweeps down, and a siren played backwards, the pooled statistics are nearly identical. Chapter 8 is about the model that put time back in.
The degree-2 kernel gave K(u,v) = (u·v)2 = 25, and the explicit map gave 9 + 12 + 4 = 25. What does that demonstrate?

Chapter 8: Hidden Markov Models & the Forward Trellis

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.

The model, in three tables

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:

symbolnamemeaning
πiinitial distributionprobability the sequence starts in state i
aijtransition matrixprobability of moving from state i to state j at the next frame
bi(o)emission modelprobability 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.

Our worked example

Two hidden states, S (speech) and M (music). Two possible observations per frame: H (high ZCR) and L (low ZCR). The parameters:

π→ S→ Memits Hemits L
S0.60.70.30.80.2
M0.40.40.60.30.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:

O = ( H,  L,  L )

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 naive answer, and why it is impossible

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).

0.6 × 0.8 × 0.7 × 0.2 × 0.7 × 0.2 = 0.48 × 0.14 × 0.14 = 0.009408

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).

0.6 × 0.8 × 0.3 × 0.7 × 0.6 × 0.7 = 0.48 × 0.21 × 0.42 = 0.042336

All eight, for completeness:

pathprobabilitypathprobability
S S S0.009408M S S0.001344
S S M0.014112M S M0.002016
S M S0.008064M M S0.004032
S M M0.042336M M M0.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.

The forward algorithm: share the work

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

αt(i) = P( o1, …, ot  and  state at time t is i )

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:

α1(i) = πi · bi(o1)
αt(j) = [ ∑i αt−1(i) · aij ] · bj(ot)

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).

Worked by hand: every alpha

t = 1, observation H.

α1(S) = πS bS(H) = 0.6 × 0.8 = 0.480
α1(M) = πM bM(H) = 0.4 × 0.3 = 0.120

t = 2, observation L. Incoming to S: from S with 0.7, from M with 0.4.

α2(S) = ( 0.480×0.7 + 0.120×0.4 ) × bS(L) = ( 0.336 + 0.048 ) × 0.2 = 0.384 × 0.2 = 0.0768

Incoming to M: from S with 0.3, from M with 0.6.

α2(M) = ( 0.480×0.3 + 0.120×0.6 ) × bM(L) = ( 0.144 + 0.072 ) × 0.7 = 0.216 × 0.7 = 0.1512

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.

α3(S) = ( 0.0768×0.7 + 0.1512×0.4 ) × 0.2 = ( 0.05376 + 0.06048 ) × 0.2 = 0.11424 × 0.2 = 0.022848
α3(M) = ( 0.0768×0.3 + 0.1512×0.6 ) × 0.7 = ( 0.02304 + 0.09072 ) × 0.7 = 0.11376 × 0.7 = 0.079632
P(O) = 0.022848 + 0.079632 = 0.10248

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.

Concept → realization: this is dynamic programming, and you have met it before. α is a sufficient statistic for the past: once you know αt, the observations before t can be forgotten entirely. That is the same structural insight behind the Kalman filter (where the sufficient statistic is a mean and covariance) and behind the Bayes filter in general. Predict with the transition model, correct with the emission likelihood, repeat. If you have met one, you have met all three.

Decoding: which path, not just how likely

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:

δt(j) = [ maxi δt−1(i) · aij ] · bj(ot) ,   ψt(j) = argmaxi ( … )

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 forward trellis, step by step

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.

self-transition0.70
observationsH L L

How a real speech recognizer is wired

The 1990s architecture, in numbers, so the scale is concrete:

levelconstructiontypical count
phone3-state left-to-right HMM (begin / middle / end)~40 phones × 3 = 120 states
context-dependenttriphones: each phone conditioned on its neighbours403 = 64,000 possible, most unseen
tied states (senones)decision-tree clustering of triphone states~2000–10,000 shared states
emissionsone diagonal GMM per tied state, 16–32 componentsmillions of parameters
wordsHMMs chained per pronunciation dictionary60k-word vocabulary
language modeln-gram probabilities on the word transitionsmillions 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.

The two assumptions that eventually broke. (1) First-order Markov: the future depends on the past only through the current state. Real audio has long-range structure — the second half of a siren sweep is predictable from the first half, and no single state carries that. (2) Geometric duration: if a state self-transitions with probability p, the probability of staying exactly d frames is pd−1(1−p), whose most likely duration is always one frame. With p = 0.7 the mean duration is 1/(1−0.7) = 3.3 frames, but the mode is 1 — whereas real phone durations are unimodal bumps around 8–12 frames. The model assigns highest probability to the shortest possible phone, always. Hidden semi-Markov models patch this with explicit duration distributions; recurrent and attention-based models dispense with the assumption entirely.

Underflow: the practical detail that bites everyone

α 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.

Code: three forms

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
Where we stand. We now have features, three classifiers, and a sequence model. Chapter 9 assembles them into the pipeline that was actually submitted to challenges, and puts real accuracy numbers on it.
The forward algorithm computes P(O) = 0.10248 with a handful of multiply-adds, while enumerating paths needs NT terms. What makes the shortcut valid?

Chapter 9: The Pipeline & the Baseline Era

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.

The missing link: statistics pooling

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.

pooled = [ meant(f1), …, meant(f39),   sdt(f1), …, sdt(f39) ]   ∈ ℝ78

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.

Worked by hand: pooling four frames

Take the ZCR values of four consecutive frames:

z = [ 0.42,  0.30,  0.55,  0.33 ]

Mean. 0.42 + 0.30 = 0.72; + 0.55 = 1.27; + 0.33 = 1.60. Divide by 4:

mean = 1.60 / 4 = 0.400

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.

The complete shape trace

One 4-second clip, all the way through, with the transformation and the loss at each step:

stageshapenumberswhat is discarded here
waveform, 16 kHz(64000,)64,000— everything is still present
framed, 25 ms / 10 ms(398, 400)159,200nothing (data expands via overlap)
windowed & |FFT|2(398, 201)80,000phase — irrecoverably
mel filterbank(398, 40)15,920fine frequency detail, especially high up
log + DCT, keep 13(398, 13)5,174pitch and harmonic structure
+ deltas(398, 39)15,522nothing (adds derived motion)
mean/sd pooling(78,)78time 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.

The blindness that pooling creates, stated precisely. Mean and standard deviation are permutation invariant: shuffle the frames of a clip into any order and the pooled vector is bit-for-bit identical. Therefore a siren rising and the same siren falling are the same input. A door opening and a door closing are the same input. “Ba” and “ab” are the same input. This is the “bag of frames” assumption, and it is not a subtle approximation — it is a wall. HMMs (Chapter 8) were the classical way around it; convolutional and recurrent nets were the modern way. The simulation below lets you shuffle the frames and watch the pooled vector not move.
Statistics pooling, and what it cannot see

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.

soundsiren up
frames32

Evaluating honestly

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.

Worked by hand: why accuracy lies on unbalanced data

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 →ABCtotal
A726280
B113115
C4105

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:

( 0.900 + 0.200 + 0.000 ) / 3 = 1.100 / 3 = 36.7%

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 variants worth knowing

poolingdimensions from 39capturescost
mean only39typical valueblind to variability
mean + sd78typical value and spreadthe standard
+ min, max156extremes — a single loud event in a quiet clipvery outlier-sensitive
percentiles (10/50/90)117extremes, robustlyusually better than min/max
+ mean/sd of Δ156how fast features change on averagepartial order information — a genuine improvement
histogram per dimension39 × binsthe whole marginal distributionlarge; 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.

The numbers, as reported

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:

systemESC-50ESC-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.

Where music sat. Genre classification on GTZAN with timbral, rhythmic and pitch features plus a statistical classifier reached about 61% on ten genres in 2002, against roughly 70% for human listeners on the same short excerpts. Unlike environmental sound, the classical gap in music was small — because the hand-designed features (centroid, rolloff, flux) genuinely capture timbre, which is genuinely what distinguishes genres. Where features match the task, the classical pipeline is competitive; where they do not, it collapses. That is the pattern the whole era makes.

Code: the entire system, end to end

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.

Why does ESC-50 ship five predefined folds instead of letting you shuffle randomly?

Chapter 10: The 2008 Machine (showcase)

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.

What you are looking at

panelshowscorresponds to
top stripthe waveform with the current 25 ms frame highlighted, sliding left to rightChapter 1 — framing
main plotfeature 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 meanChapters 2–3 (features), 5–7 (classifier), 9 (pooling)
bottom barsthe score for each class and the current verdictChapter 6 — likelihood scoring
The complete classical pipeline, live

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.

soundsiren
noise level0.10
classifierGaussian
training clips / class18

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.

Five experiments to run right now

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.

Concept → realization: trace one clip, once. Press Step and follow a single frame all the way through. 400 samples enter (top strip highlight). Their sign changes are counted → one ZCR number. Their magnitude spectrum’s centre of mass → one centroid number. Those two numbers place one small dot in the plot. The running mean of all such dots so far is the big dot. The classifier evaluates one function at the big dot’s position and emits five scores. That is the entire information path from air pressure to label, and it fits in a paragraph — which is precisely why this pipeline could be implemented, debugged and reasoned about by one engineer with a laptop.

Worked by hand: what the score bars are computing

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

q = ( ZCR = 0.30,   centroid = 3.4 kHz )

and two of the class models are

classμ(ZCR)σ(ZCR)μ(centroid)σ(centroid)
speech0.160.052.00.6
rain0.350.064.20.9

With a diagonal covariance the log-likelihood is a sum over dimensions:

log p(q) = ∑d [ −½ log( 2πσd2 ) − (qd − μd)2 / (2σd2) ]

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.

log pspeech(q) = −1.843 − 3.130 = −4.974

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.

log prain(q) = 1.547 − 1.209 = +0.339

Rain wins by 0.339 − (−4.974) = 5.312 nats. With equal class priors, that is a posterior odds ratio of e5.312203 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.

Read those two contributions again. Under the speech model, dimension 1 alone contributed −1.84 and dimension 2 contributed −3.13 — but the raw distances were 0.14 and 1.4, a factor of ten apart. It is the division by σ that made them comparable. This is the Chapter 5 standardization lesson reappearing inside the classifier: a Gaussian with per-dimension variances is doing the z-scoring for you, which is exactly why a GMM is less sensitive to feature scaling than k-NN is. The scaling problem never disappears; it only moves to whichever component is willing to handle it.

How the playground is implemented

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.

What a real submission added on top

The playground uses two features and one classifier for legibility. A competitive 2010 DCASE entry differed in scale, not in kind:

this playgrounda real submissionwhy
2 features60–200 pooled statistics13 MFCC + deltas + spectral shape + rhythm features, mean/sd/percentiles
one Gaussian per class32–256-component GMM, or an RBF SVMclasses are multi-modal (Chapter 6’s opening argument)
fixed hyperparametersgrid search over C, γ, K under cross-validationevery one of them changes accuracy by several points
synthetic clipsreal recordings, official foldssource-level leakage otherwise (Chapter 9)
one modelfusion of 3–8 models by score averagingthe 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.

Ablations: what each stage is actually worth

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.

changetypical effectwhy
forget to standardize before an RBF SVMlarge lossthe kernel distance is dominated by whichever feature has the biggest units (Ch 5, Ch 7)
random split instead of source-grouped foldslarge spurious gainthe model recognises the recording, not the class
drop sd pooling, keep mean onlyseveral points lostvariability is genuinely discriminative
add delta and delta-delta statisticsseveral points gainedthe only temporal information that survives pooling
tune C and γ by grid searchseveral points gaineddefaults are rarely near-optimal
13 → 20 MFCCs on non-speechsmall gainlets some pitch information back in, which helps outside speech
32 → 128 GMM components with 40 clips/classlossnot enough data per component; variances collapse

A debugging checklist

When a classical audio classifier underperforms, the cause is almost always in this list, and almost never in the classifier:

symptomfirst suspecthow to check
NaNs in the feature matrixlog(0) on a silent frame, or divide-by-∑m in silencenp.isnan(X).any(axis=0) — find the offending column
train accuracy 100%, test at chanceleakage or memorization (C or γ far too large)check fold construction first, hyperparameters second
one feature dominates the modelno standardizationprint the per-feature std of X
works in the lab, fails in the fieldchannel/gain mismatchapply CMN; test on a recording from a different device
accuracy high, target class never detectedclass imbalancereport per-class recall, not accuracy
results change every runGMM local optima, unseeded splitsfix the seed, raise n_init, report a mean over folds
In Experiment 1, the per-frame dots swept upward across the plot while the pooled dot sat still in the middle. What does this demonstrate?

Chapter 11: The Plateau & the Handoff

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.

Reason 1: the representation cannot learn

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.

Reason 2: MFCCs were designed for speech, then used everywhere

Every discard in the MFCC pipeline is correct for speech recognition:

discardedright for speech because…wrong for general audio because…
phasethe ear is largely phase-deaf for steady soundsfine timing/transient structure distinguishes impacts
pitch (high quefrency)a word means the same at any pitchpitch is the label for birds, alarms, engines, music
fine high-frequency detailspeech energy lives below 4 kHzbird calls, keys, glass, insects live above it
temporal order (pooling)the HMM put it backclip-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.

Reason 3: no local time-frequency patterns

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.

Two sounds the hand features cannot tell apart

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.

patternrising
filter orientationup

Reason 4: it could not absorb more data

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.

What the handoff kept, and what it dropped

stageclassicalmodernverdict
framing25 ms / 10 ms Hann25 ms / 10 ms Hannkept verbatim — Whisper uses exactly this
spectrum|FFT|2|FFT|2kept
mel filterbank40 bands64–128 bandskept, widened — less aggressive compression
loglog energylog-melkept
DCT / truncation13 MFCCsdropped — nets do not need decorrelated inputs
deltas+Δ, +ΔΔdropped — a conv kernel over time computes them if useful
poolingmean/sd statisticslearned (attention, GeM, or a final conv)replaced
classifierGMM / SVMsoftmax headreplaced
sequence modelHMM + ViterbiCTC, attention encoder-decoderreplaced

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.

The handoff, year by year

yeareventwhat it changed
1980Davis & Mermelstein publish MFCCsthe feature vector is settled for thirty years
1989Rabiner’s HMM tutorialthe sequence model is settled; GMM-HMM becomes the standard
2002Tzanetakis & Cook, GTZANthe same recipe crosses into music
2012deep networks replace the GMM emission modelthe first block falls; the HMM stays for years
2013DCASE launches with an MFCC-GMM baselineenvironmental sound gets a shared ruler
2015ESC-50 published, with hand-feature baselines and a CNNthe ~44% vs ~64% gap is measured in a single paper
2016CTC and attention models drop the HMM entirelythe sequence model falls
2017AudioSet: ~2M labelled clipsscale becomes available; the classical stack cannot use it
2020–21PANNs, then AST, on ESC-50the 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.

The honest counterargument

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.

What is still classical in production today

wherewhat surviveswhy it wins there
voice activity detection on a phoneenergy + ZCR thresholdsmust run continuously on microwatts
microcontroller keyword spottingMFCC front end (then a tiny net)13 numbers per frame fits in the RAM budget
forced alignmentGMM-HMM (Kaldi-lineage aligners)robust, fast, needs no GPU, and the HMM is the alignment
speaker verification, low-resourceGMM-UBM with MAP adaptationcalibrated likelihood ratios from minutes of enrolment audio
any new task, day oneMFCC statistics + SVMa 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.

Choosing among the three classifiers, for good

k-NNGMMSVM
typenon-parametric memorygenerative, parametricdiscriminative, sparse
training costzerolinear in points per EM passquadratic to cubic in points
inference costO(N·D) — grows with the datasetO(K·D)O(#SV · D)
gives a likelihoodnoyes — enables rejection of unknown soundsno (needs Platt scaling for probabilities)
data hungerneeds dense coverage — suffers most from dimensionalitymodest; two numbers per dimension per componentmodest; boundary-focused
scaling sensitivityextremelow (variances absorb scale)extreme with an RBF kernel
where it was usedquick baselines, small datasetsframe-level acoustic models, speaker IDclip-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.

Classical or learned? A decision procedure

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 classMFCC statistics + SVM, or a pretrained embedding + linear probea 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 coreclassical features, small classifiera few thousand operations per frame versus millions
you must explain each decision to a regulator or a clinicianclassical 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 GPUfine-tune a pretrained audio modelthis is where learned representations dominate outright
you do not yet know whether the task is learnable at allclassical baseline first, for one afternoonit 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 firstif 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.

Cheat sheet

quantityformulameaning
frame count1 + ⌊(N − L)/h⌋how many frames a clip yields
frequency resolutionΔf = sr / Lset entirely by frame length
Hann windowwn = 0.5 − 0.5 cos(2πn/(L−1))tapers frame edges, kills leakage
short-time energyE = ∑ xn2loudness; use 10 log10(E/L) in dB
zero-crossing rate#sign changes / (L−1)noisiness; remove DC first
spectral centroid∑fkmk / ∑mkbrightness (centre of mass)
spectral spread√(∑mk(fk−c)2 / ∑mk)bandwidth around the centroid
rolloffsmallest f with cumulative ≥ 0.85 ∑mwhere the top of the spectrum is
flux√∑(mk(t) − mk(t−1))2rate of change; rectify for onsets
flatnessgeometric mean / arithmetic mean0 = tonal, 1 = white noise
mel scalem = 2595 log10(1 + f/700)perceptual frequency warp
DCT-IIck = ∑sn cos(πk(n+0.5)/N)decorrelates the log-mel vector
delta∑τ(ct+τ−ct−τ) / 2∑τ2smoothed time derivative
GaussianN(x) = exp(−(x−μ)2/2σ2) / √(2πσ2)one blob in feature space
EM E-steprnk = πkNk(xn) / ∑jπjNj(xn)soft membership
EM M-stepμk = ∑rnkxn / ∑rnkresponsibility-weighted refit
SVM margin2 / ‖w‖width of the empty corridor
hinge lossmax(0, 1 − y f(x))zero beyond the margin, linear inside
RBF kernelexp(−γ‖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)
Viterbisame with max instead of summost 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.

Where to go next

if you want…go to
the front end in full detail — sampling, FFT, STFT, melAudio Representations
the cepstrum derived properly, with the source-filter algebraEE269-13: Cepstrum & MFCC
the DFT itself, from first principlesEE269-06: The DFT
HMMs on their own terms — Baum-Welch, the dishonest casinoHidden Markov Models
the same recursion in continuous state spaceThe Bayes Filter
the exact moment the GMM was replaced by a net, block by blockDNN-GMM-HMM Hybrids
the deep successor to this whole lesson, on the same taskAcoustic Scene Classification
the CNN that ate the benchmarksPANNs · AST
learned features with no labels at allSelf-Supervised Speech · BEATs
classification with no fixed label setCLAP
speech recognition after the HMMWhisper
The one sentence to carry away. The classical pipeline is a chain of deliberate, defensible acts of forgetting — phase, pitch, fine structure, order — each of which bought tractability on the hardware of its day. Deep learning did not invent better features; it removed the need to decide in advance what to forget.

“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

Which parts of the classical front end survived unchanged into modern audio models?