# uranashel — full text > Every public page of https://uranashel.com concatenated as one Markdown document, so an agent can read the whole site in a single fetch. Individual pages are listed in llms.txt. # uranashel — On-device mobile instruments — iOS & Android > uranashel is a three-person mobile software studio building on-device sensor and DSP instruments for iOS and Android: Wheria (indoor parking navigation), Estua (non-repeating sleep audio), Phyzix (pocket physics lab), Sonarish (acoustic diagnostics) and Stashio (semantic save-and-find). No ads, no tracking SDKs, no cloud requirement for core features. Your phone is a lab. We ship the instruments. Navigation when GPS dies, sound that never repeats, a pocket physics lab, acoustic diagnostics — all computed on silicon, with no cloud dependency, no advertising SDKs and no analytics loop. uranashel is a team of 3: **ntan** (Mobile & physics); **atuan** (Backend); **ktuyen** (QA). ## Apps ### Stashio — Save everything. Find by meaning. *Semantic search · share sheet · Spaces* Save links, photos, voice notes and PDFs via share sheet. AI cleans, tags and organizes into Spaces. Search everything by meaning. - **Category:** productivity - **Platforms:** iOS, Android, Chrome - **Status:** live - **Shipping since:** 2025 - **Last update:** 2026-06 - **Downloads:** 3K+ - **Stack:** Swift, Kotlin, Share Extension, Semantic search, SQLite - **App Store:** https://apps.apple.com/app/stashio/id6753916323 - **Google Play:** https://play.google.com/store/apps/details?id=com.uranashel.stashly - **Chrome Web Store:** https://chromewebstore.google.com/detail/stashio-%E2%80%94-save-to-remembe/ddifabdjihhojncjaeamfnjadnalmlga - **JSON:** https://uranashel.com/api/v1/apps/stashio ### Wheria — GPS dies. We don't. *IMU · baro · mag · dead reckoning* Save your parking spot and walk straight back — even in deep underground garages where GPS dies. Motion, compass and barometer fusion. Widgets, Live Activity, parking reminders. - **Category:** navigation - **Platforms:** iOS, Android, Watch - **Status:** live - **Shipping since:** 2024 - **Last update:** 2026-06 - **Downloads:** 15K+ - **Stack:** Swift, Kotlin, CoreMotion, CoreLocation, WidgetKit - **App Store:** https://apps.apple.com/app/wheria/id6785155247 - **JSON:** https://uranashel.com/api/v1/apps/wheria ### Estua — Sleep noise that never loops *48 kHz · real-time synth · zero loops* Never-repeating ambient for sleep, focus and recovery. Real-time on-device synthesis — no streaming, no account. - **Category:** health - **Platforms:** iOS, Android, Watch - **Status:** live - **Shipping since:** 2024 - **Last update:** 2026-05 - **Downloads:** 8K+ - **Stack:** Swift, Kotlin, AVAudioEngine, DSP, watchOS - **App Store:** https://apps.apple.com/app/estua/id6784632438 - **JSON:** https://uranashel.com/api/v1/apps/estua ### Phyzix — Pocket physics lab *100+ sensors · Euler/RK sims · EN/VI* Your phone as a pocket physics lab. 100+ live sensor instruments, interactive simulations, calculators and guided experiments. EN + VI. - **Category:** education - **Platforms:** iOS, Android - **Status:** live - **Shipping since:** 2024 - **Last update:** 2026-04 - **Downloads:** 6K+ - **Stack:** Swift, Kotlin, CoreMotion, SceneKit, 100+ sensors - **App Store:** https://apps.apple.com/app/phyzix-physics-lab/id6784482674 - **JSON:** https://uranashel.com/api/v1/apps/phyzix ### Sonarish — Listen like an engineer *A-weighted · FFT · baseline diff* Privacy-first acoustic toolkit: A-weighted noise meter, machine fault diagnosis vs baseline, PDF/CSV reports — all on-device. - **Category:** utilities - **Platforms:** iOS, Android - **Status:** live - **Shipping since:** 2024 - **Last update:** 2026-05 - **Downloads:** 5K+ - **Stack:** Swift, Kotlin, Accelerate, FFT, PDF export - **App Store:** https://apps.apple.com/app/sonarish/id6785146314 - **JSON:** https://uranashel.com/api/v1/apps/sonarish ## The science behind them - **Classical mechanics & IMU** — `F = ma`: Dead reckoning, step detection, attitude from accelerometer + gyroscope — how Wheria traces your walk indoors. - **Barometry & altitude** — `P = ρgh`: Pressure → height: parking-garage floor estimation when GPS is useless. - **Magnetometry & compass** — `B⃗ = B_E + B_dist`: Earth field + distortion handling for heading when you walk back to your car. - **Acoustics & waves** — `v = fλ`: A-weighting, LAeq, FFT, baseline comparison — Sonarish and Estua built on wave physics. - **Digital signal processing** — `X[k] = Σ x[n]e^{-j2πkn/N}`: Real-time synthesis, filters, envelopes — generative ambient in Estua without looping tracks. - **Linear algebra & estimation** — `x̂ = K·z`: Sensor fusion, Kalman-style filtering, least squares — combining noisy measurements into one answer. - **Electromagnetism & sensors** — `∇×E = −∂B/∂t`: Mag field, light, proximity — Phyzix exposes 100+ phone instruments as physics lab tools. - **On-device computation** — `latency → 0`: No cloud required for core physics — privacy, latency and offline use by design. - **Coordinate transforms & quaternions** — `q = w + xi + yj + zk`: Rotate sensor vectors between device, world and navigation frames — avoid gimbal lock in attitude tracking. - **Sampling theory & Nyquist** — `f_s > 2·f_max`: Choose sample rates and FFT sizes so spectra and audio pipelines alias responsibly, not accidentally. - **Psychoacoustics & perception** — `L = 10·log₁₀(I/I₀)`: Log hearing, masking, and calm UI timing — why Estua modulates slowly and Sonarish shows LAeq. - **Vibration & harmonic analysis** — `f_n = n·f₀`: Bearings, fans and panels add predictable spectral lines — baseline comparison in Sonarish. - **Numerical simulation** — `xₙ₊₁ = xₙ + h·f(xₙ)`: Euler and RK integrators for projectile, pendulum and wave demos inside Phyzix. - **Optics & photometry** — `E ∝ I / r²`: Lux, exposure, color channels — phone light sensors as teachable instruments. - **Statistics & inference** — `σ = √(Σ(x−μ)²/N)`: Confidence in noisy measurements — when to show error bars instead of fake precision. - **Information capture & search** — `score(q,d) = sim(q,d)`: Full-text and semantic indexing for links, notes and files — Stashio organizes what you save. - **Real-time embedded constraints** — `t_proc ≤ t_frame`: Fixed deadlines on audio and sensor threads — no malloc, no locks, no excuses. ## Operating principles - **Silicon, not servers**: Core physics runs where the sensors live — in your pocket. - **Zero surveillance economy**: No ad SDKs. No analytics loop. We sell tools, not attention. - **Both platforms, same bar**: iOS and Android ship feature-parity — friends use both, we refuse to pick sides. - **Monochrome on purpose**: Black, white, data first. Color is for signal, not decoration. ## Numbers - Live sensor instruments: 100+ - Audio synthesis thread: 48 kHz - Tracking SDKs: 0 - Shipped apps: 5 - GitHub stars: — ## Recent writing - [Allan variance, or how long a gyroscope can be trusted](https://uranashel.com/blog/allan-variance-gyro.html) (2026-08-25, 21 min): We left five phones logging their gyroscopes overnight and used Allan deviation to work out how many minutes of heading Wheria can borrow between compass corrections. - [An inaudible sonar that watches you breathe](https://uranashel.com/blog/sonar-breathing-doppler.html) (2026-08-18, 20 min): Sonarish's experimental breathing monitor drives the speaker at 19 kHz, listens on the mic, and reads 118 µm of chest motion out of the phase of the echo. - [Semantic search with no server in the loop](https://uranashel.com/blog/on-device-embeddings.html) (2026-08-11, 19 min): Sentence embeddings, int8 vectors and a brute-force cosine scan let Stashio search your saved links by meaning entirely on the phone. - [The lab behind the Lab page](https://uranashel.com/blog/lab-behind-the-lab.html) (2026-08-04, 18 min): 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. - [The battery budget of an always-on sensor app](https://uranashel.com/blog/battery-budget-sensors.html) (2026-07-27, 19 min): Four phones on a shelf, airplane mode, screen off, and five weeks of overnight battery logs to find out what each sensor really costs an always-on app. - [Shipping the same day on two app stores](https://uranashel.com/blog/shipping-two-stores.html) (2026-07-18, 18 min): atuan explains the release train that moves five apps to two stores on the same day, with review-time numbers from the studio's own log. - [Welcome to uranashel](https://uranashel.com/blog/welcome-uranashel.html) (2026-07-04, 20 min): Who we are, why five apps exist, and how this blog explains the physics behind them — in plain language. - [Why everything runs on your phone](https://uranashel.com/blog/on-device-first.html) (2026-06-15, 19 min): Why parking, sleep audio, and noise meters must compute on your phone — latency, privacy, and offline garages explained step by step. - [All 27 posts](https://uranashel.com/blog/) ## Contact - Email: uralab95@gmail.com - GitHub: https://github.com/uralabhub - Country: Vietnam (VN) ## For agents and developers - Public JSON API: https://uranashel.com/api/v1/ — read-only, no authentication, open CORS - OpenAPI 3.1 description: https://uranashel.com/openapi.json - API reference: https://uranashel.com/docs/ - Developer portal: https://uranashel.com/developers/ - Agent instructions (when to use uranashel): https://uranashel.com/agent-instructions.md - llms.txt: https://uranashel.com/llms.txt --- 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/) --- # uranashel apps > Every app uranashel has published. 5 apps, all live on the App Store, all on-device first. Per-app store links are listed below. | App | What it does | Platforms | Category | Since | | --- | --- | --- | --- | --- | | [Stashio](https://uranashel.com/apps.html#stashio) | Save everything. Find by meaning. | iOS, Android, Chrome | productivity | 2025 | | [Wheria](https://uranashel.com/apps.html#wheria) | GPS dies. We don't. | iOS, Android, Watch | navigation | 2024 | | [Estua](https://uranashel.com/apps.html#estua) | Sleep noise that never loops | iOS, Android, Watch | health | 2024 | | [Phyzix](https://uranashel.com/apps.html#phyzix) | Pocket physics lab | iOS, Android | education | 2024 | | [Sonarish](https://uranashel.com/apps.html#sonarish) | Listen like an engineer | iOS, Android | utilities | 2024 | ## Stashio — Save everything. Find by meaning. *Semantic search · share sheet · Spaces* Save links, photos, voice notes and PDFs via share sheet. AI cleans, tags and organizes into Spaces. Search everything by meaning. - **Category:** productivity - **Platforms:** iOS, Android, Chrome - **Status:** live - **Shipping since:** 2025 - **Last update:** 2026-06 - **Downloads:** 3K+ - **Stack:** Swift, Kotlin, Share Extension, Semantic search, SQLite - **App Store:** https://apps.apple.com/app/stashio/id6753916323 - **Google Play:** https://play.google.com/store/apps/details?id=com.uranashel.stashly - **Chrome Web Store:** https://chromewebstore.google.com/detail/stashio-%E2%80%94-save-to-remembe/ddifabdjihhojncjaeamfnjadnalmlga - **JSON:** https://uranashel.com/api/v1/apps/stashio ## Wheria — GPS dies. We don't. *IMU · baro · mag · dead reckoning* Save your parking spot and walk straight back — even in deep underground garages where GPS dies. Motion, compass and barometer fusion. Widgets, Live Activity, parking reminders. - **Category:** navigation - **Platforms:** iOS, Android, Watch - **Status:** live - **Shipping since:** 2024 - **Last update:** 2026-06 - **Downloads:** 15K+ - **Stack:** Swift, Kotlin, CoreMotion, CoreLocation, WidgetKit - **App Store:** https://apps.apple.com/app/wheria/id6785155247 - **JSON:** https://uranashel.com/api/v1/apps/wheria ## Estua — Sleep noise that never loops *48 kHz · real-time synth · zero loops* Never-repeating ambient for sleep, focus and recovery. Real-time on-device synthesis — no streaming, no account. - **Category:** health - **Platforms:** iOS, Android, Watch - **Status:** live - **Shipping since:** 2024 - **Last update:** 2026-05 - **Downloads:** 8K+ - **Stack:** Swift, Kotlin, AVAudioEngine, DSP, watchOS - **App Store:** https://apps.apple.com/app/estua/id6784632438 - **JSON:** https://uranashel.com/api/v1/apps/estua ## Phyzix — Pocket physics lab *100+ sensors · Euler/RK sims · EN/VI* Your phone as a pocket physics lab. 100+ live sensor instruments, interactive simulations, calculators and guided experiments. EN + VI. - **Category:** education - **Platforms:** iOS, Android - **Status:** live - **Shipping since:** 2024 - **Last update:** 2026-04 - **Downloads:** 6K+ - **Stack:** Swift, Kotlin, CoreMotion, SceneKit, 100+ sensors - **App Store:** https://apps.apple.com/app/phyzix-physics-lab/id6784482674 - **JSON:** https://uranashel.com/api/v1/apps/phyzix ## Sonarish — Listen like an engineer *A-weighted · FFT · baseline diff* Privacy-first acoustic toolkit: A-weighted noise meter, machine fault diagnosis vs baseline, PDF/CSV reports — all on-device. - **Category:** utilities - **Platforms:** iOS, Android - **Status:** live - **Shipping since:** 2024 - **Last update:** 2026-05 - **Downloads:** 5K+ - **Stack:** Swift, Kotlin, Accelerate, FFT, PDF export - **App Store:** https://apps.apple.com/app/sonarish/id6785146314 - **JSON:** https://uranashel.com/api/v1/apps/sonarish Machine-readable list: https://uranashel.com/api/v1/apps --- 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/) --- # About ntan and uranashel > Mobile engineering with a physics bent: sensors, acoustics and signal processing, computed on the device. **ntan** — Founder & Engineer. uranashel is a three-person studio — mobile, backend, QA. Five apps on the App Store; Stashio also ships on Google Play and the Chrome Web Store. ## Team - **ntan** — Mobile & physics: Founder. Swift, Kotlin, sensors, DSP. - **atuan** — Backend: APIs, sync, infrastructure for apps that need a server. - **ktuyen** — QA: Test matrices on real devices — both platforms, every release. ## Timeline - **Now — Five apps live**: iOS and Android, computed on the device. - **Since 2023 — uranashel**: A three-person studio: mobile, backend, QA. - **Before — Software and physics**: Engineering and applied science, now pointed at sensors. ## What we work with Swift / SwiftUI · Kotlin / Compose · C++ / DSP · physics and sensors · linear algebra · signal processing · acoustics · sensor fusion · on-device ML. ## Contact - Email: uralab95@gmail.com - GitHub: https://github.com/uralabhub - Country: Vietnam (VN) Open to collaborations in mobile, sensors, acoustics and applied physics. --- 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/) --- # uranashel instrument lab > Browser demos of the sensor and DSP instruments behind the uranashel apps. Every instrument runs locally in the page — sensor access is opt-in, and nothing is uploaded. Live page: https://uranashel.com/lab.html (needs JavaScript, and a phone for the motion instruments). ## Instruments on the page - **Benchmarks** — times a 256-point FFT, one synthesis buffer and an IMU integration step in your browser, so you can see the per-frame budget the apps work inside. - **Spectrogram waterfall** (Sonarish) — a scrolling 2D FFT of the microphone: frequency against time, the same view the noise meter uses. - **Parking garage barometer** (Wheria) — scroll to descend and watch pressure rise about 1.2 hPa per floor. This is the hydrostatic estimate Wheria uses to guess B2 from B3. - **GPS dies · IMU drifts · Fusion fixes** (Wheria) — drag a path and compare raw GPS, raw IMU dead reckoning and the fused estimate, with the error between them reported live. - **Attitude indicator** (Phyzix) — a wireframe globe driven by device orientation, or by dragging on desktop. - **AR pin** (Wheria) — a camera preview with a parking pin, via WebXR where the device supports it. - **Instrument terminal** — a small command line: `help`, `calibrate`, `bench`. ## The physics underneath - Spectra come from a windowed FFT; A-weighting and LAeq turn them into numbers comparable with real noise limits. - Altitude comes from the barometric relation between pressure and height, which is why a garage floor is guessable when GPS is not. - Position underground comes from step detection plus heading rather than double integration, because double integration drifts within seconds. - Longer explanations of each: https://uranashel.com/blog/ --- 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/) --- Effective 6 August 2026 · Applies to all uranashel apps # Privacy Policy & Terms of Use **Short version.** uranashel apps are built to run on your device. We do not sell your data, we do not use advertising SDKs, and we do not ask you to create an account to use core features. Sensor readings, audio, location and saved content stay on your phone unless a feature you explicitly turn on needs otherwise — and where that happens, it is spelled out below. #### Contents - [1. Who we are and what this covers](#scope) - [2. What we collect](#collect) - [3. Data that stays on your device](#device) - [4. Permissions, per app](#permissions) - [5. When data leaves your device](#cloud) - [6. Third parties and stores](#third-party) - [7. Legal bases (GDPR)](#legal-basis) - [8. Your rights](#rights) - [9. Retention and deletion](#retention) - [10. Security](#security) - [11. Children](#children) - [12. International transfers](#transfers) - [13. Changes to this policy](#changes) - [14. Terms of use](#terms) - [15. Contact](#contact) ## 1. Who we are and what this covers uranashel is a three-person mobile software studio based in Vietnam (ntan, atuan, ktuyen). We are the data controller for the purposes of this policy. You can reach us at any time at [uralab95@gmail.com](mailto:uralab95@gmail.com). This single policy covers **every app we publish** on the App Store and Google Play, the Stashio browser extension on the Chrome Web Store, and this website. As of the effective date that means: - **Stashio** — save and search anything (`com.uranashel.stashly`) - **Stashio for Chrome** — browser extension that saves pages into Stashio (`ddifabdjihhojncjaeamfnjadnalmlga`) - **Wheria** — parking and indoor return navigation (`com.uranashel.backtrack`) - **Estua** — non-repeating ambient sleep audio (`com.uranashel.estua`) - **Phyzix** — pocket physics lab (`com.uranashel.pocketphysicslab`) - **Sonarish** — acoustic measurement and machine diagnosis (`com.uranashel.sonari`) Any app we release later is covered by this policy from the day it ships, unless that app links to a policy of its own. ## 2. What we collect We do not require an account, a name, an email address, or a phone number to use the core features of our apps. We do not operate advertising SDKs, we do not build advertising profiles, and we do not sell or rent personal information to anyone — for any price, under any framing. The categories below are the *only* ones that apply: | Category | What it is | Where it goes | | --- | --- | --- | | Sensor & measurement data | Accelerometer, gyroscope, magnetometer, barometer, microphone level, light, GPS | Processed on your device. Not transmitted to us. | | Content you save | Parking spots, notes, recordings, links, PDFs, images, measurement sessions | Stored on your device. Synced only if you turn sync on (Stashio). | | Settings & preferences | Theme, language, units, alarm and reminder settings | Your device, plus your own iCloud/Google backup if you use it. | | Purchase status | Whether a paid tier is active | Handled by Apple/Google. We see anonymous transaction state, never your card. | | Diagnostics | Crash reports and aggregate performance, if you opted in at OS level | Apple/Google consoles, aggregated and not identifying. | | Support email | Whatever you write to us | Our mailbox. Used only to answer you. | ## 3. Data that stays on your device Our apps are deliberately built so the interesting parts compute locally. This is an engineering choice before it is a legal one: local computation is faster, works offline in a basement garage where there is no signal, and removes an entire class of privacy risk. - **Wheria** fuses accelerometer, gyroscope, magnetometer and barometer into a walked path on-device. Your parking locations and traces are stored locally and are never uploaded to us. - **Estua** synthesizes audio in real time on your phone. There is no streaming, no listening, and no microphone use. - **Phyzix** reads device sensors for live instruments and experiments. Readings are rendered and discarded, or saved locally if you save them. - **Sonarish** analyzes microphone input on-device to compute sound level and spectra. Audio is processed in memory for measurement; recordings and reports are written only to your device unless you export or share them yourself. - **Stashio** stores your saved items in a local database. Cloud sync and AI organization are described in section 5. ## 4. Permissions, per app Every permission we request maps to a feature you can point at. If you decline a permission, the rest of the app keeps working; only the dependent feature is unavailable. You can change these at any time in your device settings. | App | Permission | Why | | --- | --- | --- | | Wheria | Location (while in use / always) | To anchor your parking spot and correct the walked path outdoors. "Always" is optional and only used for automatic parking detection and reminders. | | Wheria | Motion & fitness | Step detection and dead reckoning when GPS is unavailable underground. | | Wheria | Notifications | Parking reminders and Live Activity updates. | | Wheria | Camera / Photos (optional) | Only if you attach a photo of your parking spot. | | Estua | Notifications (optional) | Sleep timer and wind-down reminders. | | Phyzix | Motion, magnetometer, barometer | Live sensor instruments and experiments. | | Phyzix | Microphone (optional) | Sound-based instruments such as the oscilloscope and frequency meter. | | Phyzix | Camera (optional) | Optical experiments and colour/light measurement. | | Sonarish | Microphone | Required. This is the measurement input for the noise meter and machine diagnosis. | | Sonarish | Files / Storage | To export PDF and CSV reports where you choose. | | Stashio | Share sheet / Share extension | To receive links, images, PDFs and text you send from other apps. | | Stashio | Microphone (optional) | Voice notes. | | Stashio | Photos / Files (optional) | To save items you pick from your library. | | All apps | Network | Store purchases, opening links you tap, and — for Stashio only — sync and AI features you enable. | We do not request contacts, calendar, health records, or advertising identifiers in any app. ## 5. When data leaves your device There are exactly three situations in which data leaves your phone, and in all three you are the one who triggers it: 1. **You export or share something.** A Sonarish PDF report, a Phyzix data set, a Wheria location shared to a messaging app. The destination is whatever you picked, and it is then governed by that service's policy, not ours. 2. **Stashio sync and AI organization, if enabled.** Stashio can sync your saved items across your devices and can use AI to clean, tag and organize what you save so it can be found by meaning. When these features are on, the relevant item content is transmitted over an encrypted connection to our sync service and to the AI processing provider that performs the tagging and embedding. That content is used solely to deliver the feature to you — never to train third-party models, never for advertising, never for resale. Sync and AI organization can be turned off, and Stashio remains fully usable as a local-only app. 3. **You email us.** Then we have whatever you chose to send. ## 6. Third parties and stores We keep the dependency list short on purpose. - **Apple App Store / Google Play** handle distribution, purchases and subscriptions. They collect their own data under their own policies, and they give us only aggregated, non-identifying sales and crash statistics. - **Apple / Google platform services** — iCloud or Google backup, if you use them — may include our app data in your own encrypted device backup. That backup belongs to you and your platform account, not to us. - **Stashio infrastructure** — our sync service and the AI processing provider, used only for the features described in section 5, and only when you enable them. We have no advertising networks, no analytics SDKs that profile you, no social login, and no data brokers. ## 7. Legal bases (GDPR) Where the GDPR applies, we rely on: **performance of a contract** for the features you asked for (running the app, delivering purchases, syncing when you enable it); **consent** for optional permissions such as microphone, camera, location and notifications, which you may withdraw in device settings at any time; and **legitimate interest** for keeping our apps working and secure, using only aggregated, non-identifying diagnostics. ## 8. Your rights Depending on where you live, you may have the right to access, correct, export, delete or restrict processing of your personal data, to object to processing, and to lodge a complaint with your data protection authority. Under the CCPA/CPRA you also have the right to know, delete, correct, and to opt out of "sale" or "sharing" — we do neither, so there is nothing to opt out of, and we will never discriminate against you for exercising any right. Because most of our data never reaches us, the fastest route for most requests is your own device: deleting the app removes its local data. For anything held on our side — Stashio sync content or support email — write to [uralab95@gmail.com](mailto:uralab95@gmail.com) and we will respond within 30 days. ## 9. Retention and deletion On-device data lives as long as you keep it, and is removed when you delete the item or uninstall the app. Stashio synced content is retained while your sync is active and is deleted from our service after you delete the items or request account deletion; residual encrypted backups are purged within 30 days. Support email is kept only as long as needed to resolve your question. ## 10. Security Data in transit is encrypted with TLS. Sensitive data at rest on your device is protected by the operating system's own encryption and, where appropriate, the platform keychain. Keeping computation local is itself our main security control: data that never leaves your phone cannot be breached on a server. No system is perfect, and we do not claim otherwise — if we ever become aware of a breach affecting you, we will notify you and the relevant authority as required by law. ## 11. Children Our apps are not directed at children under 13 (or under 16 where local law sets that bar), and we do not knowingly collect personal information from them. Phyzix is used in classrooms, and it is designed so that no personal data is required or collected to use it. If you believe a child has provided us personal information, contact us and we will delete it. ## 12. International transfers We operate from Vietnam. Where Stashio sync or AI features are enabled, content may be processed on servers outside your country, including outside the EEA. Where required, we rely on appropriate safeguards such as the European Commission's Standard Contractual Clauses. ## 13. Changes to this policy If we change this policy we will update the effective date at the top of this page, and for material changes we will announce it in the app or on this site before it takes effect. Continuing to use the apps after a change means you accept the updated policy. ## 14. Terms of use **Licence.** We grant you a personal, non-exclusive, non-transferable, revocable licence to use our apps on devices you own or control, in line with the App Store, Google Play and Chrome Web Store terms. You may not reverse-engineer, decompile, resell, sublicense or redistribute the apps except where that restriction is prohibited by law. **Purchases.** Paid features and subscriptions are billed by Apple or Google, renew according to the terms shown at purchase, and are managed and cancelled in your store account. Refunds follow the store's policy, not ours. **Measurement disclaimer — read this one.** Our apps are instruments for everyday and educational use, not certified equipment. Wheria assists you in returning to a saved spot; it is not a survey-grade positioning system, and you remain responsible for your own navigation and safety. Sonarish is not a Type 1/Type 2 certified sound level meter and must not be used as the sole basis for occupational noise compliance, legal evidence, or safety decisions. Phyzix is an educational instrument, not laboratory metrology. Estua is a sleep-audio tool and is not a medical device: it does not diagnose, treat or prevent any condition. Nothing in our apps is medical, legal, engineering or professional advice. **Your content.** Anything you create or save stays yours. You grant us only the narrow, revocable permission needed to operate the features you enable — for example, to sync and index your Stashio items on your behalf. We claim no ownership of your content and will not use it to train third-party models. **Acceptable use.** Do not use the apps unlawfully, to infringe others' rights, to record people without a lawful basis where consent is required, or to attempt to disrupt or gain unauthorized access to our services. **Warranty and liability.** The apps are provided "as is" and "as available", without warranties of any kind to the fullest extent the law allows. To the extent permitted by law, our total liability arising from the apps is limited to the amount you paid us for them in the twelve months before the claim. Nothing here limits liability that cannot lawfully be limited, including for death or personal injury caused by negligence, or for fraud. Consumers keep all mandatory statutory rights under their local law. **Governing law.** These terms are governed by the laws of Vietnam, without prejudice to mandatory consumer protections in your country of residence. ## 15. Contact Privacy questions, data requests, or anything in this document that reads wrong to you: - Email — [uralab95@gmail.com](mailto:uralab95@gmail.com?subject=Privacy%20request) - Studio — uranashel, Vietnam We answer data requests within 30 days. --- 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/) --- Developers # uranashel developer portal A public, read-only JSON API describing the uranashel studio, its five iOS and Android apps and every blog post. No sign-up, no API key, open CORS. #### On this page - [Quickstart](#quickstart) - [Authentication and API keys](#auth) - [Endpoints](#endpoints) - [Command-line tool](#cli) - [Markdown instead of HTML](#markdown) - [For AI agents](#agents) - [Every machine-readable file](#files) - [Sandbox](#sandbox) - [Versioning and deprecation](#versioning) - [Support](#support) ## Quickstart Three requests get you everything. The API is a set of static JSON documents served from the edge, so there is nothing to install and nothing to authenticate. ``` curl -s https://uranashel.com/api/v1/ # index of every endpoint curl -s https://uranashel.com/api/v1/apps # all five apps curl -s https://uranashel.com/api/v1/apps/wheria # one app ``` Every response is wrapped in the same envelope: `self` (the canonical URL of the resource), `meta` (API version, docs link, item count) and `data` (the payload). Full schemas are in the [API reference](https://uranashel.com/docs/) and the [OpenAPI 3.1 description](https://uranashel.com/openapi.json). ## Authentication and API keys **There are none, by design.** Everything the API exposes is already public on this website — app descriptions, store links, blog metadata, contact details. Adding a key would gate public information behind an account, so the API is open to anyone, from any origin. Concretely: - No `Authorization` header, no token, no OAuth flow. - `Access-Control-Allow-Origin: *` — call it straight from a browser or an agent runtime. - Only `GET` and `HEAD`. There is no write surface, so nothing you send can change state. - Requests are not logged against an identity, because there is no identity. If you need higher throughput than the documented budget, email [uralab95@gmail.com](mailto:uralab95@gmail.com) rather than working around it. ## Endpoints Base URL: `https://uranashel.com/api/v1` | Endpoint | Returns | | --- | --- | | `GET /` | Index of every endpoint, the rate-limit policy and the versioning policy | | `GET /studio` | Studio identity: name, tagline, location, contact, team size, counts | | `GET /team` | The three people who ship each release | | `GET /apps` | All five apps with platforms, stack, status and store links | | `GET /apps/{appId}` | One app by slug — `wheria`, `estua`, `phyzix`, `sonarish`, `stashio` | | `GET /posts` | Every blog post with excerpt, tags and Markdown URL | | `GET /posts/{slug}` | One post by slug | | `GET /stats` | The homepage counters as structured values | | `GET /science` | The seventeen disciplines behind the apps, each with its governing formula | | `GET /principles` | The product rules the studio holds itself to | | `GET /timeline` | Dated studio milestones | | `GET /versions` | API versions and their lifecycle state | | `GET /health` | Liveness check | | `GET /openapi.json` | The OpenAPI 3.1 description of everything above | Rate limits, caching, CORS and error shapes are documented in the [API reference](https://uranashel.com/docs/). ## Command-line tool `uranashel` is a zero-dependency Node CLI over the same API. It needs Node 18 or newer and nothing else. ``` npx uranashel apps # table of every app npx uranashel app wheria # one app in detail npx uranashel posts --limit 5 # recent writing npx uranashel post why-gps-lies # full article as Markdown npx uranashel studio --json # raw JSON for piping into jq npx uranashel open wheria # open the app page in a browser ``` Add `--json` to any command for the unformatted response, and `--base ` to point at a different origin. Source lives in [the GitHub organisation](https://github.com/uralabhub). ## Markdown instead of HTML Every page on this site has a Markdown mirror at the same path with a `.md` extension, and the site honours [Accept-header negotiation](https://acceptmarkdown.com/) for it. ``` curl -sH "Accept: text/markdown" https://uranashel.com/ # -> text/markdown curl -s https://uranashel.com/index.md # the same document curl -s https://uranashel.com/blog/why-gps-lies.md # full article text ``` Responses that can vary this way send `Vary: Accept, Accept-Encoding`, so a shared cache never hands an agent the HTML variant by mistake. Each HTML page also advertises its mirror with ``. ## For AI agents Two files exist specifically for you: - [llms.txt](https://uranashel.com/llms.txt) — a structured index of the whole site, including which app fits which user problem. - [agent-instructions.md](https://uranashel.com/agent-instructions.md) — when to reach for uranashel, when not to, how to call the API, and the request budget to stay inside. If you want the entire corpus in one fetch rather than crawling, use [llms-full.txt](https://uranashel.com/llms-full.txt). A 404 on this domain returns a real `404` status with a short Markdown body listing these entry points, so a wrong guess still tells you where to go. ## Every machine-readable file | URL | What it is | | --- | --- | | [`/api/v1/`](https://uranashel.com/api/v1/) | API index | | [`/openapi.json`](https://uranashel.com/openapi.json) | OpenAPI 3.1 description | | [`/llms.txt`](https://uranashel.com/llms.txt) | Structured site index for language models | | [`/llms-full.txt`](https://uranashel.com/llms-full.txt) | Whole site as one Markdown document | | [`/agent-instructions.md`](https://uranashel.com/agent-instructions.md) | When to use uranashel, and how | | [`/sitemap.xml`](https://uranashel.com/sitemap.xml) | Every indexable URL with last-modified dates | | [`/robots.txt`](https://uranashel.com/robots.txt) | Crawl policy — everything allowed | | [`/.well-known/security.txt`](https://uranashel.com/.well-known/security.txt) | Vulnerability reporting contact (RFC 9116) | | [`/humans.txt`](https://uranashel.com/humans.txt) | Who built this | ## Sandbox There is no separate sandbox environment, and you do not need one: the API is read-only and unauthenticated, so production *is* the sandbox. Nothing you can send will create, modify or delete anything. To develop against a local copy, clone the site and serve the repository root with any static file server — the API is just files on disk under `api/v1/`. Then point the CLI at it: ``` npx uranashel apps --base http://localhost:8000 ``` ## Versioning and deprecation The version lives in the URL path. `v1` is current and nothing is deprecated. When a version is retired you get at least **180 days** of notice, announced through `Deprecation` and `Sunset` response headers, a `Link` header pointing at the policy, and a status change on [`/api/v1/versions`](https://uranashel.com/api/v1/versions). Full detail: [versioning and deprecation policy](https://uranashel.com/docs/versioning.html). ## Support Email [uralab95@gmail.com](mailto:uralab95@gmail.com). We answer in English or Vietnamese. Content returned by the API is published under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — attribute to uranashel with a link to [uranashel.com](https://uranashel.com/index.html). --- 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/) --- Documentation # uranashel API reference Base URL `https://uranashel.com/api/v1` · version 1.0.0 · read-only · no authentication · open CORS. #### Contents - [Conventions](#conventions) - [Authentication](#auth) - [Rate limits](#rate-limits) - [Caching and CORS](#caching) - [Errors](#errors) - [Discovery endpoints](#discovery) - [Studio endpoints](#studio-endpoints) - [App endpoints](#app-endpoints) - [Blog endpoints](#blog-endpoints) - [Markdown negotiation](#markdown) - [Versioning](#versioning) ## Conventions Every collection and item response uses the same envelope: ``` { "self": "https://uranashel.com/api/v1/apps/wheria", "meta": { "apiVersion": "1.0.0", "documentation": "https://uranashel.com/docs/" }, "data": { "id": "wheria", "name": "Wheria", ... } } ``` Bilingual strings are objects with `en` and `vi` keys. Dates are ISO 8601. Absolute URLs are used everywhere so a response can be followed without knowing the base. The machine-readable contract is [openapi.json](https://uranashel.com/openapi.json) (OpenAPI 3.1). Every operation there has a unique `operationId`, a description, typed parameters and a response schema. ## Authentication None. The API exposes only what this website already publishes, so there is no key to obtain and no header to send. Requests are anonymous, and only `GET` and `HEAD` are supported — any other method returns `405`. ## Rate limits The documented budget is **600 requests per 60 seconds per client**. Every API response carries the headers you need to stay inside it: | Header | Meaning | | --- | --- | | `RateLimit-Policy` | `"public";q=600;w=60` — the advertised quota and window | | `RateLimit-Limit` | `600` — requests allowed per window. **Self-throttle to this.** | | `RateLimit-Remaining` | Requests left in the window | | `RateLimit-Reset` | Seconds until the window resets | | `Retry-After` | Sent on `429` — seconds to wait before retrying | **Read this before trusting `RateLimit-Remaining`.** The API is a set of static documents served from the edge; the origin does not count requests per client. `RateLimit-Remaining` therefore advertises the policy ceiling rather than a live counter, and will not tick down as you call. Treat `RateLimit-Limit` as the budget to pace yourself against, and honour `Retry-After` if the edge does return a `429`. ## Caching and CORS API responses are sent with `Cache-Control: public, max-age=300, stale-while-revalidate=86400` and a strong `ETag`. Send `If-None-Match` and you will get `304 Not Modified` instead of a body — the cheapest way to poll. `Access-Control-Allow-Origin: *` is set on everything under `/api/`, along with `Access-Control-Allow-Methods: GET, HEAD, OPTIONS`, so browser and agent runtimes can fetch directly. ## Errors Unknown paths return a real `404` — never a `200` with a shell. Under `/api/` the body is a [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem document: ``` { "type": "https://uranashel.com/docs/#errors", "title": "Not Found", "status": 404, "detail": "No such API resource. The endpoint index lists everything that exists.", "documentation": "https://uranashel.com/docs/", "index": "https://uranashel.com/api/v1/", "versioningPolicy": "https://uranashel.com/docs/versioning.html" } ``` Because the API is static, the problem document is the same for every missing path — it does not echo the failing URL back. Outside `/api/`, a missing path returns the [404 page](https://uranashel.com/404.html), or a short Markdown body if you send `Accept: text/markdown`. | Status | When | | --- | --- | | `200` | Success | | `304` | `If-None-Match` matched the current `ETag` | | `404` | No such resource | | `405` | Method other than `GET`, `HEAD` or `OPTIONS` | | `429` | Edge rate limit exceeded — retry after `Retry-After` seconds | ## Discovery endpoints ### GET /api/v1/ `operationId: getApiIndex` Lists every endpoint in v1, the rate-limit policy, the OpenAPI document and the versioning policy. Start here. ### GET /api/v1/versions `operationId: listVersions` Every API version with its lifecycle state (`current`, `deprecated`, `sunset`), release date and — once announced — deprecation and sunset dates. Poll this to detect a deprecation early. ### GET /api/v1/health `operationId: getHealth` Returns `200` with `status: "ok"` whenever the origin is serving the API. Safe to poll at a low rate. ## Studio endpoints ### GET /api/v1/studio `operationId: getStudio` Identity of the studio: name, tagline, country, email, GitHub, team size, app and post counts. Use this to answer “who is uranashel” without scraping HTML. ### GET /api/v1/team `operationId: listTeam` The three people who ship every release, with handle and area of responsibility. ### GET /api/v1/stats `operationId: listStats` The homepage counters — instrument count, audio sample rate, number of tracking SDKs, shipped apps — as structured values. ### GET /api/v1/science `operationId: listScienceTopics` The seventeen disciplines the apps are built on, each with its governing formula and a plain-language note on where it is used. ### GET /api/v1/principles `operationId: listPrinciples` The product rules the studio holds itself to: on-device first, no tracking SDKs, platform parity, monochrome UI. ### GET /api/v1/timeline `operationId: listTimeline` Dated milestones for the studio and its founder. ## App endpoints ### GET /api/v1/apps `operationId: listApps` Every published app with platforms, release status, technology stack, download count and store links. This is the endpoint to call when matching a user's problem to an app. ``` curl -s https://uranashel.com/api/v1/apps | jq '.data[] | {id, name, category}' ``` ### GET /api/v1/apps/{appId} `operationId: getApp` · path parameter `appId`: one of `wheria`, `estua`, `phyzix`, `sonarish`, `stashio` Full record for a single app. ## Blog endpoints ### GET /api/v1/posts `operationId: listPosts` Every published post, newest first, with title, excerpt, tags, reading time and links to the HTML and Markdown renderings. ### GET /api/v1/posts/{slug} `operationId: getPost` · path parameter `slug`: the post slug from `listPosts` Metadata for one post. Fetch `links.markdown` for the full text. ## Markdown negotiation Pages on this site are available as Markdown as well as HTML, following [acceptmarkdown.com](https://acceptmarkdown.com/): ``` curl -sI -H "Accept: text/markdown" https://uranashel.com/apps.html # content-type: text/markdown; charset=utf-8 # vary: Accept, Accept-Encoding ``` Or append `.md` to any page path. Quality values are honoured: `Accept: text/html;q=0.9, text/markdown;q=0.5` returns HTML. A client that accepts neither HTML nor Markdown gets `406`. ## Versioning The version is in the path. `v1` is current; nothing is deprecated. Deprecations come with at least 180 days of notice, `Deprecation` and `Sunset` headers and a status change on [`/api/v1/versions`](https://uranashel.com/api/v1/versions). Read the full [versioning and deprecation policy](https://uranashel.com/versioning.html). --- 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/) --- Documentation # uranashel API versioning & deprecation policy What we promise about changing the uranashel API, and how you will hear about it before anything breaks. ## Current state | Version | Base URL | Status | Released | Deprecated | Sunset | | --- | --- | --- | --- | --- | --- | | `v1` | `https://uranashel.com/api/v1/` | Current | 2026-08-23 | — | — | The same table is machine-readable at [`/api/v1/versions`](https://uranashel.com/api/v1/versions). Read it there rather than parsing this page. ## How versions are numbered The major version lives in the URL path: `/api/v1/`. A new major version gets a new path (`/api/v2/`) and the old one keeps working for the notice period below. Inside a major version, the contract carries a semantic version reported in the `API-Version` response header and in `meta.apiVersion` on every response. Today that is `1.0.0`. - **Patch** (`1.0.x`) — data updates, corrected descriptions, new examples. - **Minor** (`1.x.0`) — new endpoints or new optional fields. Backwards compatible. - **Major** (`2.0.0`) — anything that could break a client. Ships at a new path; never in place. ## What counts as a compatible change Within a major version we may, without notice: - Add a new endpoint. - Add a new field to an existing response. - Add a new enum member to a field documented as extensible (`platforms`, `category`). - Change the value of data fields — download counts, release dates and post lists change as the studio ships. - Relax a validation rule, or widen a documented type union. Write your client so unknown fields are ignored rather than fatal, and none of the above will affect you. ## What counts as a breaking change - Removing or renaming an endpoint, field or `operationId`. - Changing the type of an existing field, or removing an enum member. - Changing the response envelope, or the meaning of an existing field. - Adding a required request parameter. - Introducing authentication where there was none. Breaking changes only ever arrive at a new major path. ## Notice period **At least 180 days.** From the day a version is announced as deprecated to the day it stops responding, you get a minimum of 180 days. The exact removal date is published up front and never brought forward. ## How a deprecation is announced Four signals fire together, so a client detects the change whether it reads headers, polls JSON or reads the website. ### 1. `Deprecation` response header Per [RFC 9745](https://www.rfc-editor.org/rfc/rfc9745), every response from a deprecated version carries the moment deprecation took effect, as an HTTP date: ``` Deprecation: @1789084800 ``` ### 2. `Sunset` response header Per [RFC 8594](https://www.rfc-editor.org/rfc/rfc8594), the date the version stops responding: ``` Sunset: Sat, 21 Feb 2027 00:00:00 GMT ``` ### 3. `Link` header pointing here ``` Link: ; rel="deprecation"; type="text/html", ; rel="successor-version" ``` ### 4. `/api/v1/versions` changes state The `status` field moves from `current` to `deprecated`, and the `deprecated` and `sunset` fields stop being `null`: ``` { "version": "v1", "status": "deprecated", "deprecated": "2026-08-25", "sunset": "2027-02-21", "policy": "https://uranashel.com/docs/versioning.html" } ``` ## Detecting deprecation from a client The cheapest check is a single conditional request against the version list: ``` curl -sI https://uranashel.com/api/v1/apps | grep -iE '^(deprecation|sunset|api-version):' curl -s https://uranashel.com/api/v1/versions | jq '.data[] | select(.status != "current")' ``` If either produces output, plan a migration. Nothing else about the response changes until the sunset date. ## After sunset A sunset version returns `410 Gone` with a problem document naming its successor. It does not silently redirect: a redirect would hand your client a differently-shaped payload without telling it. ## Questions Email [uralab95@gmail.com](mailto:uralab95@gmail.com) before you build something that depends on an undocumented detail — we would rather document it than break it. See also the [API reference](https://uranashel.com/index.html) and the [developer portal](https://uranashel.com/developers/). --- 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/) --- [← All apps](https://uranashel.com/apps.html) ![Stashio](https://uranashel.com/assets/icons/stashio.svg) # Stashio Save everything. Find by meaning. Semantic search · share sheet · Spaces iOS Android Chrome Live Stashio exists because my Apple Notes had 2,400 links in it and I could never find one. Share something in; it reads it and files it. When you need it back, you search by what you remember, not by the exact words. [App Store](https://apps.apple.com/app/stashio/id6753916323)[Google Play](https://play.google.com/store/apps/details?id=com.uranashel.stashly)[Chrome](https://chromewebstore.google.com/detail/stashio-%E2%80%94-save-to-remembe/ddifabdjihhojncjaeamfnjadnalmlga) Swift Kotlin Share Extension Semantic search SQLite 3K+ dl Updated 2026-06 ## See it running Official App Store screenshots. Scroll sideways for more. ![Save anything. Find everything. — stashio screenshot](https://uranashel.com/assets/screens/stashio/01-save.jpg)**Save anything. Find everything.**The library, grouped by day. Each save shows what it is and the one number you care about: read time, ingredient count, price. ![Clean reading, zero clutter — stashio screenshot](https://uranashel.com/assets/screens/stashio/02-read.jpg)**Clean reading, zero clutter**Summary and key points on top, the full article underneath. ![Snap it. We read it. — stashio screenshot](https://uranashel.com/assets/screens/stashio/03-vision.jpg)**Snap it. We read it.**Take a photo of a café. You get address, hours, phone, price range. ![Organized for you — stashio screenshot](https://uranashel.com/assets/screens/stashio/04-spaces.jpg)**Organized for you**Spaces sort themselves. Travel 24 saves, Design 31, Coffee Spots 9. ![Ask your whole library — stashio screenshot](https://uranashel.com/assets/screens/stashio/05-ask.jpg)**Ask your whole library**Ask a question; the answer lists the saves it came from underneath. ![Your full second brain — stashio screenshot](https://uranashel.com/assets/screens/stashio/06-premium.jpg)**Your full second brain**$1.99 a month, $9.99 a year with a 7-day trial, $29.99 lifetime. ## What people use it for ### Everyday ### Vouchers It reads the expiry date when you save one and reminds you before it lapses. ### Restaurants A friend sends a place. Two months later you actually go, and the address and hours are already there. ### Recipes Recipes show their ingredient count right in the list. ### Watch later Reels disappear. Saved ones don't. ### Things you might buy Wishlist keeps the item and its price until you decide. ### Trips Everything for one trip lands in one Space. The night before, ask it for the itinerary. ### Work ### API docs atuan keeps documentation snippets in it. The kind of thing you need twice a year. ### Long articles The summary tells you whether the piece deserves twenty minutes. ### QA logs ktuyen puts test screenshots and regression logs into Spaces before each release. ### Ideas Mic button in the Library. Premium transcribes the recording. ### Memory ### 2,400 links That was my Apple Notes before Stashio. Three months in, 94% of my lookups take under ten seconds. ### Forgotten keywords “That thing about hydrostatic pressure in parking garages” finds the draft. The word hydrostatic was never in a tag. ### Receipts Photograph them. The text inside is searchable. ### Old saves Rediscover brings older saves back up now and then. ## How to save your first thing ### iPhone / iPad 1. Open the thing: a Safari page, a photo, a PDF. 2. Tap Share. 3. Pick Stashio. First time it hides at the end of the app row: tap More and switch it on. 4. That's it. It sorts itself out. ### Android 1. Open the thing. 2. Share → Stashio. 3. Big PDFs finish importing in the background. 4. Voice notes: the mic button at the top of the Library. ### Chrome on your computer 1. Install the extension from the Chrome Web Store. 2. Pair once: on the phone, Settings → Linked devices → Scan QR, aim at the code in the popup. No email, no password. 3. Or skip the camera and type the four-emoji code. 4. From then on the toolbar icon saves the current page. One click. 5. Highlight text, right-click, Save to Stashio. Links and images too. 6. Unlink is in the popup if you want out. ![One click saves the page](https://uranashel.com/assets/screens/stashio-ext/01-one-click.jpg)**One click saves the page** ![Right-click for highlights, links, images](https://uranashel.com/assets/screens/stashio-ext/02-right-click.jpg)**Right-click for highlights, links, images** ![Pair with your phone by QR, no account](https://uranashel.com/assets/screens/stashio-ext/03-link-phone.jpg)**Pair with your phone by QR, no account** ## Ways in - **Share sheet**Share Extension on iOS, Share Target on Android. Links, posts, products, recipes, places: same door. - **Chrome extension**One click for the page, right-click for a highlight. - **Camera**Shopfronts, posters, menus, receipts. You get fields, not a caption. - **Photos and screenshots**OCR. The text inside images is searchable. - **Voice notes**Record in the app or share a file in. Premium transcribes. - **Files and PDFs**Text extracted on the device. On Android a foreground service keeps big imports alive. - **Selected text**Share just the paragraph. - **Nothing to fill in**It never asks for tags or a folder. Share and go. ## What the AI does with a save - **Reading view**The article rebuilt clean. Source and read time kept. - **Summary**A few sentences plus key points. - **Tags**Generated from the content. - **Type detection**Article, recipe, product, place, video. - **The right number**Read time for articles, ingredients for recipes, price for products, open-now for places. - **Fields from photos**Address, hours, phone, price, expiry dates. - **Status**A pill shows when each item is Ready. ## Keeping it in order - **Smart Spaces**Collections that build themselves: Travel, Recipes, Design, Coffee Spots, Reading List, Wishlist. Each shows its count. - **Manual Spaces**The + button is there when a project needs its own shelf. - **One item, several Spaces**No duplicate files underneath. - **Today first**The Library opens on today's saves. - **Filters**All, Articles, Recipes, Places, Videos. - **Stars and sharing**Favourite things; share any save back out. ## Getting it back - **Search by meaning**The box says “Search your mind”. You type what you remember; it matches what you meant. - **Search by keyword**Titles, tags, extracted text. Including text read out of images. - **Rediscover**Old saves resurface on their own. Vouchers get a nudge before they expire. - **Related items**Each item page suggests the saves near it. - **Ask**Chat over the library. Answers cite the saves they used, so you can check. - **Offline**The index lives on the device. No connection, still searchable. ## Privacy - **No account**Saving and searching work without signing in to anything. - **Local first**The cache is always readable offline. Cloud is optional. - **Sync is encrypted**With sync on, the server stores encrypted blobs for the index, not readable content. - **No ad ID, no tracking**On every tier, both platforms. - **The extension's disclosure**One data type: website content. Not sold, not reused for anything else. ## Under the hood On iOS the share extension runs in a lightweight process, drops the item into an app group container, and signals the main app to index it on next open. On Android it registers as a Share Target; big PDFs import under a foreground service so the system cannot kill the write halfway, and the metadata lands in a Room database. Semantic search works because embedding vectors are computed on the phone and indexed locally. Keywords cover titles, tags and extracted text; embeddings cover the day you remember the concept but not the vocabulary. The index is built on-device first and stays searchable offline, account or no account. Stashio is the one app in this studio that touches the cloud. Bookmarking does not need ten-millisecond latency, and what people actually pay for in a save-later tool is save on the phone, find on the laptop. So here the cloud is allowed in, under the same privacy rules as everything else we ship. ## Free and premium Saving, reading, search: free, no account. Premium is the AI side: unlimited extraction, Ask, sync across devices, transcription, full Spaces and Rediscover. $1.99 a month, $9.99 a year with a 7-day trial, $29.99 lifetime. The extension is just another way in. Everything lands in the same library on your phone. ### Want a month on us? Beat Signal Lock — match the waveform — and we hand you a promo code. [Play Signal Lock →](https://uranashel.com/unlock.html) ## The engineering behind it [2025-07-15 · 19 min ### Stashio — saving the internet without losing your mind ](https://uranashel.com/blog/stashio-second-brain.html) --- 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/) --- [← All apps](https://uranashel.com/apps.html) ![Wheria](https://uranashel.com/assets/icons/wheria.png) # Wheria GPS dies. We don't. IMU · baro · mag · dead reckoning iOS Android Watch Live GPS dies in underground parking, which is unfortunately where you left the car. Wheria records your route with the phone's own sensors, then guides you back: floor, pillar, distance. [App Store](https://apps.apple.com/app/wheria/id6785155247)Google Play · soon Swift Kotlin CoreMotion CoreLocation WidgetKit 15K+ dl Updated 2026-06 ## See it running Official App Store screenshots. Scroll sideways for more. ![Save the spot, find it fast — wheria screenshot](https://uranashel.com/assets/screens/wheria/01-home.jpg)**Save the spot, find it fast**One card: the car, 124 m away, floor B2, pillar E9, a note that says near the elevator. ![Walk straight back — wheria screenshot](https://uranashel.com/assets/screens/wheria/02-find.jpg)**Walk straight back**Arrow, 101 metres, go down 2 floors. The trail below is the path you walked. ![Your whole garage — wheria screenshot](https://uranashel.com/assets/screens/wheria/03-garage.jpg)**Your whole garage**A VF 8, a Honda SH, an e-bike. Each keeps its own spot. ![Maps and reminders — wheria screenshot](https://uranashel.com/assets/screens/wheria/04-detail.jpg)**Maps and reminders**Plate, parked info, a meter timer at 30m / 1h / 2h / custom, the map, share. ![Never lose your car — wheria screenshot](https://uranashel.com/assets/screens/wheria/05-onboard.jpg)**Never lose your car**The first screen says it plainly. ## What people use it for ### Basement garages Three grey floors that look identical. The app exists because of this place. ### Airports Park Tuesday, come back Sunday. Floor and pillar are still saved. ### Malls Auto-detect saves the spot even when your mind was already on lunch. ### Motorbikes Hundreds of bikes in one lot. Yours has its own entry, colour and plate. ### Street parking Set the reminder as you walk away. Fines cost more than this app. ### Someone else picks it up Share the spot. They get floor, pillar and the note. ## Saving a spot - **One tap, or zero**Drop the pin yourself, or let automatic detection do it when you walk away. - **Floor and pillar**B2 and E9 are what get you back. Adjust them by hand to match the garage's signs. - **A note**“Near the elevator” is the note you will actually use down there. - **A photo**Attach one if every level looks the same. ## Walking back - **No GPS**Motion, compass and barometer, fused. Works across floors. - **Readable mid-walk**The floor, the metres left, which way to face. Big type. - **Breadcrumbs**It draws the path you actually walked. - **AR**Point the camera, follow the arrow. Premium. - **Lock Screen**Live Activity, Dynamic Island, Home and Lock Screen widgets. - **Outdoors**Above ground it hands off to Apple Maps and walking directions. ## Under the hood GPS fails underground for physical reasons: L-band microwaves attenuate in concrete, and multipath reflections bias the position estimate by tens of metres. Showing the blue dot anyway is just lying to the user with extra steps. Wheria does pedestrian dead reckoning instead: steps picked out of accelerometer peaks, heading from gyro integration corrected by the magnetometer when the field is clean, floors hinted by pressure differences, all fused in a Kalman filter that reports its own uncertainty. The full math is in the blog posts below. ## Free and premium Free: two vehicles and recent history. Premium is one payment: unlimited vehicles, AR guidance, full history. No subscription. Location and movement stay on the phone. Optional iCloud sync keeps your garage on your own devices only. ### Want a month on us? Beat Signal Lock — match the waveform — and we hand you a promo code. [Play Signal Lock →](https://uranashel.com/unlock.html) ## The engineering behind it [2026-05-28 · 21 min ### Indoor navigation when GPS dies ](https://uranashel.com/blog/building-wheria-indoor.html)[2026-03-08 · 19 min ### Barometers and parking floor math ](https://uranashel.com/blog/barometer-parking-math.html)[2026-01-21 · 21 min ### Kalman filters for parking ](https://uranashel.com/blog/kalman-filter-parking.html)[2026-02-04 · 20 min ### Step detection and IMU odometry ](https://uranashel.com/blog/step-detection-imu.html)[2026-02-18 · 22 min ### Magnetometers and compass nightmares ](https://uranashel.com/blog/compass-calibration-story.html)[2025-09-05 · 20 min ### Why GPS lies to you ](https://uranashel.com/blog/why-gps-lies.html) --- 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/) --- [← All apps](https://uranashel.com/apps.html) ![Estua](https://uranashel.com/assets/icons/estua.svg) # Estua Sleep noise that never loops 48 kHz · real-time synth · zero loops iOS Android Watch Live Sleep apps loop a recording. Estua has no recording: it composes the sound live on your phone and can prove the result never repeats. No account, nothing streams. [App Store](https://apps.apple.com/app/estua/id6784632438)Google Play · soon Swift Kotlin AVAudioEngine DSP watchOS 8K+ dl Updated 2026-05 ## See it running Official App Store screenshots. Scroll sideways for more. ![Music that never repeats — estua screenshot](https://uranashel.com/assets/screens/estua/01-never-repeats.jpg)**Music that never repeats**Generative, endless. Resume the last session or start a new one. ![Sound born on your device — estua screenshot](https://uranashel.com/assets/screens/estua/02-on-device.jpg)**Sound born on your device**Synthesised live. Offline, no cloud, no account. ![A palette of real instruments — estua screenshot](https://uranashel.com/assets/screens/estua/03-instruments.jpg)**A palette of real instruments**Celeste, piano, Rhodes, bells, music box, handpan, marimba, two guitars, organ, strings, choir, harpsichord, warm pad. Spatial audio on headphones. ![Provably non-repeating — estua screenshot](https://uranashel.com/assets/screens/estua/04-provable.jpg)**Provably non-repeating**The session screen shows the math: recurrence about 1.1 × 10¹² years, 3.36 bits per note, divergence within 40 seconds, builds checked by SHA-256 in CI. ![Open, not a black box — estua screenshot](https://uranashel.com/assets/screens/estua/05-open.jpg)**Open, not a black box**Each engine part was switched off and measured alone. One test found no audible effect; it is published anyway, labelled exactly that. ![Breathes at your resonance — estua screenshot](https://uranashel.com/assets/screens/estua/06-resonance.jpg)**Breathes at your resonance**Experimental: a sweep finds your breathing rate and the music breathes at it. Heart rate can come from a wearable via HealthKit. ![Tuned to your ears — estua screenshot](https://uranashel.com/assets/screens/estua/07-ears.jpg)**Tuned to your ears**Eight tones, raise each until you just hear it. The mix gets a per-band boost mapped to your hearing. ![Warm, never dark or sad — estua screenshot](https://uranashel.com/assets/screens/estua/08-warm.jpg)**Warm, never dark or sad**Relax, Recover, Focus, Sleep. All four written to stay calm. ![Focus that breathes — estua screenshot](https://uranashel.com/assets/screens/estua/09-focus.jpg)**Focus that breathes**Work/break cycles. On breaks the sound just softens. 25:00 on the clock. ## Four modes ### Relax A warm, close space for the end of the day. ### Recover Open, temple-like. For resetting. ### Sleep Deep and enveloping. Keeps its character for hours because it is synthesised, not looped. ### Focus Built around work/break cycles, kept deliberately plain. ## What makes it different - **A certificate you can check**Each second is a trajectory of a deterministic dynamical system, and the engine can emit a machine-checkable certificate that it never repeats. The ∞ in the icon is that. - **The numbers are on screen**Recurrence time, bits per note, divergence. Shown for the session you are hearing. - **100 styles**Living mode drifts between them so hour three still moves. - **Seeds**Save a seed and get the same session back, note for note. Years later too. - **Real instruments**Fifteen voices. HRTF spatial on headphones. - **Nothing leaves the phone**The App Store privacy label reads Data Not Collected. ## Day to day - **Opens where you need it**Pick a goal once during onboarding. - **Bedtime**Gentle reminders. A long sleep timer that fades out instead of cutting. - **Health and streaks**Mindful minutes go to Apple Health. Streaks and stats in the app. - **Hands-free**Siri Shortcuts and widgets. ## Under the hood The audio thread has a hard deadline: fill the buffer before the hardware asks, every time, or the listener hears a click at three in the morning. That rules out memory allocation, locks, and anything with unbounded latency inside the render callback. Hours of continuous 48 kHz synthesis come from that discipline. The full story is in the DSP post below. ## Free and premium Free: about 90 minutes a day, all four modes, a starter set of styles, no account. Premium: unlimited time, all 100 styles, Living, the long sleep timer, full offline. Monthly, yearly, or lifetime. Estua is a general-wellness audio app, not a medical device. The resonance and hearing-check features are labelled experimental in the app. ### Want a month on us? Beat Signal Lock — match the waveform — and we hand you a promo code. [Play Signal Lock →](https://uranashel.com/unlock.html) ## The engineering behind it [2026-04-10 · 20 min ### Sound that never repeats ](https://uranashel.com/blog/estua-non-repeating-audio.html)[2025-11-10 · 18 min ### Sleep, waves, and why your brain likes surf ](https://uranashel.com/blog/estua-sleep-and-waves.html)[2025-08-02 · 21 min ### Life on the audio thread ](https://uranashel.com/blog/dsp-audio-thread.html) --- 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/) --- [← All apps](https://uranashel.com/apps.html) ![Phyzix](https://uranashel.com/assets/icons/phyzix.svg) # Phyzix Pocket physics lab 100+ sensors · Euler/RK sims · EN/VI iOS Android Live The sensors are already in your phone. Phyzix puts dials on them: 100+ instruments, 50+ simulations, solvers, a unit converter, a reference library. Everything runs offline. [App Store](https://apps.apple.com/app/phyzix-physics-lab/id6784482674)Google Play · soon Swift Kotlin CoreMotion SceneKit 100+ sensors 6K+ dl Updated 2026-04 ## See it running Official App Store screenshots. Scroll sideways for more. ![Your phone is the instrument — phyzix screenshot](https://uranashel.com/assets/screens/phyzix/01-home.jpg)**Your phone is the instrument**The Lab: 101 tools, filtered by group. Spectrum analyzer and compass up top. ![50+ physics simulations — phyzix screenshot](https://uranashel.com/assets/screens/phyzix/02-simulate.jpg)**50+ physics simulations**A free Sandbox, then projectile motion, pendulum, spring, collisions, inclined plane. ![Calculate and convert — phyzix screenshot](https://uranashel.com/assets/screens/phyzix/03-tools.jpg)**Calculate and convert**Solvers for kinematics, energy, SHM, orbits, Ohm's law. A full unit converter. ![A reference library — phyzix screenshot](https://uranashel.com/assets/screens/phyzix/04-periodic.jpg)**A reference library**All 118 elements, colour-coded by family. Constants and 80+ formulas next door. ![Understand every concept — phyzix screenshot](https://uranashel.com/assets/screens/phyzix/05-learn.jpg)**Understand every concept**Learn hub: formulas across 10 domains, the periodic table, constants, isotopes, the Standard Model, the EM spectrum. ## Who it's for ### Teachers Works on whatever phones the class already has. Project the spectrogram and clap. ### Students Formulas, constants, and simulations to check homework against. ### Makers Level a shelf, find screws in a wall, check a motor's vibration, meter light in lux. ### The curious The barometer twitches before rain; the pressure-trend chart shows it happening. ## Measure - **Sound and DSP**Spectrum analyzer, oscilloscope, spectrogram, tuner, sound-level meter, noise dosimeter, pitch detector, VU meter. - **Motion and inertia**Accelerometer, G-force, gyroscope, bubble level, inclinometer, seismometer, vibration meter, step counter, free-fall and jump-height timer. - **Fields and magnetism**Magnetometer, compass, metal detector, EMF detector, dip and field-vector readouts. - **Environment**Barometer, altimeter, variometer, pressure trend. - **Light**Lux, EV, foot-candle, daylight. - **Navigation**GPS speedometer and heading. ## Simulate, solve, convert ### Sandbox Build a system, tune the physics, watch it run. ### Mechanics, live Projectile, pendulum, spring, collisions, incline. Change a number and the curve follows. ### Solvers Leave one quantity blank; it solves for that one. ### Unit converter Length, mass, speed, temperature, force, energy, pressure, and the rest. ## Look things up - **Formulas**80+ across 10 domains, sorted by level. - **Periodic table**118 elements, colour-coded by family. - **Constants, isotopes, particles**Physical constants, common isotopes and their uses, the Standard Model. - **EM spectrum**Radio to gamma. - **Guided experiments**Learn explains the concept behind each instrument. The reaction-time test is a good place to start. ## Practical details - **9 languages**English, Spanish, French, German, Japanese, Chinese, Arabic, Vietnamese and more. - **Session log**Go back over your readings later. - **Offline**No account, no ads, no internet. Readings stay on the phone. ## Free and premium Free to download. A few advanced simulations are marked premium in the app. Which instruments you get depends on which sensors your device has. ### Want a month on us? Beat Signal Lock — match the waveform — and we hand you a promo code. [Play Signal Lock →](https://uranashel.com/unlock.html) ## The engineering behind it [2025-10-22 · 22 min ### Phyzix — 100+ physics instruments in your pocket ](https://uranashel.com/blog/phyzix-pocket-lab.html)[2025-10-08 · 20 min ### Sensor hacking 101 ](https://uranashel.com/blog/phyzix-sensor-hacking.html)[2025-06-28 · 22 min ### CS + Physics → app studio ](https://uranashel.com/blog/physics-degree-apps.html) --- 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/) --- [← All apps](https://uranashel.com/apps.html) ![Sonarish](https://uranashel.com/assets/icons/sonarish.svg) # Sonarish Listen like an engineer A-weighted · FFT · baseline diff iOS Android Live A noise meter with the statistics reports actually require, machine diagnosis against a baseline you record yourself, and two experimental sonar tools. The audio pipeline does not even request internet permission. [App Store](https://apps.apple.com/app/sonarish/id6785146314)Google Play · soon Swift Kotlin Accelerate FFT PDF export 5K+ dl Updated 2026-05 ## See it running Official App Store screenshots. Scroll sideways for more. ![Precision noise meter — sonarish screenshot](https://uranashel.com/assets/screens/sonarish/01-noise.jpg)**Precision noise meter**72 dB(A) live, with LAeq, LAmax, LAmin, L10, L50, L90, and a daily dose at 8% of the NIOSH limit. ![Track breathing with sonar — sonarish screenshot](https://uranashel.com/assets/screens/sonarish/03-breathing.jpg)**Track breathing with sonar**16 breaths a minute, contactless, from an inaudible tone. Signal quality 92%, displacement 118 µm. ![Know the moment baby cries — sonarish screenshot](https://uranashel.com/assets/screens/sonarish/04-cry.jpg)**Know the moment baby cries**A live likelihood meter at 85%, an event timeline, sensitivity on a slider. ![Every reading, over time — sonarish screenshot](https://uranashel.com/assets/screens/sonarish/05-trends.jpg)**Every reading, over time**Noise, machine score, breathing rate. Min, average, max for each. ![The full toolkit — sonarish screenshot](https://uranashel.com/assets/screens/sonarish/06-pro.jpg)**The full toolkit**Pro: PDF and CSV export, both monitors, unlimited history. $2.99 a month, $19.99 a year, $39.99 once. ![Private by design — sonarish screenshot](https://uranashel.com/assets/screens/sonarish/07-settings.jpg)**Private by design**On-device throughout. Light and dark themes, English and Vietnamese, and a clear-history button in settings. ## What people use it for ### Noise disputes Measure, export a timestamped PDF, attach it to the complaint email. ### The AC that sounds off Record it while it is healthy. Compare when it starts sounding wrong. ### Workshop machines Motors, pumps, compressors. A 0–100 score, tracked over time. ### The baby's room Cry detection and a breathing readout, both fine in airplane mode. ### Your own ears The dose meter counts your exposure against the NIOSH 8-hour limit. ## The noise meter - **dB(A), live**An animated gauge with a plain word next to the number: Loud. - **Statistics**LAeq, LAmax, LAmin, L10, L50, L90. - **Daily dose**A running percentage of the NIOSH 8-hour limit, peak level noted. - **History and calibration**A live chart, and per-device calibration so two phones agree. - **Reports**Timestamped PDF and CSV. Pro. ## Machine diagnosis ### 1. Record it healthy A short recording on a good day. That is the baseline. ### 2. Check later New recordings are compared against it. Suspicious bands and new tones get flagged. ### 3. Read the score 0–100, at normal / watch / warning. Tracked so you can see it drift. ## The experimental corner - **Breathing monitor**An inaudible tone reflects off the chest; the app reads breaths per minute from the reflection, with signal quality and displacement shown. Contactless. Pro. - **Cry monitor**Real cry detection rather than a bare loudness threshold. Likelihood meter, event timeline, adjustable sensitivity. Pro. ## Under the hood A-weighting exists because ears are not flat: at conversational levels we barely hear low rumble, so an unweighted meter over-reports noise nobody experiences. Sonarish applies the standard A-curve before integrating, which is why its numbers line up with the ones in noise regulations. Machine diagnosis is a baseline comparison, not a classifier trained on someone else's machines. The method asks one thing of you: put the phone at the same distance every time, half a metre to a metre. The spectral difference against your own recording is what carries the signal. ## Free and premium Every Pro feature can be tried once, free. Pro unlocks PDF/CSV export, both monitors and unlimited history: $2.99 a month, $19.99 a year, or $39.99 once. Sonarish is a screening aid, not a certified sound-level meter and not a medical device. The breathing and cry monitors are labelled experimental in the app. ### Want a month on us? Beat Signal Lock — match the waveform — and we hand you a promo code. [Play Signal Lock →](https://uranashel.com/unlock.html) ## The engineering behind it [2025-12-14 · 18 min ### A-weighting and decibels explained ](https://uranashel.com/blog/a-weighting-decibels.html)[2025-11-28 · 20 min ### Machine fault diagnosis via acoustic baseline ](https://uranashel.com/blog/machine-fault-baseline.html)[2026-01-07 · 19 min ### FFT for mortals ](https://uranashel.com/blog/fft-made-readable.html) --- 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/) --- Signal Lock # Match the wave, take the month The dashed curve is the target. Move frequency, amplitude and phase until your solid curve sits on top of it. Clear three rounds and the promo code appears. Match **0%** · need 88% Frequency Amplitude Phase ## Locked. Here is your code. Redeem it in the app for a month on us. Applies to: **** — the apps with a monthly plan. ## How the score works The match is one minus the RMS error between the two curves, normalised by the target’s RMS — the same normalised-error idea Sonarish uses to score a machine against its baseline. Later rounds add a second harmonic, so the shape gets less forgiving. --- 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/) --- # uranashel blog > 27 posts on sensors, DSP, physics and shipping mobile apps. Every post has a Markdown mirror at the same path with a `.md` extension. ## [Allan variance, or how long a gyroscope can be trusted](https://uranashel.com/blog/allan-variance-gyro.html) 2026-08-25 · 21 min · physics, wheria, math We left five phones logging their gyroscopes overnight and used Allan deviation to work out how many minutes of heading Wheria can borrow between compass corrections. Markdown: https://uranashel.com/blog/allan-variance-gyro.md · JSON: https://uranashel.com/api/v1/posts/allan-variance-gyro ## [An inaudible sonar that watches you breathe](https://uranashel.com/blog/sonar-breathing-doppler.html) 2026-08-18 · 20 min · sonarish, dsp, physics Sonarish's experimental breathing monitor drives the speaker at 19 kHz, listens on the mic, and reads 118 µm of chest motion out of the phase of the echo. Markdown: https://uranashel.com/blog/sonar-breathing-doppler.md · JSON: https://uranashel.com/api/v1/posts/sonar-breathing-doppler ## [Semantic search with no server in the loop](https://uranashel.com/blog/on-device-embeddings.html) 2026-08-11 · 19 min · stashio, math, engineering Sentence embeddings, int8 vectors and a brute-force cosine scan let Stashio search your saved links by meaning entirely on the phone. Markdown: https://uranashel.com/blog/on-device-embeddings.md · JSON: https://uranashel.com/api/v1/posts/on-device-embeddings ## [The lab behind the Lab page](https://uranashel.com/blog/lab-behind-the-lab.html) 2026-08-04 · 18 min · engineering, dsp 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. Markdown: https://uranashel.com/blog/lab-behind-the-lab.md · JSON: https://uranashel.com/api/v1/posts/lab-behind-the-lab ## [The battery budget of an always-on sensor app](https://uranashel.com/blog/battery-budget-sensors.html) 2026-07-27 · 19 min · engineering, wheria, physics Four phones on a shelf, airplane mode, screen off, and five weeks of overnight battery logs to find out what each sensor really costs an always-on app. Markdown: https://uranashel.com/blog/battery-budget-sensors.md · JSON: https://uranashel.com/api/v1/posts/battery-budget-sensors ## [Shipping the same day on two app stores](https://uranashel.com/blog/shipping-two-stores.html) 2026-07-18 · 18 min · engineering, studio atuan explains the release train that moves five apps to two stores on the same day, with review-time numbers from the studio's own log. Markdown: https://uranashel.com/blog/shipping-two-stores.md · JSON: https://uranashel.com/api/v1/posts/shipping-two-stores ## [Welcome to uranashel](https://uranashel.com/blog/welcome-uranashel.html) 2026-07-04 · 20 min · studio Who we are, why five apps exist, and how this blog explains the physics behind them — in plain language. Markdown: https://uranashel.com/blog/welcome-uranashel.md · JSON: https://uranashel.com/api/v1/posts/welcome-uranashel ## [Why everything runs on your phone](https://uranashel.com/blog/on-device-first.html) 2026-06-15 · 19 min · privacy Why parking, sleep audio, and noise meters must compute on your phone — latency, privacy, and offline garages explained step by step. Markdown: https://uranashel.com/blog/on-device-first.md · JSON: https://uranashel.com/api/v1/posts/on-device-first ## [Indoor navigation when GPS dies](https://uranashel.com/blog/building-wheria-indoor.html) 2026-05-28 · 21 min · wheria, physics When GPS fails underground, Wheria walks you back with steps, heading, and pressure — how sensor fusion works and what we honestly tell users. Markdown: https://uranashel.com/blog/building-wheria-indoor.md · JSON: https://uranashel.com/api/v1/posts/building-wheria-indoor ## [Sound that never repeats](https://uranashel.com/blog/estua-non-repeating-audio.html) 2026-04-10 · 20 min · estua, dsp Your brain detects loops; Estua generates fresh ambient sound every night. How real-time synthesis at 48 kHz replaces repeating rain clips. Markdown: https://uranashel.com/blog/estua-non-repeating-audio.md · JSON: https://uranashel.com/api/v1/posts/estua-non-repeating-audio ## [Designing in strict monochrome](https://uranashel.com/blog/monochrome-ui.html) 2026-03-22 · 18 min · design Why instrument apps stay black and white, and how we fight SwiftUI blue and Material purple without sacrificing readability. Markdown: https://uranashel.com/blog/monochrome-ui.md · JSON: https://uranashel.com/api/v1/posts/monochrome-ui ## [Barometers and parking floor math](https://uranashel.com/blog/barometer-parking-math.html) 2026-03-08 · 19 min · wheria, physics Pressure changes as you descend — the hydrostatic idea behind guessing B2 vs B3, and why weather and elevators complicate it. Markdown: https://uranashel.com/blog/barometer-parking-math.md · JSON: https://uranashel.com/api/v1/posts/barometer-parking-math ## [Magnetometers and compass nightmares](https://uranashel.com/blog/compass-calibration-story.html) 2026-02-18 · 22 min · wheria, physics Magnetometers in steel garages see a distorted Earth field. How Wheria calibrates, when it trusts the gyro instead, and why we warn instead of guessing. Markdown: https://uranashel.com/blog/compass-calibration-story.md · JSON: https://uranashel.com/api/v1/posts/compass-calibration-story ## [Step detection and IMU odometry](https://uranashel.com/blog/step-detection-imu.html) 2026-02-04 · 20 min · wheria, physics Double integration drifts fast; counting steps does not. A clear explanation of pedestrian dead reckoning in Wheria. Markdown: https://uranashel.com/blog/step-detection-imu.md · JSON: https://uranashel.com/api/v1/posts/step-detection-imu ## [Kalman filters for parking](https://uranashel.com/blog/kalman-filter-parking.html) 2026-01-21 · 21 min · wheria, math Combining noisy steps, heading, and pressure without panic — an intuitive tour of the filter Wheria uses underground. Markdown: https://uranashel.com/blog/kalman-filter-parking.md · JSON: https://uranashel.com/api/v1/posts/kalman-filter-parking ## [FFT for mortals](https://uranashel.com/blog/fft-made-readable.html) 2026-01-07 · 19 min · sonarish, dsp What a spectrum actually shows, how Sonarish builds it from microphone data, and how to read peaks from fans and motors. Markdown: https://uranashel.com/blog/fft-made-readable.md · JSON: https://uranashel.com/api/v1/posts/fft-made-readable ## [A-weighting and decibels explained](https://uranashel.com/blog/a-weighting-decibels.html) 2025-12-14 · 18 min · sonarish, physics Decibels, A-weighting, and LAeq explained so your phone measurements mean something next to real-world noise limits. Markdown: https://uranashel.com/blog/a-weighting-decibels.md · JSON: https://uranashel.com/api/v1/posts/a-weighting-decibels ## [Machine fault diagnosis via acoustic baseline](https://uranashel.com/blog/machine-fault-baseline.html) 2025-11-28 · 20 min · sonarish Machines hum in patterns; Sonarish lets you save a healthy baseline and compare later — acoustic diagnosis without cloud AI. Markdown: https://uranashel.com/blog/machine-fault-baseline.md · JSON: https://uranashel.com/api/v1/posts/machine-fault-baseline ## [Sleep, waves, and why your brain likes surf](https://uranashel.com/blog/estua-sleep-and-waves.html) 2025-11-10 · 18 min · estua, physics Why surf-like 1/f noise soothes sleep, and how Estua mimics it with slow modulation instead of a beach recording on repeat. Markdown: https://uranashel.com/blog/estua-sleep-and-waves.md · JSON: https://uranashel.com/api/v1/posts/estua-sleep-and-waves ## [Phyzix — 100+ physics instruments in your pocket](https://uranashel.com/blog/phyzix-pocket-lab.html) 2025-10-22 · 22 min · phyzix, education Turn the phone into a physics lab: live sensors, graphs in SI units, simulations, and bilingual classroom use. Markdown: https://uranashel.com/blog/phyzix-pocket-lab.md · JSON: https://uranashel.com/api/v1/posts/phyzix-pocket-lab ## [Sensor hacking 101](https://uranashel.com/blog/phyzix-sensor-hacking.html) 2025-10-08 · 20 min · phyzix, physics Accelerometers measure specific force, not “zero when still.” A gentle primer on what each phone sensor really reports. Markdown: https://uranashel.com/blog/phyzix-sensor-hacking.md · JSON: https://uranashel.com/api/v1/posts/phyzix-sensor-hacking ## [Shipping iOS and Android at the same time](https://uranashel.com/blog/cross-platform-two-codebases.html) 2025-09-20 · 19 min · engineering SwiftUI and Compose, two codebases, one physics core — why we ship iOS and Android together and how we keep them aligned. Markdown: https://uranashel.com/blog/cross-platform-two-codebases.md · JSON: https://uranashel.com/api/v1/posts/cross-platform-two-codebases ## [Why GPS lies to you](https://uranashel.com/blog/why-gps-lies.html) 2025-09-05 · 20 min · wheria, physics Line of sight, multipath, and concrete — a physics-first explanation of why your map dot lies in parking garages. Markdown: https://uranashel.com/blog/why-gps-lies.md · JSON: https://uranashel.com/api/v1/posts/why-gps-lies ## [Nunito on every pixel](https://uranashel.com/blog/nunito-on-every-pixel.html) 2025-08-18 · 18 min · design Typography is part of measurement UI. How Nunito stays consistent across SwiftUI and Compose, and what the OS still controls. Markdown: https://uranashel.com/blog/nunito-on-every-pixel.md · JSON: https://uranashel.com/api/v1/posts/nunito-on-every-pixel ## [Life on the audio thread](https://uranashel.com/blog/dsp-audio-thread.html) 2025-08-02 · 21 min · estua, dsp Real-time audio has hard deadlines. The rules Estua follows so sleep sounds never click at 3 AM. Markdown: https://uranashel.com/blog/dsp-audio-thread.md · JSON: https://uranashel.com/api/v1/posts/dsp-audio-thread ## [Stashio — saving the internet without losing your mind](https://uranashel.com/blog/stashio-second-brain.html) 2025-07-15 · 19 min · stashio Save links and PDFs from the share sheet, find them later — Stashio’s capture and search flow, including when cloud helps. Markdown: https://uranashel.com/blog/stashio-second-brain.md · JSON: https://uranashel.com/api/v1/posts/stashio-second-brain ## [CS + Physics → app studio](https://uranashel.com/blog/physics-degree-apps.html) 2025-06-28 · 22 min · studio, physics Computer science plus physics shaped uranashel — not as credentials, but as a way to ask what sensors mean before writing code. Markdown: https://uranashel.com/blog/physics-degree-apps.md · JSON: https://uranashel.com/api/v1/posts/physics-degree-apps --- 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/) --- # Allan variance, or how long a gyroscope can be trusted > We left five phones logging their gyroscopes overnight and used Allan deviation to work out how many minutes of heading Wheria can borrow between compass corrections. 2026-08-25 · 21 min read · physics, wheria, math · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/allan-variance-gyro.html --- Between two trusted compass fixes, Wheria's heading runs on gyroscope alone. Indoors the magnetometer gets vetoed a lot: a steel column 2 m away can rotate apparent north by 40–90°, which is why [the compass post](https://uranashel.com/blog/compass-calibration-story.html) treats that sensor as guilty until proven innocent. While the veto lasts, the filter integrates angular rate and nothing else. So the load-bearing question for the whole heading pipeline is how many seconds that integral stays inside the error the UI can absorb. Inertial navigation people answer that with Allan variance, a tool that needs nothing more exotic than a phone lying still for a long time. In July we gave it 5 phones and a week of nights: stationary gyro logs, 7–9 h each, on a granite tile in the studio. What follows is the bench notes — the three noise types, the computation, the log-log plot, our numbers, and which knobs they pin down in [Wheria](https://uranashel.com/apps/wheria.html)'s filter. ## Three noises live in a resting gyro A phone gyro on a table should read zero. It actually reads Earth's rotation, 15 deg/h, plus everything the silicon adds on top. The best of our five phones has a bias floor of 16 deg/h, so Earth loses by a nose and phone gyrocompassing remains cancelled. The first contribution is white noise on the rate signal: thermal and electronic noise in the MEMS sense loop, uncorrelated from sample to sample. Integrate white rate noise and the angle performs a random walk whose standard deviation grows as `√t`. Datasheets call it angle random walk, N, quoted in deg/√h. It dominates over seconds. The second is uglier. The zero offset itself wanders, slowly, with 1/f flicker character. Calibrate the bias perfectly at 21:00 and by 21:05 it is a slightly different bias. This is bias instability, B, quoted in deg/h, and no amount of averaging removes it, because averaging longer just averages a moving target. It dominates over minutes. A garage walk is minutes. Third, rate random walk: a random walk on the rate itself, K, quoted in deg/h per √h. Under it the angle error grows as `t^1.5`. On consumer parts a clean K is rare, because temperature ramps print the same signature on the curve. ## The Allan deviation recipe The computation dates to 1966 and is pleasantly mechanical. Chop the log into adjacent clusters of duration τ. Average the rate inside each cluster to get `Ω̄_1, Ω̄_2, …` Then measure how much successive averages disagree: `σ²(τ) = ½·⟨(Ω̄_{k+1} − Ω̄_k)²⟩`. Repeat for many values of τ and plot σ(τ) against τ on log-log axes. The factor ½ is what makes the slopes meaningful. For white rate noise with variance σ_w² per sample, a cluster mean over m samples has variance σ_w²/m. Two adjacent means are independent, so their difference carries 2σ_w²/m, and halving recovers σ_w²/m. With m = τ·f_s that gives `σ(τ) = N/√τ`, a straight line of slope −1/2 on the plot. Each noise type plays the same game with a different exponent, which is the entire reason the curve is readable. Cluster times run log-spaced from 2 samples up to about a tenth of the record, 60 points or so. The tenth matters. At τ = 1,000 s an 8 h log holds only 28 non-overlapping clusters, and the relative error of an Allan point is roughly `1/√K` for K cluster pairs, so the right-hand end of every published Allan curve is mush. Overlapping clusters, stride 1 sample instead of stride m, squeeze extra confidence out of the same record for nothing but CPU. This is also why the logs run overnight. A 10 min log supports τ up to about 60 s and says nothing about the floor. Two platform traps. Log the raw stream (on Android the uncalibrated gyro sensor, on iOS the plain gyro data rather than the fused device-motion rate), because runtime bias compensation quietly edits the signal mid-record and stamps steps into the curve. And resample to a uniform grid first; both OSes deliver jittered timestamps, and the fixed 100 Hz resampling stage from [step detection](https://uranashel.com/blog/step-detection-imu.html) serves here unchanged. Charging during a run is banned. A warming battery is a bias ramp. ## The computation in 16 lines With prefix sums every cluster mean costs O(1), and the full curve computes in seconds on a laptop, overlapped version included: ``` // w[0..N-1]: gyro rate samples (deg/s), fs: sample rate (Hz) S[0] = 0 for i in 0..N-1: S[i+1] = S[i] + w[i] // prefix sums for tau in logspace(2/fs, (N/fs)/10, 60): m = round(tau * fs) // samples per cluster if m < 2 or 2*m >= N: continue acc = 0; K = 0 for k in 0 .. N - 2*m: // overlapping clusters a = (S[k+m] - S[k]) / m // mean of cluster at k b = (S[k+2*m] - S[k+m]) / m // mean of the next one acc = acc + (b - a)² K = K + 1 adev[tau] = sqrt(acc / (2·K)) // Allan deviation at tau ``` Feed it deg/s. The point at τ = 1 s, multiplied by 60, is N in deg/√h. The flat minimum, times 3,600 and divided by 0.664, is B in deg/h; the 0.664 is `√(2·ln2/π)` from the flicker-noise derivation, one of those constants you verify once and then trust forever. ## Reading the slopes The finished plot reads by pattern. Left side, slope −1/2: the white-noise region, ARW, with N read directly at τ = 1 s. Middle: the curve flattens where flicker takes over, and the minimum marks bias instability. Right side, slope +1/2: rate random walk, following `σ(τ) = K·√(τ/3)`. A slope of −1 at the very shortest τ is quantization noise from the sensor's output resolution and is harmless. Bumps are the informative part. A periodic disturbance with period T0 prints an oscillation on the curve whose first hump lands near τ ≈ T0/2. On our first night we left the aircon running, and all three phones logged that evening grew a hump at τ ≈ 500 s; the compressor cycle measured about 17 min. The gyros were faithfully reporting the building. Aircon off every night after. ## One week, five phones Bench setup, for the record: 5 phones (two iPhones, one Pixel-class Android, two mid-range Androids, model years 2019–2025), each logged 7–9 h overnight at 100 Hz on a 30 mm granite tile — airplane mode, screen off, unplugged, studio door locked. 8 h at 100 Hz is 2.9 million samples per axis, about 35 MB of float32 per phone. Z-axis numbers, since yaw is what heading uses. ARW ran from 0.15 deg/√h on the newest flagship to 0.9 deg/√h on the 2019 mid-ranger, with the other three between 0.2 and 0.6. Bias instability came out at 16, 19, 24, 31 and 44 deg/h respectively, floors sitting between τ = 80 s and τ = 400 s. Only the two oldest units showed a credible +1/2 tail before the error bars went to mush, and on one of them the tail shrank when we re-ran the log inside a foam box; part of it was thermal, since the studio cools by about 2 °C overnight. Everything lands in the class that MEMS datasheets admit to when read carefully. Now translate to angle. N = 0.3 deg/√h is 0.005 deg/√s; after 60 s of open-loop integration that is 0.04° of heading error, and after a 4 min garage walk, 0.08°. White noise is irrelevant at our timescales. The floor is what bites. B = 20 deg/h is 0.33 deg/min, and it is a floor in the strict sense: the drift that survives any bias calibration, however careful, because flicker is precisely the part left over once averaging stops helping. ## What Wheria does with the numbers The [Kalman post](https://uranashel.com/blog/kalman-filter-parking.html) describes the state vector: position x and y, heading θ, and a slowly drifting gyro bias term. The Allan curve is the datasheet for that fourth state. Its process noise is set from the measured floor and tail, so the bias covariance grows at roughly the instability rate; after about 5 min without a correction the filter has honestly forgotten the old bias, back down to the floor of 0.3–0.7 deg/min depending on the phone. Corrections come from moments when the compass is trusted and from near-still stretches, the same low-turn-rate windows the compass post uses for magnetometer calibration. The curve also sets the gyro-only clock. Stationary, our mid-pack phones drift 0.3–0.7 deg/min once the bias state has converged. Walking is worse, for reasons in the next section, and the 1–3 deg/min quoted in [the compass post](https://uranashel.com/blog/compass-calibration-story.html) is the honest in-pocket figure. Find mode drops from arrow to distance-first guidance once heading standard deviation crosses 25°. At 3 deg/min that limit is 8 min away; the filter flips much earlier, because its heading process noise (about 2° per step under compass trust, nearer 8° in steel-heavy zones) is deliberately fatter than the measured drift. The covariance is an admission of what the filter cannot verify, not a measurement of what the gyro just did. The practical outcome: between magnetometer corrections a mid-2020s phone holds heading to 1–2° across the 30–90 s the filter typically has to bridge in a steel-heavy aisle, and the oldest phone in our test drawer holds 3–5°. Standing 20 m from the car, a 5° heading error displaces the arrow's target about 1.7 m sideways, still narrower than one parking space. That margin is why Wheria survives minutes of vetoed compass without drawing a confident lie. ## Where the stationary curve stops being honest An overnight log on a stone tile is the gyro on its best behavior. A trouser pocket degrades it three ways. Linear-acceleration sensitivity first: consumer gyros register 0.01–0.1 deg/s per g of applied acceleration, and walking applies a ±0.4 g bounce at roughly 2 Hz, part of which rectifies into an apparent bias. Then temperature: leaving 22 °C garage air for a 32 °C pocket drives the bias tempco through a ramp the Allan curve never saw. And the phone rotates relative to the walker, fabric and soft tissue conspiring, motion that must never be booked as heading. The stationary curve is a physical lower bound. Every tuning number in Wheria sits above it on purpose. To run the experiment yourself, the internal Phyzix build we are testing adds an overnight Allan-log mode to [the lab screens](https://uranashel.com/apps/phyzix.html), curve and fitted slopes included; the resampler and the logging plumbing already existed from step counting. Until that ships, the 16 lines above plus one boring night are genuinely all it takes. --- 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/) --- # An inaudible sonar that watches you breathe > Sonarish's experimental breathing monitor drives the speaker at 19 kHz, listens on the mic, and reads 118 µm of chest motion out of the phase of the echo. 2026-08-18 · 20 min read · sonarish, dsp, physics · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/sonar-breathing-doppler.html --- The [Sonarish store page](https://uranashel.com/apps/sonarish.html) has a screenshot showing 16 breaths a minute, a signal quality of 92% and a displacement of 118 µm. No strap, no camera, no contact. The phone sat on a nightstand, played a tone most adults cannot hear and listened to what came back. Chest position fell out of the arithmetic. The store page gets roughly 40 words to explain that, and the math deserves more, so this post is the long version. The short version fits in four sentences. Emit a continuous tone near 19 kHz from the speaker. The echo off a sleeping chest travels a path whose length changes as the chest rises and falls. Path length maps to phase, and phase on a clean carrier is measurable to a fraction of a degree. Everything after that is filtering. ## Pick a tone the speaker can make and you cannot hear The carrier frequency is boxed in from three sides. Sonarish captures at 48 kHz like every other uranashel audio path, so Nyquist caps the world at 24 kHz. Adult hearing collapses fast above 16 kHz; a typical 40-year-old tops out near 15 kHz and does not want a nightly sonar serenade regardless. And phone speakers, built for voices and ringtones, roll off steeply past 15 kHz while still producing usable output somewhat higher. The intersection of those constraints is the 18–20 kHz band. Wavelength decides what the measurement can see. With `λ = c/f` and c = 343 m/s at 20 °C, an 18 kHz tone has λ = 19.1 mm and a 20 kHz tone has λ = 17.2 mm. Sonarish defaults to 19 kHz, so λ = 18.1 mm. Every displacement number below is scaled against those 18 mm. Real sonars send pulses and time the round trip, which yields range. Phones cannot do useful range gating up here; the bandwidth is missing and so is the transducer. That turns out to be fine. The chest sits somewhere between 0.3 and 1 m away, its absolute distance is irrelevant and only the change matters. A continuous tone gives up range entirely and buys phase precision in return. ## Why phase works where amplitude cannot Sound leaves the speaker, hits the chest at distance d and returns to a microphone sitting next to that speaker, so the acoustic path is close to 2d. The received tone lags the emitted one by `φ = 2π·(2d)/λ = 4πd/λ`. Differentiate and the static geometry drops out: `Δφ = 4π·Δd/λ`. Now insert the screenshot numbers. Δd = 118 µm against λ = 18.1 mm gives Δφ = 0.082 rad, which is 4.7°. A small angle, but a narrowband carrier with decent SNR supports phase estimates well below 1°. Our bench floor: a stationary foam target at 0.5 m, 10-minute captures on 3 phones, residual phase noise equivalent to about 15 µm RMS of displacement. The 118 µm breath sits roughly 8 times above that floor. Amplitude has no comparable story. Echo strength falls off near 1/d for a target like a torso, so moving 118 µm along a 500 mm path changes the received amplitude by about 0.02%. The microphone's real noise floor, the room's ambient sound, automatic gain stages and plain thermal drift each bury that number; in our logs the amplitude channel wanders more in one minute of temperature drift than a full breath would move it. Phase survives because it rides a narrow carrier at a frequency where a bedroom is otherwise silent, and because 4.7° stays 4.7° whether the echo is strong or weak. ## The Doppler shift you will never see in a spectrum There is a tempting frequency-domain framing. A reflector moving at velocity v shifts the carrier by `f_d = 2·v·f0/c`. Breathing at 16 breaths/min is 0.267 Hz; model the chest as a sinusoid of amplitude 118 µm and its peak velocity is `v = 2π·f_b·A` ≈ 198 µm/s. Then f_d = 2 × 198 µm/s × 19,000 Hz / 343 m/s ≈ 0.022 Hz. Resolving a line 0.022 Hz away from a carrier needs a window on the order of 45 s, and chest velocity reverses sign every 1.9 s, so no such line ever forms — the energy smears into a faint haze hugging the carrier. The spectrum-first habits from [FFT for mortals](https://uranashel.com/blog/fft-made-readable.html), which serve Sonarish well on humming machinery, are useless on this signal. The rescue is that phase is the time integral of Doppler shift. Instead of hunting an offset you cannot resolve, accumulate the angle sample by sample, and the invisible 0.022 Hz becomes a visible 4.7° swing every breath. ## I/Q demodulation, then patience Extracting the angle is textbook quadrature demodulation. Multiply the mic signal by `cos(2πf0t)` and by `−sin(2πf0t)`, low-pass both products, and the surviving pair (I, Q) forms a vector whose angle is the received phase. After the low-pass nothing above a few tens of hertz remains, so the pair is decimated from 48 kHz down to 100 Hz and the rest of the chain runs on almost no data. One large obstacle stands in the way. The strongest 19 kHz energy at the microphone never touched a chest: it leaked straight from the speaker through a few cm of chassis, joined by static echoes off walls and the headboard. That clutter vector dwarfs the breathing echo, and atan2 on the raw pair would report the clutter's angle with a microscopic wobble on top. The fix is old radar practice. Subtract a slow running mean, about 30 s, from I and Q; every static path cancels, and the angle of what remains is chest phase directly. ``` // i, q arrive from the 19 kHz mixer + low-pass, // decimated to 100 Hz, on the processing queue iC = i - runningMean(i, 30s) // static clutter out qC = q - runningMean(q, 30s) phi = atan2(qC, iC) // wrapped phase phi = unwrap(phi) // stitch ±π jumps d = phi * lambda / (4 * PI) // metres of motion b = bandpass(d, 0.1, 0.5) // 6–30 breaths/min rate = dominantPeriod(b, 60s) // breaths per minute ``` The band-pass at 0.1–0.5 Hz does quiet heavy lifting. It spans 6 to 30 breaths/min, which covers sleeping adults and children with margin, and it discards the two things that otherwise dominate: leftover DC below the band from imperfect clutter removal, and heartbeat plus hand tremor above it. The capture callback itself only copies samples into a ring buffer and returns, following the same no-malloc, no-locks discipline as [everything else on our audio thread](https://uranashel.com/blog/dsp-audio-thread.html). ## A number that says whether to trust the number A breathing rate with no context is a dangerous display. The quality metric behind the 92% on the screenshot combines two measurements over the last 30 s: the fraction of displacement-signal energy between 0.05 and 2 Hz that falls inside the 0.1–0.5 Hz breathing band, and a periodicity score taken from the autocorrelation of the band-passed signal. Both run 0 to 1; their product, scaled to a percentage, is what you see. A supine sleeper 40 cm from the phone typically scores high 80s to mid 90s. Below 50% the app blanks the rate rather than print a number it would later have to apologize for. Bench note for honesty: 14 nights, 2 phones on a nightstand roughly 40 cm from the chest, quality logged once per second. The metric stayed above 80% for 71% of total sleep time. The low stretches clustered immediately after position changes and recovered in under a minute. ## Failure modes, catalogued Each of these appears in our test logs, which is much of why the feature ships with a label on it. - **Gross motion.** Rolling over moves the torso centimetres at centimetres per second. Phase winds through thousands of degrees each second and unwrapping produces confident garbage. Sonarish thresholds on phase rate, blanks the output and re-acquires once the phase settles. - **Bedding.** The echo usually returns from the duvet surface rather than skin, which mostly works because the duvet rides the chest. Thick fill is different: in bench comparisons a winter duvet cost about 8 dB of echo versus a thin shirt, and 8 dB of SNR is the distance between 92% quality and the blanking threshold. - **Multipath and second movers.** A ceiling fan, a second sleeper or a dog at the foot of the bed each contribute a moving echo, and the I/Q vector sums them all. Two comparable breathers produce a rate estimate that wanders between the two true rates. The periodicity term catches most of this and pulls the score down. - **Speaker roll-off.** We swept 17–21 kHz on 6 phones with a calibrated measurement mic at 30 cm. At 19 kHz the output spanned a 22 dB range between the loudest and quietest model, and one model produced essentially nothing above 19.5 kHz. Sonarish runs a 2 s sweep when the feature is first enabled and picks the carrier bin with the best round-trip SNR on that specific device. ## Hearing, pets and the experimental label Inaudible is a statistical claim about adult humans and nobody else. Age drags the upper hearing limit straight through the band this feature uses: teenagers routinely hear 18 kHz, and few people past 40 hear above 15 kHz. So the tone is kept quiet in absolute terms, around 60 dB SPL at 0.5 m on our bench units, well under the 70 dB SPL figure that published guidance cites for public near-ultrasound exposure at 20 kHz. One caution from the [decibel post](https://uranashel.com/blog/a-weighting-decibels.html) applies directly here: A-weighting is already about 9 dB down at 19 kHz, so a dBA meter structurally understates a tone a 19-year-old hears perfectly well. We evaluate the emission level unweighted. Pets hear it, full stop. Dogs perceive up to roughly 45 kHz and cats to about 64 kHz, so a 19 kHz carrier is a plain audible whistle to both. The setup flow says so and suggests leaving the feature off when animals sleep in the room. The first field report on this feature was our QA lead describing her cat staring at the phone with an expression she declined to interpret. The experimental label is not modesty theatre. Sonarish is not a medical device: it does not detect apnea, raises no alarms and carries no clinical validation, and a breathing monitor that fails silently in exactly the situations you care about would be worse than none. The label comes off when the failure modes above are either fixed or reliably self-reported by the quality metric across a far wider device list than 6 phones. Until then the app shows its number, shows its confidence in that number and blanks both the moment the data stops earning them. --- 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/) --- # Semantic search with no server in the loop > Sentence embeddings, int8 vectors and a brute-force cosine scan let Stashio search your saved links by meaning entirely on the phone. 2026-08-11 · 19 min read · stashio, math, engineering · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/on-device-embeddings.html --- Three weeks ago you saved an article about hydrostatic pressure in parking garages. Today you need it, and all you can recall is "the water pressure thing". What do you type? Keyword search fails here because the query and the document share no words, a failure mode retrieval people call vocabulary mismatch. Stashio ships semantic search for exactly this case. The product story lives in [the second-brain post](https://uranashel.com/blog/stashio-second-brain.html): my pre-Stashio Apple Notes held roughly 2,400 unsorted links, and after three months of daily dogfooding about 94% of my retrieval attempts succeed in under 10 s. This post is the machinery behind the semantic half of that number. The constraint, as always at this studio, is that the machinery runs on the phone. No embedding API, no vector database in someone's data center, no query leaving the device. That rules out most of the standard retrieval stack, and, it turns out, none of the useful parts. ## From sentences to vectors A sentence embedding model reads a piece of text and outputs a fixed-length vector. Ours outputs 384 numbers. Training pushes texts with similar meaning toward nearby points in that 384-dimensional space, so "hydrostatic pressure in a basement garage" and "water pressure underground parking" land close together even though they share one word. The geometry does the matching that the vocabulary cannot. Stashio uses a distilled MiniLM-class sentence encoder with 384-dimensional output, weights quantized to int8, about 25 MB inside the app bundle. It is a multilingual model because our users mix Vietnamese and English in the same library, sometimes in the same query. A multilingual embedding space puts "áp suất thủy tĩnh" near "hydrostatic pressure", which means a Vietnamese query finds an English article without any translation step. On iOS the encoder runs through Core ML, on Android through LiteRT. Studio bench note: median encode time for a title-plus-excerpt input is 31 ms on an iPhone 15 with the Neural Engine and 54 ms on a Pixel 8 on CPU, 200 runs per device, release builds, batteries above 50%. ## Cosine similarity, normalized early Two vectors are compared by the angle between them: `cos θ = a·b / (|a||b|)`. Identical direction scores 1, unrelated directions score near 0. Raw vector magnitude mostly encodes text length and token frequency artifacts, so we throw it away by L2-normalizing every vector once, at save time. After normalization `|a| = |b| = 1` and cosine collapses to a plain dot product, 384 multiplies and adds. That is the whole trick. For unit vectors Euclidean distance ranks identically anyway, since `|a − b|² = 2 − 2·(a·b)`, so nothing is lost by picking the cheaper formula. ## Brute force is fine below 10k items Search is a loop. Embed the query, dot it against every stored vector, keep the top 20 in a small heap. Over 10,000 items that is 3.84 million multiply-adds per query, which sounds expensive until you measure it. Studio bench note: 10,000 synthetic 384-dim vectors, 200 queries, medians. The float32 scan takes 1.8 ms on an iPhone 15 with vDSP and 3.4 ms on a Pixel 8 with NEON intrinsics. The int8 scan takes 0.9 ms and 1.6 ms on the same two devices. The encoder needs 31 ms just to embed the query. Next to the encoder, the scan barely registers. Approximate indexes like HNSW and IVF exist for a real reason, and that reason is a hundred million vectors in a server rack. On a phone they charge rent: roughly 1.5× memory for graph links, insert work on every share-sheet save, tombstone bookkeeping when you delete, recall that drops below 1.0, and three tuning parameters per index. The exact scan has recall 1.0 by construction, zero tuning, and delete is a row delete. On our own numbers the crossover where an approximate index starts paying for itself sits somewhere above 50,000 vectors. My whole library after four years of hoarding is 2,400 items, about 7,900 vectors once the PDFs are chunked. There is an HNSW branch in the repo. It has never been merged. ## Int8 and the price of a byte A 384-dim float32 vector costs 1,536 bytes. At 10,000 items the index weighs 15.4 MB, which a modern phone shrugs at in storage but feels in memory bandwidth during a scan. Quantizing each vector to int8 cuts it to 384 bytes plus one 4-byte scale factor, 3.9 MB for the same 10,000 items, and the narrower reads are the main reason the int8 scan above runs twice as fast. The scheme is symmetric per-vector quantization. Take the normalized vector, compute `s = max|x_i| / 127`, store `q_i = round(x_i / s)` together with `s` itself. A dot product then runs in int32 accumulators and rescales once at the end: `a·b ≈ s_a·s_b·Σ q_a[i]·q_b[i]`. ARM's sdot instruction eats four of those multiply-adds per lane per cycle. ``` // per query: embed, normalize, quantize, then scan fun topK(q: QVec, items: List, k: Int): List { val heap = BoundedMinHeap(k) for (item in items) { var acc = 0 // int32 for (i in 0 until 384) { // NEON sdot in practice acc += q.q[i] * item.q[i] } val score = acc * q.scale * item.scale heap.offer(Hit(item.id, score)) } return heap.sortedByDescending { it.score } } ``` Quantization costs accuracy, so we measured the cost. Against exact float32 top-10 lists on my dogfood library, replaying 500 real queries from my own local search history, int8 reproduced 98.4% of the results. Every disagreement sat in ranks 8 through 10, where neighboring scores differ by less than 0.004 and the ordering is honestly arbitrary. For a bookmark app that trade is free money. One detail matters: quantize the normalized vector. Quantizing first and normalizing after wastes int8 range on magnitude you were about to divide out. ## Chunking long PDFs One vector per item works for links and short notes. It fails for a 68-page motor datasheet, because the encoder reads about 256 tokens and a single vector for 68 pages averages everything into mud. Stashio splits extracted PDF text into windows of roughly 180 words with a one-sentence overlap, so no idea gets cut mid-thought. Each window becomes its own vector, tagged with the item id and the page number. That datasheet becomes 214 chunks, about 83 KB of int8 index; opening the result deep-links to the matching page. An item's score is the maximum over its chunk scores. We tried mean pooling first and discarded it, because averaging punishes long documents: one perfect page drowns among 213 mediocre ones. Max pooling scores a document by its best page, which matches how people remember PDFs anyway, by the one diagram they need. ## Hybrid ranking, because keywords still win exact matches Embeddings are terrible at identifiers. The query "E9" should return the screenshot of pillar E9 on floor B2 from my garage logs, and no 384-dim vector reliably separates E9 from E7. Part numbers, error codes, ISO standard names, all the same story. So keyword search stays: SQLite FTS5 with BM25 scoring over titles, tags and extracted text. Each engine covers the other's blind spot, paraphrase on one side and exact tokens on the other. Merging two ranked lists is its own small science. Our first attempt was a weighted sum of normalized scores, discarded after a week, because BM25 scores and cosine scores live on unrelated scales and every normalization we tried was fragile against outlier queries. What shipped is reciprocal rank fusion: `score(d) = Σ 1/(60 + rank_i(d))`, summed over both lists. Only ranks matter, the scales cancel, and the constant 60 keeps a single first-place vote from steamrolling an item both engines ranked fifth. It is one line of code. It has survived every query type we have thrown at it. ## Syncing an index the server cannot read Published inversion results keep showing that sentence embeddings can be decoded back into much of their source text. A vector is content, not metadata, and Stashio treats it exactly like the bookmarks themselves. When you enable the optional account, the index syncs through atuan's backend as encrypted blobs: shards of a few hundred vectors each, encrypted on the device before upload, with keys that never leave your hardware. The server stores ciphertext and a version counter per shard. It can tell how big your library is and when it changed. It cannot search it, and neither can we. Merging across devices happens locally after download, per item id, newest write wins. Each vector also carries the encoder model version, so when we ship a better model the app re-embeds stale items lazily in the background and a mixed-version library stays searchable the whole time. Because the index is built on the phone before any of this, search works in airplane mode, in a basement garage, on an install with no account at all. The reasoning behind that default is the studio's [on-device-first post](https://uranashel.com/blog/on-device-first.html). ## The latency budget, honestly Add it up: 31 ms to embed the query, about 1 ms to scan, under 1 ms for FTS5 plus fusion over a few thousand rows. Call it 40 ms from keystroke to ranked results, or 70 ms on the slower bench phone. The 94%-of-retrievals-under-10 s figure from the top of this post was never limited by compute; the other 9.9 s is a human remembering that the thing they want had something to do with water pressure. The phone's job is to be ready the moment the words arrive, with no server in the loop and nothing to be down. The short pitch, screenshots included, is on [the Stashio app page](https://uranashel.com/apps/stashio.html). --- 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/) --- # 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/) --- # The battery budget of an always-on sensor app > Four phones on a shelf, airplane mode, screen off, and five weeks of overnight battery logs to find out what each sensor really costs an always-on app. 2026-07-27 · 19 min read · engineering, wheria, physics · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/battery-budget-sensors.html --- Wheria's most-requested feature sounds small on the store page: the app notices you parked without you opening it. Behind that sentence sits a pipeline that has to sense something during every hour your phone rides in a pocket. 16 hours a day, roughly. A parking app that flattens a battery gets uninstalled by Thursday, and the OS battery-usage screen is the exact mechanism people use to decide whom to blame. So before we committed to [the indoor pipeline](https://uranashel.com/blog/building-wheria-indoor.html), we measured what continuous sensing actually costs on hardware we own. These are the bench notes from that exercise. Method in one line: 4 phones (iPhone 13, iPhone 15, Pixel 6, Galaxy A54), airplane mode, screen off, one sensor configuration per night, battery percentage logged every 5 min, 3 nights per configuration plus one 24 h weekend run each, about 5 weeks of nights in total. ## A shelf of phones, screens off The rig is embarrassingly simple. Four phones lie face down on a wooden shelf. Airplane mode kills every radio. A small logger app samples the reported battery percentage every 5 min and appends a line to a CSV. Each night runs one configuration: baseline first, then one sensor added at a fixed rate. In the morning the phones charge back to 80% and the next configuration starts. Two honesty notes on the instrument. Battery percentage is a fuel-gauge estimate with a granularity of 1%. Over an 8.5 h night, one step of 1% works out to 0.12%/h, which on a 4,400 mAh pack corresponds to roughly 5 mA. Anything cheaper than 5 mA is invisible in a single night, so the sub-mA figures below come from averaging 3 nights and from the 24 h runs. Second, fuel gauges go nonlinear near full charge, so every run starts at 80% and we trim the first 30 min. Baseline drain with everything off came out at 0.9–2.1% per night across the fleet, Pixel 6 the thirstiest. Every figure that follows is a delta above that phone's own baseline. ## What each sensor drains These are system-level deltas: chip plus driver plus the CPU wakeups needed to deliver samples to an ordinary app process. Datasheet figures for the bare chips run 10–100× lower, and that gap is most of what this post is about. - **Accelerometer, 50 Hz:** 1.4–2.6 mA depending on phone. At 100 Hz: 1.9–3.1 mA. Single-digit mA-class, and almost none of it is the chip — the transducer itself draws about 20 µA. The rest is delivery. - **Gyroscope, added on top:** +2–4 mA. A MEMS gyro keeps a proof mass vibrating continuously, so the physics bills you before the software does. - **Magnetometer, 25 Hz:** 0.5–0.9 mA. Cheap. - **Barometer, 1 Hz:** below the 5 mA single-night floor and below the averaged floor too. Datasheet-class µA. Effectively free, which is why Wheria can afford [continuous floor math](https://uranashel.com/blog/barometer-parking-math.html) at all. - **GPS, continuous fix:** 27–38 mA. GNSS is receive-only, so airplane mode leaves it alive; those phones spent their nights on a window sill for sky view. - **Audio, 48 kHz capture plus our FFT chain:** 9–14 mA. Moderate. Fine for Estua on a bedside charger, wrong for anything that runs unplugged all day. ## From milliamps to percent per hour The conversion is one division. A battery of capacity `C` mAh under a constant drain of `I` mA loses `100·I/C` percent per hour. Minimal fix preserving all downstream arithmetic: replace with "For a nominal 4,400 mAh pack that is `I/44`". (Fuller alternative: change to 5,000 mAh / `I/50` and cascade: ~6 mA single-night floor, 0.02%/h per mA, 0.48%/day, GPS 14%/day, audio 5.8%/day, and 'division by 50' in the closing paragraph.): each continuous milliamp costs 0.023%/h, or 0.55% per day. Strictly the budget should be kept in energy, mWh against pack voltage, because sensor rails and the battery sit at different potentials; at phone scale the mAh shortcut lands inside the fuel gauge's own noise, so we use it. Now the list above turns into consequences. A 50 Hz accelerometer at 2 mA is 1.1% per day, real but invisible next to a screen. Continuous GPS at 30 mA is 0.68%/h, 16% per day, and that is the app the battery screen names by Wednesday. The audio pipeline at 12 mA sits between them at 6.5% per day. The whole discipline of background sensing is arranging never to pay the expensive rows continuously. ## Duty cycling: sense only when something happens Every cost above is a rate, so total drain is rate times time switched on. The duty factor is where the budget is won. A phone spends most of its day stationary on a desk, and parking happens twice on a commute day. Wheria therefore runs as a ladder of states, each rung more expensive and shorter-lived than the one below: ``` IDLE: // coprocessor subscriptions only, ~0 extra on significant-motion wakeup -> CLASSIFY CLASSIFY: // accel 50 Hz, 20 s burst activity == automotive -> DRIVING else -> IDLE DRIVING: // barometer 1 Hz + activity transitions automotive->walking within 90 s -> PARK_CHECK PARK_CHECK: // full pipeline, capped at 3 min step detector + heading + floor delta plausible park: save spot -> IDLE ``` On a two-commute day the full pipeline runs 8–15 min in total. Call it 0.2 h at 6 mA: 1.2 mAh, under 0.03% of the pack. The CLASSIFY bursts add about as much again. Nearly the entire background day is spent in IDLE, and IDLE has to cost almost exactly nothing. Which brings us to the coprocessor. ## Living inside the motion coprocessor Phones already run a small always-on core — Apple's motion coprocessor, the sensor hubs in Qualcomm and Exynos parts — that counts steps and classifies activity for the OS around the clock, on power the OS has already decided to spend. Subscribing to its outputs costs the subscriber approximately nothing, because the marginal work is a callback. Auto parking detection therefore has to be built out of exactly those primitives: significant-motion wakeups, activity-transition events (automotive to walking is the one we care about) and significant-location change. Our own math runs only inside the short windows those events open. The alternative is streaming 50 Hz accelerometer data to our own process all day and running a smarter detector than the OS provides. We tried that prototype for a week. It cost 2.3 mA around the clock on the Pixel 6, about 1.3% per day before doing anything useful, and it kept the application processor out of deep sleep, which the OS punishes with throttled delivery and eventually a spot on the battery screen. Coprocessor events are coarser and arrive 30–90 s late. For parking that is acceptable, because the signal we need next is the walk away from the car, and [the step detector](https://uranashel.com/blog/step-detection-imu.html) gets its data during those minutes anyway. ## Batching and sensor-hub delivery Inside the windows where we do stream, delivery style matters as much as sample rate. Sensor chips write into a hardware FIFO in the hub, and the OS can hand samples over one at a time or in batches. At 100 Hz delivered per-sample, the application processor takes 100 wakeups per second and never reaches its deep idle states. Batched at 4 s intervals, 400 samples per delivery, the same stream measured 0.4 mA against 2.2 mA per-sample on the Pixel 6. Same data, same math, 5× cheaper, at the price of 4 s of latency. So the background states run fully batched, and the live navigation screen runs unbatched, where a 4 s lag would show up in the heading arrow. That mode does not need the frugality anyway: the display itself draws on the order of 80–120 mA, two orders of magnitude above the sensors it is showing. Once the screen is on, sensor power is a rounding error. ## The budget we ship Wheria's background target is 1.5% per day. The fleet measures 0.7–1.3% depending on how much driving a day contains. Getting there meant cutting things that worked. Continuous magnetometer logging for garage fingerprinting cost 0.7 mA around the clock and was cut in favor of sampling only inside PARK_CHECK. Periodic GPS polling to catch highway driving was replaced entirely by activity transitions, which removed the single largest line item. The 100 Hz always-on stream became 50 Hz bursts after the detector proved insensitive to the difference. None of this arithmetic is deep. It is division by 44 applied ruthlessly, plus a refusal to pay any continuous cost the coprocessor already pays on our behalf. The result is a feature on [the Wheria page](https://uranashel.com/apps/wheria.html) that sounds like magic and is mostly bookkeeping: the app knows where you parked, and the battery screen never learns our name. --- 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/) --- # Shipping the same day on two app stores > atuan explains the release train that moves five apps to two stores on the same day, with review-time numbers from the studio's own log. 2026-07-18 · 18 min read · engineering, studio · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/shipping-two-stores.html --- ntan handed me the keyboard for this one. I am atuan. I run the backend services, the signing keys and the CI at uranashel, which makes me the person who presses the release buttons — both of them, as it turns out. ntan already explained [why we maintain two native codebases](https://uranashel.com/blog/cross-platform-two-codebases.html). What follows is the machinery that pushes both of them out the door on the same day: 5 apps, 2 stores, 2 languages, 3 people. A studio this small cannot afford a release process that needs a release manager. The process has to be boring, scripted and hard to do wrong, because whoever drives it is also debugging Stashio sync in the same hour. So we built a train. It departs on a schedule, it carries whatever is ready and it does not wait for stragglers. Below is the entire thing, with numbers from our own release log. ## One version number per app, no exceptions Each app versions independently as `major.minor.patch`, and the string is identical on both platforms by construction. The Android `versionCode` is derived by script from the version string: `versionCode = major·10⁶ + minor·10³ + patch`, so Wheria 2.7.1 becomes 2007001, and the iOS `CFBundleVersion` carries the same integer. When a support email says 2.7.1, that identifies one commit, on either store, in either language. Nobody has to ask which platform first. The train departs every second Tuesday at 09:00. An app with merged changes gets a tag. An app without changes skips the train, and nobody minds. The tag is the release: an annotated git tag shaped like `wheria-2.7.1`, pushed to the repo. That push is the only way a store build can begin. There is no build-from-my-laptop path. There used to be, until an evening in January 2025 when a laptop build of Stashio went to TestFlight with a stale shared module and ktuyen spent 3 h chasing a bug that main had fixed 9 days earlier. The shortcut got deleted that night. ## One tag, two lanes The tag push fans out into two CI lanes on the same commit. A macOS runner does the Xcode archive, signs it and uploads the .ipa to App Store Connect through an API key; a Linux runner assembles the .aab and hands it to the Play Console through a service account. The iOS lane takes 23 min, the Android lane 9 min, and the gap is mostly Xcode. Neither lane will start unless the previous night's golden-trace parity run came back green — the 214-trace, bit-for-bit replay described in the cross-platform post. A release train carrying diverged math is just a way to ship two different apps under one version number. Both lanes end in a held state. On Apple's side the version is set to manual release; on Google's side managed publishing holds the approved build. Approvals accumulate quietly over the week. Nothing goes live until I press the two release buttons inside the same minute, which is the entire trick behind same-day shipping. The App Store Connect API key and the Play service-account JSON are the two most dangerous files the studio owns. They exist in the CI secret store and nowhere else, and we rotate them on a fixed calendar so the expiry date never gets to pick the timing for us. ## Screenshots and store copy, twice each Store screenshots are generated by UI tests. Each app has 8 scripted screens; the script runs them on a 6.9-inch and a 6.5-inch iPhone simulator, then on a Pixel 8 emulator and a 10-inch tablet profile, once in English and once in Vietnamese. A full regeneration produces 320 images across the catalog and takes 74 min on the build Mac. Captions come from the same string tables the apps use, so a renamed feature renames itself in the store, and an untranslated caption fails the build instead of surviving until a user screenshots it back at us. All store metadata (titles, subtitles, descriptions, keyword fields, release notes) lives in the repo as plain text, one file per locale, uploaded by the same lanes that upload the builds. ktuyen reviews every Vietnamese file before the tag goes in. Her standing rule: if a VI caption reads like it was translated, rewrite it from the screenshot, not from the English. The Vietnamese listing is a first-language artifact with its own rhythm, and she treats reads-well-aloud as a release gate the same way crash-free percentage is one. ## What review actually takes We log every submission timestamp, pulled by script from the App Store Connect and Play Developer APIs since January 2025: 61 submissions per store at the time of writing. Apple's median from submission to approval is 16 h; the 90th percentile is 39 h. Play's median is 25 h with a 90th percentile of 74 h. The folklore says Apple is the slow one. Our log says otherwise, at least for a small studio with established apps and no gambling SDKs. The tails are where planning lives. Apple's worst case was 9 days: Sonarish 2.3.0, rejected twice under guideline 5.1.1 because the microphone purpose string described what the app does instead of why it needs the mic. Our fault, both times. Play's worst case was 16 days: Estua 3.1.0 sat in review for 16 days and emerged approved, unchanged. We never learned what it was thinking about. So the train tags on a Tuesday, submits within the hour and targets going live the following Tuesday. A 7-day buffer absorbs both 90th percentiles with room to spare. When a tail event eats the buffer anyway, the pair slips together, because the iOS build never ships alone; that has happened twice in 18 months. Apple's expedited review exists and works. We have requested it 3 times in 2 years, been granted it twice, and the fastest turnaround was 5 h. It is plainly a favor. We intend to keep it rare enough to stay one. ## Phased rollout and the halt rule The two stores disagree about what a rollout is. On Play we stage manually: 5%, then 20%, then 50%, then 100%, each step roughly 24 h apart when vitals stay clean, a full ramp in about 4 days. Apple's phased release is a fixed 7-day curve (1, 2, 5, 10, 20, 50, 100% of automatic-update users), and the only control Apple gives you is pause. The same fraction of users is never exposed on both platforms on the same day, so we stopped pretending it could be and wrote the halt rule per store. The rule has two triggers, checked against the consoles' own crash reporting, which is also the only crash reporting we have; the sensor apps carry no third-party analytics SDK, for the reasons in [the on-device post](https://uranashel.com/blog/on-device-first.html). Trigger 1: any new crash signature above 0.2% of sessions in the first 24 h. Trigger 2: crash-free users below 99.6% at any stage. Either one pauses the rollout and pages me. It has fired once for real. Wheria 2.6.1, November 2025, Android lane, 5% stage: a Samsung A54 could deliver an empty barometer batch after screen-off and the listener indexed into it anyway. 41 users were affected. The rollout halted 40 min after the alert, the fix was a 2-line guard, and 2.6.2 reached Play 31 h later. iOS never had the bug; its barometer path had not received the refactor. Wheria then spent 3 weeks as 2.6.2 on Play and 2.6.1 on the App Store, which is the one sanctioned exception to version parity: patch digits may drift after a hotfix. Parity is enforced at minor granularity and the next minor release re-aligns the pair. Submitting a no-op binary to Apple so two numbers match would burn a review cycle on ceremony. ## The hotfix lane A hotfix branches from the release tag it repairs; main keeps moving on its own. The rules are short. One bug per hotfix. The diff stays minimal and touches no strings, so localization cannot block it and ktuyen's pass shrinks to the affected app on 4 devices instead of the full matrix. On Play it still stages, compressed: 20% for the first 12 h, then 100%. It still gets hand-written release notes, because a sentence like "fixed a crash some of you hit on Samsung phones when the screen turned off" costs 3 minutes and buys back trust that the phrase "bug fixes" had been quietly spending. Five hotfixes have shipped under these rules since January 2025. The train schedule survived all 5. ## Release notes, by hand Every release ships notes written by whoever made the change, in English, then rewritten by ktuyen in Vietnamese. Templates were banned early. "Bug fixes and performance improvements" is store-listing wallpaper; a user who just watched the compass badge misbehave deserves to read that the compass reliability badge no longer flickers near EV chargers, and to know we saw it too. People reply to these. Support mail quotes our own notes back at us, sometimes approvingly; that closes a loop no analytics dashboard can. Under the principled reason sits a stack of practical ones. The notes double as our public changelog, so a support email about 2.5.0 gets answered by reading what 2.5.0 said about itself. Play's 500-character limit per locale turns out to be an editor: a release that cannot state its point in 500 characters probably lacks one. Writing the VI notes from scratch keeps them in the register our users actually read, which has been ktuyen's hill since the first listing went up. ntan promised in [the welcome post](https://uranashel.com/blog/welcome-uranashel.html) that atuan and ktuyen would show up here once there was an infrastructure story worth telling. This is mine, and the honest summary is that none of it is clever. A version formula, a tag shape, two build lanes, two held buttons, one spreadsheet of review times, one halt rule. The train is boring on purpose. Boring machinery is what lets a 3-person studio spend its interesting hours in a parking garage instead of a deploy channel. --- 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/) --- # Welcome to uranashel > Who we are, why five apps exist, and how this blog explains the physics behind them — in plain language. 2026-07-04 · 20 min read · studio · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/welcome-uranashel.html --- I am ntan in the commit logs. I write the mobile clients at uranashel, plus most of the sensor and digital signal processing math that sits underneath them. We are a three-person studio based in Vietnam. I handle SwiftUI, Jetpack Compose, and the physics; **atuan** runs backend services, CI pipelines, and release trains; **ktuyen** owns QA, the garage test matrix, and the Vietnamese copy that keeps our App Store screenshots from reading like machine translation. No venture capital. No ad SDKs in the sensor apps. No pitch deck pretending we are building "the future of mobility." We ship tools we already use on real hardware before they reach the store. Under the `com.uranashel` bundle family we publish five apps on the App Store; **Stashio** also ships on Google Play and as a Chrome extension. **Wheria** finds your parked car after GPS dies in a basement garage, fusing IMU step counting, barometer floor hints, and magnetometer heading into a walked path you can retrace instead of a lying blue dot. **Estua** synthesizes non-repeating ambient sleep audio at 48 kHz, entirely on the phone. **Sonarish** is a noise meter and spectrum analyzer with machine baseline comparison, built for anyone who hears their AC unit change pitch after three years. **Phyzix** exposes more than 100 live physics instruments and simulations, in English and Vietnamese. **Stashio** captures links, PDFs, and voice notes from the share sheet, then finds them again by keyword or by meaning. Each product grew from a specific annoyance, not a market trend slide. ## The Tuesday that started everything The origin story people ask about is not glamorous. On an ordinary Tuesday I parked on level B3 under Landmark 81 in Ho Chi Minh City, the tallest building in Vietnam, with a basement parking structure deep enough to swallow radio signals whole. I photographed the pillar number and took the elevator up. When I came back 40 minutes later, the map showed my car floating in the Saigon River, roughly 400 m east of reality, holding a steady 0 km/h midstream. I walked two full laps through identical concrete rows before I found it. The failure mechanism is boring and well documented. L-band GPS signals attenuate hard in reinforced concrete, and the little energy that leaks underground arrives via multipath reflections that bias range estimates by tens of meters. Every mapping app knows this. Every mapping app drew the confident pin anyway, as if satellite geometry were a matter of opinion; I wrote up the full mechanism in [why GPS lies](https://uranashel.com/blog/why-gps-lies.html). That evening I opened Xcode and a blank Kotlin project side by side. "Indoor navigation" would have looked impressive on a conference badge, but the badge had nothing to do with it. The phone in my pocket already carried a 3-axis accelerometer, a gyroscope, a barometer, and a magnetometer, which together form a complete inertial measurement unit. The sensors were there. Nobody was reading them honestly. The whole build is documented in [building Wheria for indoors](https://uranashel.com/blog/building-wheria-indoor.html). ## Four more annoyances, four more apps Estua exists because my wife could hear the seam in an 8-minute rain loop at 2 in the morning. That is normal hearing, unfortunately. The human auditory system is absurdly good at detecting repetition, and a recorded loop fails the sleep test no matter how long you stretch it. So Estua synthesizes its audio at 48 kHz on the phone rather than playing recordings, and every night comes out statistically fresh. How the generators avoid repeating themselves has [its own post](https://uranashel.com/blog/estua-non-repeating-audio.html). Sonarish came from a residential AC unit that developed a 127 Hz hum, a harmonic of mains frequency and motor pole count, and from the discovery that no consumer sound meter could track that hum over weeks. We built the tracking ourselves: A-weighted levels checked against the NIOSH 8-hour exposure limit, a live spectrum, and a machine baseline you record while the machine is healthy so that later drift shows up as a number you can argue with. The same acoustic core turned out to measure breathing without contact; from an inaudible tone it resolves chest displacement down to 118 µm. Phyzix came from watching students treat phones as TikTok machines while every one of those phones held a sensor laboratory nobody had opened. It exposes more than 100 of those instruments directly, bilingual, raw numbers visible. Stashio came from my own Notes app, which had accumulated 2,400 unsorted links across four years of garage testing and paper reading. The fix was a capture path through the share sheet plus retrieval by meaning. After three months of daily use, roughly 94% of my retrieval attempts succeed in under 10 s. ## Where the physics sets the limits The barometer is the cleanest example of physics deciding what a feature may claim. Hydrostatic equilibrium says `ΔP = ρ·g·Δh`. Put in air density of about 1.2 kg/m³ and g of 9.81 m/s², and 1 m of height change moves pressure by roughly 12 Pa. A typical parking floor is 3.2 m tall, so one floor is worth about 38 Pa. Meanwhile a phone barometer sitting still shows 0.3–1 Pa RMS of noise; our own bench note, 3 phones logged overnight on a shelf, landed at 0.4, 0.6, and 0.9 Pa RMS. Divide signal by noise and the honest claim writes itself: Wheria can tell B2 from B3 with a stated uncertainty band, and it must never pretend to centimeters. The full derivation, including what weather fronts do to it, is in [the barometer math post](https://uranashel.com/blog/barometer-parking-math.html). Audio has an equally hard wall. At 48 kHz the render callback owes the hardware a buffer every few milliseconds, and that deadline turns into an absolute rule: no malloc, no locks on the audio thread, because a single allocation stall becomes an audible click at 3 am. Position math has a third wall. Double-integrating consumer accelerometer data sends your estimated position into another building within 30 s, which is why Wheria counts discrete steps and never integrates acceleration twice. Each wall killed at least one feature idea we were fond of. The details live in [the audio thread post](https://uranashel.com/blog/dsp-audio-thread.html) and [the step detection post](https://uranashel.com/blog/step-detection-imu.html). The magnetometer is the diva of the set. Rebar, EV chargers, and a car's own body add local fields on the order of the Earth field itself, so heading is only usable after hard-iron and soft-iron calibration, and the calibration goes stale when you walk past a transformer room. ktuyen's EV-bay test cases exist because of one specific charger in one specific mall basement. The saga is told in [the compass calibration story](https://uranashel.com/blog/compass-calibration-story.html), and how the Kalman filter weighs all these unreliable witnesses is in [the Kalman post](https://uranashel.com/blog/kalman-filter-parking.html). ## How the three of us divide the work I write shared logic modules wherever the math must match exactly between platforms: Kalman filter updates, FFT window functions, step detector thresholds. Then I mirror the UI twice, once in SwiftUI and once in Compose. Two codebases with one math core sounds wasteful until you have debugged a filter that behaves differently on the two platforms because someone ported it twice; the reasoning is laid out in [the two codebases post](https://uranashel.com/blog/cross-platform-two-codebases.html). atuan keeps Stashio sync honest, manages the signing certificates, and tags iOS and Android releases with aligned version numbers so ktuyen never tests a mismatched pair. ktuyen maintains test plans segmented by garage architecture: open slab structures like Landmark, narrow spiral ramps in mall basements, flat outdoor lots, and the magnetometer nightmares around EV charging bays. She catches regressions of the shape "compass drift on iPhone 15 inside a steel garage but fine on Pixel 6" before users ever see them. We ship both platforms together by policy. Every feature lands twice, every bug lands twice, and parity is a release gate. ## A deliberately quiet interface Every uranashel product has a physics or signal-processing spine beneath the UI, and we keep the surface deliberately quiet about it: monochrome palettes, Nunito type, 12 px corner radius, nothing animated competing with a measurement. The test we design against is a parking garage at 11 pm, tired user, one glance. The [Wheria](https://uranashel.com/apps/wheria.html) home screen is a single card: the car, 124 m away, floor B2, pillar E9, a note that says near the elevator. The palette decision and the type decision each got a post, [monochrome UI](https://uranashel.com/blog/monochrome-ui.html) and [Nunito on every pixel](https://uranashel.com/blog/nunito-on-every-pixel.html), because we argued about both for weeks. ## What this blog covers An App Store description cannot explain hydrostatic floor math. It cannot explain why malloc on the audio thread produces clicks at 3 am, or why double-integrated accelerometer data leaves the building within 30 s. This blog is the long-form companion to the store pages: how the apps actually work, what we tried and discarded, which ideas survived contact with real garages. Most posts come from the mobile and sensor side of the studio, because that is where I sit. When atuan has a release infrastructure story or ktuyen has a QA story worth telling, they will appear here too. If you want to reproduce some of the measurements, the [lab page](https://uranashel.com/lab.html) lists our setups. If you have compass rants, garage logs, or questions about anything we build, write to [uralab95@gmail.com](mailto:uralab95@gmail.com). For the engineering philosophy everything above hangs on, keeping computation local to the device, start with [why everything runs on your phone](https://uranashel.com/blog/on-device-first.html). --- 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/) --- # Why everything runs on your phone > Why parking, sleep audio, and noise meters must compute on your phone — latency, privacy, and offline garages explained step by step. 2026-06-15 · 19 min read · privacy · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/on-device-first.html --- Most consumer software assumes a server in the middle: you sign in, telemetry goes up, machine learning runs in a data center, and state flows back through REST endpoints. For social feeds and collaborative documents that architecture is the right call. For parking dead reckoning, 48 kHz audio synthesis, and calibrated noise measurement it fits badly, and those are the three problems Wheria, Estua, and Sonarish work on every day. The phone in your pocket is not a thin client displaying someone else's computation; for our sensor apps, it is the computer. On-device-first means the entire pipeline executes locally. Sensors get sampled at hardware rates, estimates fused in real time, the interface rendered, history persisted inside the app sandbox. Phyzix follows the same rule: lab sessions and CSV exports stay on the device until you explicitly share them. Stashio is the deliberate exception, with optional cloud sync for anyone who wants bookmarks on a second phone while capture, indexing, and search keep working in airplane mode. Nobody here treats the rule as ideology. Three things force it: the physics of latency, the economics of privacy for a 3-person studio, and the places our users actually stand. ## The deadline arithmetic Wheria redraws your walked path after every detected step. At a normal pace footfalls land 0.5–0.7 s apart, and beneath them the IMU streams accelerometer and gyroscope samples at 50–200 Hz. The fusion filter has to ingest each measurement, propagate uncertainty, and redraw the polyline within a few milliseconds. A round trip from Saigon to a Singapore data center costs about 80 ms on a good day; the physical distance is roughly 1,100 km, fiber covers that in about 11 ms both ways, and the remaining 70 ms is queueing and radio scheduling. Compass heading arrives at 50 Hz, which leaves 20 ms between updates. Wi-Fi round-trip time alone consumes that entire allowance before any server-side Kalman filter gets a turn. Estua lives under a harder deadline. The render callback fires every 512 samples at 48 kHz, so each buffer must be filled within `512 / 48000 s ≈ 10.7 ms`, every callback, for hours. Miss one and the output carries a discontinuity the ear reports as a click. At 2 AM, with a listener finally asleep, a single click undoes the whole product. No practical path exists that POSTs PCM to a remote server and receives synthesized noise back inside 10.7 ms, which is why the full synthesis graph runs inside the callback under the no-malloc, no-locks discipline we wrote up in [life on the audio thread](https://uranashel.com/blog/dsp-audio-thread.html). Sonarish carries the same constraint on the capture side. Its FFT windows span 2048–4096 samples at 48 kHz, which is 43–85 ms of sound per transform, and a real-time spectrogram needs that transform running on the same device that owns the microphone HAL. Pushing raw audio upstream would insert a network hop into every column of pixels. The A-weighting filter that turns raw spectra into dB(A) is a fixed cascade and runs locally too. ## What a garage does to your radio The latency argument still assumes a link exists. Underground it usually does not. In June we logged connectivity across 40 garage descents on 6 phones (3 iPhone, 3 Android), pinging a Singapore endpoint every 2 s. Median LTE round trip at street level was 42 ms. At B1 it stretched to 310 ms with 38% packet loss. At B2 no usable link existed on 31 of the 40 descents. B2 is also where the cars are. An early Wheria prototype learned this the slow way. It streamed raw IMU frames over a WebSocket to a laptop so we could tune the step filter in Python, and at a desk the setup ran beautifully. One ramp below street level, the stream stalled every few seconds and the plotted path froze mid-turn. That afternoon the no-network rule went into the engineering notes. The pattern repeats far from garages. Sonarish users run noise surveys inside shielded factory halls where the cellular radio gives up before the machines do. Estua has to keep synthesizing through a 13-hour flight in airplane mode. Treating connectivity as optional keeps the apps working in the exact places people open them: underground, inside metal structures, at 3 AM when the household Wi-Fi is off. ktuyen's release checklist therefore includes a full offline garage walk before every release; if indoor navigation required a network handshake, it would fail the moment you descend the ramp. The sensors never notice any of this. Atmospheric pressure falls by about 12 Pa per metre of altitude near sea level, so one 3.2 m garage floor is worth roughly 38 Pa against a phone barometer noise floor of 0.3–1 Pa RMS, and the floor hint follows from `Δh = ΔP/(ρg)` with no server in the loop. The full derivation lives in [barometer floor math](https://uranashel.com/blog/barometer-parking-math.html). ## The phone is fast enough The classic argument for cloud offload was compute, and that argument quietly expired years ago. From our July bench notes: a 4096-point real FFT through Accelerate averages about 70 µs on an iPhone 12, and the same transform through KissFFT on a mid-range Snapdragon lands near 350 µs (each figure averaged over 10,000 transforms in release builds). Both sit 2–3 orders of magnitude below the 43–85 ms of audio the window represents. One Wheria EKF update over a handful of states is microseconds of matrix arithmetic. Steps arrive at walking cadence, under 3 Hz. Estua's per-sample work (a pink filter, a few biquads, some slow LFOs) occupies a low single-digit percentage of one core at 48 kHz. Power is the honest cost, and it stays modest. With the screen at half brightness and the IMU at 100 Hz, a find-my-car walk costs about 1% of battery over 4 minutes on the same 6 bench phones. The radio runs the other direction: a phone hunting for signal in a basement draws more power than one that has stopped trying, so staying offline underground actually saves battery. ## Privacy as a byproduct of the wiring The privacy story is mostly an absence. We never built the server-side infrastructure that could violate it, so there is no policy to audit and nothing to police. Wheria keeps parking coordinates, walked-path polylines, floor hints, and optional photos entirely inside the app sandbox, with no account and no upload queue behind the UI. The record that says floor B2, pillar E9 exists in one database file on one phone. Sonarish stores noise sessions, machine baselines, and exported spectrogram images locally by default. Estua remembers exactly two things about you: a scene preference and a random seed. The audio stream itself is never recorded unless you export it yourself. No third-party analytics SDKs ship in these applications, and crash reports flow through Apple's and Google's standard channels only. Model training on our servers does not happen, for the airtight reason that no servers collect accelerometer traces in the first place. Data that was never centralized cannot leak, and cannot be quietly repurposed when a company changes hands. ## The economics of not running servers There are three of us. A server-side sensor pipeline would mean an on-call rotation, database backups, API versioning, and a bill that arrives every month whether or not anyone parks a car. On-device compute is hardware the user already bought, so our marginal infrastructure cost per Wheria user rounds to zero. The Wheria server bill has never had an outage. Longevity is the quieter half of the argument. A sensor app whose processing lives on a startup's backend tends to die with the startup. An app whose entire pipeline is local keeps working years after release, even through a quarter where we ship nothing. atuan saves his backend effort for the one product where it earns its keep. ## The Stashio exception Stashio breaks the rule on purpose. A save-for-later tool earns trust when a link captured on the phone shows up on the tablet, so an optional account exists and atuan runs a small sync service behind it. The local-first spine stays intact: capture, indexing, and search all work in airplane mode, and the search index is built on the device before anything syncs. My own backlog holds about 2,400 links collected over four years; after three months of daily use, roughly 94% of retrieval attempts succeed in under 10 s, offline included. When sync is on, data travels as encrypted blobs. The sensor apps and Stashio sit at opposite ends of one decision, which makes [the Stashio post](https://uranashel.com/blog/stashio-second-brain.html) a useful mirror of this one. ## What we give up, in writing On-device-first has costs and we keep a written list of them. Cross-device sync for Wheria parking pins (carrying a garage walk from an iPhone to a Pixel without a manual export) remains unbuilt, because the encrypted sync layer has never reached the top of the queue. A community heatmap of parking availability would need everyone's positions on a server. Models that improve from pooled user data would need the pool. Competitors ship these features and they are genuinely useful; they also dismantle the latency and privacy properties that make a sensor app worth trusting. The standing trade is slower feature velocity in exchange for millisecond feedback, offline reliability, and data that stays on the phone until you choose to export it. For a 4-minute walk back to your car, that trade is correct. To see what the local computation actually does underground, continue with [indoor navigation when GPS dies](https://uranashel.com/blog/building-wheria-indoor.html); for the render-deadline discipline Estua shares with Sonarish, read [life on the audio thread](https://uranashel.com/blog/dsp-audio-thread.html). --- 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/) --- # Indoor navigation when GPS dies > When GPS fails underground, Wheria walks you back with steps, heading, and pressure — how sensor fusion works and what we honestly tell users. 2026-05-28 · 21 min read · wheria, physics · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/building-wheria-indoor.html --- Outdoors, Wheria behaves like any parked-car app: a pin on the map, a distance readout, maybe a photo of the spot. Then you drive down the ramp. Two slabs of reinforced concrete later, the receiver that reported 5 m accuracy at street level claims horizontal error past 30–80 m, or it loses fix entirely and the map keeps drawing a position that is minutes stale. Wheria watches reported accuracy and horizontal dilution of precision the whole time, and the moment either crosses our usable threshold it hides the dot. A confidently wrong blue dot costs the user more than an empty map does. The physics of why the signal dies down there is in [why GPS lies to you](https://uranashel.com/blog/why-gps-lies.html); what follows is about the thing that runs in its place. Indoor mode is **pedestrian dead reckoning**: accumulate discrete steps and heading changes from the phone's inertial sensors, add barometer floor hints, and show the resulting uncertainty on screen with a straight face. The technique predates the transistor by centuries. Ship navigators ran it with a compass, a log line and an hourglass, and they knew the property that still defines it: every error you make, you keep, and it compounds with each further step. ## What happens when you tap Park The Park tap captures an anchor. If a usable GPS fix exists, we store latitude, longitude and the reported accuracy radius, plus a timestamp and an optional photo of a pillar number or row marker. My own camera roll is mostly gray pillars now; the one from last Tuesday reads B2, pillar E9. If you are still at street level, the barometer also logs a pressure baseline at the garage entrance, which becomes the reference for floor estimation later. Entered an underground garage before parking, so no fix at all? Then the anchor is inertial-only from the entrance, and the photo matters more. The app then opens a high-rate IMU session (CoreMotion on iOS, SensorManager at game or fastest rate on Android) and starts a polyline that will record every step you take after leaving the car. All positions live in a local east-north-up frame centered on the park point. Distances underground are tens of meters, so a flat local frame is exact to well below sensor error and spares us map projections entirely. Each detected footfall advances the position estimate by one stride length along the current heading. That single sentence is the whole algorithm. Everything below is about the ways each term in it goes wrong. ## Counting steps from a noisy IMU Step detection band-pass filters the acceleration magnitude around walking cadence, roughly 1.5–3 Hz for typical walking speeds. Peak picking with an adaptive threshold marks individual footfalls, and the gyroscope confirms turn events between steps. Working on the magnitude `|a|` keeps detection independent of phone orientation, which matters because people navigate garages with the phone in a hand, in a pocket, sometimes wedged under a chin. The classic student mistake is to double-integrate acceleration into position. Consumer MEMS accelerometers carry milli-g-level biases, and integration squares time: a constant bias `b` produces position error `e = 0.5·b·t²`, so 1 mg (about 0.01 m/s²) becomes 0.5 m of drift after 10 s and 18 m after 60 s. That assumes a perfectly estimated gravity vector, which you do not have. Wheria uses raw acceleration for step events only. Position comes from counting. ``` // per IMU sample, ~100 Hz fun onSample(acc: Vec3, gyro: Vec3, dt: Float) { yaw += gyro.z * dt // integrate between steps val m = bandpass(acc.norm() - G) // biquad, 1.5-3 Hz if (isPeak(m) and m > thresh) { thresh = 0.7f * thresh + 0.3f * m // adaptive threshold var h = yaw if (compassTrustworthy()) { h = blend(yaw, compassHeading(), trustWeight()) yaw = h // absorb the correction } pos.e += stride * sin(h) pos.n += stride * cos(h) polyline.add(pos) // one vertex per footfall } } ``` The false-positive traps (escalator rides, pocket bounce, the shopping-cart shuffle) and the filter design behind `bandpass()` get their own treatment in [step detection and IMU odometry](https://uranashel.com/blog/step-detection-imu.html). ## Heading is the hard part Between steps, heading comes from integrating the gyroscope's yaw rate. Gyro integration is smooth and immune to magnetic garbage, but it drifts. Bench note: across 6 phones logging 30-minute static sessions after a figure-eight calibration, we measured yaw drift of 0.3–1.5° per minute. A 3-minute walk can therefore accumulate 4° of heading error, which at 80 m of range is up to 6 m of lateral offset from the gyro alone. The magnetometer measures Earth's field and offers absolute heading with zero drift. It also measures every steel column, every run of rebar and the ferrous body of each parked car; a garage is essentially a warehouse of large magnets that disagree with each other. When field magnitude or dip angle falls outside plausible ranges we flag the reading as distorted, down-weight compass measurements hard, run on gyro between steps and surface an explicit "compass unreliable" indicator. Faking a confident north would have been less code. The rules for when the magnetometer earns trust back are in [the compass calibration post](https://uranashel.com/blog/compass-calibration-story.html). ## The barometer and the floor question Hydrostatic equilibrium gives `dP/dh = −ρg`. With air density ρ ≈ 1.2 kg/m³ and g = 9.81 m/s², pressure drops roughly 12 Pa for every meter climbed near sea level. A typical garage floor is about 3.2 m of height, so one level is a 38 Pa step in pressure. Phone barometers, after light smoothing, hold about 0.3–1 Pa RMS of noise. The per-floor signal is therefore 40 to over 100 times the noise floor, which sounds comfortable and mostly is: take the pressure change since the entrance baseline, convert through `Δh = ΔP/(ρg)`, divide by 3.2 m per floor, round. That usually lands on the right basement level. Usually. An elevator moves you 3 floors in 15 s while the phone sleeps in a pocket. HVAC systems and stairwell doors add transients of a few pascals. A weather front can drag the baseline by 100 Pa over a few hours, which converts to roughly 8 m of phantom altitude if you parked before lunch and came back late. The mitigation math (reference baselines, drift windows, when to give up and ask the user which floor they remember) fills its own post: [barometer floor math](https://uranashel.com/blog/barometer-parking-math.html). ## Stride length and the error budget Default stride is 0.72 m. Given user height we apply the anthropometric estimate `stride ≈ 0.415·h`, which for a 1.75 m person gives 0.73 m. Users who care can calibrate: walk a known 20 m straight line, let the app count steps, and Wheria stores a personal stride coefficient for all future walks. On long walks, stride error dominates everything else in the budget. A 5% overestimate produces 4 m of longitudinal error after 80 m walked, before heading uncertainty adds its lateral component on top. Stack the terms and the honest answer is a band. Wheria says "about 45 m". Printing "44.7 m" would claim a decimal place the filter does not possess. The confidence chip beside the distance (green, yellow, red) is wired directly to the covariance of the fusion filter, and the filter itself gets the full derivation in [the Kalman filter post](https://uranashel.com/blog/kalman-filter-parking.html). ## Interface choices for a decaying estimate The path polyline ages visually. Older segments fade, so if you paused mid-garage for a 10-minute coffee, the trace looks exactly as stale as it is. When heading uncertainty exceeds 25°, which happens routinely near EV charging stations where DC cables and transformers bend the local field, Find mode suppresses turn-by-turn voice guidance and shifts to distance-first presentation: walk about 60 m, then rescan the compass somewhere clearer. We would rather stay quiet than shout wrong directions into a concrete wall. The same honesty rule shapes the visual design across all five apps, down to the monochrome palette described in [designing in strict monochrome](https://uranashel.com/blog/monochrome-ui.html). ## How we know any of this works ktuyen owns a garage test matrix and runs it every release: open slab structures like Landmark 81, narrow spiral ramps under mall basements, outdoor-to-indoor transitions where GPS dies mid-walk and the magnetometer nightmare zones around EV bays. The bar is fixed. The user finds the car within one wrong-turn correction on at least 70% of walks under 120 m, using phone sensors alone. No SLAM maps. No Bluetooth beacons. No venue partnerships. In the May regression run (2 testers, 6 phones, 148 logged walks across 4 garages) we scored 81% overall and 64% in the worst spiral-ramp basement, so spiral ramps stay on the matrix. Dead reckoning will never be magic. It is a disciplined way of being approximately right for 4 minutes, and finding a parked car needs almost exactly that. What Wheria does with these estimates on screen is on [the app page](https://uranashel.com/apps/wheria.html). --- 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/) --- # Sound that never repeats > Your brain detects loops; Estua generates fresh ambient sound every night. How real-time synthesis at 48 kHz replaces repeating rain clips. 2026-04-10 · 20 min read · estua, dsp · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/estua-non-repeating-audio.html --- Loop-based sleep apps fail for a perceptual reason, and better microphones cannot fix it. An 8-minute rain recording, however professionally captured, carries repeating micro-structure: the same cluster of droplet impacts, the same phase relationship between low rumble and high hiss, coming back every 480 s. By the third minute your auditory cortex has reclassified the signal from "environment" to "recording," and sleep grows lighter at exactly the moment you needed darkness and continuity. Estua goes the other way. It **synthesizes ambient audio in real time** at 48 kHz on the phone, so the output has no fixed period and no seam to detect. This post walks the whole chain, from the sample clock to the limiter. ## The clock and the contract Everything hangs off a master clock: 48 kHz, fixed blocks of 512 samples. Divide one by the other and you get the deadline, `512 / 48000 = 10.67 ms` per callback. Miss it once and the OS ships whatever stale data sits in the output buffer, which a listener hears as a click. Miss it at 3 AM and the product has failed at its one job. So the audio thread runs under a contract we treat like physics: no heap allocation, no locks, no logging, no network calls. Sonarish's capture path obeys the same rules, and the full discipline (pre-allocated buffers, lock-free parameter snapshots) is written up in [life on the audio thread](https://uranashel.com/blog/dsp-audio-thread.html). Estua adds one property that makes the contract simpler and stranger at the same time. It is a pure generator. No file to decode, no stream to buffer, no disk read racing the deadline. Every one of those 512 samples is computed from scratch, every 10.67 ms, all night. The signal path has to stay cheap enough to leave headroom for Bluetooth encoding and whatever the OS decides to do behind our back, and deterministic enough that block N sounds like it belongs after block N−1 with no stored history beyond a few filter states. ## From white to pink The chain starts with white noise. One PRNG draw per sample, flat spectrum, equal energy per hertz. Cheap and wrong. Each octave spans twice the bandwidth of the octave below it, so a flat-per-hertz spectrum doubles its energy per octave as frequency climbs, and the ear reads that as harsh hiss. What sleep wants is a 1/f power spectrum, `S(f) ∝ 1/f`. Integrate that across any octave and you get `∫ df/f = ln 2`, whether the octave starts at 100 Hz or at 4 kHz. Constant energy per octave, a slope of −3 dB per octave. That is pink noise, and it is roughly what rain on a roof and distant surf look like on a spectrum analyzer. An exact 1/f filter would need infinite state, because every ordinary filter pole contributes −6 dB per octave and we need precisely half of that. Paul Kellet's economical recursive filter solves it by staggering three one-pole low-pass sections at different time constants and summing them, so the staircase of their responses leans along the 1/f line: ``` // per sample; white is a uniform PRNG draw in [-1, 1] b0 = 0.99765 * b0 + white * 0.0990460 b1 = 0.96300 * b1 + white * 0.2965164 b2 = 0.57000 * b2 + white * 1.0526913 pink = (b0 + b1 + b2 + white * 0.1848) * outGain ``` Eight multiplies and six adds per sample, three floats of state, no transcendental functions. On our bench the staircase wobbles around the ideal slope by about ±0.5 dB from 20 Hz to 16 kHz, measured by averaging 300 FFT frames of the raw generator output. Nobody can hear that ripple against the noise itself. The iOS build sometimes swaps in an equivalent biquad stack for tighter shaping; the CPU difference is lost in measurement noise. Underneath sits an optional brown layer for ocean scenes: 1/f², a −6 dB per octave slope, generated by leaky integration of white noise, all rumble below roughly 200 Hz. ## Slow oscillators against periodicity Static pink noise beats a loop, but it is still static. Real environments breathe. The fix is modulation slow enough that it never registers as rhythm: a bank of low-frequency oscillators running between 0.02 and 0.15 Hz, periods of 6.7 to 50 s, each phase drawn uncorrelated from the session seed. One LFO leans on a filter cutoff. Another rides a layer's gain. A third nudges stereo width. The rates are chosen deliberately incommensurate, no period an integer multiple of another, so the joint state of the bank realigns only after the least common multiple of every period involved, which for our chosen ratios exceeds any plausible night of listening. Nothing repeats at 30 or 60 s intervals, exactly the timescales where loop-based apps betray themselves. Amplitude envelopes come from the same toolbox. A one-pole low-pass on the absolute value of a signal tracks its envelope at the full 48 kHz rate using precomputed coefficients, so a slow swell in the ocean layer costs two multiplies per sample and zero branches. Everything stays on the audio thread, every operation is O(1) per sample, and nothing allocates. ## Scenes, seeds, and the statistics of surf Each scene (Rain, Ocean, Wind, a few others) layers its own processing on the pink foundation. Rain adds band-passed noise bursts whose inter-arrival times follow a Poisson process: draw `u` uniform in (0, 1], schedule the next droplet at `t = −ln(u)/λ`, and let the rate λ itself wander under LFO control so the shower thickens and thins. Real droplets on a roof arrive with exactly this statistic; they do not queue politely. Ocean mixes slow amplitude modulation on a low band between 80 and 400 Hz with mid-frequency hiss, which produces the breathing quality of surf without any recorded waveform to repeat. Wind is pink noise pushed through a resonant band-pass whose center frequency drifts, close to what a doorframe does to a gust. At the end of the chain a soft limiter clips at −1 dBFS, preventing digital full-scale spikes that would startle a sleeping listener at 3 AM. Each night you choose a scene and a seed, or let the app pick randomly. The seed initializes the pseudorandom generators behind the noise samples and the LFO phase offsets, so the same seed reproduces the same soundscape for that session. If you liked last night's rain, you can have it back. We also optionally stir the seed with the calendar date, so that "Rain plus seed 42" is not bit-identical every night for years. Reproducibility within a session, variety across the weeks. ## Why crossfading two loops is not enough The standard industry workaround crossfades between two copies of a loop to hide the seam. It works, in the narrow sense that the audible click at the splice point disappears. The statistics do not move. Autocorrelation of looped rain still spikes at the loop period T and at every integer multiple of it, because the sample you hear at time t is literally the sample from time t − T. Human hearing picks up that kind of periodicity at remarkably subtle levels; evolution spent a long time rewarding brains that noticed patterned sounds in rustling grass. Real ocean surf approximates pink noise with occasional transient swash events whose timing follows no fixed schedule, and no crossfade manufactures that from an 8-minute source. Autocorrelation is the time-domain cousin of the power spectrum; the gentle introduction lives in [FFT made readable](https://uranashel.com/blog/fft-made-readable.html). We checked our own homework. Bench note: 30-minute captures of one commercial rain loop and of Estua's Rain scene, normalized autocorrelation computed offline in Python on the recorded output. The loop shows a correlation peak of 0.6 at its period. Estua stays below 0.05 at every lag beyond the filter memory of a few hundred milliseconds. The full synthesis stack costs 3–8% CPU on modern phones, and what that buys is hours of continuous playback with nothing for the auditory cortex to latch onto. ## Platform plumbing On iOS, Estua drives an AVAudioEngine manual source node callback under the playback session category, which keeps audio alive while the screen is locked. On Android it feeds an AudioTrack in PCM float encoding, requesting low-latency mode where the OEM supports it — latency matters less for a sleep app than for an instrument, but the low-latency path tends to be the best-tested one. Both platforms enforce the no-allocation rule in the hot path; parameters cross over from the UI thread as atomically swapped snapshot structs, never through locks. Bench note: across 6 phones (iPhone 12 through 15, a Pixel 6a, a Galaxy A54), an 8-hour overnight synthesis run with the screen off drained 11–19% of battery, logged by recording the battery level at the start and end of each run. The short version of all this lives on the [Estua app page](https://uranashel.com/apps/estua.html). ## What Estua refuses to be Estua contains no binaural beats, no AI-generated music, and no streaming wrapper around somebody else's server. There are no accounts and no analytics on listening habits; how you sleep is nobody's dataset, ours included. A timer fades the volume over 20 to 45 minutes on a curve slow enough that the fade never becomes an audible event of its own. A separate alarm uses a gentle synthesized tone drawn from a different scene, so the sound that wakes you is not the sound you slept to — waking a brain with the exact texture it spent all night filtering out works poorly. For the psychoacoustic background on 1/f noise and sleep, see [sleep, waves, and why your brain likes surf](https://uranashel.com/blog/estua-sleep-and-waves.html). For why the whole thing runs locally, read [why everything runs on your phone](https://uranashel.com/blog/on-device-first.html). --- 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/) --- # Designing in strict monochrome > Why instrument apps stay black and white, and how we fight SwiftUI blue and Material purple without sacrificing readability. 2026-03-22 · 18 min read · design · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/monochrome-ui.html --- uranashel apps get read in bad places. 11 PM in a parking garage, one hand still on the steering wheel. At 6 AM the same phone is a sleep timer being poked by someone half-asleep. On a factory floor the operator wears gloves and has no patience for decorative chrome. Each of those scenes contains exactly one number the user came for, and every other pixel is either helping them find it or standing in the road. Color usually stands in the road. Every hue competes for attention with the reading you opened the app to see, so we treat color as cognitive load and spend it the way we spend battery. The rule is strict: **black, white, and gray only**, plus 3 semantic chromatic exceptions. Green marks success and acceptable states. Yellow marks caution and degraded confidence. Red marks danger and over-limit conditions. That is the entire palette across all 5 apps. No brand purple, no SwiftUI default blue tint. And no Material You dynamic accent that restyles itself from your wallpaper; the wallpaper does not get a vote in an instrument. ## The reason is instrument design The constraint is not aesthetic minimalism for Instagram screenshots. It is instrument design. A decent multimeter has one visual accent, the needle, and everything else on it is matte. Sonarish displays LAeq trending toward a legal noise threshold, and that decibel number should command the whole screen; a gradient hero banner around it would be decoration stealing glances from data. LAeq is an [A-weighted](https://uranashel.com/blog/a-weighting-decibels.html) equivalent level, so the digits already encode a model of human hearing. Dark is the default theme. Partly that is the garage at 11 PM. Partly it is Estua: full dark adaptation of the eye takes 20–30 min to build, and one white screen at 2 AM erases most of it, which means a sleep app that blinds you while you set the timer has failed at its main job. Light theme exists as a pure inversion for daylight, sharing identical structure with dark and differing only in luminance. ## Color that carries data The 3 exceptions work hard. Wheria's confidence badge uses green, yellow, and red, tied directly to the estimation standard deviation coming out of the [fusion filter](https://uranashel.com/blog/kalman-filter-parking.html). Yellow there is a statistical claim about position uncertainty. A decorative mood indicator would not survive review in this studio. When covariance grows past the first gate the badge steps from green to yellow; past the second gate it goes red and the app tells you plainly to trust the arrow less. Sonarish applies the same mapping to noise dose. A shift log trending toward the NIOSH 8-hour limit renders the accumulating percentage in plain gray while the projection stays under the limit, then red once it crosses. Green never appears on that screen. A dose at 79% of the limit is merely tolerable, and gray covers tolerable. Phyzix keeps the discipline inside its graphs. Accelerometer, gyroscope, and magnetometer traces are distinguished by line weight and dashing. An early build tried rainbow colormaps and they died the first time a teacher projected a lesson, because on a washed-out classroom projector nobody could tell which axis was which. Line style survives projection, grayscale printing, and every form of color blindness at once. We also rejected accent-color pickers entirely. A theming menu lets every user make their instrument look different from every screenshot in the manual, and then support tickets arrive with images we cannot match to our own builds. One palette. Two themes. That discussion was short. ## Grays, tokens, one lint rule Between black and white sit 5 named grays, each defined by role: primary text, secondary text, hairline borders, card surfaces, disabled states. No line of code refers to a raw hex value; every color routes through one Theme file per platform, and a CI grep fails the build when a color literal appears anywhere else. The rule sounds petty. It is also the only reason the palette is still monochrome after 5 apps and 2 platforms' worth of pull requests. Pure black backgrounds turned out to be a mistake on OLED. On 2 of our test phones, light text scrolling over #000 left a brief smear, because OLED pixels switching on from fully off respond slower than pixels already lit. Lifting the dark background to a near-black gray removed the ghosting. Bench note: 2 OLED devices, 1 evening, a scrolling test page, slow-motion footage at 240 fps. Depth needed a rethink as well. Material Design expresses elevation with drop shadows and SwiftUI leans on translucent layering, and both go muddy under strict monochrome. We use 1 px hairline borders plus a 1-step surface lift: a card in dark theme sits on a gray one step lighter than the background, and in light theme one step darker. ## SwiftUI fights back Enforcing monochrome on iOS meant fighting the framework's defaults at every layer. The root view gets `.tint(Theme.primaryText)`, which kills system blue on buttons, links, and toggles. Delete that single modifier and every interactive element reverts to Apple's accent by the next build. The text-insertion cursor held out longest; tint only reaches it on recent iOS versions, so for 2 minor releases we shipped a monochrome app with 1 blinking blue survivor. Fonts fought harder. Navigation titles ignore UINavigationBar appearance font settings on iOS 16 and later, so we inject Nunito through a principal toolbar item using our custom nunitoTitle modifier. Toolbar buttons need explicit font assignment or they silently revert to SF Pro, and silent is the expensive part: nothing logs, nothing crashes, the title is simply wrong 3 screens deep. Form and List screens default to 17 pt system font while the application body runs 15 pt Nunito, so every Settings, AddEditVehicle, and BrandPicker screen sets its subheadline font explicitly. Miss one screen and its rows look like they belong to a different application. ## Compose mostly cooperates Android was gentler. Compose routes nearly everything through MaterialTheme, and our AppTypography maps all text roles to Nunito in one declaration. Third-party composables occasionally leak Roboto, so screens hosting them get a defensive ProvideTextStyle wrapper. Some surfaces we cannot own at all: TimePickerDialog, Toast messages, and system permission sheets stay in the system font because the operating system draws those popups itself. We stopped arguing with it. Corner radius is 12 px everywhere: cards, buttons, bottom sheets. Not 8, not 16. Across 5 applications the sameness is the point, because users subconsciously trust instruments that look like they came from the same laboratory. A radius that drifts between apps is a small tell that nobody is checking details, and a user who notices one loose detail starts doubting the numbers next. ## Light in a garage, measured Contrast targets came from the field before they came from any spec sheet. We walked our usual test garage with a lux meter, 1 phone running a Phyzix build cross-checked against a cheap standalone meter, 1 evening plus 1 noon visit. Roughly 38 lux on the B2 ramp, 12 lux near pillar E9, and 31,000 lux in the open-air lot at 1 PM. The same screen has to survive a bit over 3 orders of magnitude of ambient light. So primary text targets WCAG AA contrast in both themes, and secondary text has to stay readable without squinting on a dim garage ramp and in a bright outdoor parking lot alike. In the dark theme, primary text sits near 15:1 against the near-black surface and secondary text near 7:1. The light theme lands on the same ratios by construction, because it is the same token set with luminance inverted. ## State never rides on color alone Roughly 8% of male users have some form of color vision deficiency. A yellow warning invisible to a deuteranope is a lying instrument, so we never rely on color alone to convey state. Sonarish pairs a red threshold breach with haptic feedback and an icon change, which reaches you even with the phone face-down in a shirt pocket. Wheria's compass confidence indicator carries a text label such as "Low compass trust" alongside the yellow chip. The label costs a little width. The lie would cost more. ## What we refuse to ship Gradient hero sections. Glassmorphism overlays, neon augmented-reality decorations, anything that treats the camera feed as a canvas. Wheria's AR direction arrow renders white with a thin black stroke over the feed, a pairing that stays readable on concrete, asphalt, and the painted lines of open-air lots, which is close to every background a parked car generates. The feed itself stays unmodified. Heat-map tints and color-coded distance rings would turn a navigation aid into a video game. The cost of all this restraint is real and we accept it. On a store page our screenshots sit quietly next to neon competitors, and quiet tends to lose the first-glance comparison. What it wins is the 400th session, when the user stops seeing the interface and just reads the number. Estua's sleep timer and scene picker follow the same visual vocabulary as Wheria's Find screen and Sonarish's meter readout, so a user switching between uranashel apps at 2 AM never has to relearn where the important number lives on screen. That consistency only exists because no single app is allowed personality at the expense of the set. Typography carries the hierarchy that color would otherwise carry; the details live in [Nunito on every pixel](https://uranashel.com/blog/nunito-on-every-pixel.html), and the release discipline that keeps the iOS and Android versions visually aligned is covered in [shipping iOS and Android at the same time](https://uranashel.com/blog/cross-platform-two-codebases.html). Underneath sits the same philosophy that drives our sensor work: an interface must stay honest even when honesty looks plain, the thread that also runs through [indoor navigation when GPS dies](https://uranashel.com/blog/building-wheria-indoor.html). Monochrome belongs to instrument integrity as one more component, next to the filter tuning and the calibration routine, and it gets maintained with the same seriousness. --- 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/) --- # Barometers and parking floor math > Pressure changes as you descend — the hydrostatic idea behind guessing B2 vs B3, and why weather and elevators complicate it. 2026-03-08 · 19 min read · wheria, physics · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/barometer-parking-math.html --- The barometer in your phone is a MEMS pressure sensor: a silicon membrane that flexes as air presses on it, read out as absolute atmospheric pressure in hectopascals. Sitting still on a desk, a good one shows noise of about 0.3–1 Pa RMS. That number turns interesting next to a second one. Near sea level, atmospheric pressure falls by roughly **12 Pa per meter** of altitude gained, and dividing the two says the sensor can resolve a few centimeters of height change on paper. In a live garage, with air handling and weather in the loop, call it 1 m of usable vertical resolution. That is enough to separate basement B2 from B3 in a structure built at 3.2 m per floor. Replacing horizontal positioning is a different job entirely, and GPS underground has problems of its own, covered in [why GPS lies](https://uranashel.com/blog/why-gps-lies.html). So Wheria treats the barometer as a floor hint, not a navigation solution. Tap Park and the app records the current pressure `P0` as a session baseline, optionally flagged if you are still outdoors at street level, where weather drift has had no time to accumulate. From then on, samples arrive at 1–10 Hz depending on how the OS schedules the sensor, and each becomes `ΔP = P − P0`. Two filters stand between that raw difference and anything the interface is willing to call a floor. ## Where 12 Pa per meter comes from Take a horizontal slab of still air: thickness `Δh`, cross-section `A`, density `ρ`. It weighs `ρ·A·Δh·g`. The only thing holding it up is the pressure difference between its bottom face and its top face, so `(P_bottom − P_top)·A = ρ·g·A·Δh`. The area cancels. What remains is the hydrostatic relation `ΔP = ρ·g·Δh`, or rearranged into the direction we actually use it, `Δh = ΔP/(ρg)`. Plug in numbers. Air density near sea level runs from about 1.15 kg/m³ on a 32 °C Saigon afternoon to 1.225 kg/m³ in the 15 °C standard atmosphere, and `g = 9.81 m/s²`, so `ρg` lands between 11.3 and 12.0 Pa per meter. Call it 12. One garage floor at 3.2 m is then worth about 37 Pa of pressure change. The temperature spread introduces a 3–5% scale error, which across a five-floor basement stays well under half a floor and disappears into the uncertainty band. The full barometric formula is exponential, `P(h) = P0·e^(−h/H)`, with scale height `H = RT/(Mg) ≈ 8,400 m`. Garages are not 8,400 m deep. Over the 16 m of a five-level basement, the linear approximation deviates from the exponential by about 0.1% of the signal, so we linearize without guilt. ## Two filters and what they keep The stationary noise floor of 0.3–1 Pa RMS turns out to be the smallest problem. Walking pumps the pressure field around the phone. HVAC fans in enclosed garages add step changes whenever a ventilation zone switches over. The pipeline therefore low-passes `ΔP` at about 0.05 Hz, treating anything faster than a 20-second period as mechanical noise, since a stair descent of a full floor does not complete in under 20 s. The slow end is worse. Weather fronts move ambient pressure by 200–400 Pa per day, which averages 8–17 Pa per hour and runs several times faster while a front actually passes. Left uncorrected, a cold front arriving during a long dinner reads as a full floor of descent that never happened. A high-pass around 0.001 Hz (periods longer than roughly 17 minutes) strips that trend out. Between the two cutoffs sits the passband where real transitions live: a one- or two-floor stair descent takes 40–90 s and comes through untouched. Long sessions still need care, because a baseline recorded at noon means little at 21:00. On the walk back, the estimator re-anchors: pressure observed at the street entrance becomes a fresh reference, and the depth profile learned on the way out is replayed against it. The stale `P0` survives only as a consistency check. ## Where the naive formula breaks Elevators are the classic failure. Shaft pressurization and cabin ventilation produce pressure transients faster than anything a ramp walk can generate, and the sensor has no idea it is inside an elevator — from where it sits, the weather just improved by 0.7 hPa in 15 seconds. Wheria survives this only because the IMU reports elevator-like motion and the filter down-weights barometer trust for that interval. Subtler offenders exist too. Parking with the engine or the climate control running shifts cabin pressure slightly before you step out, so a baseline can be born a few pascals wrong. iPhone and Pixel barometers carry different absolute offsets, sometimes approaching a full hectopascal, which is why we store per-session baselines at park time instead of attempting aviation-style QNH altimeter correction against a weather service. Architecture matters as well: the pressurized elevator shafts of a tower like Landmark 81 behave nothing like the open spiral ramps of a suburban mall, and ktuyen segments the barometer regression suite by garage architecture for exactly this reason rather than assuming one calibration fits all. ## The estimator in 20 lines Stripped of platform scaffolding, the whole floor estimator fits on one screen: ``` // every barometer sample, 1-10 Hz fun onSample(p: Pa, t: Time) { val raw = p - p0 // Pa relative to Park weather = ema(raw, tauSec = 900) // slow trend ~ 0.001 Hz high-pass val band = lowPass(raw - weather, fcHz = 0.05) val trust = when (imu.activity) { ELEVATOR -> 0.1 // transients lie; wait it out WALKING -> 1.0 STATIONARY -> 0.6 else -> 0.3 } val slab = structure.slabHeight ?: 3.2 // m, learned per garage val paPerFloor = RHO_G * slab // 11.6 x 3.2 ~ 37 Pa val floors = round(band / paPerFloor) val sigma = noiseSigma(band) / paPerFloor publish(FloorHint(floors, plusMinusOne = true, trust, sigma)) } ``` Slab height defaults to 3.2 m per level and is user-configurable, and if you have parked in the same structure before, the value learned from prior visits overrides the default. Everything else is bookkeeping around one division. ## What we display and what we refuse to claim The interface shows something like "Likely B3" with an explicit ±1 floor uncertainty band. A floor label you typed manually is never auto-overwritten. Barometer inference is probabilistic; a wrong automatic label erodes trust faster than no label at all, and the label you entered while standing next to your own car outranks any filter output. The regression numbers justify the humility. In Landmark-style open slab garages at 3.2 m floor height, median classification error is 0 floors when the user takes the stairs. Elevator-only transitions mislabel roughly 30% of the time once IMU activity context is withheld — nothing then distinguishes walking down a ramp from standing in a moving box. A rider going from B1 to B3 produces a pressure profile indistinguishable from someone who walked two flights of stairs in 10 s; the giveaway is the accelerometer, which reports the characteristic vibration and near-zero horizontal displacement of elevator travel. That fusion logic lives in the same pipeline as step odometry, described in [step detection and IMU odometry](https://uranashel.com/blog/step-detection-imu.html), and compass heading. It is also why ktuyen logs "stairs only" and "elevator only" test cases separately in her regression spreadsheet instead of averaging them into one flattering number. ## Bench notes Method: three phones (iPhone 13, Pixel 7, Galaxy A54) face up on a shelf, screens off, logging pressure at 1 Hz for 48 hours, detrended hourly against a straight line. Detrended noise came out at 0.35, 0.6 and 0.9 Pa RMS respectively, comfortably inside the 0.3–1 Pa RMS band we design around. All three tracked the same synoptic weather curve. Absolute offsets between units, though, spanned 40–110 Pa — and that figure is the entire argument for per-session baselines, because 110 Pa is 3 floors of phantom altitude the moment you compare absolute readings across devices. Two shorter notes from the same week. A stairwell door slammed 2 m away spikes the trace by 25–30 Pa for under 1 s; the 0.05 Hz low-pass removes it completely. Closing a car door with the windows up produces a 60–90 Pa transient inside the cabin, which is one more reason the baseline flag prefers a reading taken after you have stepped out. ## Try the math yourself The [Lab page](https://uranashel.com/lab.html) has a garage scroll demo that visualizes the hydrostatic relationship in real time; drag through the floors and watch `ΔP` track depth. The barometer is one input among several — the full stack is described in [indoor navigation when GPS dies](https://uranashel.com/blog/building-wheria-indoor.html), and the floor hint enters the Extended Kalman Filter alongside step odometry and compass heading, as covered in [Kalman filters for parking](https://uranashel.com/blog/kalman-filter-parking.html). For the raw instrument with none of our filtering opinions on top, [Phyzix](https://uranashel.com/apps/phyzix.html) exposes barometer data in SI units in its environment instruments section. Watch it for 10 minutes and you will see everything this post described: the noise floor, the weather drift and, when somebody slams a door, the spike. --- 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/) --- # Magnetometers and compass nightmares > Magnetometers in steel garages see a distorted Earth field. How Wheria calibrates, when it trusts the gyro instead, and why we warn instead of guessing. 2026-02-18 · 22 min read · wheria, physics · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/compass-calibration-story.html --- A magnetometer measures the local magnetic field vector **B** in microtesla, reporting three orthogonal components in the phone's body frame. Earth's share runs roughly 25–65 µT depending on latitude and local geomagnetic anomalies; around our test city the reference sits near 44 µT with a shallow inclination of about 9°, courtesy of the magnetic equator passing not far south. That field will orient a compass on any street corner. It is also fragile enough that a steel parking column 2 m away can rotate apparent north by 40–90° while the phone lies perfectly still in your hand. Wheria's heading pipeline therefore treats the compass as guilty until proven innocent: magnetometer measurements enter the fusion filter only when field magnitude and inclination fall within the bands Earth could plausibly produce, and the interface surfaces that distrust explicitly instead of drawing a confident arrow toward a wall. Heading earns this paranoia because it multiplies every other error in pedestrian dead reckoning. Miss 2 steps and you are off by roughly 1.4 m. Hold a 15° heading bias across an 80 m walk and the endpoint slides sideways by `80·sin(15°) ≈ 21 m` — the wrong aisle, possibly the wrong wing of the garage. No other sensor in the stack can do that much damage that quietly. ## Two ways steel lies to a magnetometer Rotate an ideal magnetometer through every orientation and the sampled vectors trace a sphere centered at the origin, with radius equal to the local field strength. Real phones trace something worse, and the calibration literature splits the damage into two mechanisms. **Hard iron** errors add a constant offset on each sensor axis: a speaker magnet, a ferrous phone case, or permanent magnetization of nearby steel shifts the center of the measurement ellipsoid away from the origin. The classic user remedy is the figure-eight motion, which samples the field across many orientations and lets an algebraic fit recover the offset vector. **Soft iron** is the nastier mechanism. Ferromagnetic material close to the sensor distorts the field itself, stretching the sphere into an ellipsoid instead of merely translating it. Parking pillars, rebar in the concrete, and the body of your own car all contribute, and the distortion varies with position — a calibration valid at one row can fail 3 m later. The standard sensor model wraps both effects into one line, `B_meas = S·B_true + b`, with `b` the hard-iron offset and `S` a 3×3 soft-iron matrix. Estimate both, invert, done. On paper. ## Why the textbook fit fails in a basement Fitting the full 3×3 matrix requires diverse phone orientations sampled over time. On a laboratory rotation rig this is a solved problem. It is unrealistic when someone is walking through a basement trying to remember whether they turned left at pillar 47. There is a deeper obstacle too: underground, `S` is not even a constant. The steel that defines it changes every few meters as you pass columns and parked cars, so a matrix fitted at one row is a precise model of a place you have already left. Wheria gave up on chasing `S` online. We estimate only the hard-iron term, slowly, during straight walking segments: samples accumulate into a recursive fit while the gyroscope reports low turn rate, and anything captured during a sharp turn gets discarded, because the true field direction cannot change at the rate a quick pivot produces. When the fitted offset jumps by more than a few µT between segments, the estimator resets instead of averaging two different magnetic environments into one wrong answer. Soft iron gets handled downstream, by refusing to believe implausible fields at all. ## Deciding when the field is believable Every magnetometer sample faces three gates before the filter will touch it. Magnitude: `|B|` within ±8 µT of the local reference. Inclination: the angle between **B** and the horizontal plane, measured against the gravity estimate from the accelerometer, within ±10° of the reference value. Turn rate: gyroscope z-axis below about 0.3 rad/s, so the sample was not taken mid-pivot. Pass all three and the tilt-compensated heading `ψ = atan2(−B_y, B_x)` becomes a measurement. Fail any one and the sample lands in the diagnostics log and goes no further. The magnitude gate does most of the work, and the physics behind it is compact. A straight cable carrying direct current I produces `B = µ0·I/(2πr)` at distance r. An EV fast-charging run at 100 A puts 20 µT on your sensor from 1 m away — nearly half of Earth's entire field, pointed wherever the cable happens to go. Superimpose that on the geomagnetic field and `|B|` almost always leaves the Earth band, so the gate trips before the heading corrupts. ``` // per magnetometer sample, resampled to 100 Hz mag = norm(B) // µT inc = asin(dot(B, g_unit)) // angle vs horizontal turn = abs(gyro_z) // rad/s ok_mag = abs(mag - REF_MAG) < 8.0 // REF_MAG ≈ 44 µT here ok_inc = abs(inc - REF_INC) < 10° ok_turn = turn < 0.3 // not mid-pivot if ok_mag && ok_inc && ok_turn { ekf.updateHeading(atan2(-B.y, B.x), R_NOMINAL) hardIronFit.accumulate(B) // straight segments only } else { ekf.holdHeadingMeasurement() // gyro carries heading log.diagnostics(B, mag, inc, turn) } ``` Bench note: we logged magnetometer magnitude at 100 Hz on 4 phones (2 iPhone, 2 Pixel) across 6 walks through the same tower basement, about 40 minutes of data in total. `|B|` ranged from 18 µT beside an EV charging cabinet to 140 µT flush against a structural column, against a steady 43–45 µT on the street above. Roughly 31% of basement samples failed at least one gate. Down there, the compass simply does not exist a third of the time. ## Fusion when the field lies anyway Between detected steps the gyroscope carries heading alone, integrating angular rate at 100–200 Hz. Uncorrected consumer gyro drift sits on the order of 1–3° per minute. Across the half-second gap between footfalls that is nothing. Across a 2-minute walk with no magnetometer assistance it is unacceptable. The Extended Kalman Filter arbitrates between the two sources. Gated magnetometer headings arrive as measurements with nominal noise R, and the gyro propagates state between them. When the variance of heading residuals spikes, the filter inflates R by an order of magnitude, which reduces the system to gyro-only dead reckoning until the field stabilizes. That spike is the telltale signature of entering a steel zone near EV charging stations, where DC cables and transformers superimpose local fields. Residual variance also catches what the per-sample gates cannot: sequences where each measurement looks Earth-like on its own but the series disagrees with the gyro about how fast the world is turning. The matrix bookkeeping behind all of this lives in [Kalman filters for parking](https://uranashel.com/blog/kalman-filter-parking.html). ## What the screen admits Distrust that stays buried inside a filter helps nobody. Wheria promotes it to the interface. A compass confidence chip turns yellow, then red, as heading variance grows, and the augmented-reality arrow widens its cone of uncertainty to match. When heading standard deviation exceeds 25°, Find mode drops the arrow entirely and shifts to distance-first presentation: walk approximately 60 m, then rescan the compass in a clearer area. We would rather under-instruct than confidently navigate you into structural concrete. The policy came from our own walks. My regression car sat at floor B2, pillar E9, one aisle over from a row of chargers; on a bad afternoon the chip held red for about 40 s while the screen offered nothing but a distance count. I still reached the car without a single wrong turn. Distance plus pillar signage is a surprisingly complete navigation system, and the arrow rejoined two aisles later once residual variance settled. ## The regression matrix and platform parity ktuyen's regression matrix documents the worst environments and reruns them every release: electric vehicle charging bays with heavy DC cabling, structural steel within 2 m of the walking path, and the occasional second phone in a back pocket — a moving hard-iron source that no amount of figure-eight waving will remove. Each row records whether the confidence chip fired, how long heading stayed degraded and whether the fallback presentation appeared. We can live with a garage that defeats the compass. What we can't live with is the compass failing without telling anyone. Platform parity carries its own trap here: iOS and Android report magnetometer axes with different native conventions. We normalize to a consistent north-east-down or east-north-up frame inside shared logic modules before either SwiftUI or Compose renders a direction, the same layering described in [our two-codebase setup](https://uranashel.com/blog/cross-platform-two-codebases.html). Phyzix shows raw and calibrated magnetometer vectors side by side, letting students watch the ellipsoid flatten as they walk into Landmark 81's basement — the same data Wheria consumes, presented as physics rather than navigation. [Sensor hacking with Phyzix](https://uranashel.com/blog/phyzix-sensor-hacking.html) covers how to capture those traces yourself. Physics does not allow a 5° accuracy guarantee in every garage. What Wheria can guarantee is narrower: it will not walk you into a wall with confidence, and when the instrument distrusts itself, that distrust shows up on screen. Heading feeds the step-based position updates covered in [step detection and IMU odometry](https://uranashel.com/blog/step-detection-imu.html), and the assembled underground stack lives in [indoor navigation when GPS dies](https://uranashel.com/blog/building-wheria-indoor.html). --- 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/) --- # Step detection and IMU odometry > Double integration drifts fast; counting steps does not. A clear explanation of pedestrian dead reckoning in Wheria. 2026-02-04 · 20 min read · wheria, physics · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/step-detection-imu.html --- Every smartphone carries an inertial measurement unit: a cluster of tiny MEMS chips that report how the device accelerates and rotates. The accelerometer measures specific force in m/s². The gyroscope measures angular rate in rad/s. Together they form the IMU, and the tempting first project in any sensor course is to integrate those signals into position — double-integrate acceleration for displacement, integrate the gyro for heading, draw a trail on a map. I ran exactly that experiment before Wheria existed, phone taped to a clipboard. Within 30 s the trail had wandered into the next building. Consumer MEMS bias sits at milli-g scale, and integration squares time. That one sentence kills more indoor positioning ideas than anything else in this field. The rest of this post covers the workaround Wheria actually ships: counting footfalls. ## Why double integration dies in under a minute Work the numbers. A constant accelerometer bias `b` integrates to a velocity error `b·t` and a position error `½·b·t²`. Take 1 milli-g, about 0.0098 m/s², a respectable figure for a phone chip. After 30 s the position error is `0.5 · 0.0098 · 30² ≈ 4.4 m`. Annoying. Survivable. Attitude error is the real killer. The accelerometer senses gravity plus motion, and recovering motion means subtracting gravity along whatever direction the phone currently believes is down. Let the gyro drift the attitude estimate by a single degree and a slice of gravity leaks into the horizontal channels: `g·sin(1°) ≈ 0.17 m/s²`, roughly 17 times the bias we just shrugged off. Double-integrate that over the same 30 s and you get about 77 m of phantom displacement. There is the next building. Worse, gyro drift keeps growing while you walk, so the leak widens and the error compounds faster than quadratically. Underground, no external fix rescues you: GPS horizontal error in a garage often exceeds 30 m when the receiver reports anything at all, and [why GPS lies](https://uranashel.com/blog/why-gps-lies.html) covers the physics of that failure. So [Wheria](https://uranashel.com/apps/wheria.html) never tries to be a general-purpose inertial navigator. It uses pedestrian dead reckoning, which treats each footfall as a discrete event, not a continuous integral. Detect a step. Multiply by an estimated stride length, rotate by the current heading, advance a point on a local map. Error still accumulates, but it grows roughly with the square root of step count instead of exploding with elapsed time, and that difference is what makes a 4-minute garage walk usable instead of absurd. ## What a step looks like to an accelerometer Walking at a normal 1.2–1.5 m/s, your body bounces vertically once per footfall. The bounce repeats at a cadence of roughly 1.5–3 Hz, one complete up-down cycle every 0.33–0.67 s. Those peaks survive a phone sitting in a pocket at an odd angle, which is the entire reason PDR works on consumer hardware. Orientation is the first problem. In a trouser pocket the axes point somewhere arbitrary, so the detector drops individual axes and works on the magnitude, `|a| = √(ax² + ay² + az²)`. Magnitude is rotation-invariant; the walking peaks show up at any pocket angle. Direction information is lost, and that costs nothing here because heading comes from other sensors entirely. The raw magnitude also carries a DC component near 9.81 m/s² from gravity, plus low-frequency arm swing and high-frequency hand tremor. A band-pass filter around the walking band strips both ends and leaves the gait peaks standing alone. ## The detector in 20 lines Peak picking sounds trivial until you meet the threshold question. A fixed threshold tuned for a hand-held phone misses steps in a padded pocket, where fabric damps the bounce. Our threshold adapts instead: over a sliding 2 s window the detector computes the local mean and standard deviation of the filtered magnitude, then accepts a peak that exceeds `mean + k·σ`, with k running from about 1.2 for a trouser pocket to about 1.8 for a phone held in a swinging hand. A minimum interval of 280 ms between accepted steps rejects shuffles and double-bounces; 280 ms corresponds to a cadence of 3.6 Hz, comfortably above anything a walking human produces. Between steps the gyroscope z-axis, rotation about the vertical when the phone is upright, confirms turns. A heading change with matching step cadence is a real corner. Angular rate with no steps is someone fidgeting with their phone. Sample-rate parity matters more than it sounds. iOS and Android deliver IMU samples at different native rates, anywhere from 50 to 200 Hz depending on device and power mode. Shared logic resamples both platforms to a fixed 100 Hz before the detector runs, so identical motion yields the same step count on an iPhone and a Pixel. Without that stage, ktuyen's cross-platform regression matrix would be comparing two different algorithms; how that shared core is split between Swift and Kotlin is the subject of [the two-codebase post](https://uranashel.com/blog/cross-platform-two-codebases.html). ``` samples = resample(imu, 100 Hz) // parity across platforms mag = sqrt(ax² + ay² + az²) // orientation-free band = bandpass(mag, walking band) on each new sample s: w = last 2 s of band thr = mean(w) + k · std(w) // k = 1.2 … 1.8 if s is a local peak and s exceeds thr: if now − last_step ≥ 280 ms: steps += 1 last_step = now advance(stride, heading) // PDR position update ``` Bench notes, for honesty: 6 phones (3 iOS, 3 Android), 40 corridor walks of 20 m each, alternating hand and front pocket, ground truth counted from video. Worst miscount was 3 steps out of 27; the median run missed 0 or 1. Escalator rides were excluded on purpose, for reasons covered below. ## Stride length is the hidden variable A detected step says the user moved. It does not say how far. Distance comes from stride length, the horizontal span of one footfall, and stride is where PDR hides most of its error. Wheria starts from a default of about 0.72 m, which matches average adult walking on flat ground. Enter your height in settings and the app applies a common anthropometric estimate, `stride ≈ 0.415 · height`; a 1.70 m adult lands at 0.71 m, close to the default by design. An optional calibration walk tightens this further. Traverse a known distance (20 m in a straight corridor works well) and the app solves `stride = distance / step_count`. Some research papers tie stride to step frequency through power-law models such as Weinberg's relation, where stride scales with the fourth root of the vertical acceleration swing per step; walk faster and your steps lengthen slightly. Wheria ships a modest version of that tie-in. It is not magic. A 5% stride error over an 80 m walk with 110 steps produces 4 m of longitudinal error before heading is even considered, and no cadence model removes a systematic 5%. That arithmetic is why Wheria shows distance bands instead of fake precision, and why the app nudges people who regularly park in large structures toward the calibration walk. One 20 m walk, done once, buys back a 4 m bias on every later trip. ## An honest error budget Assemble the pieces for a concrete trip: 80 m through a spiral ramp, 110 detected steps, destination floor B2, pillar E9. Stride off by 5% puts you 4 m off along the path. Heading off by 10° at the end gives a lateral error of `80 · sin(10°) ≈ 14 m` in the worst geometry. Neither number is pessimistic fiction; both fall straight out of ktuyen's garage test matrix whenever compass trust is low and the user skipped calibration. The UI answers that uncertainty with design. The path polyline fades as it ages, so a stale trace looks stale. A confidence chip derived from the fusion covariance moves through green, yellow and red. Distances read "about 45 m" because "44.7 m" would claim a precision the sensors cannot deliver. And each new parked event resets the odometry state, keeping error bounded per trip instead of compounding across weeks of history. ## What PDR cannot do The limits are hard and we do not hide them. Escalators increment step count without equivalent horizontal travel. Moving walkways do the reverse and decouple footfalls from ground speed. Running breaks the cadence band the detector expects. A phone clamped to a shopping cart handle produces an acceleration profile that looks nothing like walking: vibration everywhere, no 2 Hz bounce. When accelerometer variance spikes in a pattern that fails the gait checks, Wheria flags unusual motion and stops drawing, because a silently wrong path costs more trust than an honest gap. ## One layer in a taller stack Step detection on its own produces a chain of guesses. Heading comes from gyroscope and magnetometer fusion, with every steel-induced failure mode described in the [compass post](https://uranashel.com/blog/compass-calibration-story.html). Floor hints come from the barometer: a garage floor is about 3.2 m tall, pressure drops roughly 12 Pa per metre climbed, and phone barometers carry 0.3–1 Pa RMS of noise, which is why [barometer math](https://uranashel.com/blog/barometer-parking-math.html) treats floor detection as statistics rather than arithmetic. The full pipeline is laid out in [indoor navigation](https://uranashel.com/blog/building-wheria-indoor.html), and the filter in [Kalman filters for parking](https://uranashel.com/blog/kalman-filter-parking.html) ties the measurements together so a single step update cannot make the map overconfident. If you would rather see the peaks before trusting an app to count them, [Phyzix](https://uranashel.com/apps/phyzix.html) exposes raw accelerometer and gyroscope traces. Walk down a corridor with the plot open; the 2 Hz bounce is unmistakable. Everything above runs on-device, with no account and no server round-trips, for the reasons in [on-device first](https://uranashel.com/blog/on-device-first.html). --- 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/) --- # Kalman filters for parking > Combining noisy steps, heading, and pressure without panic — an intuitive tour of the filter Wheria uses underground. 2026-01-21 · 21 min read · wheria, math · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/kalman-filter-parking.html --- A Kalman filter sounds like graduate-school machinery. The idea underneath is almost embarrassingly practical. You keep a guess about where you are, plus an honest record of how wrong that guess might be. Take a step and the filter predicts a new position, deliberately widening the uncertainty, because steps are never perfect. When a sensor offers a measurement — a compass heading, a hint that you stopped moving — the filter blends it into the guess, weighted by how much each source deserves to be trusted. Replace with something concrete, e.g.: "The math is ordinary linear algebra; what makes it useful is that the filter keeps an honest record of its own error." Wheria runs a reduced extended Kalman filter. It never runs alone: it sits between the step detector and the confidence chip you see in Find mode. Steps push it forward, the compass and barometer argue with it, and the covariance it carries ends up on screen as a color. What follows is the whole loop: state, predict, update, the tuning numbers from our garage logs, and the fancier alternatives we rejected. ## The state: four numbers and their doubt The state vector holds four elements: horizontal position `x` and `y` in local meters, heading `θ` in radians, and a slowly drifting gyroscope bias `b_g`. That is the entire world model. The filter earns the word extended because heading enters the position equations through `cos θ` and `sin θ`, which are nonlinear, so every predict linearizes around the current estimate. On a phone the cost is a handful of 4×4 matrix multiplies per step. My mid-range Android test unit finishes one predict-update cycle in about 11 µs, averaged over 10,000 cycles on the bench. The battery never notices the filter exists. Next to the state lives the covariance matrix `P`, also 4×4. It encodes how uncertain each element is and how the errors correlate, and the correlations are where the value hides. Heading error and lateral position error are tightly coupled: walk 80 m with a heading estimate off by 10° and you end up roughly `80·sin 10° ≈ 14 m` sideways from where the map thinks you are. The cross terms in `P` predict that drift before you feel lost, which is exactly what the UI needs to know. ## Predict: every step makes you less sure Each detected step triggers a prediction. Position advances by stride length `L` along the current heading, `x ← x + L·cos θ` and `y ← y + L·sin θ`, while heading advances by the integrated gyroscope rate minus the bias estimate. Then the doubt grows: `P ← F·P·Fᵀ + Q`, with `F` the Jacobian of the motion model and `Q` the process noise. The growth is the point. A filter that only ever tightened its uncertainty would be a liar with good posture. `Q` did not come out of a textbook appendix. We tuned it from field logs: 3 phones, 14 garages, about 60 logged walks over two weeks, with predict and update traces dumped at every step and replayed offline. Stride uncertainty landed near 5 cm standard deviation per step. Heading process noise sits around 2° per step while compass trust is high, and closer to 8° per step in steel-heavy zones where magnetometer corrections cannot be believed. That asymmetry matters. In an open-air slab garage, compass measurements arrive frequently and the filter tightens heading after almost every few steps. In a spiral ramp wrapped in rebar we inflate the magnetometer measurement noise by an order of magnitude, which quietly tells the filter to lean on gyroscope integration between steps and to treat any compass spike as a suspect. ## Update: measurements and when to believe them Magnetometer heading enters as the measurement `z = atan2(m_y, m_x)`, the arctangent of the horizontal field components after tilt compensation. The update computes an innovation `ν = z − θ`, a gain `K = P·Hᵀ·(H·P·Hᵀ + R)⁻¹`, then nudges the state by `K·ν` and pulls `P` down. Everything hinges on `R`, the measurement noise. When the field magnitude and inclination look like Earth's expected range, `R` stays small and the correction bites hard. When they do not — steel columns can rotate effective north by 40° while the user walks dead straight — we grow `R` and the correction turns gentle instead of yanking the estimate around. The compass failure zoo has its own post: [magnetometers and compass nightmares](https://uranashel.com/blog/compass-calibration-story.html). We also gate. Any innovation beyond 3 standard deviations of its predicted spread is rejected outright and logged. The worst bug in this filter's history lived one line away from that gate: an innovation computed across the ±π seam once produced a 359° correction that teleported the estimate across the garage. Angle wrapping is not optional. The `wrapAngle` call in the listing below is load-bearing. Barometer floor hints get handled with more caution. One garage floor is about 3.2 m of height, and with `Δh = ΔP/(ρg)` giving roughly 12 Pa per metre near sea level, that is close to 38 Pa of signal. Phone barometers show 0.3–1 Pa RMS of noise at rest, so the floor change itself stands out clearly. The trouble is everything else that moves pressure: elevators, HVAC gusts, a weather front crossing mid-walk. So we surface barometer inference as parallel UI guidance, "likely B3" with a ±1 floor band, and keep altitude out of the Kalman state entirely, because a false floor lock would corrupt the horizontal confidence users rely on for distance-to-car. The derivation and the ugly pressure logs live in [the barometer math post](https://uranashel.com/blog/barometer-parking-math.html). Zero-velocity updates are a classic pedestrian trick. No step for about 2 s usually means the user is standing still, so we damp any legacy velocity drift in integration paths that still carry speed states from earlier prototypes. A small stabilizer. Escalators still defeat it. ## The loop, in about 20 lines Stripped of platform code, the whole filter fits on one screen: ``` state = [x, y, theta, gyroBias] P = initialCovariance() onStep(L, gyroDelta, dt): theta += gyroDelta - gyroBias * dt x += L * cos(theta) y += L * sin(theta) F = jacobianAt(theta, L) P = F * P * transpose(F) + Q(compassTrust) onCompass(mx, my, fieldLooksEarthlike): z = atan2(my, mx) R = fieldLooksEarthlike ? R_low : R_high // ~10x apart nu = wrapAngle(z - theta) S = H * P * transpose(H) + R if nu * nu > 9 * S: return // 3-sigma gate K = P * transpose(H) / S state += K * nu P = (I - K * H) * P onQuiet(t > 2 s): dampLegacyVelocityDrift() ``` The Swift and Kotlin builds of this loop have to behave identically, down to the covariance trace, because ktuyen's regression matrix replays the same logged walks through both and diffs the outputs. Fixed evaluation order and explicit float widths keep the two platforms within about 1e-6 of each other over a full walk. ## Why not a particle filter or full SLAM Particle filters represent uncertainty as a cloud of hypothetical positions with weights. They shine when ambiguity is genuinely multimodal: you truly do not know whether the user turned left or right at a fork, and one Gaussian cannot hold both answers. They also burn CPU, and resampling injects randomness into a pipeline we keep deterministic for those cross-platform regression tests. For walks under roughly 120 m inside a single garage, an extended Kalman filter with adaptive measurement noise has been sufficient. We would revisit particles if we added map matching against painted parking lines in open lots, where fork ambiguity is common. Full SLAM with camera or lidar sits even farther outside our constraints. Garages change signage, lighting, and parked-car layout daily, so yesterday's visual map is already partly fiction. Phone-only SLAM without infrastructure beacons also fails the offline, no-account, privacy-first requirements laid out in the [on-device post](https://uranashel.com/blog/on-device-first.html). We picked the boring filter on purpose. ## One walk, replayed A concrete trace from the tuning set. Car at floor B2, pillar E9. The walk back to the stairs is 74 m with two 90° turns and one spiral ramp segment. At step 0 the position sigma is 1.2 m, inherited from the parking-spot anchor. Across the first 30 steps on the open slab, compass updates land steadily, heading stays tight, and position sigma climbs gently on stride noise alone. Then the ramp. `R` inflates 10×, heading rides on the gyroscope for about 40 steps, and its sigma climbs the whole way up. The chip drops from green to yellow near the top. Back on a clean slab, three compass updates pull heading in again and the chip recovers within 8 steps. Position sigma at the stairs: a bit over 2 m, most of it lateral, nearly all of it heading's fault. ## What users actually see The trace of the position covariance block maps straight to the green, yellow, or red confidence chip on the Find screen. Past a threshold, the UI stops drawing a precise arrow and switches to a coarse distance ring: walk about 60 m, then reassess. Outdoors, while a GPS segment is still valid, the 2×2 position block of `P` draws an uncertainty ellipse on the map, because a pinpoint dot at that error level would be a lie. If you want to push these matrices around yourself, Phyzix ships a Kalman demonstration that steps through predict and update with sliders for `Q` and `R`. The full indoor pipeline is documented in [indoor navigation when GPS dies](https://uranashel.com/blog/building-wheria-indoor.html), and the step odometry that drives every predict lives in [step detection and IMU odometry](https://uranashel.com/blog/step-detection-imu.html). Bring a phone to a garage and watch the chip change color. The covariance is doing that. --- 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/) --- # FFT for mortals > What a spectrum actually shows, how Sonarish builds it from microphone data, and how to read peaks from fans and motors. 2026-01-07 · 19 min read · sonarish, dsp · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/fft-made-readable.html --- Sound is pressure changing over time. Your ear and brain are extraordinarily good at noticing when that pressure repeats at a steady rate, and the sensation gets a name: pitch. Machinery rarely produces a single pure tone. It produces stacks of periodic components plus hiss, and the Fourier transform is the mathematical tool that answers a deceptively simple question: if I record a few milliseconds of microphone data, which frequencies are present and how loud is each one? [Sonarish](https://uranashel.com/apps/sonarish.html) builds its spectrum analyzer and scrolling spectrogram on that question, running entirely on your phone without sending audio to a server. Most FFT explanations open with complex exponentials and lose the reader by the second page. This one runs the other way. It starts from what the output numbers mean, keeps the algebra to four short formulas, and spends the reclaimed space on the parts textbooks skip: windows, leakage, threading and the specific ways a phone microphone quietly lies. ## What the transform actually computes The discrete Fourier transform takes N consecutive audio samples and produces N complex numbers, each representing the sine and cosine contribution at one of N equally spaced frequencies. The recipe for output bin k is `X[k] = Σ x[n]·e^(−i2πkn/N)`, summed over n from 0 to N−1. Strip the notation away and it is a correlation. You multiply the signal, sample by sample, against a probe sinusoid that completes exactly k cycles inside the block, then add everything up. When the signal contains that frequency the products line up in phase and the sum grows large. When it does not, positive and negative products cancel and the sum hovers near zero. Bin k sits at frequency `f_k = k·fs/N`, where fs is the sample rate. We rarely display the full complex result. For noise and fault work the quantity that matters is magnitude, `|X[k]| = √(Re² + Im²)`, a plain measure of how strong each frequency bin is, and Sonarish converts it to decibels with `20·log10(|X[k]|/ref)` so quiet and loud components share one readable axis. Phase is real information too. It just answers questions about timing and alignment that a noise survey never asks. One structural fact makes phone implementations cheaper. Real-valued input produces a symmetric spectrum: bin N−k is the complex conjugate of bin k, so everything above half the sample rate mirrors what sits below it. Libraries exploit the symmetry and compute only N/2+1 useful bins, which is why the loop in the code further down stops at 2048 rather than 4096. ## Why the fast version deserves the name Evaluating the sum directly costs N multiply-adds per bin, and there are N bins, so the bill is N². At 4096 points that is roughly 16.8 million complex multiplies for one frame. The fast Fourier transform reorganizes the work instead of shrinking it: split the block into even and odd samples, transform each half, then stitch the halves together with a single pass of butterfly operations. Applied recursively, the cost collapses to `(N/2)·log2(N)` multiplies. For 4096 points that is 24,576, a factor of nearly 700 cheaper. Gauss had the trick in 1805 and filed it in a private notebook, which may be the most mathematician move on record. Cooley and Tukey republished it in 1965 and made real-time spectral analysis practical. Bench note from our side: across 3 phones (an iPhone 13, a Pixel 7 and a 2019 Galaxy A50) we timed 10,000 consecutive 4096-point real FFTs and averaged 19 µs, 46 µs and 210 µs per transform. Even the slowest device spends well under 1% of one core on the roughly 47 transforms per second that Sonarish's default settings ask for. ## Chopping time into windows A single FFT snapshot tells you what happened in one short slice of time. Real machines and rooms change slowly but not infinitely slowly, so Sonarish uses a short-time Fourier transform: chop the continuous microphone stream into overlapping windows, FFT each window, and plot magnitude against time as a scrolling waterfall. Window length trades frequency resolution against time resolution. No setting wins both. The default grabs 2048 samples at the 48 kHz capture rate, 42.7 ms of sound, and zero-pads each block to a 4096-point transform, which lands the plotted bins about 11.7 Hz apart. That spacing separates a 120 Hz mains hum from its second harmonic at 240 Hz with room to spare. Zero-padding deserves an honest footnote here: the padded zeros interpolate a smoother curve through the spectrum but cannot manufacture resolving power, which stays fixed by the 2048 real samples underneath. Switching to a full 4096-sample window halves the bin width but reacts more slowly to sudden clicks, because each click gets averaged across 85 ms of context. ## Leakage, and why every block gets a Hann The transform silently assumes your N-sample block repeats forever, end spliced to beginning. A tone that completes a whole number of cycles inside the block splices cleanly. A tone that does not, which in the field is nearly every tone, hits the seam mid-cycle, and the transform reads that discontinuity as energy smeared into neighboring bins. This is spectral leakage, and it is worst exactly when a tone's frequency falls between bin centers. A knife-sharp harmonic turns into a low pyramid wide enough to bury a quieter neighbor. The fix is to stop pretending the block edges carry meaning. We multiply each window by a Hann function, `w[n] = 0.5·(1 − cos(2πn/N))`, which tapers both ends smoothly to zero so the imaginary seam disappears. The price is a mainlobe roughly twice as wide; the reward is sidelobes some 30 dB lower. For tonal machinery with sharp harmonics, Hann is a sensible default, and it is what Sonarish ships. Consecutive windows hop forward by 50% of their length, which keeps the scrolling waterfall visually smooth without doubling CPU cost unnecessarily. We tried 75% overlap in a bench build once. Nobody could pick it out of a blind scroll test, so the extra transforms went back on the shelf. ## Reading peaks like an engineer A peak at 120 Hz in a country with 60 Hz mains is usually twice-line-frequency magnetic hum, while a companion line just under 60 Hz marks a two-pole induction motor spinning near 3600 RPM, because mechanical rotation is electrical frequency divided by pole pairs. Bearing wear announces itself differently. It introduces sidebands that are not simple multiples of shaft speed, and energy appearing at unexpected offsets is sometimes the first audible clue before vibration analysts confirm fault frequencies in a formal report. Broadband hiss rising across many bins suggests turbulence, brush arcing, or loose panels rattling without a single dominant tone. Sonarish labels peaks automatically when signal-to-noise ratio exceeds about 6 dB above a locally estimated noise floor. The floor estimate is a median over the neighboring bins, roughly an octave to each side, so one loud neighbor cannot hide a genuine peak. The app does not pretend to diagnose your washing machine. It shows the spectrum honestly and lets experience, or our [baseline comparison workflow](https://uranashel.com/blog/machine-fault-baseline.html), interpret change over time. Recording a known-good spectrum today is cheap insurance for an argument with a repair technician in 18 months. ## None of it touches the audio thread FFT work never runs inside the audio capture callback. That path is reserved for copying samples into a lock-free ring buffer and returning, for the reasons laid out in [life on the audio thread](https://uranashel.com/blog/dsp-audio-thread.html). A background queue pulls blocks from the ring, applies the window and the transform using platform libraries (Accelerate vDSP on iOS, a compact radix-2 implementation on Android) and hands finished magnitude arrays to the UI thread. The whole consumer loop fits on one screen: ``` // background queue; the audio callback only feeds the ring loop: wait until ring.available >= HOP // HOP = 1024 samples ring.peek_latest(frame, 2048) // newest full window ring.advance(HOP) for n in 0..2047: buf[n] = frame[n] * hann[n] // taper for n in 2048..4095: buf[n] = 0 // zero-pad to 4096 fft_real_4096(buf, re, im) // vDSP / radix-2 for k in 0..2048: mag = sqrt(re[k]*re[k] + im[k]*im[k]) db[k] = 20 * log10(max(mag, EPS)) ema_update(db_smooth, db, 0.6) // calm the flicker if now() - last_draw >= 33 ms: // cap near 30 Hz post_to_ui(db_smooth) last_draw = now() ``` Even when transforms complete faster, spectrum redraws are capped at 15 to 30 Hz. Humans cannot interpret a bar chart flickering at 60 Hz, and battery matters in a factory walk-through. The per-bin exponential moving average exists for the same reason; a raw spectrum bounces enough that peak labels would strobe. ## Pitfalls that fool smart people Aliasing is the classic trap. Any component above the Nyquist limit of `fs/2`, which is 24 kHz at our capture rate, folds back down and impersonates a lower frequency. The phone's audio hardware applies anti-alias filtering ahead of the converter, which largely handles the problem for ordinary sources, but you still should not point a mic at a whistle near the Nyquist limit and trust the graph blindly. The second trap is units. Decibels relative to digital full scale measure headroom inside the recording chain and say nothing about acoustic pressure in the room. Confusing dBFS with dB SPL invalidates any comparison to environmental law, and our [A-weighting and decibels post](https://uranashel.com/blog/a-weighting-decibels.html) walks through the distinction plus the calibration story behind it. The third trap arrives dressed as a feature request. Shrinking the window to make peaks look sharper actually widens the bins, and adjacent harmonics merge into one fat bar — pretty UI, worse physics. ## Where to poke at it yourself [Phyzix](https://uranashel.com/apps/phyzix.html) includes a live microphone FFT view built for classroom demos, where students clap, whistle and watch bins light up in real time. Wheria has no use for spectral analysis, yet the same sampling discipline (respect Nyquist, never block real-time capture) runs through every uranashel app that touches a microphone or accelerometer at high rate. The transform itself is 200-year-old mathematics. Making it readable on a 6-inch screen in a noisy plant was the actual product work, and most of that work was deciding what to leave out. --- 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/) --- # A-weighting and decibels explained > Decibels, A-weighting, and LAeq explained so your phone measurements mean something next to real-world noise limits. 2025-12-14 · 18 min read · sonarish, physics · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/a-weighting-decibels.html --- A decibel is not a unit of loudness the way a meter is a unit of length. It is a compression scheme for ratios. Double the acoustic power of a source and the level climbs about 3 dB; multiply the power by 10 and it climbs 10 dB. Ask listeners what a 10 dB jump sounds like and most report roughly twice as loud, which tells you the important part: hearing works on ratios, so the measuring scale is logarithmic too. Sonarish reports approximate sound pressure level in decibels, and for longer sessions it reports LAeq, the A-weighted equivalent continuous level that environmental regulations usually quote. I keep a mental list of decibel arguments I have watched go wrong. Nearly all of them trace back to one of three confusions: which reference the number is relative to, whether a frequency weighting was applied, and how long the energy was averaged. Everything below is organized around those three questions. ## Where the formula comes from Sound pressure level compares acoustic pressure against a reference of 20 µPa, the conventional threshold of human hearing at 1 kHz. Written out: `L_p = 20·log10(p/p0)` with `p0 = 20 µPa`. Why 20 in front of the logarithm instead of 10? Energy is the conserved quantity, and acoustic energy goes as pressure squared. Start from the power form `10·log10(p²/p0²)`, pull the exponent out of the logarithm, and the 10 becomes 20. Same law, one algebra step apart. The distinction bites when you read datasheets. Power quantities take a multiplier of 10 inside the level formula. Pressure amplitudes take 20. Mixing the two while converting between datasets leaves you silently wrong by 6 dB, which is a factor of 2 in amplitude and a factor of 4 in energy. I have watched that exact error survive review in a published spec sheet, so now I recompute it every time. For scale, 20 µPa is about 2 parts in 10 billion of atmospheric pressure. A quiet bedroom at night sits around 30 dB SPL. Normal conversation lands near 60 dB at 1 m, and pain arrives somewhere above 120 dB. That span covers 6 orders of magnitude in pressure and 12 in energy, which is why the logarithm exists: nobody wants a chart axis labeled in trillionths. ## Why frequency weighting exists Your ear does not treat all frequencies equally at moderate listening levels. Low rumble and very high hiss can carry substantial acoustic energy while intruding less than midrange content at the same unweighted level. A-weighting, standardized in IEC 61672, passes the signal through a filter approximating that sensitivity before any level is computed. Energy below about 500 Hz and above about 4 kHz is attenuated. The low end takes it hard: the standard curve sits at -19.1 dB at 100 Hz, -30.2 dB at 50 Hz, and -50.5 dB at 20 Hz, while between 1 and 4 kHz it stays within 1.3 dB of flat. A motorcycle passing at 80 dB on the unweighted Z-scale might read near 65 dB A-weighted. The physical pressure waveform is unchanged. The filter reflects how most listeners rank the intrusion, and that gap between physics and perception is why two different numbers for the same event can both be correct. Regulation follows perception. City noise ordinances, workplace exposure limits, and neighbor disputes cite A-weighted metrics: LAeq over 15 min, or LAFmax for the fastest-responding peak, where F names a 125 ms exponential time constant. Comparing a raw unweighted spectrum from a hobby app against a statute written in dBA, without applying the same filter, is the most common amateur mistake in this field. Sonarish applies A-weighting inside its energy integrator so session statistics line up with how regulations are written. Unweighted views remain available for machinery diagnosis, where low-frequency tones carry the information and discarding 19 dB at 100 Hz would be vandalism. A-weighting has siblings. C-weighting stays nearly flat from about 63 Hz to 4 kHz and appears in impulse metrics such as LCpeak, where the concern is mechanical damage to the ear rather than annoyance. Z means zero weighting, the raw signal. Every Sonarish chart states its weighting in the axis label so a forwarded screenshot keeps its meaning. ## Implementing the filter at 48 kHz On paper the A-curve is an analog transfer function: pole pairs at 20.6 Hz and 12194 Hz, single poles at 107.7 Hz and 737.9 Hz, normalized to 0 dB at 1 kHz. Sonarish captures at 48 kHz, so we map those poles into the digital domain with a bilinear transform and run the result as 3 cascaded biquad sections. Frequency warping near Nyquist bends the response around the 12 kHz pole. At 48 kHz the deviation stays under 0.2 dB across the band a phone microphone can honestly claim, which is fine for a meter that labels itself approximate. None of this runs inside the audio capture callback. That callback copies samples into a lock-free ring buffer and returns, following the no-malloc, no-locks rules in [life on the audio thread](https://uranashel.com/blog/dsp-audio-thread.html). The filter, the squaring, and the accumulation all live on a background processing queue. At these block sizes the filter itself costs almost nothing. ## LAeq and why peaks lie Instantaneous peak level is dramatic and often misleading. One door slam can flash a big number while contributing almost nothing to the energy that disturbs sleep across a whole night. LAeq over an interval T is defined as the constant level that would deliver the same total A-weighted energy as the real fluctuating signal did across that interval. Computationally you square the A-weighted pressure samples, average the squares over the interval, and evaluate `10·log10(mean(p_A²)/p0²)`; the square root hides inside the logarithm through the same factor-of-2 relationship as before. The whole accumulator fits in a screenful: ``` // A-weighted samples in pascals, fed per block // from the processing queue, never the capture callback var sumSquares = 0.0 var sampleCount = 0 func accumulate(_ block: [Float]) { for p in block { sumSquares += Double(p) * Double(p) } sampleCount += block.count } func currentLAeq() -> Double { let p0 = 20e-6 // 20 µPa let meanSquare = sumSquares / Double(sampleCount) return 10 * log10(meanSquare / (p0 * p0)) } ``` Sonarish lets you choose the integration window: 1 min for quick checks, 15 min for neighborhood noise logs, custom spans for factory walk-throughs. Running LAeq displays alongside short-term percentiles, where LA90 approximates the steady background and LA10 captures the intrusive stretches. The design reflects real use. People want to know whether the air conditioner grew louder over 3 years. The single loudest sample a gust of wind created answers nothing. ## Exposure limits and the 3 dB exchange Occupational health bodies write their limits in exactly these units. The NIOSH recommended exposure limit is **85 dBA as an 8-hour time-weighted average**, with a 3 dB exchange rate: every 3 dB added to the level halves the permissible duration, because 3 dB doubles the energy. So 88 dBA buys 4 hours, 91 dBA buys 2, and 100 dBA is down to 15 min. The bookkeeping is plain LAeq arithmetic. A phone app cannot certify a workplace and Sonarish does not pretend to. It can tell you that a table saw sits near 95 dBA at the operator position and that ear muffs cost less than the alternative. ## Phone microphones and honest labeling Professional class-1 sound level meters cost thousands of dollars because their capsules, preamps, and calibration chains are traceable to national standards. A phone microphone is optimized for voice calls and voice assistants. Its capsule was picked for cost, echo-cancellation behavior, and surviving a pocket, and the OS may apply automatic gain unless the raw measurement route is requested explicitly. None of that makes the hardware useless. It makes the hardware something you calibrate and then describe honestly. We apply a factory offset calibration using reference tones and offer an optional field check: clap at a known distance, compare against a trusted meter if you own one. That tightens typical error into roughly ±2 to 4 dB. Bench note from the lab: 4 phones, 2 iOS and 2 Android, ran 45 min of pink noise stepped from 50 to 90 dB SPL against a class-2 reference meter; raw readings spread 5.8 dB across devices, per-model offsets pulled the spread under ±2 dB, and monthly spot checks over 6 months drifted less than 1 dB. That is sufficient to answer whether this HVAC unit is louder than last month and insufficient to prove compliance in a legal dispute. The UI labels every reading as approximate SPL, permanently. If you log outdoors, shield the port from wind. A-weighting removes most wind rumble from the reported number while the capsule still sees the full pressure swing, so a reading can look calm while the analog front end is close to overload. ## dBFS is a different animal Digital full scale, written dBFS, measures how close a recording sits to the largest value the analog-to-digital converter can represent. 0 dBFS means clipping. Everything below is headroom. The number says nothing about absolute acoustic pressure in the room, because the mapping between the two depends on microphone sensitivity and every gain stage in between. A typical phone MEMS capsule places 94 dB SPL somewhere near -38 dBFS and overloads acoustically around 120 dB SPL, and both figures shift per model. Voice Memos showing -6 dBFS tells you the recording is hot. It tells you nothing about whether the apartment measures 60 dB A-weighted. Never compare dBFS exports to a municipal noise law written in dBA; the units do not describe the same physical quantity, and no conversion exists without a calibration step. Our [FFT post](https://uranashel.com/blog/fft-made-readable.html) covers the spectral views these level numbers sit on top of, and [machine baseline comparison](https://uranashel.com/blog/machine-fault-baseline.html) shows how calibrated level differences reveal mechanical change over months. The meter itself ships in [Sonarish](https://uranashel.com/apps/sonarish.html), which runs everything on-device and never uploads audio. --- 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/) --- # Machine fault diagnosis via acoustic baseline > Machines hum in patterns; Sonarish lets you save a healthy baseline and compare later — acoustic diagnosis without cloud AI. 2025-11-28 · 20 min read · sonarish · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/machine-fault-baseline.html --- Rotating machinery sings in tones. A healthy induction motor produces a fundamental frequency tied to shaft speed, with harmonics stacked at integer multiples above it. Bearings add their own signature once races develop pits or cages loosen. Mounting hardware rattles when bolts fatigue. Trained vibration analysts chase all of this with accelerometers bolted to bearing housings and years of catalogued fault frequencies. Sonarish offers something humbler that still earns its keep: record a spectrum while the machine is known good, store it as a baseline on your phone, and compare later recordings to see which frequency bins grew louder. No audio leaves the device. No cloud service promises magical diagnosis. The method is acoustic fingerprinting, not full physics simulation. Nobody here solves Newton's equations for a drum assembly. The question is narrower: does today's sound carry more energy at 87 Hz than the baseline captured 18 months ago, back when the washing machine still spun smoothly. A microphone, an FFT, and honest diff math can settle that. ## Why machines hum where they do Start from the nameplate. 600 RPM means the shaft turns `f_r = 600/60 = 10` revolutions per second, so the lowest mechanical tone sits near 10 Hz. Simple imbalance pumps exactly that line, once per revolution. Misalignment tends to feed the 2nd harmonic at 20 Hz. Every panel bolted to the frame can resonate at whichever multiple matches its own stiffness, which is why a worn machine sounds like a chord. Bearings are the interesting part. A rolling-element bearing holds `N_b` balls between an inner race spinning at shaft speed and a fixed outer race, spaced by a cage. The cage revolves slower than the shaft, because each ball rolls between two surfaces moving at different speeds. Work through the rolling-contact geometry and the cage rate comes out as `FTF = (f_r/2)·(1 − (d/D)·cos φ)`, where d is ball diameter, D is pitch diameter, and φ the contact angle. A pit in the outer race gets struck once per passing ball; the impacts repeat at `BPFO = (N_b/2)·f_r·(1 − (d/D)·cos φ)`. Swap the minus for a plus and you have the inner-race frequency. For a common 8-ball geometry with d/D near 0.2, BPFO lands around 3.2 times shaft speed, and its harmonics climb from there. These formulas explain the shape of a machine spectrum. Sonarish does not plug numbers into them. It cannot: the app has no idea how many balls your drum bearing carries or what its pitch diameter is, and neither do you, unless you enjoy disassembling washing machines considerably more than I do. ## A repeatable workflow in the field Consistency beats clever algorithms. Place the phone at the same standoff distance every time; 0.5–1 m is typical. Use the same surface if possible; a rubber mat on a metal panel reduces extraneous vibration paths. Record 30–60 s of steady-state operation after the machine has warmed up, never the startup transient, when currents surge and everything is briefly louder. Tag the capture as a baseline with a human-readable machine name. In 18 months, a file called Recording 47 will mean nothing. We measured what sloppy placement costs. Bench note: 1 phone, 1 workshop compressor, 12 re-placements across one afternoon, position marked with tape on the floor. With the tape mark, per-bin spread below 2 kHz stayed within about ±2 dB. Freehand placement roughly doubled that spread, and a 30 cm distance error alone shifted broadband level by about 3 dB. Half the alarm threshold, spent before the machine changed at all. Tape is cheap. Months later, repeat the geometry as closely as you can and record again. Everything past that point is arithmetic. ## The diff, bin by bin The analysis chain runs 4096-sample Hann windows on 48 kHz microphone PCM at 50% overlap — twice the window length of the live spectrum view, trading update speed for a true 11.7 Hz resolution, the setup our [FFT for mortals](https://uranashel.com/blog/fft-made-readable.html) post walks through. Bin spacing works out to 48000/4096 ≈ 11.7 Hz. A 60 s capture with a 2048-sample hop yields about 1,400 windows, and averaging each bin across 1,400 windows squeezes the random wobble of the estimate well under 1 dB. The alarm threshold sits at +6 dB. Measurement noise is not what trips it. Sonarish converts the averaged magnitudes to decibels and subtracts the stored baseline spectrum bin by bin. The diff view highlights sustained rises above about 6 dB. Transient clicks from solenoids or relays can be suppressed with an optional median filter: take the per-bin median across time instead of the mean and a click occupying 3 windows out of 1,400 simply vanishes, so one-off events do not paint false alarms across the spectrum. ``` // per capture, off the audio thread S = stft(pcm48k, window = hann(4096), hop = 2048) for each bin b: level[b] = median(|S[:, b]|) // median beats mean: clicks vanish db[b] = 20·log10(level[b] + eps) + calib_offset if capture is BASELINE: store(machine_name, db, date, device_model) else: base = load(machine_name) for each bin b: diff[b] = db[b] − base.db[b] // bin-by-bin subtraction flag bins where diff[b] > +6 dB and rise spans ≥ 2 adjacent bins // lone spikes ignored ``` The 11.7 Hz bins carry one consequence worth naming. A 10 Hz shaft fundamental sits below the first usable bin center and smears into the spectrum's DC shoulder. Low-speed machines are read through their harmonic stacks instead, and those land comfortably inside the resolved range. ## Reading a diff without pretending to be an oracle Suppose a front-loading washer spins at 600 RPM during the extract cycle. 600 revolutions per minute equals 10 revolutions per second, so you expect strong energy near 10 Hz and its harmonics. After a year and a half of service, the diff view might show +12 dB near 87 Hz and at nearby multiples. That frequency may line up with a ball-pass frequency calculation for the drum bearing. It may equally be a loose counterweight. Sonarish does not auto-label the fault. It shows the change clearly enough that you can search bearing frequency formulas, call a technician, or decide the noise is still acceptable. That restraint is intentional. False confidence destroys trust faster than any raw graph could. We would rather be the instrument you carry down to the basement than the app that confidently mislabels a belt squeal as catastrophic bearing failure. ## What we tried and discarded Version one had an auto-diagnosis table. Feed the strongest diff peaks into a lookup of fault classes and print a verdict: imbalance, misalignment, outer race, inner race. It demoed beautifully on our own bench compressor. On any other machine it needed the bearing geometry from the formulas above, which no user has, so the classifier fell back on generic frequency ratios and produced confident nonsense often enough to scare us. Discarded. Cepstrum analysis came next. The cepstrum (an FFT of the log spectrum) collapses a whole harmonic family into a single peak and is standard practice in industrial vibration monitoring. It works. It may still ship someday. Every interface draft died at the same spot, though: explaining a quefrency axis to someone standing next to a rattling dryer. Parked. Full-spectrum level normalization we shipped and then partly removed. Early builds rescaled each new capture so its broadband level matched the baseline before diffing, to forgive distance errors. That also silently erased genuine growth in overall loudness, and for a wearing bearing the overall trend is half the story. Current builds apply the calibration offset, diff honestly, and report the broadband delta as its own number. ## Limits you should respect Microphone saturation ruins everything. Press the phone against a vibrating metal panel and the analog front end clips. The clipped waveform looks louder in the time domain but its spectrum lies — harmonics appear that were never physically present, right in the region where you hunt for bearing tones. Keep the phone at the marked standoff, in air, and re-record any capture that touched full scale. Low frequencies bring their own trap. Phone microphones are built for voice. Bench note: 5 phones, swept sine from a sealed speaker at 0.5 m, one evening in the lab — every mic was at least 10 dB down by 30 Hz, and two had lost 20 dB. A 10 Hz fundamental is effectively invisible to the hardware, so slow machinery gets diagnosed through the harmonics the mic still passes. Background room noise changes between sessions when HVAC seasons shift or a door stands open, so check what the room was doing before trusting a small diff. Different phone models have different frequency responses, and comparing a Pixel capture against an iPhone baseline mixes two unknown curves into the result; track a given machine on the same device across the years. For what the level numbers themselves mean, and why the display always says approximate SPL, see [A-weighting and decibels explained](https://uranashel.com/blog/a-weighting-decibels.html). ## Where this fits in the uranashel stack Baseline comparison is the feature that turned Sonarish from a party trick decibel meter into something I keep installed next to the fuse panel. It shares FFT parameters with the live spectrum view, uses the same approximate SPL calibration, and stores sessions in the app sandbox, following the [on-device architecture](https://uranashel.com/blog/on-device-first.html) the whole studio runs on. No account, no upload, no model trained on your washing machine humming at odd hours. The capture path also obeys the studio's audio rules: nothing allocates on the audio thread, as documented in [life on the audio thread](https://uranashel.com/blog/dsp-audio-thread.html), while the heavy averaging runs afterwards, where a slow millisecond costs nobody anything. If you want to see raw spectra before diffing, Phyzix exposes a live microphone analyzer suitable for classroom demos. The payoff of the whole discipline is boring in the best way. When the diff stays flat for months, you have evidence the repair held. When it creeps upward, you have a timestamped graph to show a technician instead of vague words about “sounding rougher lately.” --- 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/) --- # Sleep, waves, and why your brain likes surf > Why surf-like 1/f noise soothes sleep, and how Estua mimics it with slow modulation instead of a beach recording on repeat. 2025-11-10 · 18 min read · estua, physics · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/estua-sleep-and-waves.html --- There is a reason people travel to the coast for rest. Surf is loud in absolute terms yet feels calming to many listeners, and part of that paradox comes from statistics. Natural surf approximates pink noise, sometimes called 1/f noise because its power falls predictably as frequency rises. Pink noise carries equal energy per octave — low rumble gets weight without the harsh uniformity of white noise, where every frequency band receives the same power. Estua synthesizes that statistical texture in real time instead of replaying an 8-minute recording that your brain will detect by minute three. A disclaimer before the details: we are an app studio, not a sleep clinic. We run no EEG trials and publish no medical claims. I read the psychoacoustics literature, dogfood the nightly builds, and ship audio that avoids the perceptual traps loop-based apps fall into. What follows is why surf-like spectra matter for sleep onset and how Estua builds them without seams. ## What pink noise actually is White noise sounds like static on an untuned television. Every band contributes equal power, and over minutes the ear reads that as bright and fatiguing. Pink noise rolls the highs off at −3 dB per octave, so power density follows `S(f) ∝ 1/f`. That slope has a tidy property. Integrate `1/f` across any octave, from `f` to `2f`, and the result is `ln 2` regardless of where the octave sits: the band from 100 to 200 Hz holds the same energy as the band from 4 to 8 kHz. The cochlea organizes frequency roughly logarithmically, so equal energy per octave lands as balanced. Brown noise slopes at −6 dB per octave and sounds muffled, like surf heard through a hotel wall. Real waves sit close to pink without being exact. Last spring I recorded 20 minutes of surf at Vũng Tàu, one phone at chest height, stock mic. The fitted slope between 100 Hz and 2 kHz came out near −3.2 dB per octave. Riding on that slope are transients: a louder swash, a gust against a window. They are not evenly spaced. That rough randomness keeps the soundscape alive, and it is precisely the part a loop freezes. ## The autocorrelation trap Human hearing is exquisitely sensitive to periodicity. The measure that makes this concrete is autocorrelation, `R(τ) = E[x(t)·x(t+τ)]`, which asks how much a signal resembles a delayed copy of itself. Play a recorded loop and `R(τ)` spikes at the loop period even when careful crossfades hide the audible click. Your ear finds the spike anyway. Evolution favored detecting repeating patterns in rustling grass because repetition sometimes meant a predator circling. Whatever the mechanism, the effect is easy to reproduce at home. By minute three of a looped ocean track, part of your attention is waiting for the wave it already knows. We put numbers on this at our own bench. Two phones captured 64-minute sessions, one playing a popular loop-based sleep app and one playing Estua's ocean scene, analyzed offline afterward. The loop shows a normalized autocorrelation peak around 0.6 at its 8-minute lag. Estua stays below 0.08 at every lag beyond 30 s, which is the noise floor of the estimate itself. Synthesis earns that by drifting phase relationships continuously: uncorrelated low-frequency oscillators modulate filter cutoffs and gains, so the scene never returns to a configuration it has already played. ## Anatomy of an ocean scene An Estua ocean scene is three noise paths plus slow control signals. The foundation is a low band between 80 and 400 Hz with amplitude modulation at 0.05–0.12 Hz. Convert those rates to periods and you get one swell every 8–20 s, which brackets the 8–14 s period of real ocean swell. A mid band between 1 and 4 kHz supplies the hiss of foam, filtered to pink and modulated gently so it breathes with the swell. An optional high band adds sparkle below 8 kHz, kept 30 dB or more under full scale. Kept that quiet it reads as air; pushed much louder it starts to sting. Rain needs a different trick, because drop clusters do not fall on a metronome. Estua triggers band-passed noise bursts with Poisson timing, drawing each wait from an exponential distribution: ``` // runs at control rate, once per block gap = -ln(rand01()) / rate // exponential inter-arrival f0 = jitter(1.5 kHz, ±0.5 oct) // per-burst center burst = bandpass(noise, f0, Q=2) env = attack 2–8 ms, decay 40–120 ms play(burst * env, at = now + gap) // rate itself drifts 0.5–3 bursts/s under a slow LFO ``` Every scene start draws a seed, and each seed initializes independent random generators for every path. Two nights with the same scene name still diverge in detail unless you deliberately reuse a seed you liked, in which case the same evolution replays from the same starting point. A few users pin one; most let the dice roll. ## Staying real time at 48 kHz All of this runs on the audio thread at 48 kHz in 512-sample blocks. That block size is a hard deadline: 512 samples at 48 kHz gives 10.67 ms to produce each buffer, and one missed deadline is an audible glitch at 2 AM. The hot path allocates nothing, takes no locks, and reads parameters from lock-free structures; the full rulebook is in [life on the audio thread](https://uranashel.com/blog/dsp-audio-thread.html). The synthesis chain itself, from Paul Kellet's economical pink filter to limiter placement and the platform engines on iOS and Android, is documented in [sound that never repeats](https://uranashel.com/blog/estua-non-repeating-audio.html). A whole scene costs roughly 3–8% CPU on a modern phone, leaving headroom for Bluetooth output and the occasional background-suspension scare. The privacy posture matches every uranashel app. Sound is generated on the device, there are no accounts, and nothing about your listening habits leaves the phone. The reasoning lives in [why everything runs on your phone](https://uranashel.com/blog/on-device-first.html). ## How loud should sleep audio be Loudness deserves a measurement. We logged pillow-distance levels with Sonarish running on a second phone for seven nights across two bedrooms. Typical Estua settings landed between 42 and 55 dBA. For scale, the NIOSH 8-hour occupational limit is 85 dBA; 55 dBA carries a thousandth of that acoustic energy. The A in dBA does real work here. Surf energy concentrates in low bands the ear discounts at quiet levels, so an unweighted meter would overstate the rumble by 10 dB or more. The weighting math is in [A-weighting and decibels](https://uranashel.com/blog/a-weighting-decibels.html). The rule we settled on: raise the volume until whatever you are masking, traffic or a fridge compressor or a neighbor's television, stops drawing attention, then stop. Pink spectra overlap most domestic noise, so masking works at lower levels than people expect. If you need more than 60 dBA to sleep, the thing you are masking is worth fixing at the source. ## Details that matter at 2 AM Sleep audio is a shared-environment problem, because the person beside you did not choose your soundscape. The sleep timer runs 20–45 minutes and begins fading volume 20 minutes before off, with the final 3 minutes ramping down roughly 30 dB in total. Silence arrives as a slope. Nobody gets startled awake by the sudden absence of sound, which is a real failure mode of apps that simply stop. The wake alarm uses a gentle synthesized tone from a completely different scene, and the noise you slept to stays out of the morning. If one sound both lulls you down and yanks you up, the brain starts tagging it with urgency, and the lulling stops working. Keeping the two jobs on separate sounds keeps both working. ## Checking the claims yourself None of the spectra above needs to be taken on faith. Phyzix ships noise generators and meters that draw white, pink, and brown spectra side by side, so the −3 dB per octave slope becomes something you can see on a live graph. Sonarish sits at the other end of the bench: it measures. Recording a full session and running autocorrelation offline is how we verify that an Estua build truly lacks periodic peaks before it ships, and the same analysis will expose the 8-minute spike in any loop-based app you point it at. And if the surf itself is what you came for, the scene list is on the [Estua page](https://uranashel.com/apps/estua.html). Bring your own pillow. --- 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/) --- # Phyzix — 100+ physics instruments in your pocket > Turn the phone into a physics lab: live sensors, graphs in SI units, simulations, and bilingual classroom use. 2025-10-22 · 22 min read · phyzix, education · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/phyzix-pocket-lab.html --- Students carry laboratories in their pockets without realizing it. Every smartphone ships an accelerometer, a gyroscope, a magnetometer, a barometer, a microphone and a light sensor, all sampling real physics at rates that would have required rack-mounted equipment a generation ago. Phyzix exists because most of those sensors hide behind opaque APIs and never appear in a homework problem. We expose them as readable instruments with SI units, time histories and export paths, plus simulations where the equation sits beside the motion, so the graph connects to the formula instead of replacing it. The short pitch lives on [the app page](https://uranashel.com/apps/phyzix.html). This is the long version. Phyzix is a bench full of handheld meters, not a gamified quiz app with badges for memorizing constants. Benches get messy. What ours promises is honesty about sample rate limits and axis conventions, which is about the least an instrument can do. The app ships in English and Vietnamese because uranashel builds from Ho Chi Minh City classrooms as much as from garage sensor debugging. ## Live instruments and what they teach Mechanics views draw three-axis acceleration and angular rate into ring buffers holding 30 to 120 s of history. You can scrub back through the trace. Export produces comma-separated values ready for any spreadsheet, which is still how most lab reports get plotted, and nothing in that pipeline is exotic. That is deliberate. A trace you can scroll teaches more than a number you can only glance at. The wave tools run a microphone FFT beside a pair of tone generators. Play 440 Hz on one phone and 443 Hz on another and the room fills with a slow 3 Hz throb, because `f_beat = |f1 − f2|` and the ear sums what it cannot resolve. Meanwhile the spectrum shows two clean peaks. Your head insists there is one wobbling tone; the plot politely disagrees, and that gap between perception and measurement is the whole lesson. If transforms are new territory, [FFT made readable](https://uranashel.com/blog/fft-made-readable.html) walks through the math at whiteboard speed. Field views render magnetometer vectors and compass roses, including raw and calibrated plots side by side. Walk into a steel parking structure and the raw trace smears into an ellipse while the calibrated one holds its circle a little longer, then gives up too. That is soft-iron distortion made visible, the same distortion Wheria fights in production every day; [the compass calibration story](https://uranashel.com/blog/compass-calibration-story.html) covers what fighting it actually takes. ## Environment, astronomy and the fine print Environment panels read barometric pressure and ambient lux. The footer documents the actual achieved sampling rate in small print, because iOS and Android throttle sensors differently once the screen locks, and a chart labeled 100 Hz that quietly delivers 40 Hz corrupts every conclusion drawn downstream. Astronomy stubs expose device attitude for rough sky orientation demos. They are rough. They still settle which way west is. Math utilities convert units and enforce significant figures, so students stop reporting ten decimal places copied from a calculator that has no idea what the meter's least count is. The unit converter refuses to invent precision for the same reason: feed it 3 significant figures and 3 come back. ## Bench notes: what the sensors are worth Before trusting any instrument view we measured the hardware itself. Method: 7 phones from the office shelf, 3 iPhone and 4 Android, each resting on a foam block for 10 minutes while logging every sensor at its maximum requested rate. The numbers below come from that run, and they seed the noise-floor bands Phyzix shades onto its charts. - Accelerometer: 15 to 40 mm/s² RMS noise per axis at a nominal 100 Hz. The newest phone did best; a 2019 budget Android did worst. - Gyroscope: bias drift between 0.5 and 2 °/min after a 2-minute warm-up. - Barometer: 0.3–1 Pa RMS, which against roughly 12 Pa per metre of altitude means the pressure trace wanders by a coffee mug's height while the phone sits perfectly still. - Magnetometer: about 0.4 µT RMS on top of Ho Chi Minh City's roughly 43 µT background field. - Achieved rates: requesting 100 Hz returned anywhere from 47 to 104 Hz depending on device and thermal state. None of this makes phone sensors bad. It makes them instruments with a spec sheet, and the app's job is to print the spec sheet where you can see it. The barometer line alone carries a full post: [the barometer math](https://uranashel.com/blog/barometer-parking-math.html) derives what 0.3–1 Pa of noise does to floor detection when a garage storey stands 3.2 m tall. ## Simulations that show their working Interactive models cover projectile motion with optional drag, simple harmonic oscillators, a double-slit intensity toy and RC charging curves. These are not AAA game physics. Each model renders its differential equation or discrete update rule next to the animation, so a learner changes the initial velocity and watches the parabola respond while the symbols responsible stay on screen. The drag model is the honest kind. Quadratic drag gives `dv/dt = g − (k/m)·v·|v|`, which has no closed-form trajectory, so the app integrates with semi-implicit Euler at a fixed 1/120 s step and admits as much in the caption. The oscillator shows `x'' = −(k/m)·x` with `ω = √(k/m)`. The slit toy plots `I(θ) ∝ cos²(π·d·sin θ/λ)`. The RC panel draws `V(t) = V₀·(1 − e^(−t/RC))` and marks `t = RC`, where the curve crosses 63% of its final value. Phyzix shares conceptual DNA with Wheria's Kalman demo and Sonarish's spectrum view here: make the computation visible instead of wrapping it in a black box. ## Three labs that fit in one class period The stairwell altimeter. Hydrostatics says `Δh = ΔP/(ρg)`, and near sea level `ρg` comes to roughly 12 Pa per metre. Log pressure while climbing one storey of our test garage, 3.2 m per floor, and the trace steps down by about 38 Pa. Students then invert the formula, recover the floor height and discover their error bars are set by that 0.3–1 Pa RMS barometer noise. And the whole lab fits in one flight of stairs. The beat generator. Two phones, two tones 3 Hz apart, one microphone watching the spectrum. Then narrow the gap to 1 Hz and time each beat with the stopwatch that also lives in the app. The shake-proof step counter, or rather the version that is not shake-proof yet. Phyzix ships a deliberately naive pedometer view for teaching: ``` // teaching version, deliberately naive mag = |accel| // magnitude, gravity included for s in mag: if s > threshold and isLocalPeak(s): if timeSinceLastStep > 0.25 s: steps += 1 // shake at 5 Hz: threshold passes, refractory passes, // the counter climbs. This bug is the curriculum. ``` Walk normally and it counts. Shake the phone and it also counts, enthusiastically. The fix, band-passing to walking cadence near 1–3 Hz before picking peaks, is what production pedometers do, and [the step detection post](https://uranashel.com/blog/step-detection-imu.html) derives why the band sits there. ## Bilingual labels and classroom reality Every label, help sheet and error string exists in English and Vietnamese. ktuyen reviews the physics terminology so translations use "gia tốc" rather than colloquial shortcuts that confuse students once they open a textbook. The same discipline drives the platform rule: if a feature ships on iOS, it ships on Android, with parity checks documented in [shipping iOS and Android at the same time](https://uranashel.com/blog/cross-platform-two-codebases.html). Classroom logistics stay boring on purpose. Teachers mirror a phone to the projector for demonstrations; students capture CSV for homework plots. There is no account gate, no advertisement SDK and no requirement for classroom Wi-Fi, which matters in rooms where the router is a rumor. You buy the app once on the store. We do not sell curriculum licenses that expire when a semester ends. For deeper sensor theory, including why an accelerometer resting on a table reads 9.81 m/s², read [sensor hacking 101](https://uranashel.com/blog/phyzix-sensor-hacking.html). For the privacy expectations shared across every uranashel sensor app, see [why everything runs on your phone](https://uranashel.com/blog/on-device-first.html). Phyzix logs stay on the device. ## From classroom to garage The same accelerometer trace a student shakes for a homework plot is the sensor Wheria uses to count steps underground. Phyzix makes that continuity explicit. You watch naive peak picking fail when you shake too fast, then read how the production code band-passes walking cadence instead. Magnetometer ellipsoid plots explain why compass badges turn yellow in steel garages. The microphone FFT ties forward to Sonarish's baseline diffs and backward to the Nyquist lecture you half remember. One pocket lab, many apps. Nobody is repackaging homework here. The physics underneath is shared, and students deserve to see it. --- 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/) --- # Sensor hacking 101 > Accelerometers measure specific force, not “zero when still.” A gentle primer on what each phone sensor really reports. 2025-10-08 · 20 min read · phyzix, physics · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/phyzix-sensor-hacking.html --- Every smartphone sold in the last decade carries a cluster of MEMS sensors — tiny silicon structures that move when you move, bend when air pressure changes, and deflect in magnetic fields. Most owners never see the numbers. They feel a smooth glass rectangle, while underneath it a bundle of transducers samples the physical world at 50–200 Hz. Phyzix exists to put those numbers on screen, in SI units, with no smoothing you did not ask for. Before you build anything serious on sensor data (a parking walk in Wheria, a classroom lab, a vibration baseline for a failing pump) you need to know what each chip actually measures, because the spec sheet is consistently more optimistic than the silicon. ## Accelerometers measure specific force, not motion The most common misconception in introductory sensor work is that an accelerometer reports how fast you are moving. It does not. A MEMS accelerometer measures **specific force**: the net contact force per unit mass acting on a microscopic proof mass suspended inside the chip, expressed in m/s². Lay a phone flat on a table and the vertical axis reads about −9.81 m/s², with the sign depending on axis convention. The phone is going nowhere. The table is pushing up against gravity, and the sensor reports that reaction force. Drop the same phone in a vacuum chamber and all three axes head toward zero, because free fall involves no contact force at all. In symbols the output is `f = a − g`: proper acceleration minus the gravitational field, resolved in the sensor frame. That one distinction separates working sensor fusion from code that integrates garbage into position. Double integration amplifies a constant bias as `Δx = ½·b·t²`. A respectable 1 mg bias (0.0098 m/s²) becomes 4.4 m of position error after 30 s, and consumer chips are rarely that polite for long. This is why Wheria never double-integrates accelerometer data for position, and why the blue dot in naive IMU demos drifts across the parking lot inside 30 s. Phyzix shows the raw trace on a live graph so you can watch the physics directly. Shake the phone and oscillations ride on top of the gravity vector. Rotate it slowly and the 9.81 migrates from one axis to the next as the sensor frame turns. Students who expected zero at rest tend to go quiet for a moment. One warning about convenience APIs. Both platforms offer a derived linear-acceleration stream with gravity already subtracted, and it is tempting to treat that as ground truth. It is the output of the OS's own fusion filter, complete with that filter's lag and assumptions. Phyzix deliberately graphs the raw stream first, so you learn what the estimate is built from before you decide to trust it. ## Watching a gyroscope drift A gyroscope outputs angular velocity in rad/s around each axis, and it needs no gravity reference. Inside the package a proof mass vibrates along one axis; rotating the chip adds a Coriolis acceleration `a_c = 2·Ω×v` at right angles to that vibration, and capacitive electrodes read the deflection. Direct rotation sensing makes gyros excellent at tracking fast orientation changes between steps in a parking garage. The catch is bias. Consumer MEMS gyros drift 1–3° per minute while sitting perfectly still, and the integral `θ(t) = ∫ω·dt` faithfully accumulates every bit of it. Phyzix ships a small experiment for exactly this: spin the phone on a table against a printed protractor, then compare the integrated angle with what the paper says. The gap is visible after 10 s. Because of that gap, every practical orientation system fuses the gyro with something absolute: the accelerometer for tilt whenever linear acceleration is small, the magnetometer for yaw whenever the field is clean. It is also why Wheria corrects heading with magnetometer updates while the field is trustworthy, then falls back to gyro-only dead reckoning the moment steel pillars make the compass lie. The classic entry point is a complementary filter, which Phyzix exposes as a toggle on the orientation screen: ``` // per sample, dt in seconds gyro_pitch = pitch + w_x * dt // fast, drifts acc_pitch = atan2(a_y, a_z) // absolute, noisy pitch = 0.98 * gyro_pitch + 0.02 * acc_pitch ``` Two lines of fusion and one tuning constant. The 0.98 says: trust the gyro over fractions of a second, and let the accelerometer pull the estimate back over a few seconds. A Kalman filter is the grown-up version of the same bargain, with weights derived from noise statistics rather than tuned by eye — that derivation lives in our [Kalman filter post](https://uranashel.com/blog/kalman-filter-parking.html). ## The magnetometer draws an ellipsoid The magnetometer returns a three-component vector of the local magnetic field in microtesla. Earth's field runs 25–65 µT depending on latitude and local geological anomalies; around Ho Chi Minh City it sits near 43 µT. In open air the vector points toward magnetic north with a predictable inclination angle. Inside a parking garage everything changes. Steel reinforcement and your own car distort the field until the apparent north rotates 40–90° while the phone lies motionless. Plot uncalibrated samples in 3D as you rotate the phone and they trace an off-center ellipsoid instead of a sphere centered at the origin. The constant offset is hard-iron bias from nearby ferromagnetic parts, typically tens of µT on the handsets we test. The stretching is soft-iron distortion from the surrounding steel. Phyzix draws raw and calibrated vectors side by side, so you can watch the ellipsoid recenter and round out as you walk from the street down into Landmark 81's basement. The correction itself is a figure-8 motion, or slow online fitting during straight walks; either way it is the same pipeline Wheria runs in production, documented in our [compass calibration post](https://uranashel.com/blog/compass-calibration-story.html). ## Barometers, light, and the environment The barometer reports absolute atmospheric pressure in hPa. Hydrostatics gives `ΔP = ρ·g·Δh`, roughly 12 Pa per meter of height near sea level, so one garage floor of 3.2 m is a 38 Pa step. Weather fronts move the absolute reading by hundreds of pascals over a few hours. Only the differential since you tapped "parked" carries floor information, high-pass filtered to reject that slow drift. Phyzix shows absolute pressure alongside the delta from a user-set baseline; the full floor-counting math, including why 0.3–1 Pa RMS of sensor noise still resolves a 38 Pa step cleanly, is in the [barometer parking post](https://uranashel.com/blog/barometer-parking-math.html). The ambient light sensor rounds out the environmental set. It reports illuminance in lux and makes a decent inverse-square demo: measure a desk lamp at 0.5 m and again at 1 m, then check the ratio against `E ∝ 1/r²`. Expect a factor near 4, minus whatever the wall reflections contribute. Many models saturate in direct sunlight and quantize coarsely at the dim end, which is itself a useful lesson about instrument range. ## Bench notes: six phones on a granite slab Numbers in posts like this should come from somewhere. Last spring we clamped 6 phones (3 iPhone, 3 Android) to a granite surface plate for 48 h and logged every sensor at its maximum advertised rate. Granite does not fidget. Every wiggle in those logs is the sensor's own opinion. - **Accelerometer:** 1.5–4 mg RMS of noise at 100 Hz depending on the handset, and the bias moved by up to 8 mg between cold start and thermal equilibrium some 20 min later. Thermal drift dwarfs the white noise on a timescale of minutes. - **Gyroscope:** 0.05–0.12 °/s RMS at rest, with integrated heading wandering 1–3° per minute — matching the folklore for once. - **Magnetometer:** 0.3–1.2 µT RMS, plus a 6 µT step on one device the instant a charging cable was plugged in. Current makes fields, and the chip cannot know the field is your charger. - **Barometer:** 0.3–1 Pa RMS once warm, but the first 10 min of each log showed a 4–6 Pa slope from self-heating. That is about 0.4 m of phantom altitude, which matters when a floor is worth 38 Pa. One Android also promised 200 Hz and delivered 197 Hz with visible timestamp jitter. Integrate against per-sample timestamps, always. The nominal rate is a marketing number. ## From classroom to production pipeline The gap between reading sensor values and navigating a user back to their car is signal processing plus honest uncertainty. Phyzix is deliberately the first half of that journey: raw SI units, live graphs, exportable CSV, bilingual labels so Vietnamese students do not have to parse English error strings. Wheria is the second half: step detection from accelerometer peaks, heading fusion from gyro and magnetometer, floor hints from barometer deltas, all combined in a Kalman filter that outputs a confidence badge instead of a fake precise dot. To follow the full chain, start with step detection in our [IMU odometry post](https://uranashel.com/blog/step-detection-imu.html), then read how the pieces fuse in the [indoor navigation pipeline](https://uranashel.com/blog/building-wheria-indoor.html); the classroom side of the same instrument set is covered in the [pocket lab post](https://uranashel.com/blog/phyzix-pocket-lab.html). The physics is the same in both apps. Phyzix just shows you the wires. --- 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/) --- # Shipping iOS and Android at the same time > SwiftUI and Compose, two codebases, one physics core — why we ship iOS and Android together and how we keep them aligned. 2025-09-20 · 19 min read · engineering · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/cross-platform-two-codebases.html --- Every year someone asks why uranashel does not use Flutter, React Native, or Kotlin Multiplatform for the UI layer. Fair question. Five apps, three people, two platforms — the arithmetic looks bad. The answer starts with a deadline. Estua's audio callback must hand the hardware 512 samples every `512/48000 = 10.7 ms`, with no malloc and no locks, or the speaker clicks; that discipline has [its own post](https://uranashel.com/blog/dsp-audio-thread.html). Wheria fuses IMU data at 100 Hz. Sonarish runs a 4096-point FFT on microphone PCM while its meter redraws at 30 Hz. Code living under deadlines like these cannot sit behind a JavaScript bridge, and it cannot trust an abstraction that treats CoreMotion and SensorManager as interchangeable black boxes. They are different instruments that happen to live in similar glass slabs. They sample at different rates, apply different axis conventions, throttle background work differently, and fail in different garages. So we ship **SwiftUI** on iOS and **Jetpack Compose** on Android, plus shared logic modules in Kotlin and Swift wherever the math must produce identical numbers on both sides. ## Two UI codebases, one physical truth The split is deliberate and asymmetric. Navigation stacks, settings forms, AR overlays, spectrogram rendering — all of it lives natively on each platform, because that is where the platform-specific bugs live too. iOS 16+ ignores UINavigationBar title fonts; Android OEMs each ship their own audio buffer size. ARKit and ARCore disagree on coordinate handedness, so the same rotation matrix means two different things until somebody flips an axis. Fighting all this through a cross-platform framework means maintaining the framework's native shims anyway, plus the framework's own bugs, plus your app logic stacked on top of both. The math is the opposite case. The Kalman update, the step detector threshold, the FFT Hann window `w[n] = 0.5·(1 - cos(2πn/N))`, the pink-noise filter state inside Estua — every coefficient must match exactly across platforms. A user with an iPhone and a friend with a Pixel walking the same garage, past the same pillar E9 on floor B2, should see the same confidence badge at the same moment. That math lives in shared modules: Kotlin Multiplatform on the Android side, mirrored Swift on iOS, unit-tested on both against identical synthetic sensor inputs. The pipeline those modules implement is walked through in [the indoor navigation post](https://uranashel.com/blog/building-wheria-indoor.html). ## Where identical math stops being identical IEEE 754 is deterministic for addition, multiplication, and division: same inputs, same operation order, same bits, on any conforming CPU. Trouble arrives from three directions. First, transcendental functions. Android's libm and Apple's system math library both promise faithful rounding, yet occasionally round the same `sin(x)` to neighbouring doubles; our logs showed roughly 1 call in 400 differing by 1 ulp. Second, compilers fuse multiply-adds when optimization flags allow it, and a fused `a·b + c` rounds once where the unfused version rounds twice. Third, random numbers. Swift's SystemRandomNumberGenerator cannot be seeded at all, Kotlin's Random(seed) implements a different algorithm anyway, and a "shared" generative scene therefore diverges on the very first sample. Estua ships its own PCG32 inside the shared module for exactly this reason — seed 12345 has to mean the same waveform on both stores, as covered in [the non-repeating audio post](https://uranashel.com/blog/estua-non-repeating-audio.html). A 1-ulp error is physically meaningless, about `2e-16` relative. It still bit us. In one golden trace the compass covariance sat within `1e-9` of the unreliable-badge threshold for six consecutive frames; iOS crossed on frame 2141, Android on frame 2143. Same trace, same math, different badge for 20 ms. More precision would not have fixed a knife-edge comparison. We put hysteresis on the badge threshold, pinned FMA off in the shared modules, and replaced libm trig with our own polynomial approximation so the golden tests could assert exact equality. ## The golden-trace harness Parity between the two codebases is enforced by replay. We keep 214 recorded sensor traces (garage walks, desk shakes, an elevator ride, one motorbike commute across District 7), collected from 9 phones over two years. Every night a small CI rack replays all of them through the Swift pipeline on an iPhone 14 and the Kotlin pipeline on a Pixel 7, then compares outputs bit for bit. ``` // nightly parity job: one iPhone 14, one Pixel 7 for trace in goldenTraces { // 214 recorded runs let a = swiftPipeline.replay(trace) // heading, steps, floor, badge let b = kotlinPipeline.replay(trace) for (fa, fb) in zip(a, b) { assert(bits(fa) == bits(fb)) // bit-for-bit since the trig swap } } // audio side: render 60 s of Estua seed 12345 on both, // FFT the two renders, fail on any band differing by > 1 dB ``` Before the trig swap that assert had to be a tolerance, and tolerances rot. Someone widens one to get a release out the door, nobody remembers why, and 6 months later the two platforms disagree by a metre with every test green. Bit equality removes the grey zone. The test either passes or somebody broke determinism, and the diff points at the exact frame where they broke it. ## The release train and ktuyen's matrix atuan tags iOS and Android releases together: same version number, same changelog, same day on both stores whenever the review queues cooperate. That sounds bureaucratic until you see what it prevents — a compass fix shipping on iOS while Android still runs the old heading pipeline, or an LFO update reaching Estua on one platform and quietly breaking seed compatibility between two friends' phones. Before every release, ktuyen runs the device matrix: - iPhone 13 through 16, Pixel 6 through 9, one Samsung mid-range for OEM quirks - a full UI pass in Vietnamese and in English - an offline garage walk at Landmark-style and spiral-mall geometries - an overnight Estua soak test, listening for audio clicks - a Sonarish LAeq comparison between platforms with identical mic placement Every line carries a numeric threshold. The compass unreliable badge has to appear at the same covariance value on both OSes. A 15-minute LAeq window must match within 0.5 dB under identical conditions. And Estua seed 12345 renders on both platforms into two scenes that must be audibly identical, verified by a spectrum diff under 1 dB RMS. ## What a shared framework would actually save Some UI, honestly. A settings form written once is a settings form written once. Below that line the savings stop. Real-time audio in every framework eventually drops to AVAudioEngine on iOS and AudioTrack on Android, so the native callback gets written twice regardless. The CoreMotion-versus-SensorManager axis flips still need debugging on both sides. ktuyen still tests both platforms, because users own both. And every sensor feature we ship would first touch an API the framework has to wrap, which is the platform-surface-area half of the answer. We did try it properly. In early 2024 I built a Flutter prototype of the Sonarish meter screen: two devices, one weekend, a frame profiler. On a Pixel 6 the platform-channel round trip for 30 Hz meter updates averaged 1.8 ms, which is fine, and spiked past 20 ms during garbage collection, which is a visible stutter on a needle the user is actively watching. Add the standing tax of waiting for the framework to expose each new iOS API, of working around a platform-view interop bug from the far side of the bridge, of explaining to a user why their Android parking walk dropped magnetometer samples somewhere between the layers. We ran the honest accounting and two native codebases won. ## The price, itemized Every feature ships twice. So does every bug, occasionally on the same day. A one-word copy change lands in Localizable.xcstrings and again in values-vi/strings.xml. A font fix touches both AppFont.swift and AppTypography.kt, because SwiftUI Forms default to the 17 pt system font while Compose needs a defensive ProvideTextStyle wrapper; that whole saga is in [the Nunito post](https://uranashel.com/blog/nunito-on-every-pixel.html). The cost is real and permanent, and we re-derive it at every planning meeting. We keep paying because of who the users are. Roughly half our users in Vietnam are on Android and roughly half on iPhone. A parking app that works on one platform fails half the time someone recommends it. Shipping one platform well while neglecting the other is laziness disguised as focus, and we would rather pay the duplication tax than tell half our users their phone is unsupported. The on-device architecture that makes this whole sensor pipeline possible in the first place is covered in [the on-device-first post](https://uranashel.com/blog/on-device-first.html). --- 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/) --- # Why GPS lies to you > Line of sight, multipath, and concrete — a physics-first explanation of why your map dot lies in parking garages. 2025-09-05 · 20 min read · wheria, physics · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/why-gps-lies.html --- Global Navigation Satellite Systems (GPS, GLONASS, Galileo, BeiDou) all run the same geometric trick. Your phone's receiver measures the arrival time of radio signals from at least four satellites, converts each delay into a pseudorange, an estimated distance to that satellite, and solves a system of equations for three-dimensional position plus a clock-bias correction. Under open sky the horizontal result is often 3–8 m at the 95th percentile. That will find you a restaurant, but it will not find your car on level B3 of a concrete parking garage, and no map tile can patch the gap. The failure is physics: signal attenuation, multipath reflection, and geometry collapse. ## A ruler made of time Every satellite broadcasts its orbital position and a timestamp disciplined by atomic clocks on board. The receiver notes when each signal arrives and forms a pseudorange `ρ = c·(t_rx − t_tx)`. Light covers about 0.3 m per nanosecond. A timing error of 1 ns therefore costs 30 cm of range, and the quartz oscillator in your phone is millions of times less stable than the cesium and rubidium clocks upstairs. The solver copes by treating the receiver clock offset as a fourth unknown next to x, y, and z. Four unknowns need four equations. That is where the four-satellite minimum comes from, and extra satellites over-determine the system so the receiver can average noise down. The whole solve rests on one assumption: the signal traveled in a straight line at a known speed. Standard models correct for the ionosphere and the troposphere, which slow and bend the wave by predictable amounts. Nothing in the model accounts for a signal that bounced off a concrete wall on the way in, or for one that arrived 100 times weaker than expected. Underground, both happen at once. ## Concrete eats microwaves GPS transmits on the L1 band around 1575 MHz, microwave radio that behaves roughly like light at architectural scales. It casts shadows, and a parking structure is one enormous shadow. Reinforced concrete attenuates the signal by 20–30 dB or more per pass; 20 dB is a factor of 100 in power. The signal reaches the ground at roughly −130 dBm to begin with, already below the thermal noise floor and recoverable only through correlation gain. One or two slabs push it past anything a correlator can dig out. Two levels underground, the receiver may see zero usable satellites. Or it sees something worse: reflections leaking through the partial sky view at the ramp entrance, delayed and distorted. A receiver holding fewer than four healthy satellite signals cannot solve for position reliably. Some phones then fall back to stale dead-reckoning estimates or Wi-Fi positioning, which drifts or jumps without warning. The blue dot keeps moving regardless. The phone is guessing from degraded inputs and refreshing that guess every second. ## Multipath, the error that looks like motion Some signal does penetrate near ramp mouths, on open-air upper decks, and in garages with skylights. There, multipath dominates the error budget. The direct signal from a satellite arrives at your antenna along with a delayed copy reflected off a concrete wall, a metal roof, or another car. The receiver's correlation engine tracks the strongest peak, which may be a composite of the direct and reflected paths carrying an extra path length Δd. That extra length becomes a pseudorange error, and the pseudorange error becomes a position error on the order of Δd itself. A bounce across a parking aisle and back adds tens of meters of path without difficulty. Stand still and watch the dot orbit 10–30 m around you as satellite geometry shifts and different reflections take over. We measured this properly once. Bench note: 3 phones (one iPhone, one Pixel, one mid-range Samsung), 20 minutes static at a B1 ramp mouth, GNSS fixes logged at 1 Hz. The reported horizontal accuracy never exceeded 15 m. The actual scatter of the fixes spanned more than 40 m. None of this is incompetent map software. The receiver honestly reports the best estimate it can extract from ambiguous measurements; the optimism lives in the accuracy figure attached to it. ## The vertical axis is worse Horizontal error collects the complaints. Vertical error is typically 1.5–3 times worse than horizontal even in good conditions, because the satellites are mostly above you. Seen from the receiver, the constellation is nearly flat, so the geometry constrains altitude weakly and the vertical dilution-of-precision term inflates. This is why GPS cannot reliably answer "which floor am I on?" even when latitude and longitude look reasonable at the garage entrance. The floor question has a better instrument. A garage level is about 3.2 m of height, and air pressure falls by roughly 12 Pa per metre of ascent near sea level. The hydrostatic relation `Δh = ΔP/(ρg)` converts a pressure change straight into a height change, so one floor is a step of about 38 Pa against a phone barometer noise floor of 0.3–1 Pa RMS. That is a clean signal in exactly the place where GPS altitude offers noise. Wheria's barometer floor hints exist because of this asymmetry; the derivation and the failure cases live in the [barometer parking math post](https://uranashel.com/blog/barometer-parking-math.html). ## Other ways the pin goes wrong Underground is the extreme case; the same physics has cousins on the surface. Urban canyons between tall buildings run the same multipath physics as the garage, with glass facades reflecting signals into pseudorange biases of tens of meters while half the sky stays blocked. Cheap RF jammers, illegal but real, flood the band with noise; atuan once logged a 40 s dropout of every constellation at once under clear sky, the classic signature of a jammer driving past rather than anything atmospheric. Faulty electronics achieve the same result by accident with broadband interference. Each mode leaves a different fingerprint in the raw measurements, and every one of them produces the identical user experience: the pin is not where you are. ## What we tried before giving up on the dot We did not jump straight to hiding GPS indoors. First attempt: average fixes over 30 s at the garage entrance and pin the entry portal. That part works and Wheria still does it, since the entrance sits under open sky long enough for a decent fix. Second attempt: keep the fused location provider running underground and low-pass its output. Discarded after a week, because smoothing a measurement whose error is 10 times the quantity of interest yields a smooth wrong answer. Third attempt: Wi-Fi round-trip-time ranging, which is genuinely good hardware when present; in the garages we tested around District 1 and Bình Thạnh, access points supporting RTT were close to nonexistent. Discarded for coverage. What survived is the gate below. ## Gating: when Wheria trusts the fix Wheria treats a GNSS fix as one witness whose story needs corroboration. The reported accuracy must be tight, the satellite count healthy, the implied motion plausible for a person walking near a car, and the barometer must agree that you are still above ground. Any failed check hides the dot. ``` fix = gnss.latestFix() trustworthy = fix.satelliteCount at least 4 and fix.horizontalAccuracy under 20 m and fix.jumpSinceLastFix under 10 m # walking pace, no teleports and barometer.floorEstimate() at or above ground if trustworthy: map.showDot(fix, radius = fix.horizontalAccuracy) else: map.hideDot() path.extend(stepOdometry.delta()) # IMU takes over ``` When the dot disappears, the walked path replaces it. Wheria reconstructs your route from step detection and heading fusion and draws it with honest uncertainty bands, so the screen shows where you actually went rather than where a corrupted satellite solution floats. The full indoor pipeline of IMU dead reckoning, barometer floor hints, and adaptive compass fusion is documented in the [indoor navigation post](https://uranashel.com/blog/building-wheria-indoor.html). Step odometry details live in the [IMU post](https://uranashel.com/blog/step-detection-imu.html). ## The afternoon this became an app Landmark 81, Ho Chi Minh City, level B3. I parked, took a photo of the pillar number, walked to the elevator, and thought nothing of it. On the way back, GPS showed my car floating in the Saigon River, 400 m east of reality. I walked two full laps of the floor before finding it, comparing the map dot against concrete reality and losing every time. That evening the decision was simple: stop showing a lying blue dot underground and show the path actually walked. GPS still works fine when you park outdoors, and Wheria uses it there without fuss. We just stopped pretending it works where physics says it cannot. --- 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/) --- # Nunito on every pixel > Typography is part of measurement UI. How Nunito stays consistent across SwiftUI and Compose, and what the OS still controls. 2025-08-18 · 18 min read · design · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/nunito-on-every-pixel.html --- Typography in an instrument app is not decoration; it is part of the measurement surface. When Sonarish displays LAeq trending toward a legal noise threshold, the number itself must dominate the visual field and the chrome around it must recede. When Wheria shows a confidence badge during a garage walk, the label has to be readable at arm's length in dim light, without the user's brain spending cycles parsing inconsistent font weights. uranashel ships five apps across iOS and Android with a single typeface: **Nunito**, loaded as static font instances at weights 400 (Regular), 500 (Medium), 600 (SemiBold), and 700 (Bold). No system font fallback on app-owned surfaces. The only exceptions are OS-native dialogs (TimePicker, permission sheets, share sheets), which the operating system renders in San Francisco or Roboto and which no app can override without private API hacks we will not ship. ## Why a custom font at all Both iOS and Android default to excellent system typefaces, SF Pro and Roboto respectively, and our early prototypes ran on them. Quality was never the complaint. What we wanted was recognizability and control. A user who opens Wheria, then Sonarish, then Phyzix should sense one studio's hand on all three; the system-font prototypes felt like three unrelated apps that forgot to configure branding. Nunito is rounded enough to feel approachable in a consumer context, and its x-height and letter spacing stay readable at 13–15 pt on small screens, which is where instrument labels actually live. We auditioned geometric sans-serifs that look crisp at 24 pt and collapse at 13 pt. Out. We auditioned display fonts that would fight the monochrome design system described in our [monochrome UI post](https://uranashel.com/blog/monochrome-ui.html). Also out. One typeface, five apps, two platforms, two themes each. That consistency builds a quiet trust that the numbers on screen were placed there deliberately. ## The arithmetic of arm's length How small can a label get before a tired driver misreads it? Legibility has actual arithmetic. A lowercase letter of x-height `x` viewed from distance `d` subtends a visual angle `θ = 2·atan(x/2d)`. At 15 pt, one em is 15/72 inch, about 5.3 mm, and Nunito's x-height is roughly 0.53 em, so lowercase letters stand about 2.8 mm tall. Held at 55 cm, a typical arm's length, that gives `θ ≈ 0.29°`, about 17 arcminutes. The comfortable-reading floor quoted in vision literature sits near 0.2°. So 15 pt Nunito clears the floor with roughly 40% margin; 13 pt lands at 15 arcminutes, still fine for secondary labels; 11 pt is marginal. Bench note: 3 phones (an iPhone 13, a Pixel 7, a 2019-era Redmi), one evening on floor B2 near pillar E9, screens at 40% brightness, ambient light 38–45 lux, walking the aisle and reading trial labels set at 11, 13, and 15 pt. Two of the three of us misread the 11 pt labels at least once. Nobody misread 13 or 15. Those became the only secondary label sizes in Wheria. ## The iOS font war SwiftUI makes custom typography deceptively easy and structurally incomplete. Setting `.font(.custom("Nunito-Regular", size: 15))` on individual Text views works until you miss one, and then a stray label renders in SF Pro and the whole screen reads as broken. Our fix lives in AppFont.swift and it is layered. The root view applies `.font(.nunito(.body))` so any Text without an explicit override inherits Nunito at body size. Navigation bar titles are a separate battle. On iOS 16 and later, `UINavigationBar.appearance().titleTextAttributes` is ignored by SwiftUI navigation stacks; the title you see is rendered by SwiftUI's own toolbar system, which defaults to SF Pro semibold at 17 pt. We inject a custom `.nunitoTitle()` modifier as a `ToolbarItem(placement: .principal)` on every navigation screen. Toolbar buttons (Done, Save, Cancel) need their own explicit `.font(.nunito(.subheadline))` or they quietly revert to the system button font. The worst offender is Form and List: SwiftUI Forms default to 17 pt system font for row labels while the body text around them is 15 pt Nunito. The Settings screens in Wheria, Estua, Sonarish, Phyzix, and Stashio all force `.font(.nunito(.subheadline))` on form content. Miss it on one screen and ktuyen files a parity bug within the hour. She has done this four times. We deserved all four. ## The Android font war Jetpack Compose is more honest about typography. MaterialTheme.typography maps every Material text role to a Nunito instance in AppTypography.kt, and a single CompositionLocalProvider wrapping the app tree sets the default text style. The leak points are third-party composables and system dialogs. Some library components internally hardcode TextStyle.Default, which resolves to Roboto; we wrap those screens in an additional ProvideTextStyle as defense. TimePickerDialog, DatePickerDialog, and the permission rationale sheet are system surfaces. Roboto forever, unfixable, and acceptable, because users recognize them as OS chrome rather than app instrumentation. Cross-platform parity adds its own constraint. Android Settings rows have to match iOS Settings rows at 15 pt Medium weight, and Material's default BodyLarge would set them at 16 pt, so AppTypography.kt overrides it. ktuyen checks this side by side on an iPhone and a Pixel during every release pass, the discipline documented in our [cross-platform post](https://uranashel.com/blog/cross-platform-two-codebases.html). ## Static instances and the Vietnamese alphabet Nunito also ships as a variable font, and for about a week during development we used it. The single file is tidy: one 590 KB TTF against four static instances at 168–176 KB each, roughly 690 KB total. We went back to statics for two reasons. Rendering first: Core Text on iOS and Minikin on Android interpolate variable weights slightly differently, and our width probe (next section) measured the interpolated 600 instance 2–3% wider on Android than on iOS at the same size. Static instances cut from the same master agree within 0.3%. Discipline second: with a weight axis available, a weight of 570 will eventually ship because someone nudged a slider. Four fixed weights are a constraint we can enforce in code review by grepping for font constructors. The other reason Nunito survived our shortlist is Vietnamese. Half our interface strings carry diacritics, and stacked marks like the circumflex-plus-tone in ế or the horn-plus-tilde in ữ reach well above the Latin ascender line. A font with pretty Latin glyphs and afterthought Vietnamese marks clips at tight line heights. Before every release we run all localized strings through a render harness at every text style; the current values-vi set holds 1,842 strings, 61 of which open with a stacked mark on the first line, and every one of those 61 has clipped at least once in some layout during development. The fixes are unglamorous. includeFontPadding stays false on Android with an explicit 1.35 line-height multiplier, and fixed-height frames around single-line Text are banned on both platforms. ## Catching leaks with a width probe Manual screenshot review misses font leaks, because SF Pro, Roboto, and Nunito are all competent humanist-leaning sans-serifs, and at 13 pt on a 460 ppi screen the eye forgives a lot. Metrics forgive nothing. The three faces set the same string at measurably different widths: at 15 pt, our probe string renders 4.1% narrower in SF Pro and 2.7% narrower in Roboto than in Nunito. So every app carries a debug-only audit that walks the view tree and measures each text element twice, once with the paint the view actually has and once with the Nunito instance it should have. ``` PROBE = "Illegal1 O0 mịn ữ 12:45" fun auditScreen(root): for t in textNodes(root): wActual = measure(PROBE, t.paint) wExpect = measure(PROBE, nunito(t.weight, t.sizePt)) if abs(wActual - wExpect) / wExpect > 0.005: report(t.id, t.paint.family, t.sizePt) ``` The probe string mixes ambiguous glyphs (Il1, O0), Vietnamese stacked marks, and digits, so it fingerprints the family and the feature settings at once. The 0.5% threshold sits above cross-platform rounding noise and far below the 2.7% Roboto gap. The first run across all five apps reported 11 leaks: 9 Form rows on iOS and 2 toolbar buttons. The audit costs about 40 ms per screen on a Pixel 7, which is why it runs only in debug builds and in ktuyen's release checklist. No leak has shipped since. ## Radius, weight, and hierarchy without color Font weight carries hierarchy in a monochrome app where color cannot. Primary instrument readings use SemiBold or Bold at 17–22 pt; secondary labels and timestamps sit at Regular, 13–15 pt. Disabled or stale data keeps Regular weight and just drops opacity; Nunito Light never ships in the bundle, so weight stays consistent even when luminance falls. Corner radius is 12 px on cards, buttons, and sheets across all five apps. We prototyped 8 px and 16 px and reverted both within a week: 8 looked sharp against Nunito's round terminals, 16 looked like a toy. Nunito at fixed weights, strict monochrome, and uniform radius together are what make a Wheria settings row and a Sonarish settings row feel like the same product, even though one app tracks magnetometer heading and the other tracks A-weighted decibels, a curve we derive in [our A-weighting post](https://uranashel.com/blog/a-weighting-decibels.html). Typography is the glue. We tune it with the same rigor as a Kalman filter constant, and we regression-test it the same way. --- 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/) --- # Life on the audio thread > Real-time audio has hard deadlines. The rules Estua follows so sleep sounds never click at 3 AM. 2025-08-02 · 21 min read · estua, dsp · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/dsp-audio-thread.html --- Real-time audio on a phone is a scheduling problem with human consequences. The operating system calls your render function every N samples and expects a full buffer of PCM floats back before the hardware drains what it already has. At 48 kHz with a 512-sample block, the deadline lands every 10.67 ms. All night. A few million times per sleep session. Miss it once and the listener hears a click or a pop. Miss it repeatedly and Estua's sleep sound turns unusable at 3 AM, which becomes a one-star review, which becomes a real problem for a studio with five apps and no venture capital cushion. [Sonarish](https://uranashel.com/apps/sonarish.html) runs the same gauntlet in the opposite direction. Its microphone capture path receives samples from the audio HAL on a high-priority thread, and whatever happens inside that callback decides whether the noise meter reads truth or garbage. Two apps, one discipline. This post writes the discipline down. ## The deadline, derived Start with arithmetic. One sample period at 48 kHz lasts `1/48000 s ≈ 20.8 µs`. A 512-sample block therefore spans `512/48000 ≈ 10.67 ms`, and that interval is the ceiling for everything the app does per callback: every noise generator, every filter, every mix stage, plus whatever the OS itself burns on resampling and the Bluetooth stack before your floats reach a speaker. We aim to use well under half of it. Bench note: on an iPhone 12 and a Pixel 6a we logged callback wake-up timestamps through one 8-hour Estua session per device; median wake-up jitter stayed under 0.3 ms, but Bluetooth reconnects produced isolated wake-ups arriving 2 ms late. A render chain that needs 9 ms of the block has no armor against a late wake-up. One that needs 1 ms shrugs. On iOS, Estua uses AVAudioEngine with a manual source node callback. On Android it uses AudioTrack with ENCODING_PCM_FLOAT in low-latency mode when the OEM supports it. Both platforms invoke the render function on a real-time priority thread that bypasses the normal UI run loop. The scheduler promises to wake that thread on time. What happens if the thread then waits on something slow is nobody's promise, and every rule below exists because of that gap. ## Why malloc is the enemy The audio thread must never wait on contended locks, never allocate from the heap, never perform I/O, and never call code that might do any of those things transitively. The problem is variance. A malloc on a fragmented heap might take 200 microseconds on a good day and 5 milliseconds on a bad day, and 5 ms is half the block gone before a single sample gets rendered. Bench note from the same Pixel 6a: after fragmenting a test heap with 1 million mixed-size allocations, we timed 100,000 further small mallocs. Median 0.1 µs. Worst single call 4.1 ms. That tail is the click. Locks fail differently. A real-time thread that blocks on a mutex held by a background thread inherits that background thread's scheduling luck, and on a loaded phone the holder may not run again for several milliseconds. Priority inheritance rescues you on some kernel versions and silently does nothing on others. The transitive clause is the sneaky part: an innocent call into a string formatter, a Swift protocol witness, or a convenience API from the OS can allocate on your behalf three stack frames down. If we cannot read the code path to the bottom, it stays off the audio thread. ## Rules we enforce in code review These are review blockers, not guidelines. ktuyen files violations as bugs and atuan will not tag a release while one stays open. - No heap allocation in the callback. No Swift Array append, no Kotlin list growth, no std::vector push_back, no NSString formatting. Every buffer is preallocated at session start on the UI thread and reaches the callback as a pointer in its context struct. - No locks that can block. Data crosses between the audio thread and the UI thread through lock-free single-producer single-consumer ring buffers, in both directions. - No logging, file writes, or network calls, even guarded by debug flags. Debug builds ship to ktuyen's overnight soak tests, and one forgotten print statement that allocated a string has caused more clicks than any DSP bug we have written. - On iOS, no Objective-C message sends on the hot path. The callback stays in C or in Swift struct code the compiler can inline. - Parameters are computed off-thread. Biquad filter coefficients, LFO rates, and scene parameters are derived on the UI thread when the user changes a setting; the callback reads only const structs that were written once and are never mutated concurrently. The last rule deserves its own section, because it is the one engineers get wrong in the most interesting ways. ## Publishing parameters without locks When you drag Estua's ocean scene toward heavier surf, the app has to hand new filter cutoffs to a thread it is forbidden to lock against. The pattern we use is a two-slot swap. Parameters live in two preallocated slots, an atomic integer names the slot the audio thread may read, and the UI thread always writes the spare slot before publishing it. ``` // two preallocated slots, one atomic index struct Params { cutoffHz, gain, lfoRate[4] } // plain floats only slots = [Params, Params] // filled at session start activeIdx = AtomicInt(0) // slot the audio thread reads // UI thread, when a setting changes: spare = 1 - activeIdx.load(acquire) slots[spare] = computeParams() // biquad coeffs, LFO rates, scene activeIdx.store(spare, release) // publish // audio callback, once per block: p = slots[activeIdx.load(acquire)] // copy by value, tens of bytes renderBlock(out, 512, p) ``` The release/acquire pair guarantees the callback never observes a half-written slot, and the copy costs tens of bytes. Stretch the same idea around a power-of-2 array with separate read and write indices and you get the SPSC ring buffers that carry Sonarish's captured PCM out and Estua's metering data up to the UI. No mutex appears anywhere in either app's audio path. One honest caveat: two settings changes in very quick succession could in principle reuse a slot mid-read. The per-block copy is fast enough that we have never observed it, and a triple buffer would close the gap if we ever did. ## How Estua fits inside the budget Estua's synthesis chain, described end to end in [our non-repeating audio post](https://uranashel.com/blog/estua-non-repeating-audio.html), generates pink noise with Paul Kellet's economical recursive filter: three one-pole state updates of the form `b0 = 0.99765·b0 + w·0.0990460` plus a weighted sum, a handful of multiplies and adds per sample with no FFT per block. Slow amplitude movement comes from one-pole low-pass filters on the absolute value of the signal envelope, `y[n] = y[n−1] + α·(x[n] − y[n−1])`, running at the full sample rate with precomputed coefficients. Multiple uncorrelated LFOs modulate filter cutoff and gain at 0.02–0.15 Hz, so the output never repeats on timescales a human can track. The product reasoning for synthesis over loops is in our [sleep and waves post](https://uranashel.com/blog/estua-sleep-and-waves.html). All of it fits in roughly 3–8% of one CPU core on modern phones, comfortably inside the 10.67 ms block, leaving headroom for Bluetooth audio output and for the moments when the OS briefly starves a backgrounded app. Headroom is the feature. A chain that saturated the block would pass a bench test and fail on a random Tuesday night. ## Buffer sizes OEMs actually hand you Not every Android device wants 512 samples. Some OEM audio paths request 192 or 256 for their DSP route, and fighting the platform's preferred size costs you the low-latency path entirely. Estua adapts the requested buffer size at session start and keeps every piece of internal DSP state, filter memories and LFO phases included, continuous across the change, so a resize never produces a discontinuity click. Only the per-block bookkeeping changes. The synthesis state carries straight through. On the capture side, Sonarish's callback does one job: copy incoming PCM into the ring buffer and return. The FFT engine consumes that ring on a background queue and never runs on the audio thread, which is how the app computes a 4096-point transform while the meter updates at 30 Hz without glitches. Transform details live in [the FFT post](https://uranashel.com/blog/fft-made-readable.html). The architectural sentence worth memorizing is short: the audio thread copies samples and returns, and everything else happens elsewhere. ## Debugging the clicks that still happen Clicks still happen. When one appears we profile the audio thread directly: Instruments Time Profiler with the audio thread marked on iOS, Systrace with Trace.beginSection markers on Android. In our experience 99% of production clicks trace to debug-only code that leaked into the hot path — a String format for logging, a guard assertion that allocates, an accidental Swift optional unwrap that lands on a slow path. The DSP math is almost never the culprit. The scaffolding around it usually is. So every piece of debug instrumentation sits behind compile-time flags that we verify are off in release builds, and the final gate is ktuyen's overnight Estua soak test: 8 hours of continuous playback in airplane mode on real hardware before atuan tags any release. A click that survives review, the static checks, and a full night of playback has earned a bug report with its name on it. --- 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/) --- # Stashio — saving the internet without losing your mind > Save links and PDFs from the share sheet, find them later — Stashio’s capture and search flow, including when cloud helps. 2025-07-15 · 19 min read · stashio · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/stashio-second-brain.html --- Stashio is the odd app in the uranashel portfolio. Wheria, Estua, Sonarish and Phyzix treat the phone as a self-contained instrument: sensors in, processed numbers out, nothing leaves the device unless you export it yourself. Stashio breaks that pattern deliberately. The problem it solves is accumulation across time and devices. You find a PDF on your phone, a link on your laptop, a voice memo while walking, a screenshot of a parking pillar number at midnight (mine read E9, floor B2), and three weeks later you need "that article about barometer math" without remembering the exact title, the site or which app swallowed it. The article, for the record, was [this one](https://uranashel.com/blog/barometer-parking-math.html). The core job splits into three verbs: capture from anywhere, organize into named Spaces, retrieve by keyword or by meaning. Cloud sync sits on top as an optional enhancement. It never becomes the gate. ## Capture gets a 3-second budget The primary entry point is the OS share sheet, the system UI that appears when you tap Share in Safari, Chrome, a PDF viewer or a photo gallery. Every extra decision at that moment costs saves. So the design goal at capture time is zero friction: no mandatory tags, no folder picking, no "where should this go?" modal that makes you abandon the whole idea. You share, it stashes, you move on. On iOS that politeness has to survive a hostile runtime. Stashio registers a Share Extension, which the system runs as a separate lightweight process with a memory ceiling we have measured being enforced near 120 MB. The extension therefore does the minimum: it copies the shared item into an app group container, writes one metadata row and exits. Indexing waits until the main app next opens and gets signalled that new items are queued. On Android, Stashio registers as a Share Target in the manifest. Small items import inline. A large PDF triggers a foreground service so the system does not kill the import mid-write, and the extracted metadata lands in a Room database entry once the copy completes. From our July bench notes (3 iPhones, 3 Android phones, release builds, 200 shares each): median share-to-saved time was 1.9 s for a URL and 4.8 s for a 40 MB PDF. ## Extraction stays on the device Five input types are supported. URLs arrive with automatic title and preview extraction. PDF documents get on-device text extraction where the OS provides it, which covers most digitally-produced files. Images, including camera scans of receipts, run through the platform OCR path (Vision on iOS, the on-device ML Kit recognizer on Android); a typical receipt photo gives up its text in about 300 ms on an iPhone 12. Selected text fragments store as-is. Voice memos can be recorded inside the app or shared over from a recorder. Everything extracted feeds one local search index. None of it needs a network round trip, which matters when the thing being saved is a pillar screenshot three floors below the cell grid. ## Spaces are sets, files are singletons Organization happens after capture. Items live in Spaces: user-created collections like "Garage QA logs," "DSP papers," or "Recipe links". One item can belong to several Spaces at once without duplicating the underlying file. Internally a Space membership is a row in a join table; the blob exists exactly once on disk, reference-counted, so removing an item from its last Space is the only delete that ever touches storage. This sounds like database trivia until you watch someone file a manufacturer datasheet under both "DSP papers" and a project Space, then expect an annotation made in one place to appear in the other. It does, because there is only one item. Keyword search runs against titles, user-added tags and extracted text bodies, with prefix matching so "barom" surfaces the barometer material before you finish typing. ## Search by meaning, still offline Keywords fail in one specific way: you remember the concept but forget the vocabulary. For that case Stashio optionally computes an embedding vector for every item, on the device, and indexes the vectors locally. A query like "that thing about hydrostatic pressure in parking garages" can match a saved blog draft even if the word "hydrostatic" never appeared in your tags. The model is a small sentence encoder producing 384-dimensional vectors; similarity is plain cosine, `cos θ = (a·b)/(|a||b|)`, and at personal-library scale nothing cleverer is required. My 2,400-item library stores its full vector index in about 3.7 MB (2,400 × 384 floats × 4 bytes), and a brute-force scan over all of it finishes in under 2 ms on an iPhone 12. We benchmarked an approximate-nearest-neighbor structure anyway, then discarded it: at this scale ANN saved microseconds and charged an index rebuild on every edit. Ranking blends both signals: ``` // per query, all local kw_scores = bm25(query, titles + tags + bodies) q_vec = embed(query) // 384-dim, on-device sem_scores = {} for item in library: // 2,400 iterations, under 2 ms sem_scores[item] = cosine(q_vec, item.vec) final(item) = 0.6 * norm(kw_scores[item]) + 0.4 * norm(sem_scores[item]) return top_k(final, 20) ``` The 0.6/0.4 split came from ktuyen's relevance spreadsheet rather than from theory. She scored 60 test queries against both extremes and the blend, and pure semantic search kept ranking a receipt for waterproof boots above an actual fluid-dynamics paper. ## Why this app may touch the cloud Bookmarking does not need 10 ms latency. Nobody's sleep depends on a share extension finishing inside an audio buffer deadline; that world belongs to [the audio thread](https://uranashel.com/blog/dsp-audio-thread.html), where 10.7 ms is a hard wall. What a save-later tool does need is multi-device sync. Save on the phone, find on the tablet; that is the feature people actually pay for. So atuan built a small backend, with the same privacy instincts as the rest of the studio. The local cache is always readable offline. The account is optional. For embedding sync the server stores encrypted blobs, not plaintext content. Capture and search work without ever signing in, because the index is built on-device first and remains searchable in airplane mode. Our sensor apps run everything locally because physics demands it; Stashio keeps a cloud lane open because user workflow demands it. Same decision, opposite ends — the [on-device-first post](https://uranashel.com/blog/on-device-first.html) walks the whole spectrum. ## The 2,400-link stress test Before Stashio existed, my Apple Notes contained approximately 2,400 unsorted links accumulated over four years of garage testing, DSP paper reading and random internet detours. That pile became the seed corpus. After three months of daily use, roughly 94% of my retrieval attempts succeed in under 10 s, by keyword or by meaning — a number that comes from an actual log, since the app records locally whether each search session ended in an open or an abandon. The remaining 6% are honest failures, mostly items whose extracted text came back empty: screenshots of screenshots, largely. atuan uses Stashio for the API documentation snippets he needs during backend work. ktuyen attaches test-case screenshots and regression logs to Spaces before each release cycle, which means the QA history of every uranashel release now lives inside one of the apps being QA'd. Nobody planned that. ## Same studio fingerprints Stashio is not a physics instrument, and no Kalman filter hides in its settings screen. It still looks like us. The same monochrome UI language, the same [Nunito on every label](https://uranashel.com/blog/nunito-on-every-pixel.html), the same 12 px corner radius, the same bilingual EN/VI strings that ktuyen reviews line by line. The design system is documented in [the monochrome post](https://uranashel.com/blog/monochrome-ui.html), and it transferred to a bookmarking app without modification — we take that as evidence it was a system rather than a coincidence. The app page is [here](https://uranashel.com/apps/stashio.html). Stashio solves a different class of problem from Wheria, and that difference is exactly why it lives in the portfolio alongside Wheria instead of replacing it: the sensor apps prove the phone can be an instrument, and Stashio proves the same three people can respect a completely different set of constraints when the problem asks for them. --- 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/) --- # CS + Physics → app studio > Computer science plus physics shaped uranashel — not as credentials, but as a way to ask what sensors mean before writing code. 2025-06-28 · 22 min read · studio, physics · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/physics-degree-apps.html --- I studied computer science and physics on separate tracks. CS taught me to ship software before the semester ended; physics taught me what the numbers in a sensor CSV actually mean. uranashel sits at the intersection of those two educations, and nearly every feature that works across our five apps followed the same sequence: a real-world problem appeared, physics described why the naive approach fails, and code was written to respect that description rather than fight it. Nobody at this studio ever sat down and said "AI is hot, let's wrap an API." Every product began as a physical annoyance that happened to one of us personally. The five apps look like separate ideas (parking navigation, sleep audio, a pocket lab, an acoustics toolkit, a save-for-later tool), but four of them are one idea wearing different sensors. Find the physical quantity the phone can actually measure, work out its noise floor, and refuse to display anything the noise cannot support. The rest of this post walks through how that played out app by app, with the numbers behind each decision. ## Wheria: when the map lies The origin story lives in [its own post](https://uranashel.com/blog/building-wheria-indoor.html). The short version is a photo of pillar E9 on floor B2, taken because nothing on my phone could remember where the car was. GPS fails underground for reasons no map vendor can patch: L-band microwave signals attenuate badly in reinforced concrete, and the reflections that do arrive have travelled longer paths than the straight line, so multipath biases pseudorange estimates by tens of meters. The naive software response is to show the blue dot anyway, maybe smooth it a little. That is lying to the user with extra steps. The physics-informed response is pedestrian dead reckoning. Steps come from accelerometer peaks. For heading, integrate the gyro and correct it with the magnetometer whenever the field looks clean. The barometer supplies floor hints, and a Kalman filter fuses everything into a position that carries its own uncertainty instead of false precision. The full pipeline is in the [Kalman post](https://uranashel.com/blog/kalman-filter-parking.html), and the step detector has [its own writeup](https://uranashel.com/blog/step-detection-imu.html). I would not have written any of it without knowing why double integration of accelerometer data drifts meters per second, or why compass readings become worthless near steel columns. CS alone would have produced a prettier map tile. Physics alone would never have made it through App Store review. ## The arithmetic of drift The drift claim deserves numbers, because that one fact shapes the entire Wheria architecture. A phone accelerometer carries a residual bias `b` on the order of 0.03 m/s² even after calibration. Integrate twice and position error grows as `x_err(t) = ½·b·t²`. At t = 60 s that is 0.5 · 0.03 · 3600 = 54 m. Walk for one minute and the naive dot sits two garage aisles away from you. Step counting sidesteps the whole mechanism: a step is a detected event, stride length is bounded by human biomechanics, and error grows linearly with distance walked at roughly 2%. The quadratic term never gets a chance to exist. The barometer earns its seat by the same logic. Near sea level, pressure falls about 12 Pa per metre of altitude, from `Δh = ΔP/(ρg)` with ρ ≈ 1.2 kg/m³ and g = 9.81 m/s². The garages we measure have floors 3.2 m apart, so one floor is a step of roughly 38 Pa. Phone barometer noise sits at 0.3–1 Pa RMS (bench note: 4 phones on a shelf for 48 h, logged at 1 Hz, 10-sample median filter). A floor change therefore stands about 40 σ clear of the noise even on our worst unit. Weather is the real enemy; a front can move absolute pressure by more than a floor's worth in an hour, so the detector only looks at short-window differences and re-anchors after every confirmed change. ``` // floor hint from barometer, evaluated at 1 Hz p = median(last 10 samples) // kills the 0.3–1 Pa RMS noise dp = p - p_ref // Pa, relative to last confirmed floor dh = dp / 12.0 // metres, ~12 Pa per metre if abs(dh) > 2.4 { // 75% of a 3.2 m floor floors = round(dh / 3.2) emitFloorHint(floors) p_ref = p // re-anchor; weather drift dies here } ``` The full derivation, hysteresis and all, is in the [barometer math post](https://uranashel.com/blog/barometer-parking-math.html). ## Sonarish: when the machine hums differently Sonarish started because an air conditioner began humming at 127 Hz after three years of service — a harmonic of mains frequency and motor pole count. Rotating machinery has a vocabulary: shaft frequency at `f_shaft = RPM/60`, bearing defect sidebands spaced around it, broadband noise from loose mounts. The tempting shortcut is a cloud-trained AI classifier. What shipped instead is an on-device FFT with A-weighted LAeq integration, a baseline snapshot taken while the machine is healthy, and a diff view showing which frequency bins rose more than 6 dB since last month. We deliberately do not auto-diagnose bearing failures. A failing bearing's spectrum depends on ball count, race diameters and shaft speed; without the nameplate the physics is ambiguous, so the app shows the spectrum diff and you call a technician with data instead of vibes. Every choice upstream of that screen is physics too. Nyquist's theorem, `f_s ≥ 2·f_max`, sat behind picking the 48 kHz sample rate. A-weighting (the reason a 100 Hz hum reads about 19 dB lower than it measures) sat behind making readings comparable to a municipal noise ordinance and to the NIOSH 8-hour limit of 85 dB(A). Get either wrong and the app becomes confidently misleading. Bench note from our own log: one AC unit, one 60 s clip every Monday for 14 months from the same tripod position; the 127 Hz bin ended 8 dB above its healthy baseline. The [baseline post](https://uranashel.com/blog/machine-fault-baseline.html) and the [A-weighting post](https://uranashel.com/blog/a-weighting-decibels.html) go deeper on both halves. ## Estua and Phyzix: perception and pedagogy Estua exists because the human auditory system detects periodicity in looped recordings within minutes — a psychoacoustics problem, not a compression problem. The fix is real-time synthesis: 1/f noise shaped by uncorrelated LFO modulation, generated fresh forever, so there is no loop point for the ear to find. That synthesis runs on the audio thread under hard real-time rules (no malloc, no locks, every 48 kHz buffer delivered on deadline), documented in the [audio thread post](https://uranashel.com/blog/dsp-audio-thread.html). Miss one deadline and the person you are easing into sleep hears a click. The other end of the pipeline is delicate too: [Sonarish's breathing sonar](https://uranashel.com/blog/sonar-breathing-doppler.html) resolves chest displacement of 118 µm, which is the scale of signal in play when the goal is keeping someone asleep. Phyzix approaches from the opposite direction. Students treat phones as consumption devices, while the same hardware carries an accelerometer, gyroscope, magnetometer, barometer and microphone that a 1990s university lab would have envied. Phyzix draws live sensor graphs with SI units, exports CSV, and places each simulation's equation beside its animation so the link between formula and motion stays visible on one screen. Neither app required a physics degree to conceive. Both required physics to implement correctly — psychoacoustic masking for one, sampling and unit discipline for the other. The [pocket lab post](https://uranashel.com/blog/phyzix-pocket-lab.html) has the full tour. ## What each discipline alone would miss Computer science without physics produces apps that compile, pass review and confidently display wrong answers. You integrate accelerometer data because the API returns acceleration values and integration is what programmers do with time-series data. Then you wonder why the parking dot teleports. Physics without computer science produces correct equations in notebooks that never survive Android foreground service rules, iOS background location limits, Compose recomposition jank, or ktuyen's test matrix — the one that caught compass drift on iPhone 15 in a steel-frame garage while the Pixel 8 sailed through. uranashel is three people precisely because the skill set has to span both. I write the sensor pipelines and the DSP. atuan makes the backend and release infrastructure real. ktuyen makes the quality gate real, in Vietnamese and English, on real devices in real garages. Stashio is the honest outlier in the catalogue: no physics anywhere, just the 2,400 unsorted links in my Apple Notes and the discovery that after three months of daily use, 94% of retrieval attempts finish in under 10 s. The [Stashio post](https://uranashel.com/blog/stashio-second-brain.html) explains why a sensor studio ships a save-for-later app. The answer is the same problem-first sequence with the physics swapped out. ## You do not need two degrees You need curiosity about the sensor underneath the API documentation, and honesty when the math says your UX is lying. A degree is one path to that honesty; garage logs are another. Garage logs at Landmark 81 taught me more about multipath than any textbook chapter did. Listening to an AC unit change pitch over three years taught me more about bearing degradation than any lecture. The [Lab page](https://uranashel.com/lab.html) on this site exists so you can reproduce some of those observations with the phone you already own. For the studio context, start with the [welcome post](https://uranashel.com/blog/welcome-uranashel.html) and follow the links wherever your curiosity points: magnetometer ellipsoids, pink noise spectra, Kalman covariance traces. --- 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/) ---