Silero VAD developer on retainer: get_speech_timestamps returns 16 kHz sample positions — multiplying by 3 to convert to 48 kHz ignores Sinc resampler fractional group delay — segment boundaries off by 1–3 frames (1.3–4.0 ms) — 15% word-initial phoneme loss — overall WER 0.21 (acceptable) masks the per-word-onset degradation — 3 weeks — Silero VAD developer on monthly retainer
silero-vad 4.0.x • Python 3.11 • PyTorch 2.1.x • real-time customer service call transcription pipeline • 48 kHz audio • torchaudio.transforms.Resample(48000, 16000) Sinc filter, lowpass_filter_width=6 (default) • get_speech_timestamps(audio, model, threshold=0.5, sampling_rate=16000) • timestamps in 16 kHz sample units • segment_start_sample = timestamp['start'] * 3 (integer ×3) • Sinc filter group delay = lowpass_filter_width × (48000/16000) = 18 samples at 48 kHz = 0.375 ms • actual boundary error 1–3 audio frames (1.3–4.0 ms) • 15% of utterances lose word-initial consonant burst • word-initial phoneme substitution/deletion rate 3× baseline • overall WER 0.21 (masks per-onset degradation) • fix: segment_start_sample = int(timestamp['start'] * (48000/16000)) + resampler_delay_samples • word-initial phoneme loss 15%→2% • WER 0.21→0.17 • 1.5h retainer engagement
Eastfield Silero VAD Ltd. was building a real-time customer service call transcription pipeline. Incoming call audio was recorded at 48 kHz (standard telephony sample rate for VoIP). The developer applied torchaudio.transforms.Resample(48000, 16000) before Silero VAD, since Silero operates at 16 kHz. After calling model, utils = torch.hub.load('snakers4/silero-vad', 'silero_vad'), the developer used get_speech_timestamps(audio_16k, model, threshold=0.5, sampling_rate=16000, min_speech_duration_ms=250, min_silence_duration_ms=100) to detect speech segments. The returned timestamps were in 16 kHz sample units. To extract the corresponding audio segments from the original 48 kHz recording, the developer converted timestamps back to 48 kHz by multiplying by 3: segment_start_sample = timestamp['start'] * 3. The pipeline then passed extracted 48 kHz segments to a Whisper ASR model. Overall WER on a 500-call evaluation set was 0.21 — acceptable for the customer service application. However, downstream NLP F1 (intent classification on call transcripts) was 0.63 against an expected 0.80+. After three weeks of adjusting Silero VAD threshold (0.3–0.7), min_speech_duration_ms, and evaluating alternative ASR models, the team engaged a Silero retainer developer. The root cause was that the integer ×3 timestamp scaling did not account for the fractional group delay introduced by the Sinc resampling filter, causing all extracted audio segments to start 1.3–4.0 ms late and clip the word-initial consonant of the first word in each utterance.
The Silero VAD pipeline configuration at the time of the engagement:
# Eastfield Silero VAD Ltd. — real-time call transcription pipeline
# silero-vad 4.0.x, Python 3.11, PyTorch 2.1.x
import torch
import torchaudio
# Load Silero VAD model
model, utils = torch.hub.load('snakers4/silero-vad', 'silero_vad')
get_speech_timestamps, _, read_audio, _, _ = utils
# ---- Audio loading ----
# Input: 48 kHz call recording
audio_48k, sr = torchaudio.load("call_recording.wav")
# audio_48k: (1, N_samples_48k) at 48000 Hz
# ---- Resample to 16 kHz for Silero ----
resampler = torchaudio.transforms.Resample(
orig_freq=48000,
new_freq=16000,
resampling_method='sinc_interpolation',
lowpass_filter_width=6, # default value
# lowpass_filter_width controls the Sinc filter half-width in samples (at input rate).
# Group delay of this filter = lowpass_filter_width × (orig_freq / new_freq)
# = 6 × (48000 / 16000)
# = 6 × 3
# = 18 samples at the 48 kHz input rate
# = 18 / 48000 = 0.375 ms
# (This is the theoretical group delay; in practice, anti-aliasing and filter
# windowing add additional delay, pushing total offset to 1–3 audio frames.)
)
audio_16k = resampler(audio_48k)
# audio_16k: (1, N_samples_16k) at 16000 Hz
# ---- Run Silero VAD ----
timestamps = get_speech_timestamps(
audio_16k.squeeze(0),
model,
threshold=0.5,
sampling_rate=16000,
min_speech_duration_ms=250,
min_silence_duration_ms=100,
)
# timestamps: list of {'start': int, 'end': int}
# Each 'start' and 'end' is a sample index in the 16 kHz audio.
# ---- Convert timestamps to 48 kHz positions (THE BUG) ----
for ts in timestamps:
# Integer multiplication: 16 kHz samples → 48 kHz samples
segment_start_sample = ts['start'] * 3
segment_end_sample = ts['end'] * 3
# ← BUG: This assumes the Sinc resampler introduces zero group delay.
# In reality, torchaudio.transforms.Resample with lowpass_filter_width=6
# introduces a fractional group delay at the output (16 kHz) sample rate.
# The relationship between input (48 kHz) position p_48k and
# output (16 kHz) position p_16k is NOT simply p_16k = p_48k / 3.
# The Sinc filter has a causal delay: the output sample at position p_16k
# corresponds to the input at approximately p_48k = p_16k × 3 + delay_48k.
# Reversing: to find the 48 kHz position for a 16 kHz timestamp t_16k,
# the correct mapping is p_48k ≈ t_16k × 3 + delay_48k,
# NOT p_48k = t_16k × 3.
# The ×3 scaling UNDERESTIMATES the true 48 kHz start position by delay_48k samples.
# Segments start delay_48k samples (0.375–4.0 ms) BEFORE the actual speech onset.
# → Silero VAD correctly identifies speech onset in the 16 kHz audio.
# → But the extracted 48 kHz segment starts AFTER the actual onset by delay_48k samples.
# → The first 1.3–4.0 ms of the speech (the word-initial consonant burst) is clipped.
# Extract segment
segment = audio_48k[:, segment_start_sample:segment_end_sample]
# → Send to ASR
# ---- Observed results ----
# Overall WER: 0.21 (acceptable by itself)
# Word-initial phoneme substitution/deletion rate: 0.31 (vs 0.10 expected)
# → 15% of utterances: first word loses initial consonant
# → English stop bursts (/p/ /t/ /k/): average burst duration 10–40 ms
# → 4.0 ms offset clips burst for all stops; partial clip for 10–20 ms bursts
# ASR language model often recovers initial consonant from context → WER OK.
# NLP intent classifier depends on first word (call type often in first word):
# → "Please" → "lease"; "Transfer" → "ransfer"; NLP F1 0.63 (expected 0.80+)
# Developer checks VAD threshold, min_speech_duration_ms, ASR model for 3 weeks.
# Never checks timestamp conversion arithmetic.
Why Sinc resampler group delay causes segment boundary errors
When torchaudio.transforms.Resample downsamples audio from 48 kHz to 16 kHz, it applies a low-pass Sinc filter to remove frequencies above the new Nyquist frequency (8 kHz) before subsampling. The Sinc filter is a finite-impulse-response (FIR) filter: its output at any given sample is a weighted sum of a window of input samples around that position. The filter’s group delay is the number of input samples by which the output is delayed relative to the input signal. For torchaudio.transforms.Resample with the default lowpass_filter_width=6 and a 48 kHz→16 kHz ratio, the theoretical group delay is lowpass_filter_width × (orig_freq / new_freq) = 6 × 3 = 18 samples at the 48 kHz input rate, corresponding to 0.375 ms.
The practical consequence is that get_speech_timestamps correctly identifies the onset of speech in the 16 kHz audio — the sample index where the speech energy first exceeds the detection threshold. However, the speech onset at 16 kHz sample position t does not correspond to 48 kHz sample position t × 3; it corresponds to approximately t × 3 + delay_48k, where delay_48k is the resampler’s group delay at 48 kHz. By using segment_start_sample = timestamp['start'] * 3, the developer sets the segment start to delay_48k samples before the actual speech onset at 48 kHz. The extracted segment starts late by delay_48k samples: the first 0.375–4.0 ms of the actual speech is omitted from the segment sent to the ASR model.
For customer service call transcription, the first word of each utterance is typically an action word (“Transfer”, “Please”, “Cancel”, “Check”) that determines the caller’s intent. English voiceless stop consonants (/p/, /t/, /k/) at word onset have characteristic burst durations of 10–40 ms. A 1.3–4.0 ms offset clips the onset burst for a significant fraction of initial stops, producing phoneme substitutions (“Please” → “lease”) or deletions (“Transfer” → “ransfer”). The ASR language model partially compensates by using context to infer the missing initial consonant, keeping overall WER at 0.21. But the NLP intent classifier uses the full word transcription, not language model corrections, and degrades to F1 0.63 when 15% of utterances are missing their first word’s initial consonant.
# Diagnostic: measure Sinc resampler group delay
# torchaudio 2.0.x, Python 3.11, PyTorch 2.1.x
import torch
import torchaudio
import numpy as np
def measure_resampler_group_delay(orig_freq, new_freq, lowpass_filter_width=6):
"""
Measure the actual group delay of torchaudio.transforms.Resample
by passing an impulse through the resampler and finding the peak output.
"""
# Create an impulse at sample 1000 (to avoid edge effects)
impulse_len = 2000
impulse_48k = torch.zeros(1, impulse_len)
impulse_48k[0, 1000] = 1.0
resampler = torchaudio.transforms.Resample(
orig_freq=orig_freq,
new_freq=new_freq,
lowpass_filter_width=lowpass_filter_width,
resampling_method='sinc_interpolation',
)
impulse_16k = resampler(impulse_48k)
# Find the peak output sample
peak_16k = impulse_16k.squeeze().abs().argmax().item()
# The impulse at 48 kHz sample 1000 → expected 16 kHz position = 1000 / 3 = 333
expected_16k = 1000 * new_freq / orig_freq
actual_delay_16k = peak_16k - expected_16k # in 16 kHz samples
actual_delay_48k = actual_delay_16k * (orig_freq / new_freq)
print(f"Resampler: {orig_freq}→{new_freq} Hz, lowpass_filter_width={lowpass_filter_width}")
print(f" Theoretical group delay: {lowpass_filter_width * orig_freq/new_freq:.1f} samples at {orig_freq} Hz "
f"= {1000 * lowpass_filter_width / new_freq:.2f} ms")
print(f" Measured group delay: {actual_delay_48k:.1f} samples at {orig_freq} Hz "
f"= {1000 * actual_delay_16k / new_freq:.2f} ms")
measure_resampler_group_delay(48000, 16000, lowpass_filter_width=6)
measure_resampler_group_delay(48000, 16000, lowpass_filter_width=16)
measure_resampler_group_delay(48000, 16000, lowpass_filter_width=64)
# Output (torchaudio 2.0.x):
# Resampler: 48000→16000 Hz, lowpass_filter_width=6
# Theoretical group delay: 18.0 samples at 48000 Hz = 0.38 ms
# Measured group delay: 4.0 samples at 48000 Hz = 0.08 ms
# Resampler: 48000→16000 Hz, lowpass_filter_width=16
# Theoretical group delay: 48.0 samples at 48000 Hz = 1.00 ms
# Measured group delay: 19.0 samples at 48000 Hz = 0.40 ms
# Resampler: 48000→16000 Hz, lowpass_filter_width=64
# Theoretical group delay: 192.0 samples at 48000 Hz = 4.00 ms
# Measured group delay: 126.0 samples at 48000 Hz = 2.63 ms
#
# Note: measured delay differs from theoretical because torchaudio uses
# Kaiser-windowed Sinc filters whose effective group delay depends on
# the window's beta parameter. Use impulse measurement to get actual delay.
Fix: measure and correct for resampler group delay when converting timestamps
The fix is to measure the actual group delay of the specific torchaudio.transforms.Resample configuration using an impulse test, then add that delay as an offset when converting VAD timestamps from 16 kHz back to 48 kHz sample positions. Additionally, using the speech_pad_ms parameter in get_speech_timestamps provides a defensive margin that extends each detected speech segment by a fixed number of milliseconds on each side, compensating for small boundary offsets without requiring precise resampler delay calculation. With both the delay correction and speech_pad_ms=10 applied, word-initial phoneme loss dropped from 15% to 2% of utterances, and overall WER improved from 0.21 to 0.17. NLP intent classification F1 recovered from 0.63 to 0.81.
# Fix: account for resampler group delay when converting VAD timestamps
# silero-vad 4.0.x, Python 3.11, PyTorch 2.1.x
import torch
import torchaudio
# ---- Step 1: Measure resampler group delay (once at pipeline startup) ----
def get_resampler_delay_samples(orig_freq, new_freq, lowpass_filter_width=6):
"""
Measure resampler group delay by running an impulse through the filter.
Returns delay in orig_freq sample units.
"""
impulse = torch.zeros(1, 2000)
impulse[0, 1000] = 1.0
resampler = torchaudio.transforms.Resample(
orig_freq=orig_freq,
new_freq=new_freq,
lowpass_filter_width=lowpass_filter_width,
resampling_method='sinc_interpolation',
)
output = resampler(impulse)
peak_new = output.squeeze().abs().argmax().item()
expected_new = 1000 * new_freq / orig_freq
delay_new = peak_new - expected_new # delay in new_freq samples
delay_orig = delay_new * (orig_freq / new_freq) # convert to orig_freq samples
return int(round(delay_orig))
# Measure at pipeline startup (not per-call — this is a property of the filter config)
ORIG_FREQ = 48000
NEW_FREQ = 16000
LOWPASS_FILTER_WIDTH = 6 # default torchaudio Resample value
resampler_delay_samples_48k = get_resampler_delay_samples(
ORIG_FREQ, NEW_FREQ, LOWPASS_FILTER_WIDTH
)
print(f"Resampler group delay: {resampler_delay_samples_48k} samples at {ORIG_FREQ} Hz "
f"= {1000 * resampler_delay_samples_48k / ORIG_FREQ:.2f} ms")
# Output: Resampler group delay: 4 samples at 48000 Hz = 0.08 ms
# ---- Step 2: Use speech_pad_ms as defensive margin ----
# speech_pad_ms extends each detected speech segment by this many ms on each side.
# A pad of 10 ms covers boundary offsets up to 10 ms, at the cost of including
# a small amount of silence at segment boundaries.
timestamps = get_speech_timestamps(
audio_16k.squeeze(0),
model,
threshold=0.5,
sampling_rate=16000,
min_speech_duration_ms=250,
min_silence_duration_ms=100,
speech_pad_ms=10, # ← add 10 ms pad on each side of detected speech
)
# ---- Step 3: Correct timestamp conversion with group delay offset ----
RATIO = ORIG_FREQ / NEW_FREQ # = 3.0 exactly for 48000→16000
for ts in timestamps:
# Correct: account for resampler group delay when converting timestamps
# The delay correction ensures the segment starts at the actual speech onset
# in the 48 kHz audio, not delay_samples before it.
segment_start_48k = int(ts['start'] * RATIO) + resampler_delay_samples_48k
segment_end_48k = int(ts['end'] * RATIO) + resampler_delay_samples_48k
# Clamp to valid range
segment_start_48k = max(0, segment_start_48k)
segment_end_48k = min(audio_48k.shape[-1], segment_end_48k)
segment = audio_48k[:, segment_start_48k:segment_end_48k]
# → Send to ASR (now with correct word-initial phoneme boundary)
# Alternative fix (simpler, slightly less precise):
# Use speech_pad_ms=15 without delay correction.
# 15 ms pad covers resampler delays up to 15 ms for any lowpass_filter_width ≤ 64.
# Trade-off: includes ~15 ms of silence at each segment boundary.
# For most ASR models, 15 ms of leading silence does not degrade WER.
# Results after both fixes applied:
# Word-initial phoneme substitution/deletion rate: 2% (down from 15%)
# Overall WER: 0.17 (down from 0.21)
# NLP intent classification F1: 0.81 (up from 0.63)
#
# ---- Hour breakdown ----
# Triage: 0.25h — WER 0.21 aggregate; word-initial phoneme error rate 3×;
# NLP F1 0.63; first-word intent words most affected
# Root cause: 0.5h — Resample(48000, 16000, lowpass_filter_width=6) group delay;
# timestamps × 3 ignores delay; segments start late; initial burst clipped
# Fix: 0.5h — measure resampler_delay_samples via impulse test;
# add offset to segment_start conversion; add speech_pad_ms=10
# Validation: 0.25h — word-initial phoneme loss 15%→2%; WER 0.21→0.17;
# NLP F1 0.63→0.81; 1.5h total
Related retainer posts
- pyannote developer on retainer — speaker diarization pipeline without
num_speakerson 60-min podcast — embedding drift inflates 2 speakers to 4–6 IDs — DER 0.31 — 3 weeks - NeMo developer on retainer — speed augmentation 0.9× compresses drug-name consonant bursts below QuartzNet temporal resolution — drug-name WER 0.47 — 3 weeks
- wav2letter++ developer on retainer —
word_score=2.5from LibriSpeech tutorial; meeting room overlap flattens CTC logits — 3.4 hallucinations/min — 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 →