Learn AI Series (#97) - Speaker Recognition and Diarization
Learn AI Series (#97) - Speaker Recognition and Diarization
What will I learn
- You will learn speaker verification: confirming "is this the person they claim to be?";
- speaker identification: determining "who is speaking?" from a set of known speakers;
- speaker embeddings: d-vectors and x-vectors that capture vocal identity in fixed-size vectors;
- speaker diarization: segmenting multi-speaker audio into "who spoke when?";
- voice activity detection (VAD): finding where speech occurs in audio;
- building a complete speaker verification system from scratch using cosine similarity on Mel spectrograms;
- combining ASR with diarization for speaker-attributed meeting transcripts;
- evaluation metrics: EER for verification and DER for diarization.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Python 3(.11+) distribution;
- The ambition to learn AI and machine learning.
Difficulty
- Beginner
Curriculum (of the Learn AI Series):
- Learn AI Series (#1) - What Machine Learning Actually Is
- Learn AI Series (#2) - Setting Up Your AI Workbench - Python and NumPy
- Learn AI Series (#3) - Your Data Is Just Numbers - How Machines See the World
- Learn AI Series (#4) - Your First Prediction - No Math, Just Intuition
- Learn AI Series (#5) - Patterns in Data - What "Learning" Actually Looks Like
- Learn AI Series (#6) - From Intuition to Math - Why We Need Formulas
- Learn AI Series (#7) - The Training Loop - See It Work Step by Step
- Learn AI Series (#8) - The Math You Actually Need (Part 1) - Linear Algebra
- Learn AI Series (#9) - The Math You Actually Need (Part 2) - Calculus and Probability
- Learn AI Series (#10) - Your First ML Model - Linear Regression From Scratch
- Learn AI Series (#11) - Making Linear Regression Real
- Learn AI Series (#12) - Classification - Logistic Regression From Scratch
- Learn AI Series (#13) - Evaluation - How to Know If Your Model Actually Works
- Learn AI Series (#14) - Data Preparation - The 80% Nobody Talks About
- Learn AI Series (#15) - Feature Engineering and Selection
- Learn AI Series (#16) - Scikit-Learn - The Standard Library of ML
- Learn AI Series (#17) - Decision Trees - How Machines Make Decisions
- Learn AI Series (#18) - Random Forests - Wisdom of Crowds
- Learn AI Series (#19) - Gradient Boosting - The Kaggle Champion
- Learn AI Series (#20) - Support Vector Machines - Drawing the Perfect Boundary
- Learn AI Series (#21) - Mini Project - Predicting Crypto Market Regimes
- Learn AI Series (#22) - K-Means Clustering - Finding Groups
- Learn AI Series (#23) - Advanced Clustering - Beyond K-Means
- Learn AI Series (#24) - Dimensionality Reduction - PCA
- Learn AI Series (#25) - Advanced Dimensionality Reduction - t-SNE and UMAP
- Learn AI Series (#26) - Anomaly Detection - Finding What Doesn't Belong
- Learn AI Series (#27) - Recommendation Systems - "Users Like You Also Liked..."
- Learn AI Series (#28) - Time Series Fundamentals - When Order Matters
- Learn AI Series (#29) - Time Series Forecasting - Predicting What Comes Next
- Learn AI Series (#30) - Natural Language Processing - Text as Data
- Learn AI Series (#31) - Word Embeddings - Meaning in Numbers
- Learn AI Series (#32) - Bayesian Methods - Thinking in Probabilities
- Learn AI Series (#33) - Ensemble Methods Deep Dive - Stacking and Blending
- Learn AI Series (#34) - ML Engineering - From Notebook to Production
- Learn AI Series (#35) - Data Ethics and Bias in ML
- Learn AI Series (#36) - Mini Project - Complete ML Pipeline
- Learn AI Series (#37) - The Perceptron - Where It All Started
- Learn AI Series (#38) - Neural Networks From Scratch - Forward Pass
- Learn AI Series (#39) - Neural Networks From Scratch - Backpropagation
- Learn AI Series (#40) - Training Neural Networks - Practical Challenges
- Learn AI Series (#41) - Optimization Algorithms - SGD, Momentum, Adam
- Learn AI Series (#42) - PyTorch Fundamentals - Tensors and Autograd
- Learn AI Series (#43) - PyTorch Data and Training
- Learn AI Series (#44) - PyTorch nn.Module - Building Real Networks
- Learn AI Series (#45) - Convolutional Neural Networks - Theory
- Learn AI Series (#46) - CNNs in Practice - Classic to Modern Architectures
- Learn AI Series (#47) - CNN Applications - Detection, Segmentation, Style Transfer
- Learn AI Series (#48) - Recurrent Neural Networks - Sequences
- Learn AI Series (#49) - LSTM and GRU - Solving the Memory Problem
- Learn AI Series (#50) - Sequence-to-Sequence Models
- Learn AI Series (#51) - Attention Mechanisms
- Learn AI Series (#52) - The Transformer Architecture (Part 1)
- Learn AI Series (#53) - The Transformer Architecture (Part 2)
- Learn AI Series (#54) - Vision Transformers
- Learn AI Series (#55) - Generative Adversarial Networks
- Learn AI Series (#56) - Mini Project - Building a Transformer From Scratch
- Learn AI Series (#57) - Language Modeling - Predicting the Next Word
- Learn AI Series (#58) - GPT Architecture - Decoder-Only Transformers
- Learn AI Series (#59) - BERT and Encoder Models
- Learn AI Series (#60) - Training Large Language Models
- Learn AI Series (#61) - Instruction Tuning and Alignment
- Learn AI Series (#62) - Prompt Engineering - Getting the Most from LLMs
- Learn AI Series (#63) - Embeddings and Vector Search
- Learn AI Series (#64) - Retrieval-Augmented Generation (RAG) - Basics
- Learn AI Series (#65) - RAG - Advanced Techniques
- Learn AI Series (#66) - Working with LLM APIs
- Learn AI Series (#67) - Building AI Agents (Part 1) - Foundations
- Learn AI Series (#68) - Building AI Agents (Part 2) - Advanced Patterns
- Learn AI Series (#69) - Fine-Tuning Language Models
- Learn AI Series (#70) - Running Local Models
- Learn AI Series (#71) - Text Generation Techniques
- Learn AI Series (#72) - Tokenization Deep Dive
- Learn AI Series (#73) - LLM Evaluation
- Learn AI Series (#74) - The Hugging Face Ecosystem
- Learn AI Series (#75) - Multimodal Models - Text Meets Vision
- Learn AI Series (#76) - Mini Project - Your Own AI Assistant
- Learn AI Series (#77) - Image Processing Fundamentals
- Learn AI Series (#78) - Object Detection (Part 1) - Foundations
- Learn AI Series (#79) - Object Detection (Part 2) - Modern Approaches
- Learn AI Series (#80) - Image Segmentation
- Learn AI Series (#81) - Pose Estimation and Tracking
- Learn AI Series (#82) - Optical Character Recognition
- Learn AI Series (#83) - Video Understanding
- Learn AI Series (#84) - Generative Images - Diffusion Models (Part 1)
- Learn AI Series (#85) - Generative Images - Diffusion Models (Part 2)
- Learn AI Series (#86) - Image-to-Image and Editing
- Learn AI Series (#87) - 3D Vision
- Learn AI Series (#88) - Face Analysis
- Learn AI Series (#89) - Medical and Scientific Imaging
- Learn AI Series (#90) - Self-Supervised Learning for Vision
- Learn AI Series (#91) - Mini Project - Building a Visual AI System
- Learn AI Series (#92) - Audio Fundamentals for AI
- Learn AI Series (#93) - Speech Recognition
- Learn AI Series (#94) - Text-to-Speech (TTS)
- Learn AI Series (#95) - Audio Classification
- Learn AI Series (#96) - Music Generation
- Learn AI Series (#97) - Speaker Recognition and Diarization (this post)
Learn AI Series (#97) - Speaker Recognition and Diarization
Solutions to Episode #96 Exercises
Exercise 1: Melody complexity analyzer -- categorize intervals (unison/step/skip/leap), compute pitch range, and measure contour complexity (direction changes per interval). Result: C major scale = 100% steps, zero contour changes. Twinkle Twinkle mixes steps and leaps with several direction changes. Random walk produces high contour complexity because each interval direction is essentially a coin flip.
Exercise 2: Chord progression generator with Markov transitions and consonance scoring. The V->I resolution appears as the strongest cadence -- the transition matrix gives V a 55% probability of resolving to I, reflecting how dominant-tonic resolution is the most fundamental harmonic motion. Major triads (I, IV, V) score higher consonance than the diminished triad (vii).
Exercise 3: Rhythmic pattern evaluator measuring IOI statistics, swing ratio, and tempo stability across metronome, swing, human drummer, and random patterns. The metronome has CV=0 (perfect regularity) and swing ratio=1.0. The swing pattern has ratio near 2.0. The human drummer has small but nonzero CV from Gaussian timing jitter. The random pattern has the highest CV and lowest tempo stability.
On to today's episode
Here we go! We've been deep in the audio AI domain over the past five episodes: how machines hear sound (#92), transcribing speech to text (#93), synthesizing speech from text (#94), classifying environmental sounds (#95), and generating music from scratch (#96). All of those dealt with understanding what's in the audio -- the words, the sounds, the melodies. Today we're shifting focus to understanding who is in the audio.
Speaker recognition is the audio equivalent of face recognition (episode #88). Just as FaceNet learns a compact embedding vector for each face, speaker recognition models learn embedding vectors for voices. Two audio clips from the same person produce similar vectors; clips from different people produce different vectors. The math is almost identical -- cosine similarity, angular margin losses, the whole apparatus we already built for faces. The difference is the input representation: spectrograms and Mel filterbanks instead of pixel arrays.
This is one of those topics where the practical applications are everywhere -- from "Hey Siri, is that actually me talking?" to meeting transcription systems that figure out which participant said what. The underlying techniques are elegant and connect back to nearly everything we've covered so far. Let's get into it ;-)
Speaker verification vs speaker identification
Before we write any code, let me clarify the two main tasks in speaker recognition. They sound similar but they're fundamentally different problems.
Speaker verification (SV) answers a yes/no question: "Is this the person they claim to be?" You have a stored voiceprint (an embedding vector from enrollment) and a new audio sample. Compute the similarity. If it's above a threshold -- accept. Below -- reject. This is a binary classification problem (same speaker or different speaker), and it's what banks use for phone authentication, what smart speakers use for personalization, and what forensic analysts use for voice matching.
Speaker identification (SI) answers a who question: "Which of these N known speakers is talking?" You have a database of enrolled speakers with their voiceprints. Given a new audio clip, find the closest match. This is an N-way classification problem, and it's what meeting transcription systems and surveillance systems use.
Both tasks rely on the same foundation: speaker embeddings -- fixed-dimensional vectors that capture the essential characteristics of a voice while being invariant to what's being said, background noise, and recording conditions. The quality of your embeddings determines the quality of everything downstream:
import numpy as np
class SpeakerTaskDemo:
"""Demonstrate the difference between
speaker verification (1:1) and speaker
identification (1:N)."""
def __init__(self):
self.rng = np.random.RandomState(42)
def simulate_embeddings(self,
n_speakers=5,
dim=192,
n_samples=3):
"""Create synthetic speaker
embeddings with realistic
intra/inter speaker distances."""
speakers = {}
for i in range(n_speakers):
center = self.rng.randn(dim)
center = center / np.linalg.norm(
center)
samples = []
for _ in range(n_samples):
noise = self.rng.randn(
dim) * 0.15
emb = center + noise
emb = emb / np.linalg.norm(
emb)
samples.append(emb)
speakers[f"spk_{i}"] = samples
return speakers
def cosine_sim(self, a, b):
return float(np.dot(a, b) / (
np.linalg.norm(a)
* np.linalg.norm(b)))
def verification(self, speakers):
"""1:1 -- is this the claimed
speaker?"""
print("=== Speaker Verification ===")
# Same speaker comparison
s0 = speakers["spk_0"]
same = self.cosine_sim(
s0[0], s0[1])
# Different speaker comparison
diff = self.cosine_sim(
s0[0],
speakers["spk_1"][0])
threshold = 0.7
print(f"Same speaker sim: "
f"{same:.3f} "
f"({'ACCEPT' if same > threshold else 'REJECT'})")
print(f"Diff speaker sim: "
f"{diff:.3f} "
f"({'ACCEPT' if diff > threshold else 'REJECT'})")
print(f"Threshold: {threshold}")
def identification(self, speakers):
"""1:N -- who is this?"""
print("\n=== Speaker Identification ===")
# Take a sample from spk_2
query = speakers["spk_2"][2]
# Compare against all enrolled
# speakers (using first sample
# as enrollment)
print(f"Query: spk_2 sample #3")
best_score = -1
best_match = None
for name, samples in (
speakers.items()):
sim = self.cosine_sim(
query, samples[0])
match = " <-- MATCH" if (
sim > 0.7
and sim > best_score) else ""
if sim > best_score:
best_score = sim
best_match = name
print(f" vs {name}: "
f"{sim:.3f}{match}")
print(f"Identified as: "
f"{best_match} "
f"(score: {best_score:.3f})")
def run(self):
speakers = (
self.simulate_embeddings())
self.verification(speakers)
self.identification(speakers)
# Show intra vs inter variance
print("\n=== Distance Analysis ===")
intra = []
inter = []
names = list(speakers.keys())
for n in names:
samps = speakers[n]
for i in range(len(samps)):
for j in range(
i+1, len(samps)):
intra.append(
self.cosine_sim(
samps[i],
samps[j]))
for i in range(len(names)):
for j in range(
i+1, len(names)):
inter.append(
self.cosine_sim(
speakers[names[i]][0],
speakers[names[j]][0]))
print(f"Intra-speaker sim: "
f"{np.mean(intra):.3f} "
f"(+/- {np.std(intra):.3f})")
print(f"Inter-speaker sim: "
f"{np.mean(inter):.3f} "
f"(+/- {np.std(inter):.3f})")
print(f"Separation gap: "
f"{np.mean(intra) - np.mean(inter):.3f}")
demo = SpeakerTaskDemo()
demo.run()
The key insight from the distance analysis: for speaker recognition to work, the intra-speaker similarity (same person, different utterances) must be significantly higher than the inter-speaker similarity (different people). This is the same separation criterion that drives all metric learning (episode #63 on embeddings). The wider the gap between intra and inter distances, the easier it is to set a reliable threshold for verification or identification.
Speaker embeddings: d-vectors and x-vectors
A speaker embedding model takes a variable-length audio segment and produces a fixed-size vector that captures vocal identity. Two segments from the same speaker should produce similar vectors; segments from different speakers should produce distant vectors. This is conceptually identical to how FaceNet (episode #88) maps face images to embedding vectors -- same problem, different modality.
D-vectors (Variani et al., 2014; Wan et al., 2018) use the simplest possible approach: process Mel spectrogram frames through an LSTM (episode #49), then average the frame-level hidden states to get a single utterance-level vector:
import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
class DVectorModel(nn.Module):
"""D-vector: LSTM-based speaker
embedding extractor."""
def __init__(self, n_mels=40,
hidden=256,
embed_dim=256,
n_layers=3):
super().__init__()
self.lstm = nn.LSTM(
n_mels, hidden, n_layers,
batch_first=True)
self.proj = nn.Linear(
hidden, embed_dim)
def forward(self, mel_frames):
"""mel_frames: (batch, time,
n_mels)"""
output, _ = self.lstm(mel_frames)
# Average pooling over time
embedding = output.mean(dim=1)
embedding = self.proj(embedding)
return F.normalize(
embedding, dim=1)
# Demo
model = DVectorModel()
# Simulate 2 utterances: 3 sec
# and 5 sec at ~100 frames/sec
utt_short = torch.randn(1, 300, 40)
utt_long = torch.randn(1, 500, 40)
emb_short = model(utt_short)
emb_long = model(utt_long)
print(f"Short utterance embedding: "
f"{emb_short.shape}")
print(f"Long utterance embedding: "
f"{emb_long.shape}")
print(f"Both produce {emb_short.shape[1]}"
f"-dim vectors regardless of "
f"input length")
total = sum(p.numel()
for p in model.parameters())
print(f"Parameters: {total:,}")
The averaging over time is what makes d-vectors length-invariant -- a 2-second clip and a 10-second clip both produce a 256-dimensional vector. Simple and effective, but the averaging loses temporal information. A speaker who emphasizes certain sounds differently at the beginning versus end of an utterance -- that nuance gets averaged away.
X-vectors (Snyder et al., 2018) replaced the LSTM with a TDNN (Time-Delay Neural Network) -- essentially 1D convolutions at different temporal strides -- followed by a statistics pooling layer that computes both the mean AND standard deviation across time:
class XVectorModel(nn.Module):
"""X-vector: TDNN + statistics
pooling for speaker embeddings."""
def __init__(self, n_mels=40,
embed_dim=512):
super().__init__()
# TDNN layers with increasing
# temporal context
self.tdnn1 = nn.Conv1d(
n_mels, 512, 5, padding=2)
self.bn1 = nn.BatchNorm1d(512)
self.tdnn2 = nn.Conv1d(
512, 512, 3,
dilation=2, padding=2)
self.bn2 = nn.BatchNorm1d(512)
self.tdnn3 = nn.Conv1d(
512, 512, 3,
dilation=3, padding=3)
self.bn3 = nn.BatchNorm1d(512)
self.tdnn4 = nn.Conv1d(
512, 512, 1)
self.bn4 = nn.BatchNorm1d(512)
self.tdnn5 = nn.Conv1d(
512, 1500, 1)
self.bn5 = nn.BatchNorm1d(1500)
# Stats pooling: mean + std
# = 1500 * 2 = 3000 dim
self.embed = nn.Linear(
3000, embed_dim)
def forward(self, mel):
"""mel: (batch, n_mels, time)"""
x = F.relu(self.bn1(
self.tdnn1(mel)))
x = F.relu(self.bn2(
self.tdnn2(x)))
x = F.relu(self.bn3(
self.tdnn3(x)))
x = F.relu(self.bn4(
self.tdnn4(x)))
x = F.relu(self.bn5(
self.tdnn5(x)))
# Statistics pooling
mean = x.mean(dim=2)
std = x.std(dim=2)
pooled = torch.cat(
[mean, std], dim=1)
embedding = self.embed(pooled)
return F.normalize(
embedding, dim=1)
model = XVectorModel()
mel = torch.randn(2, 40, 300)
emb = model(mel)
print(f"Input: {mel.shape}")
print(f"Output: {emb.shape}")
total = sum(p.numel()
for p in model.parameters())
print(f"Parameters: {total:,}")
print(f"\nThe statistics pooling "
f"captures BOTH the mean "
f"spectral profile")
print(f"AND the variability -- "
f"how much a speaker's voice "
f"fluctuates over time.")
The statistics pooling is the key innovation here. By computing the standard deviation across time in addition to the mean, the model captures not just what the voice sounds like on average but how much it varies. Some speakers have very consistent vocal characteristics across an utterance; others modulate pitch and energy dramatically. That variation pattern is itself an identity signal. None the less, the dilation factors in the TDNN layers (2, 3) give later layers progressively wider temporal context without increasing the number of parameters -- same idea as dilated convolutions in WaveNet (episode #94).
Modern speaker embeddings: ECAPA-TDNN
Modern speaker embedding models like ECAPA-TDNN (Desplanques et al., 2020) improve on x-vectors with three additions: channel attention (Squeeze-and-Excitation blocks that let the network focus on the most informative frequency channels), residual connections (so gradients flow through deep networks), and multi-layer feature aggregation (combining features from multiple TDNN layers before pooling, rather than using only the last layer):
class ChannelAttention(nn.Module):
"""Squeeze-and-Excitation block."""
def __init__(self, ch, r=8):
super().__init__()
self.pool = nn.AdaptiveAvgPool1d(1)
self.fc = nn.Sequential(
nn.Linear(ch, ch // r),
nn.ReLU(),
nn.Linear(ch // r, ch),
nn.Sigmoid())
def forward(self, x):
w = self.pool(x).squeeze(-1)
return x * self.fc(w).unsqueeze(-1)
class AttentiveStatsPool(nn.Module):
"""Attention-weighted stats pool."""
def __init__(self, ch):
super().__init__()
self.attn = nn.Sequential(
nn.Conv1d(ch, 128, 1),
nn.ReLU(),
nn.Conv1d(128, ch, 1),
nn.Softmax(dim=2))
def forward(self, x):
w = self.attn(x)
mean = (x * w).sum(dim=2)
var = ((x**2*w).sum(dim=2)
- mean**2)
std = torch.sqrt(
var.clamp(min=1e-9))
return torch.cat(
[mean, std], dim=1)
class SimpleECAPATDNN(nn.Module):
def __init__(self, n_mels=80,
embed_dim=192):
super().__init__()
self.layer1 = nn.Conv1d(
n_mels, 512, 5, padding=2)
self.bn1 = nn.BatchNorm1d(512)
self.se1 = ChannelAttention(512)
self.layer2 = nn.Conv1d(
512, 512, 3, dilation=2,
padding=2)
self.bn2 = nn.BatchNorm1d(512)
self.se2 = ChannelAttention(512)
self.layer3 = nn.Conv1d(
512, 512, 3, dilation=3,
padding=3)
self.bn3 = nn.BatchNorm1d(512)
self.se3 = ChannelAttention(512)
self.mfa = nn.Conv1d(
512*3, 1536, 1)
self.bn_mfa = nn.BatchNorm1d(1536)
self.asp = AttentiveStatsPool(1536)
self.embed = nn.Linear(
1536*2, embed_dim)
def forward(self, mel):
x1 = self.se1(F.relu(
self.bn1(self.layer1(mel))))
x2 = self.se2(F.relu(
self.bn2(self.layer2(x1))))
x3 = self.se3(F.relu(
self.bn3(self.layer3(x2))))
cat = F.relu(self.bn_mfa(
self.mfa(torch.cat(
[x1, x2, x3], dim=1))))
pooled = self.asp(cat)
return F.normalize(
self.embed(pooled), dim=1)
model = SimpleECAPATDNN()
mel = torch.randn(2, 80, 300)
emb = model(mel)
total = sum(p.numel()
for p in model.parameters())
print(f"Input: {mel.shape}")
print(f"Output: {emb.shape}")
print(f"Parameters: {total:,}")
The attentive statistics pooling learns which frames are most informative for speaker identity. Frames with clear speech get high attention; noisy or silent frames get low attention. This makes the model robust to variable-length inputs with noisy segments.
These models are trained with angular margin losses like AAM-Softmax -- the same ArcFace loss from episode #88 for face recognition. The loss pushes same-speaker embeddings closer on the unit hypersphere while enforcing angular margin between different speakers. Training datasets like VoxCeleb (7,000+ celebrities, 1M+ utterances from YouTube) provide the scale needed to generalize.
Voice Activity Detection: where is speech?
Before you can identify speakers, you need to know where speech actually occurs. Voice Activity Detection (VAD) classifies each audio frame as speech or non-speech. It sounds trivial -- and for clean studio audio, it practically is. But in real-world recordings with background music, air conditioning hum, keyboard typing, and other non-speech sounds, VAD becomes a serious preprocessing challenge:
import numpy as np
class SimpleVAD:
"""Energy-based Voice Activity
Detection with adaptive threshold."""
def __init__(self, sr=16000,
frame_ms=25,
hop_ms=10):
self.sr = sr
self.frame_len = int(
sr * frame_ms / 1000)
self.hop_len = int(
sr * hop_ms / 1000)
def compute_energy(self, audio):
"""Frame-level energy in dB."""
n_frames = (
(len(audio) - self.frame_len)
// self.hop_len + 1)
energy = np.zeros(n_frames)
for i in range(n_frames):
start = i * self.hop_len
frame = audio[
start:start+self.frame_len]
rms = np.sqrt(
np.mean(frame ** 2))
energy[i] = 20 * np.log10(
max(rms, 1e-10))
return energy
def detect(self, audio,
energy_thresh_db=-30,
min_speech_ms=200,
min_silence_ms=100):
"""Detect speech segments."""
energy = self.compute_energy(
audio)
speech = energy > energy_thresh_db
hop_ms = (self.hop_len / self.sr
* 1000)
min_speech = int(
min_speech_ms / hop_ms)
min_silence = int(
min_silence_ms / hop_ms)
# Fill short gaps
result = speech.copy()
in_gap = False
gap_start = 0
for i in range(len(result)):
if not result[i] and (
not in_gap):
gap_start = i
in_gap = True
elif result[i] and in_gap:
if i - gap_start < (
min_silence):
result[
gap_start:i] = True
in_gap = False
# Remove short segments
in_seg = False
seg_start = 0
for i in range(len(result)):
if result[i] and not in_seg:
seg_start = i
in_seg = True
elif not result[i] and in_seg:
if i - seg_start < (
min_speech):
result[
seg_start:i] = False
in_seg = False
return result, energy
def extract_segments(self, vad):
"""Convert frame-level VAD to
time segments."""
segments = []
in_seg = False
start = 0
for i in range(len(vad)):
if vad[i] and not in_seg:
start = i
in_seg = True
elif not vad[i] and in_seg:
t_start = (
start * self.hop_len
/ self.sr)
t_end = (
i * self.hop_len
/ self.sr)
segments.append(
(t_start, t_end))
in_seg = False
if in_seg:
t_start = (
start * self.hop_len
/ self.sr)
t_end = (
len(vad) * self.hop_len
/ self.sr)
segments.append(
(t_start, t_end))
return segments
def run(self):
rng = np.random.RandomState(42)
sr = self.sr
dur = 5.0
t = np.arange(int(sr * dur)) / sr
audio = rng.randn(len(t)) * 0.001
# Two speech segments
for start, end, f0 in [
(0.5, 1.8, 150),
(2.5, 4.0, 180)]:
s = slice(int(start*sr),
int(end*sr))
audio[s] += 0.3 * np.sin(
2*np.pi*f0*t[s])
audio[s] += 0.15 * np.sin(
2*np.pi*f0*2*t[s])
vad, energy = self.detect(audio)
segs = self.extract_segments(vad)
print(f"Audio: {dur}s")
print(f"Detected speech segments:")
for start, end in segs:
print(f" {start:.2f}s - "
f"{end:.2f}s")
print(f"Expected: ~0.5-1.8s "
f"and ~2.5-4.0s")
vad = SimpleVAD()
vad.run()
VAD might seem like a solved problem, but it's absolutly critical for downstream quality. Without it, silence and background noise get processed as speech, which wastes compute and (worse) contaminates speaker embeddings with non-speech features. Production systems like Silero VAD use small neural networks (just a few megabytes) trained on massive amounts of audio with precise speech/non-speech labels. They run in real-time on a CPU, processing hours of audio in seconds.
Building a speaker verification system from scratch
Let's put the pieces together and build a complete speaker verification system. We'll synthesize multi-speaker audio, extract simple spectral features, compute embeddings, and verify speakers using cosine similarity:
import numpy as np
class SpeakerVerificationSystem:
"""Complete speaker verification
pipeline from scratch."""
def __init__(self, sr=16000,
n_mels=40,
embed_dim=64):
self.sr = sr
self.n_mels = n_mels
self.embed_dim = embed_dim
self.rng = np.random.RandomState(
42)
def synthesize_speaker(self,
f0_range,
formants,
duration=2.0,
n_utterances=5):
"""Synthetic speech-like audio."""
utterances = []
for _ in range(n_utterances):
n = int(self.sr * duration)
t = np.arange(n) / self.sr
f0 = self.rng.uniform(
*f0_range)
signal = np.zeros(n)
for f, a in formants:
signal += a * np.sin(
2*np.pi*f*t)
for h in range(2, 6):
signal += (0.3/h) * np.sin(
2*np.pi*f0*h*t)
signal *= (0.5 + 0.5 * np.sin(
2*np.pi*4.5*t))
signal += self.rng.randn(
n) * 0.02
utterances.append(signal)
return utterances
def mel_spectrogram(self, audio):
"""Simplified Mel spectrogram."""
n_fft = 512
hop = 160
n_frames = (
len(audio) - n_fft) // hop
spec = np.zeros(
(self.n_mels, n_frames))
window = np.hanning(n_fft)
for i in range(n_frames):
start = i * hop
frame = audio[
start:start + n_fft]
fft_mag = np.abs(
np.fft.rfft(
frame * window))
# Simple Mel binning
n_bins = len(fft_mag)
mel_bins = np.linspace(
0, n_bins,
self.n_mels + 1,
dtype=int)
for m in range(self.n_mels):
lo = mel_bins[m]
hi = mel_bins[m + 1]
if hi > lo:
spec[m, i] = np.mean(
fft_mag[lo:hi])
return np.log(spec + 1e-9)
def extract_embedding(self, mel):
"""Simple embedding: mean + std
of mel features, projected."""
mean = np.mean(mel, axis=1)
std = np.std(mel, axis=1)
features = np.concatenate(
[mean, std])
# Random projection (in a real
# system this is a learned
# network)
proj = self.rng.randn(
self.embed_dim,
len(features)) * 0.1
emb = proj @ features
norm = np.linalg.norm(emb)
return emb / max(norm, 1e-9)
def cosine_sim(self, a, b):
return float(
np.dot(a, b)
/ (np.linalg.norm(a)
* np.linalg.norm(b)))
def run(self):
speaker_configs = {
'Alice': {'f0_range': (180, 220),
'formants': [(800,.4),
(1200,.3), (2500,.15)]},
'Bob': {'f0_range': (100, 130),
'formants': [(600,.5),
(1000,.25), (2200,.1)]},
'Carol': {'f0_range': (200, 240),
'formants': [(850,.35),
(1300,.28), (2700,.12)]},
'Dave': {'f0_range': (90, 115),
'formants': [(550,.45),
(950,.2), (2000,.1)]},
}
# Generate utterances
speakers = {}
for name, cfg in (
speaker_configs.items()):
utts = self.synthesize_speaker(
cfg['f0_range'],
cfg['formants'])
speakers[name] = utts
# Extract embeddings
embeddings = {}
for name, utts in (
speakers.items()):
embs = []
for utt in utts:
mel = self.mel_spectrogram(
utt)
emb = self.extract_embedding(
mel)
embs.append(emb)
embeddings[name] = embs
# Enrollment: use first 3
# utterances per speaker
enrolled = {}
for name, embs in (
embeddings.items()):
enrolled[name] = np.mean(
embs[:3], axis=0)
enrolled[name] /= (
np.linalg.norm(
enrolled[name]))
# Verification trials
print("=== Verification Trials ===")
print(f"{'Claim':>8} {'Actual':>8}"
f" {'Score':>7}"
f" {'Result':>8}")
print("-" * 36)
trials = [
('Alice', 'Alice', 3),
('Alice', 'Alice', 4),
('Bob', 'Bob', 3),
('Alice', 'Carol', 3),
('Bob', 'Dave', 4),
('Carol', 'Bob', 3),
]
threshold = 0.6
for claim, actual, idx in trials:
test_emb = embeddings[
actual][idx]
score = self.cosine_sim(
enrolled[claim], test_emb)
accept = score > threshold
correct = (
(accept and
claim == actual)
or (not accept and
claim != actual))
print(f"{claim:>8} {actual:>8}"
f" {score:>7.3f}"
f" {'ACCEPT' if accept else 'REJECT':>8}"
f" {'OK' if correct else 'ERR'}")
system = SpeakerVerificationSystem()
system.run()
In a real system, the "random projection" would be replaced by a trained neural network (d-vector LSTM or ECAPA-TDNN), but the pipeline structure is identical: synthesize/capture audio, extract Mel spectrogram, compute embedding, compare with cosine similarity. The threshold (0.6 in our demo) is the critical operating parameter -- too low and impostors get accepted (high false acceptance rate), too high and legitimate speakers get rejected (high false rejection rate).
Speaker diarization: who spoke when?
Diarization segments a multi-speaker recording into speaker-homogenous regions. The output is a timeline showing which speaker is talking at each moment, without necessarily knowing who the speakers are (they're labeled as Speaker 0, Speaker 1, etc.). Think meeting recordings, podcast transcriptions, or courtroom audio analysis.
The traditional diarization pipeline looks like this:
Audio -> VAD -> Segmentation -> Embeddings -> Clustering -> Speaker labels
Let's build one:
import numpy as np
class SpeakerDiarizer:
"""Pipeline-based speaker
diarization system."""
def __init__(self, sr=16000,
seg_dur=1.5,
seg_hop=0.75):
self.sr = sr
self.seg_dur = seg_dur
self.seg_hop = seg_hop
self.rng = np.random.RandomState(
42)
def generate_meeting(self):
"""Simulate a 20s meeting
with 3 speakers."""
dur = 20.0
n = int(self.sr * dur)
audio = self.rng.randn(n) * 0.005
profiles = {
0: (120, [500, 1000, 2200]),
1: (200, [800, 1200, 2600]),
2: (150, [650, 1100, 2400])}
timeline = [
(0, 0.5, 3.2), (1, 3.5, 6.0),
(0, 6.3, 8.5), (2, 8.8, 12.0),
(1, 12.3, 14.5),
(2, 14.8, 17.0),
(0, 17.2, 19.5)]
for spk, start, end in timeline:
f0, fmts = profiles[spk]
s = int(start * self.sr)
e = int(end * self.sr)
t = np.arange(e-s) / self.sr
sig = sum(0.2 * np.sin(
2*np.pi*f*t) for f in fmts)
for h in range(2, 5):
sig += (0.15/h) * np.sin(
2*np.pi*f0*h*t)
sig *= (0.5 + 0.5 * np.sin(
2*np.pi*3*t))
audio[s:e] += sig
return audio, timeline
def segment_audio(self, audio):
"""Split audio into overlapping
segments."""
seg_samples = int(
self.seg_dur * self.sr)
hop_samples = int(
self.seg_hop * self.sr)
segments = []
starts = []
i = 0
while i + seg_samples <= (
len(audio)):
seg = audio[
i:i + seg_samples]
# Skip near-silent segments
if np.sqrt(np.mean(
seg**2)) > 0.01:
segments.append(seg)
starts.append(
i / self.sr)
i += hop_samples
return segments, starts
def extract_features(self, segment):
"""Simple spectral features
as speaker embedding proxy."""
n_fft = 512
hop = 160
n_frames = (
len(segment) - n_fft) // hop
if n_frames < 1:
return np.zeros(80)
spec = np.zeros(
(40, n_frames))
w = np.hanning(n_fft)
for i in range(n_frames):
s = i * hop
fft = np.abs(np.fft.rfft(
segment[s:s+n_fft] * w))
bins = np.linspace(
0, len(fft), 41,
dtype=int)
for m in range(40):
if bins[m+1] > bins[m]:
spec[m, i] = np.mean(
fft[bins[m]:
bins[m+1]])
spec = np.log(spec + 1e-9)
mean = np.mean(spec, axis=1)
std = np.std(spec, axis=1)
return np.concatenate(
[mean, std])
def cluster_embeddings(self, embs,
n_clusters=3):
"""Simple K-means clustering."""
# Initialize with K-means++
centers = [embs[0].copy()]
for _ in range(1, n_clusters):
dists = np.array([
min(np.linalg.norm(
e - c)**2
for c in centers)
for e in embs])
probs = dists / dists.sum()
idx = self.rng.choice(
len(embs), p=probs)
centers.append(
embs[idx].copy())
centers = np.array(centers)
# Iterate
for _ in range(50):
# Assign
labels = np.array([
np.argmin([
np.linalg.norm(e - c)
for c in centers])
for e in embs])
# Update
new_centers = np.array([
embs[labels == k].mean(
axis=0)
if (labels == k).any()
else centers[k]
for k in range(
n_clusters)])
if np.allclose(
centers, new_centers):
break
centers = new_centers
return labels
def run(self):
audio, gt_timeline = (
self.generate_meeting())
print(f"Audio: {len(audio)/self.sr:.1f}s")
print(f"\nGround truth:")
for spk, s, e in gt_timeline:
print(f" Speaker {spk}: "
f"{s:.1f}s - {e:.1f}s")
# Pipeline
segments, starts = (
self.segment_audio(audio))
embs = np.array([
self.extract_features(seg)
for seg in segments])
labels = self.cluster_embeddings(
embs, n_clusters=3)
print(f"\nDiarization output "
f"({len(segments)} segments):")
prev_label = -1
for i, (start, label) in (
enumerate(
zip(starts, labels))):
end = start + self.seg_dur
if label != prev_label:
print(f" Speaker_{label}: "
f"{start:.1f}s - "
f"{end:.1f}s")
prev_label = label
# Simple DER at 100ms resolution
gt_l = np.zeros(200)
for spk, s, e in gt_timeline:
gt_l[int(s*10):int(e*10)] = (
spk + 1)
pr_l = np.zeros(200)
for start, label in zip(
starts, labels):
s = int(start * 10)
e = min(int((start +
self.seg_dur) * 10), 200)
pr_l[s:e] = label + 1
speech = (gt_l > 0).sum()
confusion = ((gt_l > 0)
& (pr_l > 0)
& (gt_l != pr_l)).sum()
missed = ((gt_l > 0)
& (pr_l == 0)).sum()
fa = ((gt_l == 0)
& (pr_l > 0)).sum()
der = ((confusion + missed + fa)
/ max(speech, 1))
print(f"\nDER: {der:.1%} "
f"(confusion={confusion}, "
f"missed={missed}, "
f"fa={fa})")
diarizer = SpeakerDiarizer()
diarizer.run()
The Diarization Error Rate (DER) is the standard evaluation metric. It has three components: missed speech (speech that was labeled as silence), false alarm (silence that was labeled as speech), and speaker confusion (speech that was correctly detected but assigned to the wrong speaker). State-of-the-art systems achieve 3-7% DER on benchmark datasets like AMI and CALLHOME.
Production diarization systems like pyannote.audio use end-to-end neural approaches that handle overlapping speech (two people talking simultaneously) -- a scenario where our clustering-based pipeline fails because a single time frame contains features from multiple speakers. The pyannote system uses a segmentation network trained to predict speaker activity per-frame for each possible speaker, followed by a neural clustering step.
Combining ASR with diarization
The ultimate goal for meeting transcription is answering: who said what when? This requires combining speech recognition (episode #93) with speaker diarization. The approach is straightforward: run ASR to get word-level timestamps, run diarization to get speaker-level timestamps, then match each word to its speaker based on time overlap:
import numpy as np
class ASRDiarizationMerger:
"""Merge ASR transcripts with
speaker diarization labels."""
def __init__(self):
self.rng = np.random.RandomState(
42)
def simulate_asr_output(self):
"""Simulated Whisper output."""
words = [
('Okay', .5, .8),
('let', .85, .95),
('me', .95, 1.05),
('start', 1.1, 1.4),
('the', 1.45, 1.55),
('meeting', 1.6, 2.0),
('Thanks', 3.5, 3.8),
('for', 3.85, 3.95),
('joining', 4.0, 4.3),
('everyone', 4.35, 4.8),
('I', 6.3, 6.4),
('have', 6.4, 6.6),
('the', 6.65, 6.75),
('updates', 6.8, 7.2),
('ready', 7.25, 7.5)]
return [{'word': w, 'start': s,
'end': e}
for w, s, e in words]
def simulate_diarization(self):
"""Simulated pyannote output."""
return [
('SPEAKER_00', 0.3, 2.2),
('SPEAKER_01', 3.3, 5.0),
('SPEAKER_00', 6.1, 7.8)]
def assign_speakers(self, words,
diar):
"""Map each word to its speaker
based on timestamp overlap."""
result = []
for w in words:
w_mid = (
w['start'] + w['end']
) / 2
speaker = "UNKNOWN"
for spk, s, e in diar:
if s <= w_mid <= e:
speaker = spk
break
result.append({
'speaker': speaker,
'word': w['word'],
'start': w['start']})
return result
def format_transcript(self, merged):
"""Group words by speaker."""
lines = []
cur_spk = None
words = []
for item in merged:
if item['speaker'] != cur_spk:
if words:
lines.append(
(cur_spk,
' '.join(words)))
cur_spk = item['speaker']
words = [item['word']]
else:
words.append(item['word'])
if words:
lines.append(
(cur_spk,
' '.join(words)))
return lines
def run(self):
words = (
self.simulate_asr_output())
diar = (
self.simulate_diarization())
print("=== Speaker-Attributed "
"Transcript ===\n")
merged = self.assign_speakers(
words, diar)
lines = self.format_transcript(
merged)
for spk, text in lines:
print(f"[{spk}]: {text}")
print(f"\n--- Stats ---")
print(f"Total words: {len(words)}")
spk_counts = {}
for item in merged:
spk = item['speaker']
spk_counts[spk] = (
spk_counts.get(spk, 0)
+ 1)
for spk, count in sorted(
spk_counts.items()):
print(f" {spk}: "
f"{count} words")
merger = ASRDiarizationMerger()
merger.run()
This is exactly how modern meeting transcription services work -- Whisper (or another ASR model from episode #93) provides the "what was said" with precise timestamps, and pyannote (or another diarizer) provides the "who was speaking." The merger assigns each word to the speaker who was active at that word's midpoint timestamp.
The main challenge is alignment: ASR and diarization models sometimes disagree about exact transition boundaries. A word might start during one speaker's turn and end during another's (during a rapid speaker change). Production systems handle this with more sophisticated overlap resolution, but the midpoint heuristic works surprisingly well in practice.
Evaluation metrics: EER and DER
Let's build proper evaluation metrics for both speaker verification and diarization:
import numpy as np
class SpeakerMetrics:
"""Compute EER for verification
and DER for diarization."""
def __init__(self):
self.rng = np.random.RandomState(
42)
def compute_eer(self, pos_scores,
neg_scores):
"""Equal Error Rate: threshold
where FAR == FRR."""
thresholds = np.linspace(
0, 1, 1000)
best_diff = float('inf')
eer = 0.0
eer_thresh = 0.0
for t in thresholds:
# False Rejection Rate
frr = np.mean(
np.array(pos_scores) < t)
# False Acceptance Rate
far = np.mean(
np.array(neg_scores) >= t)
diff = abs(far - frr)
if diff < best_diff:
best_diff = diff
eer = (far + frr) / 2
eer_thresh = t
return eer, eer_thresh
def run(self):
# Simulate verification scores
pos = self.rng.normal(
0.82, 0.08, 200)
neg = self.rng.normal(
0.35, 0.12, 200)
eer, thresh = self.compute_eer(
pos, neg)
print(f"=== Speaker Verification ===")
print(f"EER: {eer:.2%}")
print(f"Threshold: {thresh:.3f}")
print(f"Positive mean: "
f"{np.mean(pos):.3f}")
print(f"Negative mean: "
f"{np.mean(neg):.3f}")
# Compare systems
print(f"\n=== System Comparison ===")
systems = {
'Basic x-vector': (
self.rng.normal(.78,.1,200),
self.rng.normal(.4,.15,200)),
'ECAPA-TDNN': (
self.rng.normal(.88,.05,200),
self.rng.normal(.25,.08,200)),
'ECAPA + LM': (
self.rng.normal(.92,.04,200),
self.rng.normal(.2,.06,200))}
print(f"{'System':<16} {'EER':>8}")
print("-" * 26)
for name, (p, n) in (
systems.items()):
e, _ = self.compute_eer(p, n)
print(f"{name:<16} {e:>7.2%}")
metrics = SpeakerMetrics()
metrics.run()
EER (Equal Error Rate) is the threshold where the False Acceptance Rate (impostors accepted) equals the False Rejection Rate (genuine speakers rejected). Modern systems achieve <1% EER on VoxCeleb benchmarks, which means that at the operating threshold, fewer than 1 in 100 imposter attempts succeed AND fewer than 1 in 100 genuine speakers are locked out.
The system comparison shows how architectural improvements translate to EER gains: basic x-vectors might achieve 5-8% EER, ECAPA-TDNN pushes to 1-3%, and adding score normalization or language-model-based resegmentation (for diarization) pushes further below 1%.
Samengevat
- Speaker recognition maps voice segments to identity embeddings; verification asks "same person?" (1:1) while identification asks "which person?" (1:N) -- both rely on cosine similarity between embedding vectors;
- d-vectors (LSTM + average pooling) and x-vectors (TDNN + statistics pooling) are the two foundational embedding paradigms; ECAPA-TDNN adds channel attention, multi-layer feature aggregation, and attentive statistics pooling;
- training uses angular margin losses (AAM-Softmax / ArcFace) on large speaker datasets like VoxCeleb (7,000+ celebrities, 1M+ utterances) -- same loss family we saw for face recognition in episode #88;
- VAD (Voice Activity Detection) identifies speech vs non-speech frames using energy + zero-crossing analysis or small neural networks -- critical preprocessing that prevents noise from contaminating speaker embeddings;
- diarization segments multi-speaker audio into "who spoke when" using the pipeline: VAD -> segmentation -> embedding extraction -> clustering; DER (Diarization Error Rate) measures missed speech + false alarm + speaker confusion;
- combining Whisper (ASR from episode #93) with diarization produces speaker-attributed transcripts for meeting recordings -- map each word to its speaker using timestamp overlap;
- EER (Equal Error Rate) for verification is the threshold where false acceptance equals false rejection -- modern ECAPA-TDNN systems achieve <1% EER on standard benchmarks.
There's more to explore in the audio domain -- understanding natural language intent from spoken utterances, enhancing noisy audio recordings, and combining audio and visual streams into unified representations. The human voice carries a remarkable amount of information beyond just the words.
Exercises
Exercise 1: Build a speaker enrollment and verification simulator. Create a class SpeakerEnrollment that generates 6 synthetic speakers with distinct f0 ranges (80-250 Hz) and formant patterns, produces 5 enrollment + 3 test utterances each (1.5s, sr=16000), computes spectral embeddings (Mel spectrogram -> mean+std -> L2 normalize), averages enrollment embeddings per speaker, then runs all 108 verification trials (every test vs every enrolled speaker). Sweep thresholds 0.0-1.0 to find the EER. Print the EER, threshold, and a table of same-speaker vs different-speaker scores.
Exercise 2: Build a speaker change detector. Create a class SpeakerChangeDetector that generates 15s of audio with 4 segments (A: 0-3.5s, B: 3.5-7s, A: 7-10.5s, C: 10.5-15s). Use sliding windows (1.5s, hop 0.25s) to extract spectral features, compute cosine distance between consecutive windows, and detect peaks (local maxima above mean + 1.5*std). A change is "detected" if a peak falls within +/-0.5s of the true boundary. Print detected vs ground truth change points, precision, and recall.
Exercise 3: Build a multi-speaker overlap detector. Create a class OverlapDetector that generates 12s of audio: A alone (0-3s), A+B overlap (3-5s), B alone (5-8s), B+C overlap (8-10s), C alone (10-12s). Analyze energy in 4 frequency bands per frame, compute an overlap score (0-1) based on active band count and total energy. Compare predicted overlap frames to ground truth (3-5s and 8-10s), print precision, recall, F1, and average overlap score in overlap vs non-overlap regions.
Bedankt en tot de volgende keer!
Leave Learn AI Series (#97) - Speaker Recognition and Diarization to:
Read more #stem posts
Best Posts From scipio
We have not curated any of scipio's posts yet. But you can encourage our curation team to review posts by visiting them regularly and by referring other readers. Because we give priority to frequently read content.
More Posts From scipio
- Learn Rust Series (#12) - Smart Pointers: Box, Rc & RefCell
- Learn AI Series (#142) - AI Reasoning and Planning
- Learn Zig Series (#122) - Union-Find
- Learn Rust Series (#11) - Closures & the Iterator Trait
- Learn AI Series (#141) - Robotics and Embodied AI
- Learn Zig Series (#121) - Topological Sort
- Learn Rust Series (#10) - Lifetimes
- Learn AI Series (#140) - Scientific AI
- Learn Zig Series (#120) - Dijkstra and A*
- Learn Rust Series (#9) - Modules & Crates