FFT for mortals

FFT cho người bình thường

Sound is pressure changing over time. Your ear and brain are extraordinarily good at noticing when that pressure repeats at a steady rate, and the sensation gets a name: pitch. Machinery rarely produces a single pure tone. It produces stacks of periodic components plus hiss, and the Fourier transform is the mathematical tool that answers a deceptively simple question: if I record a few milliseconds of microphone data, which frequencies are present and how loud is each one? Sonarish builds its spectrum analyzer and scrolling spectrogram on that question, running entirely on your phone without sending audio to a server.

Most FFT explanations open with complex exponentials and lose the reader by the second page. This one runs the other way. It starts from what the output numbers mean, keeps the algebra to four short formulas, and spends the reclaimed space on the parts textbooks skip: windows, leakage, threading and the specific ways a phone microphone quietly lies.

What the transform actually computes

The discrete Fourier transform takes N consecutive audio samples and produces N complex numbers, each representing the sine and cosine contribution at one of N equally spaced frequencies. The recipe for output bin k is X[k] = Σ x[n]·e^(−i2πkn/N), summed over n from 0 to N−1. Strip the notation away and it is a correlation. You multiply the signal, sample by sample, against a probe sinusoid that completes exactly k cycles inside the block, then add everything up. When the signal contains that frequency the products line up in phase and the sum grows large. When it does not, positive and negative products cancel and the sum hovers near zero.

Bin k sits at frequency f_k = k·fs/N, where fs is the sample rate. We rarely display the full complex result. For noise and fault work the quantity that matters is magnitude, |X[k]| = √(Re² + Im²), a plain measure of how strong each frequency bin is, and Sonarish converts it to decibels with 20·log10(|X[k]|/ref) so quiet and loud components share one readable axis. Phase is real information too. It just answers questions about timing and alignment that a noise survey never asks.

One structural fact makes phone implementations cheaper. Real-valued input produces a symmetric spectrum: bin N−k is the complex conjugate of bin k, so everything above half the sample rate mirrors what sits below it. Libraries exploit the symmetry and compute only N/2+1 useful bins, which is why the loop in the code further down stops at 2048 rather than 4096.

Why the fast version deserves the name

Evaluating the sum directly costs N multiply-adds per bin, and there are N bins, so the bill is N². At 4096 points that is roughly 16.8 million complex multiplies for one frame. The fast Fourier transform reorganizes the work instead of shrinking it: split the block into even and odd samples, transform each half, then stitch the halves together with a single pass of butterfly operations. Applied recursively, the cost collapses to (N/2)·log2(N) multiplies. For 4096 points that is 24,576, a factor of nearly 700 cheaper.

Gauss had the trick in 1805 and filed it in a private notebook, which may be the most mathematician move on record. Cooley and Tukey republished it in 1965 and made real-time spectral analysis practical. Bench note from our side: across 3 phones (an iPhone 13, a Pixel 7 and a 2019 Galaxy A50) we timed 10,000 consecutive 4096-point real FFTs and averaged 19 µs, 46 µs and 210 µs per transform. Even the slowest device spends well under 1% of one core on the roughly 47 transforms per second that Sonarish's default settings ask for.

Chopping time into windows

A single FFT snapshot tells you what happened in one short slice of time. Real machines and rooms change slowly but not infinitely slowly, so Sonarish uses a short-time Fourier transform: chop the continuous microphone stream into overlapping windows, FFT each window, and plot magnitude against time as a scrolling waterfall. Window length trades frequency resolution against time resolution. No setting wins both.

The default grabs 2048 samples at the 48 kHz capture rate, 42.7 ms of sound, and zero-pads each block to a 4096-point transform, which lands the plotted bins about 11.7 Hz apart. That spacing separates a 120 Hz mains hum from its second harmonic at 240 Hz with room to spare. Zero-padding deserves an honest footnote here: the padded zeros interpolate a smoother curve through the spectrum but cannot manufacture resolving power, which stays fixed by the 2048 real samples underneath. Switching to a full 4096-sample window halves the bin width but reacts more slowly to sudden clicks, because each click gets averaged across 85 ms of context.

Leakage, and why every block gets a Hann

