l3gacyb3ta/alexa-dash

★ 0Forks 0TypeScriptGitHub ↗Compare

Project website ↗

README

This is a Next.js project bootstrapped with create-next-app.

Getting Started

First, run the development server:

npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev

Open http://localhost:3000 with your browser to see the result.

You can start editing the page by modifying app/page.tsx. The page auto-updates as you edit the file.

This project uses next/font to automatically optimize and load Geist, a new font family for Vercel.

Learn More

To learn more about Next.js, take a look at the following resources:

You can check out the Next.js GitHub repository - your feedback and contributions are welcome!

Deploy on Vercel

The easiest way to deploy your Next.js app is to use the Vercel Platform from the creators of Next.js.

Check out our Next.js deployment documentation for more details.

Office loudness logging

The panel listens on its own microphone, averages the level over 10-second windows, and writes one row per window to Postgres for Metabase to query.

Audio never leaves the device. The browser computes a single number per window and posts that; no samples are recorded, buffered to disk, or transmitted.

Setup

  1. Apply the schema to your Neon database:

    psql "$DATABASE_URL" -f db/loudness.sql
  2. Set the environment variables:

    Variable Required Purpose
    DATABASE_URL yes Neon connection string
    NEXT_PUBLIC_LOUDNESS_DEVICE no Row label, defaults to office
    LOUDNESS_INGEST_TOKEN no If set, POST /api/loudness requires it
    NEXT_PUBLIC_LOUDNESS_INGEST_TOKEN no Same value, for the panel to send

    The ingest token is shipped to the browser, so it is not really a secret — it keeps drive-by bots out of the table, nothing more.

  3. Point Metabase at the same Neon database and add office_loudness to your models. Metabase has no ingest API; it only queries, which is why the data lands in Postgres first.

Choosing a browser: stock WallPanel will not work

Stock WallPanel cannot give a web page the microphone. Granting the app the Android mic permission does not help, because nothing in it ever checks that permission. This has been open as thecowan/wallpanel-android#1 since July 2018, filed by the maintainer himself and never resolved.

The cause is in BrowserActivityNative.kt. Its onPermissionRequest forwards the WebView resource string straight into a system permission check:

PermissionRequest.RESOURCE_AUDIO_CAPTURE -> askForWebkitPermission(it, ...)
// ...
ContextCompat.checkSelfPermission(applicationContext, permission)

PermissionRequest.RESOURCE_AUDIO_CAPTURE is "android.webkit.resource.AUDIO_CAPTURE" — a WebView identifier, not an Android permission. No package declares it, so the check returns PERMISSION_DENIED every time and grant() is never reached. Two further defects sit behind it: the shouldShowRequestPermissionRationale branch is empty, and the class has no onRequestPermissionsResult, so even a granted dialog never resolves the pending request.

patches/wallpanel-mic-fix.patch fixes all three against upstream master. To rebuild it: apply the patch, drop the google-services/crashlytics plugins and Firebase dependencies (all already dead code — FirebaseApp.initializeApp is commented out), replace the dead jcenter() with mavenCentral(), and add a local.properties with sdk.dir plus stub code/hassUrl/broker/ brokerPass/brokerUsername keys, which the dev flavor reads at configure time regardless of which flavor you assemble. Build with JDK 11 — Gradle 6.5 will not run on anything newer.

Sign it with apksigner from build-tools 31 or later, passing --alignment-preserved, and zipalign -p 4 before signing rather than after (aligning afterwards invalidates the v2/v3 signatures):

zipalign -p -f 4 app-prod-release-unsigned.apk aligned.apk
apksigner sign --alignment-preserved --ks your.jks --out signed.apk aligned.apk
zipalign -c -v 4 signed.apk   # must print "Verification succesful"

("succesful" is [sic] — the misspelling is in the zipalign binary itself, so that is the string to match on. Do not correct it.)

