Attendance you mark by being in the room. The teacher's laptop plays the session code as a sound too high to hear; the students' phones listen for it through the microphone and check themselves in.
The point is that ultrasound does not travel through walls or over a phone call. Hearing the code is the proof of attendance, so nobody can mark themselves present from their dorm room.
A session gets a random 6-digit code. The teacher's browser plays that code as a sequence of tones between 18 and 20 kHz — one frequency per digit, which is frequency-shift keying. A louder 19.5 kHz "sync" tone marks the start of each repeat. The student's browser runs an FFT on the microphone input, watches those exact frequencies, reassembles the digits, and posts the code to the server. The server checks the code is still live and records the student.
Seventeen source files. Every one of them does a single thing.
| File | What it does |
|---|---|
server.js |
Starts the HTTP listener locally. Four lines. |
app.js |
Builds the Express app: middleware, three routers, serves the built React app. |
db.js |
The Postgres pool and the Redis client. |
auth.js |
Register, login, refresh. Also exports requireAuth and requireRole. |
sessions.js |
Start a session, list live ones, list past ones. |
attendance.js |
Mark attendance from a code, and the three ways of reading it back. |
schema.sql |
Three tables. Run once. |
api/index.js re-exports the same app so Vercel can run it as a serverless function.
| File | What it does |
|---|---|
main.jsx |
Mounts React. |
App.jsx |
Three routes: landing, teacher, student. |
api.js |
Talking to the server: tokens, refresh-and-retry, the useSession hook. |
ultrasonic.js |
The interesting file. The protocol, the broadcaster, and the listener. |
ui.jsx |
The sign-in screen, the header, toasts, date formatting. |
styles.css |
All of the styling. |
pages/Landing.jsx |
Pick teacher or student. |
pages/Teacher.jsx |
Sign in, start a session, broadcast it. |
pages/TeacherRecords.jsx |
Attendance for a class, grouped by session. |
pages/Student.jsx |
Sign in, listen, mark, see your history. |
-
Teacher starts a session.
POST /sessions/startgenerates a 6-digit code and writes two Redis keys with a TTL equal to the session length: one keyed by session id holding the details, one keyed by the code pointing back at the id. A permanent row also goes into Postgres for the history view. -
The browser starts playing.
useUltrasonicBroadcastqueues tones against the Web Audio clock several seconds in advance, so they stay sample-accurate even if the page is busy rendering. -
A student taps Listen.
useUltrasonicListeneropens the microphone with echo cancellation, noise suppression and auto-gain off — all three would strip out exactly the signal we want — and runs an 8192-point FFT 40 times a second. -
Decoding. Each poll finds the strongest frequency in the band and maps it back to a digit. A tone only counts once it has held steady for three polls, which throws away transient noise. Once six digits follow a sync marker, the hook reports the code.
-
Marking.
POST /attendance/mark-by-codelooks the code up in Redis. If the key has expired, the session is over and the request fails. Otherwise the student is inserted intoattendance_records. -
The teacher sees it. The broadcast screen polls
GET /attendance/session/:idevery five seconds and the name appears.
Why both Postgres and Redis? They answer different questions. Postgres
answers "who attended CS101 last Tuesday" and must never lose a row. Redis
answers "is code 048213 valid right now", and its TTL means an expired session
cleans itself up with no cron job and no expires_at column to check on every
read.
Why can't a student mark twice? Three layers. attendance_records has a
UNIQUE (session_id, student_id) constraint, so the database itself refuses.
Before that, a Redis SET NX claim means two simultaneous requests can't both
reach the insert. And before that, a plain SELECT returns "already marked"
without touching either.
Why does the code go out over sound instead of, say, a QR code? A QR code on the projector can be photographed and sent to a friend. So can a numeric code. Ultrasound is bounded by the walls of the room, which is the property we actually want. It is not unbreakable — a student could hold a phone up to a video call — but it raises the effort far above "text me the code".
Why two tokens? The access token is short-lived (2 hours) so a leaked one
expires quickly. The refresh token lasts a week and is signed with a different
key, so an access token can never be replayed as a refresh token. api.js
spends the refresh token automatically when a request comes back 401, and the
user never notices.
Why is there no state management library? There are three screens and each
one owns its own state. The only thing shared across a page is the session, and
that lives in useSession, which reads localStorage. Redux would be more code
and more indirection to do less.
Why not one file per component? Because the useful boundary here is
"everything the teacher does", not "every button". Teacher.jsx holds the sign-in
gate, the dashboard and the broadcast screen because that is one story you read
top to bottom.
You need Node 20+, a Postgres database and a Redis instance.
npm install
cp .env.example .env # then fill in DB_URL, REDIS_URL, JWT_SECRET
psql "$DB_URL" -f src/schema.sqlTwo terminals in development — Vite serves the frontend on 5173 and proxies API calls to Express on 3000:
npm run dev:api # Express, port 3000
npm run dev # Vite, port 5173Or everything from one server, the way production runs:
npm run build # builds client/ into dist/
npm start # Express serves the API and dist/Or with Docker, which brings up Postgres and Redis too:
docker compose up --buildThe microphone only works over HTTPS or on
localhost. To test on a real phone, deploy it or tunnel with something like ngrok.
test/api.mjs walks every endpoint against a real Postgres and Redis — 49
cases covering registration, login, token refresh, the role guards, session
creation and expiry, duplicate marking, and cross-teacher isolation.
docker compose up -d postgres redis
npm start
npm test| Method | Path | Who | Purpose |
|---|---|---|---|
| POST | /auth/register |
anyone | Create an account (rate limited) |
| POST | /auth/login |
anyone | Sign in (rate limited) |
| POST | /auth/refresh |
anyone | Trade a refresh token for a new access token |
| POST | /sessions/start |
teacher | Start a session, get a code |
| GET | /sessions/active |
teacher | Sessions still running |
| GET | /sessions/history |
teacher | Past sessions with head counts |
| POST | /attendance/mark-by-code |
student | Mark attendance from a decoded code |
| GET | /attendance/me |
student | Your own attendance |
| GET | /attendance/class/:classId |
teacher | Every mark for one class |
| GET | /attendance/session/:sessionId |
signed in | Who is in this session right now |
Import the repo at vercel.com/new and set three environment variables:
| Variable | Where it comes from |
|---|---|
DB_URL |
Neon, the pooled connection string |
REDIS_URL |
Upstash, the rediss:// URL |
JWT_SECRET |
openssl rand -hex 32 |
If you connect Neon or Upstash through the Vercel marketplace instead, they
inject POSTGRES_URL / DATABASE_URL / UPSTASH_REDIS_URL, and src/db.js
reads those too. Run src/schema.sql once in the Neon SQL editor.
vercel.json routes every request to api/index.js and bundles dist/** with
it, so the same function serves both the API and the React app.