The transform silently assumes your N-sample block repeats forever, end spliced to beginning. A tone that completes a whole number of cycles inside the block splices cleanly. A tone that does not, which in the field is nearly every tone, hits the seam mid-cycle, and the transform reads that discontinuity as energy smeared into neighboring bins. This is spectral leakage, and it is worst exactly when a tone's frequency falls between bin centers. A knife-sharp harmonic turns into a low pyramid wide enough to bury a quieter neighbor.

The fix is to stop pretending the block edges carry meaning. We multiply each window by a Hann function, w[n] = 0.5·(1 − cos(2πn/N)), which tapers both ends smoothly to zero so the imaginary seam disappears. The price is a mainlobe roughly twice as wide; the reward is sidelobes some 30 dB lower. For tonal machinery with sharp harmonics, Hann is a sensible default, and it is what Sonarish ships. Consecutive windows hop forward by 50% of their length, which keeps the scrolling waterfall visually smooth without doubling CPU cost unnecessarily. We tried 75% overlap in a bench build once. Nobody could pick it out of a blind scroll test, so the extra transforms went back on the shelf.

Reading peaks like an engineer

A peak at 120 Hz in a country with 60 Hz mains is usually twice-line-frequency magnetic hum, while a companion line just under 60 Hz marks a two-pole induction motor spinning near 3600 RPM, because mechanical rotation is electrical frequency divided by pole pairs. Bearing wear announces itself differently. It introduces sidebands that are not simple multiples of shaft speed, and energy appearing at unexpected offsets is sometimes the first audible clue before vibration analysts confirm fault frequencies in a formal report. Broadband hiss rising across many bins suggests turbulence, brush arcing, or loose panels rattling without a single dominant tone.

Sonarish labels peaks automatically when signal-to-noise ratio exceeds about 6 dB above a locally estimated noise floor. The floor estimate is a median over the neighboring bins, roughly an octave to each side, so one loud neighbor cannot hide a genuine peak. The app does not pretend to diagnose your washing machine. It shows the spectrum honestly and lets experience, or our baseline comparison workflow, interpret change over time. Recording a known-good spectrum today is cheap insurance for an argument with a repair technician in 18 months.

None of it touches the audio thread

FFT work never runs inside the audio capture callback. That path is reserved for copying samples into a lock-free ring buffer and returning, for the reasons laid out in life on the audio thread. A background queue pulls blocks from the ring, applies the window and the transform using platform libraries (Accelerate vDSP on iOS, a compact radix-2 implementation on Android) and hands finished magnitude arrays to the UI thread. The whole consumer loop fits on one screen:

// background queue; the audio callback only feeds the ring
loop:
    wait until ring.available >= HOP     // HOP = 1024 samples
    ring.peek_latest(frame, 2048)        // newest full window
    ring.advance(HOP)
    for n in 0..2047:
        buf[n] = frame[n] * hann[n]      // taper
    for n in 2048..4095:
        buf[n] = 0                       // zero-pad to 4096
    fft_real_4096(buf, re, im)           // vDSP / radix-2
    for k in 0..2048:
        mag = sqrt(re[k]*re[k] + im[k]*im[k])
        db[k] = 20 * log10(max(mag, EPS))
    ema_update(db_smooth, db, 0.6)       // calm the flicker
    if now() - last_draw >= 33 ms:       // cap near 30 Hz
        post_to_ui(db_smooth)
        last_draw = now()

Even when transforms complete faster, spectrum redraws are capped at 15 to 30 Hz. Humans cannot interpret a bar chart flickering at 60 Hz, and battery matters in a factory walk-through. The per-bin exponential moving average exists for the same reason; a raw spectrum bounces enough that peak labels would strobe.

Pitfalls that fool smart people

Aliasing is the classic trap. Any component above the Nyquist limit of fs/2, which is 24 kHz at our capture rate, folds back down and impersonates a lower frequency. The phone's audio hardware applies anti-alias filtering ahead of the converter, which largely handles the problem for ordinary sources, but you still should not point a mic at a whistle near the Nyquist limit and trust the graph blindly.

The second trap is units. Decibels relative to digital full scale measure headroom inside the recording chain and say nothing about acoustic pressure in the room. Confusing dBFS with dB SPL invalidates any comparison to environmental law, and our A-weighting and decibels post walks through the distinction plus the calibration story behind it. The third trap arrives dressed as a feature request. Shrinking the window to make peaks look sharper actually widens the bins, and adjacent harmonics merge into one fat bar — pretty UI, worse physics.

