The lab page 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. 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, 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, 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.
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. 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.
Trang lab sinh ra để bạn nghịch công cụ của bọn mình mà không phải cài gì: spectrogram waterfall chạy mic thật, hầm đỗ xe tụt dần theo thao tác cuộn, mô phỏng đi bộ mất GPS rồi fusion cứu lại, attitude indicator, pin AR và một terminal nhỏ. Tất cả chạy ngay trong browser, trên máy bạn. Không gói tin nào bay về server. Dựng trang đó hóa ra là một dự án kỹ thuật riêng, ràng buộc nửa mượn từ app, nửa do nền web áp xuống. Bài này là bản writeup tech-stack: demo làm từ gì, browser giúp chỗ nào, nói dối chỗ nào và vì sao đôi khi trung thực nhất là in chữ demo màu xám nhỏ lên màn hình.
Không framework, không build step
Cả site là HTML tĩnh cộng 5 thẻ script trần. Không bundler, không npm, không node_modules, không transpiler. File lab.js nặng 29 KB chưa minify. Toàn bộ JavaScript của trang lab khoảng 114 KB chia 5 file, nhẹ hơn một tấm ảnh hero trung bình ngoài internet. Deploy nghĩa là copy file lên server rồi tăng chuỗi ?v= trên thẻ script. Chuỗi query đó là toàn bộ cơ chế xóa cache.
Một phần do khẩu vị. Studio 3 người không muốn nuôi thêm một toolchain frontend có lịch bảo trì riêng. Một phần do sản phẩm: ở đây view-source chính là tài liệu. Đã tuyên bố demo chạy on-device không tracking thì kỹ sư nào ghé qua cũng phải kiểm chứng được trong một phút.
Quyết định khung quan trọng nằm trong hàm boot(). Mỗi demo có một hàm init riêng, mỗi lời gọi bọc try/catch riêng. Site không chạy telemetry nên chẳng dashboard nào báo demo AR ném exception trên một bản WebView Android nào đó. Đành chọn phương án tốt thứ nhì: crash phải nằm yên tại chỗ. initAR() chết thì terminal bên dưới vẫn phải lên. Cách ly lỗi thay cho chẩn đoán mà bọn mình từ chối thu thập.
FFT miễn phí, kèm điều khoản nhỏ
Sonarish tự viết pipeline phổ từ đầu, cửa sổ các thứ, vì app đo đạc phải kiểm soát từng bước; toàn bộ suy diễn nằm trong bài FFT. Browser thì cho đường tắt. Nối stream mic từ getUserMedia vào một AnalyserNode là có FFT chạy sẵn bằng native code trong audio engine.
Waterfall dùng fftSize 512, được 256 bin. Sample rate mặc định 48 kHz thì mỗi bin rộng fs/N = 93.75 Hz. Gọi getByteFrequencyData sẽ nhận biên độ dạng byte: spec áp cửa sổ Blackman, chạy FFT, đổi sang dB rồi ánh xạ dải −100 đến −30 dBFS vào 0–255. Cỡ 0.27 dB mỗi nấc. Nghe thô nhưng thừa đủ cho một bức tranh chuyển động. smoothingTimeConstant đặt 0.7, trung bình mũ giữa các frame liên tiếp để hình đỡ sôi.
Hai dòng điều khoản nhỏ. Thứ nhất, analyser kiểu pull. Audio engine lấp một block 512 sample mỗi 10.67 ms, đúng nhịp callback trong bài audio thread, còn bạn đọc analyser lúc nào requestAnimationFrame gọi. Máy bận thì frame rơi, nguyên mảng phổ biến mất giữa hai lần đọc, không ai báo. Thứ hai, stream getUserMedia trả về đã bị xử lý sẵn cho thoại: echoCancellation, noiseSuppression và autoGainControl đều bật mặc định trên mọi browser lớn. Riêng AGC đã đủ biến mức âm tuyệt đối thành chuyện bịa trước khi code của bạn thấy sample đầu tiên.
60 frame waterfall không giật
Waterfall cuộn lịch sử sang trái, mỗi frame ghép thêm một cột mới. Bản của bọn mình cố tình thô: giữ một ImageData đúng cỡ canvas, dịch mọi pixel sang trái 1, ghi phổ mới vào cột phải cùng rồi gọi putImageData một lần.
// mỗi frame một cột, gọi từ requestAnimationFrame
function pushColumn(spectrum) { // Uint8Array, 256 bin
var d = img.data; // ImageData, W x 220
for (var y = 0; y < H; y++) {
for (var x = 0; x < W - 1; x++) { // dịch lịch sử trái 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];
}
// cột mới nhất, tần số cao nằm trên
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 có chủ đích
d[p+3] = 255;
}
ctx.putImageData(img, 0, 0); // mỗi frame vẽ một lần
}
Khâu dịch pixel tốn nhất. Canvas 960×220 nghĩa là vòng lặp trong di chuyển cỡ 210.000 pixel mỗi frame bằng JavaScript thuần. Ghi chú bench: đo pushColumn bằng performance.now() qua 600 frame, lấy median, ra 1.6 ms trên MacBook Air M1 chạy Safari, 2.9 ms trên iPhone 13, 11.4 ms trên Galaxy A50 đời 2019 chạy Chrome. Con số A50 giải thích vì sao chiều cao canvas ghim ở 220 px. Chiều cao kéo vòng lặp tăng tuyến tính. 11 ms trong định mức 16.7 ms mỗi frame chỉ chừa vừa đủ cho browser tự composite.
Đường nhanh hơn thì thử trước rồi: ctx.drawImage tự blit canvas lên chính nó lệch 1 px, chạy GPU, 0.4 ms cũng trên con A50 đó. Bỏ. Màn hình có devicePixelRatio lẻ sẽ resample bitmap mỗi frame, mà resample lặp đi lặp lại chính là lọc thông thấp: cuộn 15 giây, cả dải lịch sử nhòe thành sương. Byte tự tay copy thì nằm im chỗ cũ.
Thú nhận thêm cho ai đã đọc bài audio thread. Vòng render này cấp phát một Uint8Array 256 byte mới mỗi frame cho getByteFrequencyData. Trong callback audio, cấp phát kiểu đó bị chặn thẳng ở code review theo luật no-malloc. Handler requestAnimationFrame chịu tòa án khác: collector dọn mảng 256 byte sống ngắn mà không gây khựng nào đo được, còn hoist buffer ra ngoài chẳng nhanh thêm trên bất kỳ máy nào của bọn mình. Để nguyên.
Grayscale chính là colormap
Tutorial spectrogram nào cũng vớ lấy cầu vồng. Bản này ghi cùng một byte vào cả 3 kênh màu, biên độ thành đúng một mức xám, tối là im, sáng là to. Đúng luật toàn site trong bài monochrome. Tình cờ đây cũng là lựa chọn đứng vững nhất về mặt dữ liệu. Colormap kiểu jet có độ sáng lên xuống thất thường, vàng giữa dải dễ nhìn to hơn đỏ cuối dải. Xám thì đơn điệu theo đúng nghĩa toán: pixel sáng hơn luôn là nhiều năng lượng hơn trong bin đó, ở cả hai theme. Theme sáng chỉ đổi nền từ #050505 sang #f5f5f5, dữ liệu qua phép đảo vẫn nguyên vẹn.
Sổ tay DeviceOrientation
Attitude indicator và quả cầu wireframe nghe event deviceorientation, 3 góc Euler tên alpha, beta, gamma. API 15 tuổi mà vẫn nói giọng địa phương.
Safari iOS từ bản 13 bắt gọi DeviceMotionEvent.requestPermission(). Lệnh này chỉ chạy khi được gọi từ trong một cử chỉ người dùng, trên trang HTTPS. Gọi lúc load là bị từ chối luôn, không hiện hộp thoại nào. Người dùng từ chối một lần thì chịu tới khi reload trang. Chrome Android chẳng hỏi gì, bắn event luôn, nhưng alpha của deviceorientation thường là góc tương đối, mốc 0 đặt đại chỗ điện thoại đang chỉ lúc trang load; bản chuẩn la bàn là event deviceorientationabsolute riêng mà iOS không làm. devicemotion mang trường interval, trên một máy test của bọn mình khai 16 ms trong khi event tới mỗi 50 ms.
Rồi tới điểm kỳ dị. Gamma định nghĩa trong [−90°, 90°], nên dựng đứng điện thoại như cầm máy ảnh là phép phân rã Euler đi qua vùng gimbal lock: gamma lật dấu, beta nhảy gần 180°. Đường chân trời nhân tạo lộn ngược. Stack cảm biến native nào cũng né bằng quaternion; API web giao góc Euler kèm luôn cú nhảy, coi như quà tặng.
Desktop có bẫy riêng. typeof DeviceOrientationEvent trên Chrome desktop vẫn defined, nhưng chẳng event nào tới vì đằng sau làm gì có cảm biến. Tin vào phép kiểm tra tồn tại là bị lừa. Nên demo rơi về kéo chuột trên canvas, còn lệnh sensors trong terminal in HTTPS, API và getUserMedia thành từng dòng riêng, vì ngoài đời mỗi thứ hỏng một kiểu độc lập.
Không tracker là ràng buộc thiết kế
Site không load analytics, không pixel quảng cáo, không font từ CDN, không JavaScript bên thứ ba. Nunito tự host dạng file TTF, nên một lượt ghé trang tạo ra đúng 0 kết nối bên ngoài. localStorage giữ 2 key: uranashel-lang và uranashel-theme. Không có banner consent vì chẳng có gì diễn ra cần xin phép.
Nghe như chú thích pháp lý, tới lúc dựng tính năng mới thấm. Walk sim có nút share. Kiến trúc bình thường sẽ POST kết quả lên backend, phát link ngắn, đếm lượt bấm. Bọn mình không có backend và cũng không muốn đếm ai, nên kết quả nhét thẳng vào URL, ?imu=12&fused=3&gps=9, đưa cho navigator.share nơi nào có, rơi về clipboard nơi nào không. Ai mở link sẽ thấy số của bạn dựng lại từ query string. Cái link chính là database, mà database này bọn mình không bao giờ đọc được.
Giá phải trả có thật, nói thẳng luôn. Không biết bao nhiêu người chạy demo, browser nào vỡ, có ai từng tìm ra terminal chưa. Thay cho telemetry là ktuyen với một ma trận browser: mỗi bản phát hành một buổi chiều, 9 tổ hợp browser/OS, tay chạm màn thật. Chậm hơn dashboard. Nhưng hữu hạn, còn dashboard thì không bao giờ.
Ghi rõ browser không đo được gì
Mỗi demo trên trang đeo một dòng trạng thái với 4 trạng thái: ok, demo, err, idle. Đây là phần mình quý nhất, vì marketing tương tác có truyền thống lâu đời trình diễn phép màu cảm biến mà sản phẩm bán ra không làm nổi. Bọn mình đi chiều ngược lại, in luôn giới hạn lên màn hình.
Waterfall cho xem hình dạng phổ, còn mức âm thì chịu. Browser không thể báo dB SPL một cách đàng hoàng: độ nhạy mic không rõ, còn AGC kể trên thì vặn gain ngay dưới chân. Sonarish tồn tại dạng app native phần lớn vì phép đo có hiệu chuẩn cần đường capture thô.
Hầm cuộn là sân khấu, có dán nhãn, vì browser không hề có API áp kế. Số trên màn dựng cho dễ đọc, 1.25 hPa với 3.5 m mỗi tầng, để giá trị nhúc nhích rõ dưới ngón tay cuộn. Vật lý thật nhỏ hơn. Thủy tĩnh cho ΔP = ρ·g·Δh ≈ 12 Pa mỗi mét đi xuống, tầng hầm thật 3.2 m đáng cỡ 38 Pa, tầm 0.4 hPa, phân giải trên nền nhiễu áp kế điện thoại 0.3–1 Pa RMS. Cách suy ra và bộ lọc bắt tầng thật nằm trong bài áp kế.
Fusion trong walk sim là màn pha trộn theo kịch bản: trọng số 0.7 cho vệt IMU đang trôi, 0.22 cho mỏ neo GPS cuối cùng, 0.08 rút dần về chỗ đỗ xe, chỉnh cho giống dáng bộ lọc thật. Bộ thật là Kalman filter với bước innovation đàng hoàng, viết trong bài Kalman. Còn benchmark đầu trang là vòng DFT thô tương quan từng bin, đo bằng performance.now(), thăm dò tốc độ tương đối máy bạn chứ chưa phải FFT bản chạy trong app.
Demo dám nhận thứ nó không đo được thì mới đáng tin ở thứ nó đo được. Câu đó là toàn bộ tài liệu thiết kế của trang lab.