# The lab behind the Lab page > A tech-stack tour of the browser demos on the lab page, from AnalyserNode fine print to hand-shifted spectrogram pixels, built with no framework and no trackers. 2026-08-04 · 18 min read · engineering, dsp · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/lab-behind-the-lab.html --- The [lab page](https://uranashel.com/lab.html) is the corner of this site where you can poke our instruments without installing anything: a live spectrogram waterfall, a parking garage that descends as you scroll, a walk simulation where GPS dies and fusion rescues the estimate, an attitude indicator, an AR pin and a small terminal. Everything runs in your browser, on your device, and no packet leaves for a server. Building the page turned into its own engineering project, with some constraints borrowed from the apps and some imposed by the web platform. This post is the tech-stack writeup: what the demos are made of, where the browser helps, where it quietly lies, and why the honest move is sometimes printing the word demo on screen in small gray letters. ## No framework, no build step The whole site is static HTML plus five plain script tags. No bundler, no npm, no node_modules, no transpiler. The lab code, lab.js, weighs 29 KB unminified; the complete JavaScript payload for the page is about 114 KB across five files, lighter than one average hero image elsewhere on the internet. Deployment means copying files to the server and bumping a ?v= query string on the script tags. That query string is our entire cache-invalidation strategy. Part of this is taste. A 3-person studio has no appetite for a frontend toolchain with its own maintenance calendar. Part of it is product: view-source is documentation here. If we claim the demos run on-device with zero tracking, any engineer passing by should be able to verify the claim inside a minute. The structural decision that matters lives in boot(). Every demo has one init function, and every call sits inside its own try/catch. We run no telemetry, so no dashboard will ever tell us that the AR demo throws on some Android WebView build. The next best thing is guaranteeing a crash stays local: if initAR() dies, the terminal below it still boots. Failure isolation substitutes for diagnostics we refuse to collect. ## A free FFT, with fine print Sonarish hand-rolls its spectral pipeline, windowing and all, because a measurement app has to control every step; the full derivation is in [FFT made readable](https://uranashel.com/blog/fft-made-readable.html). The browser offers a shortcut. Feed a getUserMedia microphone stream into an AnalyserNode and the FFT is done for you, in native code, inside the audio engine. The waterfall uses fftSize 512, which yields 256 usable bins. At the default 48 kHz context rate each bin covers `fs/N = 93.75 Hz`. Calling getByteFrequencyData returns magnitudes as bytes: the spec applies a Blackman window, runs the transform, converts to dB and maps the range from −100 to −30 dBFS onto 0–255. That is roughly 0.27 dB per step. Sounds crude, and is entirely adequate for a moving picture. We set smoothingTimeConstant to 0.7, an exponential average across successive frames that keeps the image from boiling. Two clauses of fine print. First, the analyser is pull-based. The audio engine fills a 512-sample block every 10.67 ms, the same cadence as the render callback in [life on the audio thread](https://uranashel.com/blog/dsp-audio-thread.html), but you read the analyser whenever requestAnimationFrame fires. On a busy device, frames drop and whole spectra vanish between reads without any notice. Second, the stream getUserMedia hands you has already been processed for telephony: echoCancellation, noiseSuppression and autoGainControl all default to on in every major browser. AGC alone turns absolute level into fiction before your code sees sample one. ## Sixty waterfall frames without jank A waterfall scrolls history sideways while appending one new column per frame. Our implementation is deliberately dumb: keep one ImageData the size of the canvas, shift every pixel left by one, write the fresh spectrum into the rightmost column, then call putImageData once. ``` // one waterfall column, called from requestAnimationFrame function pushColumn(spectrum) { // Uint8Array, 256 bins var d = img.data; // ImageData, W x 220 for (var y = 0; y < H; y++) { for (var x = 0; x < W - 1; x++) { // shift history left 1 px var i = (y * W + x) * 4, j = i + 4; d[i] = d[j]; d[i+1] = d[j+1]; d[i+2] = d[j+2]; } // newest column, high frequencies at the top var bin = Math.min(((1 - y / H) * bins) | 0, bins - 1); var g = spectrum[bin]; // 0..255 ~ -100..-30 dBFS var p = (y * W + (W - 1)) * 4; d[p] = d[p+1] = d[p+2] = g; // grayscale on purpose d[p+3] = 255; } ctx.putImageData(img, 0, 0); // one paint per frame } ``` The shift is the expensive part. On a 960×220 canvas that inner loop moves about 210,000 pixels per frame in plain JavaScript. Bench note: timing pushColumn with performance.now() over 600 frames and taking the median, we measured 1.6 ms on an M1 MacBook Air in Safari, 2.9 ms on an iPhone 13 and 11.4 ms on a 2019 Galaxy A50 in Chrome. The A50 number is why the canvas height is pinned at 220 px. Height scales the loop linearly, and 11 ms out of a 16.7 ms frame allowance leaves just enough room for the browser's own compositing pass. We tried the obvious faster route first: ctx.drawImage blitting the canvas onto itself shifted 1 px left, GPU-accelerated, 0.4 ms on the same A50. Discarded. On displays with fractional devicePixelRatio the self-blit resamples the bitmap every frame, and repeated resampling is a low-pass filter; after 15 s of scrolling, the entire history had smeared into fog. Bytes copied by hand stay where you put them. One confession for readers of the audio-thread post. This loop allocates a fresh 256-byte Uint8Array every frame for getByteFrequencyData. In an audio callback that allocation is a code-review blocker under our no-malloc rule. A requestAnimationFrame handler answers to a different court: the nursery collector reclaims a short-lived 256-byte array with no pause we could measure, and hoisting the buffer out of the loop changed nothing on any of our phones. It stays. ## Grayscale is the colormap Every spectrogram tutorial reaches for a rainbow. Ours writes the same byte into all three channels, so amplitude becomes a single gray level, dark for quiet and bright for loud. This follows the site-wide rule from [designing in strict monochrome](https://uranashel.com/blog/monochrome-ui.html), and it also happens to be the defensible choice for data. Jet-style colormaps have non-monotonic lightness, which lets a mid-amplitude yellow read louder than a high-amplitude red. Gray is monotonic by construction: a brighter pixel always means more energy in that bin, in both themes. Light theme swaps the background paint from #050505 to #f5f5f5 and the data survives the inversion untouched. ## A field guide to DeviceOrientation The attitude indicator and the wireframe globe listen to deviceorientation events, three Euler angles named alpha, beta and gamma. The API is 15 years old and still speaks in regional dialects. iOS Safari since version 13 requires DeviceMotionEvent.requestPermission(), and the call only works from inside a user gesture on an HTTPS page. Invoke it on load and it rejects without ever showing a prompt. Deny it once and it stays denied until the page reloads. Chrome on Android asks nothing and just starts firing, but its plain deviceorientation alpha is relative, zeroed wherever the phone happened to point when the page loaded; the compass-referenced variant is a separate deviceorientationabsolute event that iOS does not implement. devicemotion carries an interval field that on one of our test phones claims 16 ms while events arrive every 50 ms. Then there is the singularity. Gamma is defined on [−90°, 90°], so when you hold a phone upright like a camera the Euler decomposition passes through a gimbal-lock region: gamma snaps sign and beta jumps by nearly 180°. The artificial horizon flips. Native sensor stacks avoid this with quaternions; the web API ships Euler angles with the discontinuity included at no extra charge. Desktop sets its own trap. typeof DeviceOrientationEvent is defined in desktop Chrome, yet no event ever fires because there is no sensor behind it. Existence checks lie. So the demo falls back to dragging the canvas with a mouse, and the terminal's sensors command reports HTTPS, API presence and getUserMedia on separate lines, because out in the field each one fails independently. ## Zero trackers is a design constraint The site loads no analytics, no ad pixels, no fonts from a CDN and no third-party JavaScript. Nunito is self-hosted as TTF files, so a visit generates exactly 0 third-party connections. localStorage holds two keys, uranashel-lang and uranashel-theme. There is no consent banner because nothing happens that would need consent. That policy sounds like a legal footnote until you build a feature under it. The walk sim has a share button. The normal architecture would POST the result to a backend, mint a short link and count the clicks. We have no backend and want no counts, so the result serializes into the URL itself, ?imu=12&fused=3&gps=9, handed to navigator.share where it exists and to the clipboard where it does not. Whoever opens the link sees your numbers reconstructed from the query string. The link is the database, and it is a database we can never read. The cost is real and worth naming. We do not know how many people run these demos, which browsers break, or whether anyone has ever found the terminal. In place of telemetry there is ktuyen and a browser matrix: one afternoon per release, 9 browser/OS combinations, hands on glass. Slower than a dashboard. Also finite, which a dashboard never is. ## Labelling what a browser cannot measure Every demo on the page carries a status line with four states: ok, demo, err and idle. This is the part of the lab I care about most, because interactive marketing has a long tradition of showing sensor magic the shipped product cannot do. We run the other way and print the limits. The waterfall shows spectral shape, not sound level. A browser cannot state dB SPL honestly: microphone sensitivity is unknown and the AGC described above rewrites the gain under your feet. Sonarish exists as a native app largely because calibrated measurement needs the raw capture path. The scrolling garage is theatre and labelled as such, because browsers expose no barometer API at all. Its numbers are staged for legibility, 1.25 hPa and 3.5 m per floor, so the readout moves visibly under your thumb. Real physics is smaller. Hydrostatics gives `ΔP = ρ·g·Δh ≈ 12 Pa` per metre of descent, so a genuine 3.2 m garage floor is worth about 38 Pa, roughly 0.4 hPa, resolved against 0.3–1 Pa RMS of phone barometer noise. The derivation and the real floor-detection filter are in [the barometer post](https://uranashel.com/blog/barometer-parking-math.html). The walk sim's fusion is a scripted blend: weight 0.7 on the drifting IMU track, 0.22 on the last GPS anchor, 0.08 decaying toward the parked spot, tuned to look like what the real filter does. The real one is a Kalman filter with an actual innovation step, documented in [the Kalman post](https://uranashel.com/blog/kalman-filter-parking.html). And the hero benchmarks are a naive DFT correlation loop timed with performance.now(), a relative speed probe for your device rather than our production FFT. A demo that admits what it cannot measure earns the right to be believed about what it can. --- uranashel · [Home](https://uranashel.com/) · [Apps](https://uranashel.com/apps.html) · [Lab](https://uranashel.com/lab.html) · [About](https://uranashel.com/about.html) · [Blog](https://uranashel.com/blog/) · [Developers](https://uranashel.com/developers/) · [API docs](https://uranashel.com/docs/) · [Privacy](https://uranashel.com/privacy.html) Machine-readable: [llms.txt](https://uranashel.com/llms.txt) · [sitemap.xml](https://uranashel.com/sitemap.xml) · [openapi.json](https://uranashel.com/openapi.json) · [API](https://uranashel.com/api/v1/)