Where to poke at it yourself

Phyzix includes a live microphone FFT view built for classroom demos, where students clap, whistle and watch bins light up in real time. Wheria has no use for spectral analysis, yet the same sampling discipline (respect Nyquist, never block real-time capture) runs through every uranashel app that touches a microphone or accelerometer at high rate. The transform itself is 200-year-old mathematics. Making it readable on a 6-inch screen in a noisy plant was the actual product work, and most of that work was deciding what to leave out.

Âm thanh là áp suất không khí thay đổi theo thời gian. Tai người bắt cực nhạy chuyện áp suất lặp đều. Cảm giác đó có tên: cao độ. Máy móc hiếm khi phát một tone sạch. Nó phát cả chồng thành phần tuần hoàn kèm hiss nền. Biến đổi Fourier trả lời đúng một câu hỏi nghe đơn giản mà lắm bẫy: ghi vài mili giây từ micro thì trong đó có những tần số nào, mỗi tần to bao nhiêu? Sonarish dựng máy phân tích phổ và spectrogram cuộn trên nền câu hỏi đó, chạy trọn trên điện thoại, không đẩy audio lên server nào.

Đa số tài liệu FFT mở màn bằng lũy thừa số phức rồi mất người đọc từ trang hai. Bài này đi ngược. Bắt đầu từ ý nghĩa của các con số đầu ra, giữ đại số ở đúng bốn công thức ngắn, phần chỗ trống dành cho mấy thứ giáo trình hay bỏ qua: window, leakage, threading và những kiểu micro điện thoại âm thầm nói dối.

Biến đổi này thực ra tính cái gì

DFT nhận N sample audio liên tiếp rồi trả về N số phức, mỗi số là phần đóng góp sin và cos tại một trong N tần số cách đều nhau. Công thức cho bin k: X[k] = Σ x[n]·e^(−i2πkn/N), cộng n từ 0 đến N−1. Bóc hết ký hiệu đi thì đây là phép correlation. Nhân tín hiệu, từng sample một, với một sinusoid dò quét đúng k chu kỳ trong block rồi cộng tất cả lại. Tín hiệu chứa tần đó thì các tích cùng pha, tổng phình to. Không chứa thì tích dương âm triệt nhau, tổng lởn vởn quanh 0.

Bin k nằm ở tần f_k = k·fs/N với fs là sample rate. Ít khi cần hiện đủ phần phức. Đo ồn hay soi lỗi máy chỉ cần biên độ |X[k]| = √(Re² + Im²), thước đo mỗi bin mạnh cỡ nào. Sonarish đổi biên độ sang decibel bằng 20·log10(|X[k]|/ref) để thành phần nhỏ với lớn nằm chung một trục đọc được. Pha cũng là thông tin thật. Chỉ là nó trả lời chuyện timing, thứ mà khảo sát ồn chẳng bao giờ hỏi tới.

Còn một tính chất giúp code mobile rẻ hẳn đi. Input là số thực thì phổ đối xứng: bin N−k là liên hợp phức của bin k, mọi thứ trên nửa sample rate chỉ là ảnh gương phần dưới. Thư viện tận dụng luôn, chỉ tính N/2+1 bin có nghĩa. Vòng lặp trong đoạn code phía dưới dừng ở 2048 thay vì 4096 chính vì thế.

Vì sao bản fast xứng cái tên

Tính thẳng tổng đó tốn N phép nhân-cộng cho mỗi bin, mà có N bin, hóa đơn thành N². Với 4096 điểm là cỡ 16,8 triệu phép nhân phức cho một frame. FFT không rút gọn phép toán mà sắp xếp lại việc: tách block thành sample chẵn với lẻ, biến đổi từng nửa, ghép hai nửa bằng một lượt phép butterfly. Đệ quy xuống tận đáy thì chi phí sập còn (N/2)·log2(N) phép nhân. Với 4096 điểm là 24.576 phép, rẻ đi gần 700 lần.

