第一次跑真實 FFT 的失望
在第 4 階之後,你已經會算 FFT 並讀它的幅度頻譜。於是你做一個*應該*是最簡單的實驗:一個純正弦波,比方說 1000 Hz,乾淨取樣,餵進 1024 點轉換。課本理論承諾你會看到一根單一脈衝——只有一個頻格亮起,其餘全部寂靜。你按下執行,得到的卻不是一根針,而是一座凹凸不平的小山:峰值確實在 1000 Hz 附近,但兩側可以清楚看到向下延伸 30、40、50 dB 的裙襬,溢進相鄰的頻格。它看起來像雜訊,但它不是。
線索藏在你*選了哪個*頻率。選一個剛好落在頻格中心的頻率,那根尖峰就會回來,乾淨俐落。把它從中心挪開幾分之一個頻格,裙襬立刻重新爆開。轉換並沒有對你的訊號說謊——它誠實地回報了一個你沒意識到自己創造出來的訊號:那是你截取一段有限樣本的瞬間所製造出來的。
為什麼有限區塊有看不見的邊緣
這裡有一個能解決一切的心智模型。當你取 N 個樣本去跑 DFT,數學上隱含地假設這 N 個樣本是一個無限重複訊號的一個週期。轉換把你的區塊頭尾相接,鋪成一個無限迴圈。如果你的正弦波在區塊內剛好完成整數個週期,最後一個樣本就會平滑地接到下一個複本的第一個樣本——迴圈天衣無縫,你就得到一條乾淨的線。這叫做對齊頻格(bin-aligned)或相干頻率。
但真實世界的單音幾乎永遠不會落在頻格中心。假設你的 1024 點區塊在 48 kHz 下涵蓋約 21.3 ms,頻格間距為 48000/1024 ≈ 46.9 Hz。一個 1000 Hz 的單音落在第 21.33 格——*夾在*兩格之間。此時區塊內含有 21.33 個週期。最後一個樣本停在波形的半途,而下一個複本卻從零開始,於是接縫處出現一個突然的跳變,一道每個區塊重複一次的垂直懸崖。轉換看到這道懸崖,並忠實地把它分解。一個尖銳的不連續富含諧波,於是你那個單一單音的能量就被噴灑到一大片頻格上。那片噴灑*就是*洩漏。
Bin-aligned (5.0 cycles in block) Off-bin (5.3 cycles in block)
copy A | copy B copy A | copy B
/\ /\ /| /\ /\ /\ /\ /\ /|
/ \/ \/ |/ \/ \/ \ / \/ \/ |\
----------+---------- ---------+ +--------
^ ^ ^
seam is SMOOTH seam has a JUMP
-> one clean bin -> energy leaks everywhere矩形窗與它醜陋的旁瓣
不管你有沒有意識到,每一次普通的 FFT 其實都已經套了一個窗:矩形窗(boxcar),它把區塊內每個樣本乘以 1,區塊外全部乘以 0。問題出在方波在頻域長什麼樣。它的轉換是一個 sinc 形狀的圖案:一個高聳的主瓣,兩側排著一列衰減緩慢的旁瓣——矩形窗的第一個旁瓣只比峰值低約 −13 dB,而旁瓣以懶洋洋的每八度 6 dB 速率下降。那些旁瓣,正是你看到的裙襬。
為什麼 −13 dB 這麼要命?想像你的訊號裡有兩個單音:一個 1000 Hz 的大聲單音,和一個 1200 Hz、弱了 25 dB 的小聲單音。那個大聲單音的旁瓣,停在 −13 dB 又緩慢衰減,*比整個小聲單音還高*。小聲單音被它大聲鄰居的洩漏裙襬埋住,完全看不見。在音訊、射頻、振動與雷達工作中,這是每天都會碰到的危險:來自強載波的洩漏,蓋掉了你真正在乎的弱訊號。
把邊緣收尾:Hann 與 Hamming 窗
解方直接源自診斷。接縫處的懸崖來自訊號被突兀地切斷。所以不要乘上一個硬邦邦的方波,而是把你的 N 個樣本乘上一個在兩端平滑淡出為零的窗函數。現在第一個與最後一個樣本被輕柔地拉到零,重複迴圈裡的接縫變得連續,懸崖消失了——大部分洩漏也隨之消失。這就是加窗:一次逐樣本的相乘,便宜到不行,在 FFT 之前施加。
兩個升餘弦窗主宰著日常實務。Hann 窗(常被誤稱為 Hanning)是一個單一的餘弦隆起,w[n] = 0.5·(1 − cos(2πn/(N−1))),在兩端剛好碰到零。它的第一個旁瓣降到約 −31 dB,旁瓣以 18 dB/八度的速度快速滾降——非常適合獵捕弱音。Hamming 窗,w[n] = 0.54 − 0.46·cos(2πn/(N−1)),形狀幾乎相同,但兩端不完全降到零。那一點點的基座抵消了最近的旁瓣到約 −43 dB,是兩者中最好的第一旁瓣,代價是遠處的滾降較慢。
# Apply a Hann window before the FFT (Python / NumPy) import numpy as np N = 1024 sig = np.sin(2*np.pi*1000*np.arange(N)/48000) # 1 kHz, off-bin win = np.hanning(N) # raised-cosine taper, 0 -> 1 -> 0 spec_r = np.abs(np.fft.rfft(sig)) # rectangular: ugly skirts spec_w = np.abs(np.fft.rfft(sig*win)) # windowed: skirts collapse # Coherent-gain fix: a Hann window throws away ~half the energy. # Scale the magnitude back up so amplitudes read correctly: spec_w *= 2.0 / np.sum(win) # 1/mean(win) = 1/0.5 = 2.0
你永遠逃不掉的取捨
加窗不是免費的魔法——它是一筆交易。把邊緣收尾會壓制旁瓣,但同時也會加寬主瓣。矩形窗的主瓣約 1 個頻格寬(量到第一個零點是 2 個頻格)。Hann 窗的主瓣大約*兩倍*寬;Hamming 也差不多。所以殺死洩漏的這個動作本身會把峰值糊開,兩個矩形窗*剛好*能分辨的單音,在 Hann 窗下可能合併成一個胖隆起。你用頻率解析度,換來了動態範圍。
Window Main-lobe width Highest side-lobe Side-lobe roll-off
----------- --------------- ----------------- ------------------
Rectangular 1.0 bins (best) -13 dB (worst) 6 dB/oct
Hamming ~1.4 bins -43 dB 6 dB/oct
Hann ~1.5 bins -31 dB 18 dB/oct (best)
Blackman ~1.7 bins (worst) -58 dB 18 dB/oct
narrow main lobe <----------------> low side-lobes
(sharp resolution) (wide dynamic range)- 只是想在乾淨訊號裡找單音?從 Hann 開始——快速衰減旁瓣的全能好手。
- 要在強音旁邊獵捕藏起來的弱音?選用低旁瓣的窗,例如 Blackman 或 Kaiser,並接受較寬的主瓣。
- 要分開兩個頻率極度接近的單音?保持主瓣狹窄——維持矩形(或接近矩形)並忍受裙襬。
- 要量一個單音的絕對功率?先加窗以阻止洩漏把能量偷到鄰居,再套用該窗的相干增益修正。
綜合起來:像專家一樣讀頻譜
退一步看,整條鏈就講得通了。連續波透過取樣變成 離散時間訊號;取樣早就要求你遵守 Nyquist(第 3 階)。接著你截取一段有限區塊——而這個截取動作悄悄地乘上了一個窗。FFT 回報的是*訊號 × 窗*的頻譜,在頻域中就是你的真實頻譜與窗的響應做迴旋。你看到的每一個峰,都是窗的形狀,停在一個真實單音上面。一旦你把這點內化,「亂糟糟」的頻譜就不再是個謎,而是一份你能讀懂、能信任的量測。
這份直覺也是下一階的基礎。設計一個加窗-sinc 低通濾波器,*正是同一個操作*反過來跑:你拿一個理想的磚牆響應,反轉換成無限長的脈衝響應,然後把它加窗到有限長度——而你在這裡對抗的旁瓣,會以濾波器阻帶漣波的形式重新出現。現在把窗學透,FIR 濾波器設計就會像老朋友一樣親切。