Pick a music folder; the browser locally traverses it depth-first and builds a playlist of every supported track. Switch, shuffle or repeat anytime, with a live frequency-band visualizer showing the L/R spectrum of what is playing. Supports FLAC / ALAC / APE / WAV / AIFF / DXD / MP3 / AAC / M4A / WMA / OGG / OPUS / MP2 / DSF / DFF / AC-3 / DTS — 17 formats in total; those the browser cannot play natively (APE / DSF / DFF / WMA / AC-3 / DTS / ALAC / DXD) are auto-transcoded with the built-in ffmpeg (WebAssembly). Everything runs locally; nothing is uploaded.
3D time–frequency view
The same Fourier data, expressed in space: X = frequency, Z = time, Y = Fourier magnitude. Each ribbon is the full spectrum curve of one instant. Drag to orbit: facing the X axis you see stacked frequency snapshots, from the side you see how the time domain evolves, and from above it becomes a time–frequency heat map.
Exact axis mapping
| Axis | Meaning | Range | Mapping |
|---|---|---|---|
| X | Frequency | FMIN → FMAX, computed from the sample rate and fftSize (48 kHz + fftSize 4096 → 11.7 Hz → 24 kHz) | f = FMIN × (FMAX / FMIN)^x, where FMIN = sampleRate ÷ fftSize (lowest resolvable) and FMAX = sampleRate ÷ 2 (Nyquist) |
| Z | Time | now → about 5 s ago | 96 layers stored at 20 Hz; driven by each frame’s real playback timestamp, never a nominal frame rate |
| Y | Magnitude | 0 → −54 dBFS by default (−8 → −78 dBFS with “quiet detail”) | Labelled in dBFS (0 = full scale): dB = AnalyserNode reading + 14.1 (measured calibration offset) |
Why is the Y axis entirely negative? dB is a ratio, and
0 dBFSis by definition full scale — going above it in digital audio means clipping, so no legitimate signal exceeds 0. The0 dBFStick is the theoretical ceiling (real masters usually peak near −1 dBFS). To get an axis with both signs you have to move the reference, e.g. the broadcast convention that puts 0 at a −20 dBFS alignment level — a different labelling, available on request.
Where the data comes from
- FFT:
AnalyserNode,fftSize = 4096→ 2048 bins; spacingsampleRate / fftSize(11.7 Hz at 48 kHz, 10.8 Hz at 44.1 kHz — always computed from the actual sample rate). - Band mapping: logarithmic, 64 bands merged straight from FFT bins (each band covers at least one bin, same “monotonic” rule as the 2D strip).
- Channels: L and R drawn separately — L warm (red→gold), R cool (blue→teal), matching the 2D strip; the two runs are offset by half a layer along the time axis.
- Level:
getByteFrequencyData0–255, no A-weighting, dB window −66 … −12 by default (−90 … −20 with the “quiet detail” toggle). - Smoothing: asymmetric (attack 0.72 / release 0.12).
- Capture throttle: the render loop runs at rAF (≈60 fps) but frames are stored at 20 Hz max, so 48 layers cover ≈2.4 s. If the real frame rate is lower the span grows slightly (measured ≈2.7 s) — that is fine, because the time axis is labelled from each frame’s real timestamp, never from a nominal frame rate.
- Shared capture: one acquisition loop feeds both this view and the 2D spectrum strip — the FFT is read once.
Why the readings are exact
- Band centre frequencies are always derived from the real sample rate, so 48 k / 44.1 k / 96 k files all line up.
- Every ring-buffer frame stores its actual playback timestamp, so a dropped frame never shifts the time axis.
- The hover readout shows both the smoothed dB (what you see drawn) and the raw instantaneous dB of that frame.
- The readout also reports the underlying FFT bin range, so any number can be traced back to the raw data.
- Frequency is reported as measured, not as an axis position: at the low end a band is narrower than an FFT bin, so its log-axis position can differ a lot from the real data (measured: the band sitting at 84 Hz actually reads bin 17 = 205 Hz). The readout therefore reports the centre frequency of the covered bins, and quotes the axis position whenever the two differ by more than 12%. For the same reason X starts at
sampleRate ÷ fftSizerather than 0 — 0 Hz is DC (bin 0): it has no place on a log axis and is normally just an offset, not sound. AnalyserNodereads about 14 dB below true dBFS (Blackman window coherent gain + FFT normalisation; measured, not theoretical). Add +14 dB to display dBFS.
Annotations
- X ticks: 20 / 50 / 100 / 200 / 500 Hz / 1 k / 2 k / 5 k / 10 k / 16 kHz, one grid line each.
- Y ticks: one grid line every 10 dB inside the current window. Without quiet detail the window is 0 … −54 dBFS (top line = 0 dBFS = full scale); with it, −8 … −78 dBFS. Ticks outside the window are dropped rather than clamped.
- Z ticks: 0 / −0.6 / −1.2 / −1.8 / −2.4 s, one floor grid line each.
- Hover readout: frequency · band index · smoothed level · raw level · time relative to now · real timestamp · FFT bin range.
- Live extremes: current frame’s peak frequency and peak dB, always on screen.
Interaction
Drag to orbit (pointer events, touch included, with a little inertia on release), wheel to zoom, hover to read values (per channel), double-click to reset.
Controls on the panel (effect is the one-click “whole look” entry point; colour / style / overlays are the three independent dimensions for fine-tuning — 6 colours × 6 styles × 2³ overlay combos = 288 combinations. Touching any of them flips the preset to “custom”. All choices live in localStorage):
- Effect (10 presets, each applies a style + overlays (+ a suggested palette)):
classic/ridge/bars/city/cloud/rain/measuring(fill + peak trace) /stage(fill + reflection) /neon bars(bars + glow) /aurora(points + reflection + glow, best with the neon stage). - Colours: six schemes —
contrast(default) /theme(the card’s four gradient colours spread as a multi-stop ramp along the frequency axis; L runs forwards, R backwards; falls back to a built-in ramp when there is no cover) /cover(the two cover colours furthest apart in hue go to L and R; a near-monochrome cover falls back to contrast) /flat/morandi/neon(dark stage). - Style: six body styles —
fill(default) /mesh(wireframe; one ridge every 3rd layer, one longitudinal line every 4th band — both directions must be sparse or it turns into fur) /bars(one vertical stick per band) /blocks(one solid block per band, closest to a 3D bar chart) /points/streaks(points pulled along the time axis). - Five overlays, freely combinable:
reflection(mirrors the surface below the floor at 26 % alpha) /glow(a wide translucent stroke plus a thin bright one — nevershadowBlur, which would kill 96 ribbons) /peak trace(each frame’s peak joined into a line, showing where the fundamental moves). - quiet detail: widens the dB window to −8…−78 dBFS (the analyser window is shared by the 2D and 3D views, so the 2D strip changes too).
- scale: switches the Y-axis reference between dBFS (0 = full scale, every number ≤ 0) and 0 VU (0 = the −20 dBFS SMPTE alignment level, so programme sits around 0 and full scale reads +20 dB). This only changes the labels — the window, the geometry and the capture are untouched.
Implementation note: each frame first projects all layers once into flat
Float32Arrays (index(layer × 2 + channel) × bands + band), then the style functions read from them. That is what makes cross-layer effects (longitudinal mesh, streaks, mirror, peak trace) possible, and it cut the per-frame cost from 2.5 ms to ≤1 ms by removing tens of thousands of temporary point objects per frame.
Implementation notes
- Orbit camera: yaw about Y, pitch about X, perspective
k = D / (D − Z). - Scale by the bounding-sphere radius, never the bounding box — a box changes size while rotating and makes the scene “breathe”.
- Sort by depth every frame and paint back-to-front (painter’s algorithm), otherwise occlusion is wrong.
- Hover readout caches projected coordinates in a
Float32Arrayand does one O(n) nearest-point search in screen space. - Cost: 48 filled ribbons + 48 strokes + grid + 3072 projections ≈ 3–6 ms per frame; a steady 60 fps in plain Canvas 2D. No external dependencies, no WebGL.
- Optional extras: pin several readouts to compare, time-slice the volume, switch the Y axis between linear and logarithmic.
Status: the design and precision scheme above are final; wiring the code in comes after refactoring spectrum.js to a single-capture / multi-subscriber model (which must leave the existing 2D spectrum unchanged).