Gauss nghĩ ra mẹo này từ năm 1805 rồi cất trong sổ tay riêng, đúng phong cách nhà toán học. Cooley với Tukey công bố lại năm 1965, phân tích phổ real-time mới thành chuyện khả thi. Ghi chú bench của studio: đo trên 3 máy (iPhone 13, Pixel 7 và Galaxy A50 đời 2019), chạy liền 10.000 lần FFT thực 4096 điểm, trung bình lần lượt 19 µs, 46 µs và 210 µs mỗi transform. Máy chậm nhất cũng tốn chưa tới 1% một core cho khoảng 47 transform mỗi giây mà setting mặc định của Sonarish cần.

Cắt thời gian thành window

Một ảnh FFT đơn lẻ chỉ kể chuyện của một lát thời gian ngắn. Máy với phòng thay đổi chậm nhưng không đứng yên, nên Sonarish dùng short-time Fourier transform: cắt luồng micro liên tục thành các window chồng lấn, FFT từng window, vẽ biên độ theo thời gian thành waterfall cuộn. Độ dài window đánh đổi độ phân giải tần số lấy độ phân giải thời gian. Không setting nào thắng cả hai.

Mặc định app lấy 2048 sample ở rate thu 48 kHz, tức 42,7 ms âm thanh, rồi zero-pad mỗi block lên transform 4096 điểm. Các bin vẽ ra nằm cách nhau khoảng 11,7 Hz, thừa sức tách hum điện lưới 120 Hz khỏi hài bậc hai 240 Hz. Zero-pad cần một chú thích thật thà: mấy số 0 đệm vào chỉ nội suy cho đường phổ mượt hơn chứ không đẻ thêm khả năng phân giải, thứ vẫn do 2048 sample thực bên dưới quyết định. Chuyển hẳn sang window 4096 sample thì độ rộng bin giảm nửa, đổi lại phản ứng chậm hơn với click đột ngột vì mỗi click bị trung bình trong 85 ms ngữ cảnh.

Leakage và vì sao block nào cũng qua Hann

Transform ngầm giả định block N sample của bạn lặp mãi mãi, đuôi nối vào đầu. Tone hoàn thành đủ số nguyên chu kỳ trong block thì mối nối sạch. Tone không đủ, mà ngoài đời gần như tone nào cũng không đủ, sẽ gãy ngay giữa chu kỳ tại mối nối. Transform đọc chỗ gãy đó thành năng lượng tràn sang các bin lân cận. Hiện tượng gọi là spectral leakage, nặng nhất đúng lúc tần của tone rơi giữa hai tâm bin. Một hài sắc như dao biến thành kim tự tháp bè, đủ chôn hàng xóm nhỏ tiếng hơn.

Cách sửa: thôi giả vờ mép block có ý nghĩa. Mỗi window nhân với hàm Hann w[n] = 0.5·(1 − cos(2πn/N)), hai đầu thon dần về 0 nên mối nối tưởng tượng biến mất. Giá phải trả là mainlobe rộng gấp đôi. Phần thưởng là sidelobe thấp hơn chừng 30 dB. Với máy móc nhiều hài sắc, Hann là mặc định hợp lý và Sonarish ship đúng như vậy. Các window liên tiếp nhảy tới 50% độ dài, đủ cho waterfall cuộn mượt mà không đốt CPU gấp đôi vô ích. Từng thử overlap 75% trên bản bench. Chẳng ai phân biệt nổi trong bài test cuộn mù, nên đống transform thừa quay về kệ.

Đọc đỉnh phổ như dân kỹ thuật

Đỉnh 120 Hz ở nước dùng điện lưới 60 Hz thường là tiếng ù từ trường gấp đôi tần số lưới; vạch nằm sát dưới 60 Hz mới là motor cảm ứng hai cực quay gần 3600 RPM, vì vòng quay cơ khí bằng tần số điện chia số cặp cực. Bạc đạn mòn báo hiệu kiểu khác. Nó sinh sideband không phải bội số gọn của tốc độ trục. Năng lượng hiện ở mấy offset lạ đôi khi là manh mối nghe được đầu tiên, trước cả khi dân phân tích rung xác nhận tần số lỗi trong báo cáo chính thức. Hiss dải rộng dâng đều trên nhiều bin thì gợi ý dòng khí xoáy, hồ quang chổi than hoặc tấm ốp lỏng kêu lạch cạch, không tone nào trội hẳn.

