Acoustic Spectrum Analyzer — End-to-End Demo: Microphone → Hamming Window → FFT → Band Energy
A complete working demonstration of room acoustic analysis running against real hardware: 1024-sample blocks at 16 kHz, Hamming windowing, real FFT, and energy binning into footstep (60–250 Hz), voice (250–3000 Hz) and broadband (>3 kHz) bands. Run it live in your browser against your own built-in microphone via the Web Audio API — nothing to install — or bridge a specific sound card with the included Python (numpy + PyAudio) WebSocket server. Every intermediate stage is shown, and the source is included.
Capture parameters: 1024-sample blocks at 16000 Hz (64 ms per block), 16-bit mono. The real FFT produces 513 bins at 15.625 Hz resolution, with an 8000 Hz Nyquist ceiling.
Three ways to run it
- Your device's microphone — Web Audio API · nothing to install · works on the live site. getUserMedia opens your built-in microphone directly in the browser. Echo cancellation, noise suppression and automatic gain control are all switched off, because each of them would filter or reshape the signals being measured. Audio is analysed inside the tab and never leaves the device.
- Local hardware bridge — Python + PyAudio on your own machine · real device selection. A small WebSocket server runs the reference numpy implementation against a specific sound card and streams the results to this page. Use it when you need a particular capture device, true 16 kHz paInt16 capture, or headless operation. It binds to localhost and sends derived numbers only — never audio.
- Simulated source — No hardware, no permission prompt. A synthetic signal that cycles through the three band archetypes — footstep thumps at 90 Hz, a voice with formants at 700 and 1220 Hz, then broadband hiss — so the mapping from sound to band is obvious without needing a microphone.
The pipeline, stage by stage
- Capture —
data = stream.read(CHUNK_SIZE)
PyAudio hands back 1024 raw 16-bit samples — 64 ms of room air pressure, as integers between −32768 and +32767. Block size is the central trade-off in the whole pipeline. 1024 samples at 16 kHz buys 15.6 Hz of frequency resolution at the cost of 64 ms of time blur. Halve it and footsteps smear together; double it and you cannot tell two syllables apart. - Window —
windowed = samples * np.hamming(len(samples))
Each block is multiplied by a Hamming curve that tapers both ends to 0.08 and leaves the centre at 1.0. The FFT assumes the 64 ms block repeats forever. It does not, so the joint between the end and the start is a discontinuity — a click — and a click is broadband. Without windowing that fake click smears energy across every bin, which is spectral leakage. Tapering the edges removes the seam. - Transform —
fft_data = np.abs(np.fft.rfft(windowed_samples))
1024 time-domain samples become 513 frequency bins, then `np.abs` collapses each complex bin to a magnitude. rfft rather than fft: a real-valued input produces a symmetric spectrum, so the upper half is redundant and numpy drops it. Taking the absolute value discards phase — fine for "how loud at this frequency", useless if you later want to reconstruct the waveform. - Map bins to hertz —
freqs = np.fft.rfftfreq(len(windowed_samples), 1.0 / RATE)
Bin index i corresponds to i × (16000 / 1024) = i × 15.625 Hz. Bin 0 is DC; bin 512 is the 8 kHz Nyquist limit. The bins are just array positions until they are mapped. Everything above 8 kHz is invisible at this sample rate — not quiet, invisible — so an ultrasonic carrier at 40 kHz would never appear here. That is a sample-rate decision, not a hardware one. - Reduce to three bands —
low_energy = np.mean(fft_data[(freqs >= 60) & (freqs <= 250)])
Boolean masks select the bins in each range and average their magnitudes: 60–250 Hz, 250–3000 Hz, and everything above 3 kHz. 513 numbers are unreadable at a glance; three are not. The bands are chosen against physiology — footfall and structural thumps live below 250 Hz, speech energy concentrates between 250 Hz and 3 kHz, and fricatives, hiss and electrical noise sit above. - Render —
low_bar = "#" * int(np.clip(low_energy / 2000, 0, 15))
Each band energy is divided by 2000, clamped to 0–15, and drawn as that many hash characters on one rewritten terminal line. The divisor is a display constant with no physical meaning — it is tuned so ordinary room levels fill about half the bar. A different microphone or gain setting will need a different number.
Frequency bands
- Footsteps (60 – 250 Hz) — Footfall, door closes, HVAC rumble, traffic, structural transmission through floors and walls. Low frequencies pass through building structure with very little loss, which is why a neighbour's bass is audible when their conversation is not.
- Voice (250 Hz – 3 kHz) — Speech fundamentals and the first two formants, most music, television. Telephone systems band-limit to roughly 300–3400 Hz because this range carries nearly all speech intelligibility.
- Static (> 3 kHz) — Fricatives (s, f, sh), keyboard clicks, fan and electrical hiss, cooling whine. Capped at 8 kHz by the 16 kHz sample rate. Anything ultrasonic is outside this pipeline entirely — that needs a 96 kHz or 192 kHz capture path.
Installing and running the Python version
# Debian / Ubuntu / Kali
sudo apt install portaudio19-dev python3-pyaudio
pip install numpy pyaudio
# macOS
brew install portaudio
pip install numpy pyaudio
# Then
python3 spectrum_analyzer.py
import sys
import numpy as np
import pyaudio
def run_offline_spectrum_analyzer():
"""
Computes real-time FFT data from the room microphone to identify the
exact acoustic frequencies driving the microphonic loop.
"""
CHUNK_SIZE = 1024
FORMAT = pyaudio.paInt16
CHANNELS = 1
RATE = 16000 # 16 kHz sample rate handles frequencies up to 8 kHz (Nyquist)
p = pyaudio.PyAudio()
try:
stream = p.open(format=FORMAT, channels=CHANNELS, rate=RATE, input=True, frames_per_buffer=CHUNK_SIZE)
except Exception as e:
print(f"[-] Hardware Error: Audio card inaccessible ({e})")
return
print("=== LIVE ROOM ACOUSTIC SPECTRUM ANALYZER ===")
print("[*] Speak or walk to identify frequency energy points...")
print("[-] Low (60-250Hz) = Footsteps | Mid (300-3000Hz) = Vocal Whispers")
print("-" * 65)
try:
while True:
# Capture block of raw acoustic room data
data = stream.read(CHUNK_SIZE, exception_on_overflow=False)
samples = np.frombuffer(data, dtype=np.int16).astype(np.float32)
if len(samples) == 0: continue
# Apply standard Hamming window to prevent spectral leakage
windowed_samples = samples * np.hamming(len(samples))
# Compute the Fast Fourier Transform (FFT)
fft_data = np.abs(np.fft.rfft(windowed_samples))
freqs = np.fft.rfftfreq(len(windowed_samples), 1.0 / RATE)
# Group into broad bins for quick terminal scanning
low_energy = np.mean(fft_data[(freqs >= 60) & (freqs <= 250)])
mid_energy = np.mean(fft_data[(freqs > 250) & (freqs <= 3000)])
high_energy = np.mean(fft_data[(freqs > 3000)])
# Scale for console display
low_bar = "#" * int(np.clip(low_energy / 2000, 0, 15))
mid_bar = "#" * int(np.clip(mid_energy / 2000, 0, 15))
high_bar = "#" * int(np.clip(high_energy / 2000, 0, 15))
sys.stdout.write(f"\r[Footsteps: {low_bar:<15}] [Voice: {mid_bar:<15}] [Static: {high_bar:<15}]")
sys.stdout.flush()
except KeyboardInterrupt:
print("\n[*] Analyzer deactivated safely.")
finally:
stream.stop_stream()
stream.close()
p.terminate()
if __name__ == "__main__":
run_offline_spectrum_analyzer()
What this cannot tell you
- This measures your own room, from your own microphone. It shows what acoustic energy is present — it cannot show where that energy came from, or whether anyone put it there deliberately.
- Every room has a noise floor. HVAC, refrigerator compressors, traffic, computer fans and the microphone's own self-noise all register. Energy in a band is the normal state, not a finding.
- The 16 kHz sample rate caps observation at 8 kHz. Ultrasonic carriers, the thing most often asked about, are outside this pipeline by construction and need a 96 kHz+ capture path.
- The /2000 display divisor is arbitrary and gain-dependent. Bar heights are comparable between bands in one session, not between sessions, microphones or machines.
- Comparing against a baseline recorded in a quiet room at a quiet hour is the only way any reading becomes meaningful. A single measurement in isolation says nothing.
Common questions
Can I run this on my own microphone from the website?
Yes. The page uses the Web Audio API and getUserMedia to open your device's built-in microphone directly in the browser, with nothing to install. Your browser will ask permission first, and you can choose which input to use if you have several. Echo cancellation, noise suppression and automatic gain control are disabled because each would filter the signals being measured. The audio is analysed inside the browser tab and is never uploaded, recorded or sent to any server.
Why would I use the Python bridge instead of the browser?
Use the bridge when you need a specific capture device rather than whatever the browser selects — a USB measurement microphone, an audio interface, or a particular ALSA card — or when you want true 16 kHz paInt16 capture instead of the browser's resampled float pipeline, or headless capture on a machine with no browser. The bridge runs the same numpy code as the command-line tool and streams the results to the page over a localhost WebSocket, so the displayed numbers are the ones Python computed.
What does this spectrum analyzer actually measure?
It measures the acoustic energy present in your room, split into three frequency bands: 60–250 Hz (footsteps and structural rumble), 250–3000 Hz (speech), and above 3 kHz (hiss and fricatives). It captures 1024 samples at a time at 16 kHz, applies a Hamming window, runs a real FFT to get 513 frequency bins at 15.625 Hz resolution, and averages the magnitudes within each band. It reports what sound is present — not where it came from or who produced it.
Why apply a Hamming window before the FFT?
The FFT treats the 1024-sample block as one period of an infinitely repeating signal. Real audio does not line up at the seam, so the discontinuity between the block's end and start acts like a click, and a click contains every frequency. That artefact smears energy across all bins — spectral leakage. Multiplying by a Hamming window tapers both ends to 0.08 so the block joins itself smoothly, at the cost of slightly widening genuine peaks.
Why 16 kHz and 1024 samples?
The Nyquist theorem means a 16 kHz sample rate can represent frequencies up to 8 kHz, which covers speech and ordinary room sound. A 1024-sample block at that rate spans 64 ms and yields 513 bins at 15.625 Hz each (16000 ÷ 1024). Larger blocks give finer frequency resolution but blur events in time; smaller blocks react faster but cannot separate nearby frequencies. It is a direct trade, not a tuning parameter.
Can this detect ultrasonic or inaudible carriers?
No. At a 16 kHz sample rate, nothing above 8 kHz exists in the data at all — it is not faint, it is absent. Ultrasonic carriers such as those used in parametric speaker or DolphinAttack research sit at 25–40 kHz and require a capture path running at 96 kHz or 192 kHz with a microphone rated to that range. Most laptop and phone microphones roll off well before 20 kHz regardless of sample rate.
Does energy in the low band mean someone is walking outside my room?
No. The 60–250 Hz band is occupied continuously in essentially every building by HVAC, refrigeration compressors, plumbing, traffic, and structural transmission from elsewhere in the building. Low frequencies pass through walls and floors with very little loss, so they arrive from everywhere. A reading in that band is the normal state of a room, not evidence of anything.
How do I make a measurement that actually means something?
Record a baseline first: same room, same microphone, same gain, at a quiet hour, saved with a timestamp. Anything you measure later is only interpretable as a change from that baseline. Note what you were doing and what appliances were running. A single reading with nothing to compare it against cannot support any conclusion, and the most common analysis error is treating an ordinary noise floor as a discovery.
Related
If any of this frightens you: this site documents what signal physics makes possible in a laboratory, which is not evidence about what is happening to any individual. If you feel watched, targeted or unsafe, that distress is real and treatable. Read about paranoia, mental health and how to get help, or call a free 24/7 line — 988 in the US and Canada, 116 123 in the UK and Norway, 13 11 14 in Australia, or find your country at findahelpline.com.