The build-tools 30 apksigner rewrites the archive during v1 JAR signing and silently drops the alignment, with no flag to prevent it. That leaves resources.arsc off a 4-byte boundary, and since Android 11 the platform requires it to be both uncompressed and 4-byte aligned — an APK that isn't fails to install with a bare "There was a problem parsing the package", which reads like a corrupt download or an architecture mismatch and is neither.

The patched APK is signed with a self-signed key, so it cannot upgrade a Play-store install; uninstall the original first.

Two alternatives if you would rather not run a patched build:

  • Fully Kiosk Browser, which has an explicit Enable Microphone Access setting that grants the WebView permission correctly. It is a PLUS (paid) feature. This repo already targets Fully elsewhere — AwayOverlay and DimOverlay bind window.fully key events for the volume-key shortcuts.
  • Capture somewhere else entirely. POST /api/loudness accepts JSON from any client, so a Pi with a USB mic in the room needs no changes to this code and measures better than a wall panel's mic array anyway.

Whatever runs it, the dashboard must be served over HTTPS — navigator.mediaDevices only exists in a secure context, and localhost is the only exception.

The header readout is the diagnostic, and names the actual failure: no mic api for an insecure context, mic denied · NotAllowedError for a refused permission, mic error · NotReadableError if something else holds the mic. Full detail, including the secure-context flag and origin, goes to the console.

Reading the numbers

The stored value is dBFS, not dB SPL — decibels relative to a full-scale sample, which is uncalibrated. It is comparable against itself over time on the same mic in the same spot, but it is not an absolute sound level. To get real SPL, put a calibrated meter next to the panel, note the offset between the two readings at a steady level, and add that constant in Metabase.

leq_dbfs is an energy average (Leq), not a mean of decibel values — the power is averaged across the window and converted to dB once at the end. Averaging dB directly would badly under-weight loud moments: half a window of silence and half at −20 dB is −23 dB, but a naive mean reports −50 dB.

Automatic gain control, noise suppression, and echo cancellation are all explicitly disabled on the capture stream. They are tuned for voice calls and would renormalise exactly the level being measured.

Looking at the data

/loudness-dashboard is a server-rendered route. It queries Postgres directly through the connection the app already has, so the charts arrive in the HTML and the page ships no client JavaScript at all — verified rendering with JavaScript disabled. The range picker is four links that set ?range=, and the exact reading for any bucket or hour comes from a native <title> tooltip.

GET /api/loudness/summary?window=6h|24h|7d|30d&tz=<IANA zone> returns the same aggregates as JSON, for curling the data or pointing something else at it.

Set LOUDNESS_TZ to the office's zone (defaults to UTC). Bucketing by clock hour only means something in a fixed zone, and the office has one zone whether or not the person reading the page is in it. Postgres rejects an unknown zone rather than silently shifting the data.

Three things worth knowing before changing that page:

  • The Leq maths is in SQL, in app/lib/db.ts. The query text is exported (SERIES_SQL, PROFILE_SQL, COVERAGE_SQL) so it can be run against a plain Postgres in a test — the @neondatabase/serverless driver speaks Neon's HTTP protocol and cannot connect to a local database. Every aggregate converts to power, averages, and converts back once; avg(leq_dbfs) is always wrong.
  • Missing readings are hatched, not pale. The ramp's quietest step still clears 2:1 against the surface so "quiet" cannot be mistaken for "never measured" — an hour the panel was off is a diagonal texture. The ramp direction inverts between light and dark so distance from the surface always means louder; the legend states the direction.
  • The route takes the viewport as a fixed, scrolling pane. The root layout is built for the wall panel — overflow-hidden body, full height — and this keeps the report scrollable without overriding that globally. For the same reason AwayOverlay and DimOverlay only bind their keys on /: they are kiosk controls, and on a laptop g and h would dim or blank the report.

Storage

One row per 10 seconds is ~3.15M rows and roughly 285 MB per year, which fits a 0.5 GB Neon project for well over a year but not two. db/loudness.sql ends with a rollup that folds anything older than 14 days into per-minute rows, dropping the long-run cost to ~47 MB/year. Schedule it when you want it; the raw table works fine untouched until then.

Contributors

claudel3gacyb3ta

Issues