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


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 →