How it works
FFT Visualizer is, at its core, a renderer: give it an array of magnitudes and it draws
a spectrum on the GPU. Everything before that — capturing audio and turning it into
frequency data — is pluggable, and where the FFT runs depends on the mode you pick.
The pipeline
mode="local" mic / tab audio
│ getUserMedia() / getDisplayMedia()
▼
Web Audio: AudioContext ▸ MediaStreamSource ▸ AnalyserNode
│ getFloatTimeDomainData() ← raw waveform (e.g. 2048 samples)
▼
Rust / WASM FftProcessor.process()
Hann window ▸ real FFT ▸ magnitude
▸ log-spaced bands ▸ A-weighting
│
▼
┌───────────────────────────────┐
mode="websocket" │ Uint8Array — 0–255, │ ◀── a backend server streams this
mode="external" │ one value per frequency band │ ◀── you push this via props
└───────────────────────────────┘
│
▼ noise floor ▸ smoothing ▸ peak-hold ▸ aggregate to `bands`
▼
WebGL fragment shader — one draw call
│
▼
bars on screen
The Uint8Array of 0–255 magnitudes is the hand-off point. In local mode the
visualizer produces it; in websocket and external modes it arrives ready-made and the
FFT is not part of the visualizer at all.
Where the FFT runs
| Mode | Who computes the FFT | Runs where |
|---|---|---|
local | The bundled Rust/WASM processor | In the browser |
websocket | Your backend (Python / Node / Rust examples) | On a server or device |
external | Your own code (Web Audio, another analyser…) | Wherever you like |
So if you're wondering "is the audio→FFT conversion part of FFTVisualizer?" — only in
local mode. See Data modes for the API of each.
Inside local mode
When you set mode to local, the visualizer runs the whole chain client-side:
- Capture —
getUserMedia()(microphone) orgetDisplayMedia()(tab/system audio) returns aMediaStream. - Web Audio graph —
AudioContext → MediaStreamAudioSourceNode → AnalyserNode. - Grab raw samples — each animation frame it reads
analyser.getFloatTimeDomainData(), which returns the raw waveform (the audio samples themselves), not frequency data. - FFT in Rust/WASM — those samples go to
FftProcessor.process(), which:- applies a Hann window (reduces spectral leakage),
- runs a real FFT,
- takes the magnitude of each complex bin (normalised by FFT size),
- groups bins into exponentially (log-)spaced frequency bands, so bass and treble get perceptually fair spacing instead of linear,
- applies A-weighting (the loudness curve that matches how the ear weights frequencies).
- The result is a
Uint8Arrayof 0–255 values — one per band — ready for the shader.
The WASM module is lazy-loaded: it's only fetched the first time you actually capture
audio locally, so websocket/external users never download it.
AnalyserNode can compute the FFT for you (getByteFrequencyData). FFT Visualizer
deliberately grabs raw time-domain samples and runs its own FFT instead, for two
reasons: identical output everywhere — the exact same Rust code runs in the browser
(WASM) and in the backend reference servers,
so a local mic demo and a remote stream look the same — and control the browser doesn't
expose: custom windowing, log-spaced bands, A-weighting and a fixed frequency range
(100 Hz–18 kHz). The AnalyserNode here is used purely as a convenient way to read a buffer
of samples; its own FFT is ignored.Two resolutions: bins vs bands
There are two spectrum sizes in the chain, and it's worth keeping them straight:
bins— the number of frequency bands the FFT processor (or your server) emits.bands— how many bars the visualizer actually displays. It aggregates the incomingbinsdown tobandsbefore rendering.
They're often the same (e.g. 80 → 80), but you can render fewer bars than you receive — for
example feed 80-bin data but display bands: 28.
frame event (Core, Vue)
hands you exactly what the shader draws, every frame.