Sonarish tự dán nhãn đỉnh khi SNR vượt khoảng 6 dB so với nền nhiễu ước lượng cục bộ. Nền lấy median các bin lân cận, cỡ một octave mỗi bên, nên một ông hàng xóm ồn không che nổi đỉnh thật. App không giả vờ chẩn đoán được máy giặt nhà bạn. Nó hiện phổ trung thực rồi để kinh nghiệm, hoặc quy trình so baseline, diễn giải thay đổi theo thời gian. Ghi một phổ lúc máy còn ngon là khoản bảo hiểm rẻ cho trận cãi nhau với thợ sửa 18 tháng sau.

Tất cả tránh xa audio thread

FFT không bao giờ chạy trong callback capture audio. Đường đó chỉ dành cho việc copy sample vào ring buffer lock-free rồi return, lý do nằm cả trong sống trên audio thread. Một queue nền rút block từ ring, nhân window, chạy transform bằng thư viện nền tảng (Accelerate vDSP trên iOS, bản radix-2 gọn trên Android) rồi đưa mảng biên độ hoàn chỉnh cho UI thread. Cả vòng consumer gói gọn một màn hình:

// background queue; the audio callback only feeds the ring
loop:
    wait until ring.available >= HOP     // HOP = 1024 samples
    ring.peek_latest(frame, 2048)        // newest full window
    ring.advance(HOP)
    for n in 0..2047:
        buf[n] = frame[n] * hann[n]      // taper
    for n in 2048..4095:
        buf[n] = 0                       // zero-pad to 4096
    fft_real_4096(buf, re, im)           // vDSP / radix-2
    for k in 0..2048:
        mag = sqrt(re[k]*re[k] + im[k]*im[k])
        db[k] = 20 * log10(max(mag, EPS))
    ema_update(db_smooth, db, 0.6)       // calm the flicker
    if now() - last_draw >= 33 ms:       // cap near 30 Hz
        post_to_ui(db_smooth)
        last_draw = now()

Kể cả khi transform xong sớm, tốc độ vẽ phổ vẫn khóa ở 15 đến 30 Hz. Mắt người không đọc nổi biểu đồ cột nhấp nháy 60 Hz. Pin cũng là chuyện lớn khi đi khảo sát xưởng cả buổi. EMA theo từng bin tồn tại cùng lý do; phổ thô nhảy đủ mạnh để nhãn đỉnh chớp như đèn xi nhan.

Bẫy khiến người giỏi cũng ngã

Alias là bẫy kinh điển. Thành phần nào vượt giới hạn Nyquist fs/2, tức 24 kHz ở rate đang dùng, sẽ gập ngược xuống đội lốt một tần thấp hơn. Phần cứng audio của điện thoại lọc chống alias trước bộ chuyển đổi nên nguồn âm bình thường không sao. Nhưng đừng chĩa micro vào một cái còi sát Nyquist rồi tin đồ thị một cách mù quáng.

Bẫy thứ hai là đơn vị. Decibel so với digital full scale đo headroom trong chuỗi ghi âm, không nói gì về áp suất âm trong phòng. Lẫn dBFS với dB SPL là vô hiệu mọi phép so với luật môi trường; bài A-weighting và decibel đi kỹ phân biệt này cùng chuyện calibrate đằng sau. Bẫy thứ ba đội lốt feature request. Thu nhỏ window cho đỉnh trông sắc hơn thực chất nới rộng bin, các hài liền kề dính thành một cột béo. UI đẹp lên, vật lý tệ đi.

Chỗ để bạn tự vọc

Phyzix có sẵn màn FFT micro live làm cho lớp học: học sinh vỗ tay, huýt sáo, nhìn bin sáng lên theo thời gian thực. Wheria chẳng cần phân tích phổ, nhưng cùng một kỷ luật lấy mẫu (tôn trọng Nyquist, không bao giờ block capture real-time) chạy xuyên mọi app uranashel đụng tới micro hay gia tốc kế ở rate cao. Bản thân biến đổi là món toán 200 năm tuổi. Làm nó đọc được trên màn hình 6 inch giữa xưởng ồn mới là phần việc sản phẩm thật. Mà phần lớn việc đó là quyết định bỏ bớt cái gì.