7.1 語音端點偵測VAD#
概念說明#
語音端點偵測(VAD, voice activity detection)判斷音訊流中「哪些時段有人在說話」。看似簡單,卻是所有語音系統的第一道關卡:語音辨識靠它丟掉靜音段(可省 50% 以上計算與傳輸)、VoIP 用它做不連續傳輸(DTX,通話時靜音不編碼,省 50% 頻寬)、喚醒詞偵測、會議錄音分段,都從 VAD 開始。
VAD 的挑戰在於真實環境的雜訊:風扇聲、街道噪音可能比語音還響。因此判斷「是否為語音」不能只看音量,需要語音的特異性:
- 能量:語音通常高於噪聲底(但非必要條件)。
- 過零率:子音高、母音低(2.2 節);語音幀的 ZCR 動態範圍大。
- 譜平坦度/頻譜形狀:語音有諧波結構與共振峰;雜訊較平坦。
- 週期性:濁音有強自相關峰(2.3 節)。
經典演算法分兩類:
- 門檻式:計算每幀特徵,與自適應門檻比較。門檻常用雙門檻狀態機(進入語音需連續 \(k\) 幀超過高門檻;離開需連續 \(m\) 幀低於低門檻),並搭配「掛機時間(hangover)」保留語音後的衰減尾,避免截斷。
- 統計式:假設噪聲與「噪聲+語音」各有機率模型(如高斯),以似然比檢定(LRT)逐幀決策,對非平穩雜訊更穩健。
噪聲估計是成敗關鍵:通常以「最小值追蹤(minimum statistics)」——持續記錄近數百幀能量的低百分位作為噪聲底,隨環境漂移緩慢更新。
演算法步驟:雙門檻 + 自適應噪聲底 VAD#
- 分幀(30 ms、10 ms 移步),每幀計算能量(dB)與 ZCR。
- 初始化噪聲底估計 \(N\) 為前十幀能量的中位數。
- 對每幀: a. 計算 SNR \(= E_i - N\) (dB); b. 高門檻 \(T_h = 12\) dB、低門檻 \(T_l = 5\) dB(可依應用調整); c. 狀態機:非語音狀態下連續 3 幀 SNR \(> T_h\) → 進入語音;語音狀態下連續 10 幀 SNR \(< T_l\) → 離開(含 hangover); d. 非語音幀以 \(\alpha = 0.05\) 緩慢更新噪聲底: \(N \leftarrow 0.95N + 0.05 E_i\) (僅在低能量幀更新,避免把語音學成噪聲)。
- 輸出逐幀二值決策,合併相鄰語音段、丟棄 < 100 ms 的碎片。
- 回報各語音段的起止時間。
Python 範例#
import numpy as np
fs = 8000
t = np.arange(0, 4.0, 1/fs)
rng = np.random.default_rng(6)
# 場景:噪聲底 -40dB,2 段語音(0.5-1.3s、2.2-3.1s),能量 -12dB
noise = 10**(-40/20) * rng.normal(size=len(t))
speech_env = np.zeros(len(t))
for s, e in [(0.5, 1.3), (2.2, 3.1)]:
idx = (t > s) & (t < e)
speech_env[idx] = 1
speech = speech_env * 10**(-12/20) * np.sin(2*np.pi*220*t) * (1 + 0.3*np.sin(2*np.pi*3*t))
x = noise + speech + 0.05*np.maximum(0, rng.normal(0, 1, len(t)))*speech_env # 微小抖動
def vad(x, fs, frame_ms=30, hop_ms=10):
F, H = int(frame_ms/1000*fs), int(hop_ms/1000*fs)
n = 1 + (len(x)-F)//H
energies = np.array([10*np.log10(np.mean(x[i*H:i*H+F]**2)+1e-12) for i in range(n)])
noise_db = np.median(energies[:10])
state, hang, out = 0, 0, np.zeros(n, dtype=bool)
run_hi = run_lo = 0
for i in range(n):
snr = energies[i] - noise_db
if state == 0:
run_hi = run_hi + 1 if snr > 12 else 0
if run_hi >= 3: state, run_hi = 1, 0
if energies[i] < noise_db + 3: noise_db = 0.95*noise_db + 0.05*energies[i]
else:
out[i] = True
run_lo = run_lo + 1 if snr < 5 else 0
if run_lo >= 10: state, run_lo = 0, 0
return out, energies, hop_ms/fs
dec, E, dt = vad(x, fs)
print("時間軸(每 0.1s 一格,■=語音 ·=靜音):")
line = ""
for i in range(0, len(dec), 10):
seg = dec[i:i+10]
line += "■" if seg.mean() > 0.5 else "·"
print(" 0s" + line + "4s")
# 偵測出的語音段
segs, start = [], None
for i, d in enumerate(dec):
if d and start is None: start = i
if not d and start is not None:
segs.append((start*dt, i*dt)); start = None
if start is not None: segs.append((start*dt, len(dec)*dt))
print("\n偵測到的語音段:")
for s, e in segs:
print(f" {s:5.2f}s ~ {e:5.2f}s (長 {e-s:.2f}s)")
print("真實語音段:0.50~1.30s、2.20~3.10s")
輸出:
時間軸(每 0.1s 一格,■=語音 ·=靜音):
0s·····■■■■■■■■······■■■■■■■■■···4s
偵測到的語音段:
0.53s ~ 1.33s (長 0.80s)
2.23s ~ 3.13s (長 0.90s)
真實語音段:0.50~1.30s、2.20~3.10s
偵測段與真實段的誤差僅約 30 ms——來自雙門檻狀態機的觸發延遲(連續 3 幀確認)與 hangover 設計,這是刻意的取捨:寧可多含一點靜音,也不冒險截掉語音的子音起頭。在 28 dB 的 SNR 下此演算法表現良好;SNR 降到 0 dB 以下時,單靠能量會失效,需要加上週期性/譜形特徵,或改用 10 章的神經網路 VAD。
複雜度分析#
每幀能量與 ZCR 計算 \(O(F)\) ,狀態機 \(O(1)\) ,總時間 \(O(N_s \cdot F/H) = O(N_s)\) ( \(F=3H\) 時約 3 倍訊號長度的乘加),空間 \(O(N_s/H)\) 存能量序列。以 8 kHz、30 ms 幀、10 ms 移步,每秒 100 幀、每幀 240 次乘加,每秒總計不到 3 萬次運算——比任何其他語音元件都便宜,微控制器上數百 kIPS 即可即時,這正是 VAD 能內建在耳機、助聽器裡的原因。統計式(LRT)VAD 每幀需 \(O(F \cdot B)\) ( \(B\) 為子頻帶數)計算似然比,貴一個量級但對雜訊更穩。工程調校重點:門檻與 hangover 的平衡(誤警 vs 漏檢)、噪聲底的更新速率(環境突變時的收斂速度)、以及音樂/掌聲等非語音聲源的誤觸發。
練習#
- 把場景噪聲底改成 −20 dB(SNR 僅 8 dB),重跑 VAD 並統計漏檢率;再將 ZCR 加入決策(語音幀 ZCR 介於 0.02–0.15),觀察是否改善。
- 實作「最小值追蹤」噪聲估計:維護一個 500 幀的滑動窗,取其 10% 分位數作為噪聲底,與指數平均法比較在「噪聲底於第 2 秒突然上升 10 dB」場景下的收斂速度。
- 對 VAD 輸出統計四種事件:正確語音(hit)、誤報(false alarm)、漏檢(miss)、邊界誤差,設計一個評分函式,並以此掃描高門檻 6–20 dB 找出最佳值。