torchaudio developer on retainer: MFCC(sample_rate=16000) applied to mixed 44100 Hz/22050 Hz/8000 Hz recordings without Resample — 44100 Hz files produce 2.75× more frames at wrong temporal scale — CNN temporal patterns compressed — keyword spotting val_accuracy 0.68 vs expected 0.90 — 3 weeks — torchaudio developer on monthly retainer
torchaudio 2.0.x • Python 3.11 • PyTorch 2.1.x • keyword spotting on 15K recordings (10 keywords, 1-second clips) • mixed sample rates: 72% at 16000 Hz, 18% at 44100 Hz, 7% at 22050 Hz, 3% at 8000 Hz • MFCC(sample_rate=16000, n_mfcc=40, hop_length=512) applied to ALL files without checking sample_rate from torchaudio.load() • 44100 Hz recordings produce 86 frames/sec instead of 31 • temporal scale 2.75× compressed relative to 16 kHz training baseline • CNN trained on 32ms/frame patterns receives 11.6ms/frame features for 44100 Hz files • val_accuracy 0.68 (expected 0.90+) • developer checks MFCC order, delta features, CNN depth for 3 weeks • fix: Resample(orig_freq=sample_rate, new_freq=16000) before MFCC • val_accuracy 0.68→0.89 • 1.5h retainer engagement
Eastfield TorchAudio Speech Ltd. was building a keyword spotting system on a 15,000-recording dataset covering 10 wake-words, with 1-second audio clips sourced from a mix of studio recordings (16 kHz) and user-submitted web uploads (44100 Hz, 22050 Hz, and telephony at 8 kHz). The developer used torchaudio.transforms.MFCC(sample_rate=16000, n_mfcc=40, hop_length=512) as a fixed transform and applied it to all recordings without checking the sample_rate returned by torchaudio.load(). After 30 epochs of CNN training, val_accuracy plateaued at 0.68 when the literature reported 0.90–0.95 for MFCC-based CNNs on similar keyword spotting datasets. After three weeks of investigating MFCC order (13 vs 40 coefficients), delta and delta-delta features, and increasing CNN depth, the team engaged a torchaudio retainer developer. The 44100 Hz recordings were being processed by the 16 kHz MFCC transform without first resampling, producing MFCC tensors with 2.75× more frames per second at a compressed temporal scale that the CNN’s learned filters did not match.
The audio loading and preprocessing pipeline at the time of the engagement:
# Eastfield TorchAudio Speech Ltd. — keyword spotting on 15K recordings
# torchaudio 2.0.x, Python 3.11, PyTorch 2.1.x
import torch
import torch.nn as nn
import torchaudio
import torchaudio.transforms as T
from torch.utils.data import Dataset, DataLoader
# ← Problem: MFCC constructed with fixed sample_rate=16000.
# The sample_rate parameter controls two independent aspects of MFCC:
#
# (A) Mel filterbank frequency range:
# The 40 mel filters span 0 Hz to sample_rate/2 = 8000 Hz.
# Applied to a 44100 Hz waveform, the filterbank still spans 0-8000 Hz,
# but the 44100 Hz waveform contains content up to 22050 Hz. The energy
# in 8000-22050 Hz is ignored (this causes some quality degradation but
# is not the primary bug here for speech/keyword content below 8 kHz).
#
# (B) STFT temporal resolution:
# MFCC uses an STFT with hop_length=512 samples between frames.
# For sample_rate=16000 Hz: 512/16000 = 32ms per MFCC frame.
# For the ACTUAL audio at 44100 Hz: 512/44100 = 11.6ms per MFCC frame.
# The MFCC transform does NOT use the sample_rate parameter to adjust
# hop_length — it uses the raw sample count from the waveform tensor.
#
# Result for a 1-second clip:
# 16000 Hz recording → 16000 samples → ceil(16000/512) = 32 MFCC frames
# 44100 Hz recording → 44100 samples → ceil(44100/512) = 86 MFCC frames
# 22050 Hz recording → 22050 samples → ceil(22050/512) = 44 MFCC frames
# 8000 Hz recording → 8000 samples → ceil(8000/512) = 16 MFCC frames
#
# The 1D CNN expects 32-frame MFCC inputs (from 16kHz training data).
# Developer truncates all MFCC tensors to 32 frames to fix shape mismatch.
# But the 44100 Hz MFCC's 32 frames cover only 32 × 11.6ms = 371ms —
# less than 40% of the 1-second clip AND at 2.75× compressed time scale.
# Phoneme transitions (stop burst: 20-50ms, vowel: 60-200ms, fricative: 60-150ms)
# appear as shorter events in the 11.6ms/frame representation than the
# CNN's 32ms/frame filters expect. A "yes" keyword taking 400ms of speech
# spans 400/32ms = 12.5 frames at 16kHz but only 400/11.6ms = 34.5 frames
# at 44100Hz — but truncation cuts the 44100 Hz representation to 32 frames,
# showing only the first 371ms of the 400ms keyword and at wrong time scale.
#
# Dataset composition:
# - 10,800 recordings at 16000 Hz (studio, correctly handled)
# - 2,700 recordings at 44100 Hz (user-submitted from DAW exports)
# - 1,050 recordings at 22050 Hz (legacy microphone app recordings)
# - 450 recordings at 8000 Hz (telephony, narrowband)
# The 28% non-16kHz recordings corrupt the CNN's temporal pattern learning.
# Fixed MFCC transform (wrong: not accounting for per-file sample rates)
mfcc_transform = T.MFCC(
sample_rate=16000, # ← assumed fixed; ignores actual audio sample rate
n_mfcc=40,
melkwargs={'n_fft': 1024, 'hop_length': 512, 'n_mels': 64},
)
class KeywordDataset(Dataset):
def __init__(self, audio_paths, labels, max_frames=32):
self.audio_paths = audio_paths
self.labels = labels
self.max_frames = max_frames
def __getitem__(self, idx):
# torchaudio.load returns (waveform, sample_rate)
# waveform shape: (num_channels, num_samples)
waveform, sample_rate = torchaudio.load(self.audio_paths[idx])
# ← Missing: Resample to 16000 Hz if sample_rate != 16000
# sample_rate is returned here but never used for normalization.
# Mono if stereo
if waveform.shape[0] > 1:
waveform = waveform.mean(dim=0, keepdim=True)
# Apply MFCC (always with sample_rate=16000, ignoring actual rate)
mfcc = mfcc_transform(waveform) # shape: (1, 40, num_frames)
# num_frames varies: 32 for 16kHz, 86 for 44100Hz, 44 for 22050Hz, 16 for 8kHz
# Truncate or pad to fixed 32 frames to avoid collation shape errors
if mfcc.shape[2] > self.max_frames:
mfcc = mfcc[:, :, :self.max_frames] # truncates 44100Hz to 371ms
elif mfcc.shape[2] < self.max_frames:
pad = self.max_frames - mfcc.shape[2]
mfcc = torch.nn.functional.pad(mfcc, (0, pad)) # pads 8kHz with zeros
return mfcc.squeeze(0), self.labels[idx] # (40, 32), label
def __len__(self):
return len(self.audio_paths)
# 1D CNN for keyword spotting (operates on 40-MFCC × 32-frame inputs)
class KeywordCNN(nn.Module):
def __init__(self, n_mfcc=40, n_frames=32, n_classes=10):
super().__init__()
self.conv = nn.Sequential(
nn.Conv1d(n_mfcc, 128, kernel_size=3, padding=1),
nn.BatchNorm1d(128), nn.ReLU(),
nn.Conv1d(128, 128, kernel_size=3, padding=1),
nn.BatchNorm1d(128), nn.ReLU(),
nn.Conv1d(128, 256, kernel_size=3, padding=1),
nn.BatchNorm1d(256), nn.ReLU(),
nn.AdaptiveAvgPool1d(1),
)
self.head = nn.Linear(256, n_classes)
def forward(self, x): # x: (B, 40, 32)
return self.head(self.conv(x).squeeze(-1))
# Training: val_accuracy stuck at 0.68 after 30 epochs.
# 72% of correctly-processed 16kHz clips learn the temporal patterns.
# 28% of non-16kHz clips provide conflicting or truncated temporal signals.
# The CNN's conv1d filters learn a compromise that works for neither.
Why MFCC temporal scale depends on the actual audio sample rate, not the sample_rate parameter
The sample_rate parameter in torchaudio.transforms.MFCC(sample_rate=16000) is used by the transform for two purposes: constructing the mel filterbank (determining the maximum frequency covered) and optionally for normalization. It is NOT used to resample the input waveform to that sample rate. When the transform receives a 44100 Hz waveform tensor with 44100 samples, it applies the STFT with hop_length=512 raw samples — producing a spectrogram with 44100/512 ≈ 86 columns (time frames) regardless of the declared sample_rate=16000. The resulting MFCC tensor has 86 frames representing 1 second of audio, with each frame covering 512/44100 = 11.6 ms of real time.
When the CNN was trained primarily on 16 kHz data (72% of the dataset), its convolutional filters learned temporal patterns at 32 ms/frame resolution: a typical stop consonant burst covering 3 frames (90 ms), a vowel nucleus covering 5–6 frames (160–190 ms), and a fricative spanning 4 frames (128 ms). When a 44100 Hz MFCC arrives at the same CNN and is truncated to 32 frames, those 32 frames cover only 371 ms (instead of 1 second), and each frame represents 11.6 ms. The stop burst that the CNN expects to see over 3 frames appears over 8 frames; the vowel nucleus appears over 14 frames. The CNN’s learned filters, matched to 32ms temporal patterns, do not activate correctly on 11.6ms-resolution features.
# Diagnostic: check MFCC frame counts across sample rates
# torchaudio 2.0.x, Python 3.11
import torchaudio
import torchaudio.transforms as T
import torch
from collections import Counter
mfcc_transform = T.MFCC(
sample_rate=16000,
n_mfcc=40,
melkwargs={'n_fft': 1024, 'hop_length': 512, 'n_mels': 64},
)
# Step 1: scan dataset to find all distinct sample rates
sample_rate_counts = Counter()
for path in audio_paths:
_, sr = torchaudio.load(path)
sample_rate_counts[sr] += 1
print("Sample rate distribution:")
for sr, count in sorted(sample_rate_counts.items()):
print(f" {sr:6d} Hz: {count:5d} files ({100*count/len(audio_paths):.1f}%)")
# Output:
# 8000 Hz: 450 files ( 3.0%)
# 16000 Hz: 10800 files ( 72.0%)
# 22050 Hz: 1050 files ( 7.0%)
# 44100 Hz: 2700 files ( 18.0%)
# ← 28% of files have non-16kHz sample rates
# Step 2: check MFCC frame counts for each sample rate
print("\nMFCC frame counts (without Resample, hop_length=512):")
for sr in [8000, 16000, 22050, 44100]:
# Simulate 1 second of audio at each sample rate
waveform = torch.zeros(1, sr) # 1-second silence
mfcc = mfcc_transform(waveform)
ms_per_frame = (512 / sr) * 1000
print(f" {sr:6d} Hz: {mfcc.shape[2]:3d} frames | {ms_per_frame:.1f} ms/frame")
# Output:
# 8000 Hz: 16 frames | 64.0 ms/frame
# 16000 Hz: 32 frames | 32.0 ms/frame ← correct (trained on this)
# 22050 Hz: 44 frames | 23.2 ms/frame
# 44100 Hz: 86 frames | 11.6 ms/frame ← 2.75× compressed temporal scale
# Step 3: verify temporal scale mismatch impact
# A 'yes' keyword: "y" stop (20ms) + /ɛ/ vowel (120ms) + /s/ fricative (80ms)
# Total: 220ms of active speech in a 1-second clip
#
# At 16kHz (32ms/frame): stop=1 frame, vowel=4 frames, fricative=3 frames
# At 44100Hz (11.6ms/frame): stop=2 frames, vowel=10 frames, fricative=7 frames
# Same keyword, same recording time, but 2.75× more frames at wrong resolution.
# After truncation to 32 frames (371ms), the 44100Hz clip is cut mid-fricative.
print("\nKeyword 'yes' temporal representation:")
print(" 16kHz (correct): stop=1fr, vowel=4fr, fricative=3fr — covers full keyword")
print(" 44100Hz (wrong): stop=2fr, vowel=10fr, fric=7fr — truncated to 32fr=371ms")
Fix: Resample(orig_freq=sample_rate, new_freq=16000) before MFCC
The fix requires resampling every audio file to the target sample rate before applying the MFCC transform. torchaudio.transforms.Resample(orig_freq=sample_rate, new_freq=16000) takes the actual sample rate returned by torchaudio.load() and resamples the waveform to 16000 Hz. After resampling, all files produce MFCC tensors with the same 32-frame temporal resolution, and the CNN’s learned temporal filters receive consistent feature representations regardless of the original recording’s sample rate. Val_accuracy recovers from 0.68 to 0.89.
# Fix: Resample to target_sample_rate before MFCC
# torchaudio 2.0.x, Python 3.11, PyTorch 2.1.x
import torch
import torch.nn as nn
import torchaudio
import torchaudio.transforms as T
from torch.utils.data import Dataset
TARGET_SR = 16000 # all audio normalized to this sample rate
mfcc_transform = T.MFCC(
sample_rate=TARGET_SR, # now matches all resampled waveforms
n_mfcc=40,
melkwargs={'n_fft': 1024, 'hop_length': 512, 'n_mels': 64},
)
class KeywordDatasetFixed(Dataset):
def __init__(self, audio_paths, labels, max_frames=32):
self.audio_paths = audio_paths
self.labels = labels
self.max_frames = max_frames
def __getitem__(self, idx):
waveform, sample_rate = torchaudio.load(self.audio_paths[idx])
# waveform shape: (num_channels, num_samples)
# Mono if stereo
if waveform.shape[0] > 1:
waveform = waveform.mean(dim=0, keepdim=True)
# ← FIX: resample to TARGET_SR before MFCC if sample_rate differs
if sample_rate != TARGET_SR:
resample = T.Resample(orig_freq=sample_rate, new_freq=TARGET_SR)
waveform = resample(waveform)
# After Resample: waveform has TARGET_SR * clip_duration samples
# For 1-second clip: 16000 samples regardless of original sample_rate.
# For 44100 Hz input: 44100 → 16000 samples (downsampled with anti-aliasing)
# For 8000 Hz input: 8000 → 16000 samples (upsampled, bandwidth-limited)
# Apply MFCC — now always receives 16000 Hz waveform
mfcc = mfcc_transform(waveform) # shape: (1, 40, 32) for all 1-second clips
# Truncate or pad to fixed 32 frames (now all files produce exactly 32)
if mfcc.shape[2] > self.max_frames:
mfcc = mfcc[:, :, :self.max_frames]
elif mfcc.shape[2] < self.max_frames:
pad = self.max_frames - mfcc.shape[2]
mfcc = torch.nn.functional.pad(mfcc, (0, pad))
return mfcc.squeeze(0), self.labels[idx] # (40, 32), label
def __len__(self):
return len(self.audio_paths)
# Optional optimization: cache resampled waveforms to avoid on-the-fly resampling
# For a dataset with 2700 files at 44100Hz, pre-resampling saves 2700 × 0.5ms = 1.4s
# per epoch. Acceptable for 15K files; consider caching for larger datasets.
# Option: pre-process all files to .wav at 16kHz once and store on disk
def preprocess_to_16k(input_paths, output_dir):
import os
for path in input_paths:
waveform, sr = torchaudio.load(path)
if waveform.shape[0] > 1:
waveform = waveform.mean(dim=0, keepdim=True)
if sr != TARGET_SR:
waveform = T.Resample(sr, TARGET_SR)(waveform)
out_path = os.path.join(output_dir, os.path.basename(path))
torchaudio.save(out_path, waveform, TARGET_SR)
# Training results after fix:
# epoch 5: val_accuracy = 0.76
# epoch 10: val_accuracy = 0.83
# epoch 20: val_accuracy = 0.87
# epoch 30: val_accuracy = 0.89
# ← val_accuracy 0.68→0.89 after Resample added before MFCC
# All files now produce 32-frame MFCC tensors at 32ms/frame resolution.
# CNN temporal patterns trained on 16kHz correctly match all resampled features.
# ---- Hour breakdown ----
# Triage: 0.25h — val_accuracy 0.68 plateau; computed MFCC tensor shapes
# per file; found 86 frames for 44100Hz, 44 for 22050Hz, 16 for 8kHz
# vs expected 32 frames; sample rate distribution: 28% non-16kHz
# Root cause: 0.5h — MFCC(sample_rate=16000) applied without prior Resample;
# 44100 Hz produces 11.6ms/frame vs expected 32ms/frame; temporal
# scale mismatch; truncation cuts 44100Hz clips to 371ms
# Fix: 0.5h — Resample(orig_freq=sample_rate, new_freq=16000) before MFCC
# Validation: 0.25h — val_accuracy 0.68→0.89 after 30 epochs; 1.5h total
Related retainer posts
- librosa developer on retainer —
fmax=8000inmelspectrogram()cuts discriminative high-frequency content for 22050 Hz audio — glass breaking / bird call features absent — 3 weeks - SpeechBrain developer on retainer —
Resample(8000→16000)upsampling telephone audio does not create frequency content above 4 kHz — ECAPA-VDNN attention pooling on silent band — EER 0.18 — 3 weeks - timm developer on retainer —
Normalize(mean=[0.5,0.5,0.5])GAN-tutorial stats applied over ImageNet pretrained ViT-B/16 — doubly-normalized patch inputs — val_AUC 0.72 — 3 weeks
Track retainer hours without a client portal
HourTab turns your time-tracker CSV into a public URL your client bookmarks. No login. No portal. CSV in, URL out.
See pricing →