Skip to content

Repository files navigation

SMAT — Smart Attendance

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.


The idea in one paragraph

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.


The whole codebase

Seventeen source files. Every one of them does a single thing.

Backend — src/

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.

Frontend — client/src/

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.

Following one attendance mark end to end

  1. Teacher starts a session. POST /sessions/start generates 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.

  2. The browser starts playing. useUltrasonicBroadcast queues tones against the Web Audio clock several seconds in advance, so they stay sample-accurate even if the page is busy rendering.

  3. A student taps Listen. useUltrasonicListener opens 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.

  4. 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.

  5. Marking. POST /attendance/mark-by-code looks the code up in Redis. If the key has expired, the session is over and the request fails. Otherwise the student is inserted into attendance_records.

  6. The teacher sees it. The broadcast screen polls GET /attendance/session/:id every five seconds and the name appears.


Decisions worth defending

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.


Running it

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.sql

Two 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 5173

Or 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 --build

The microphone only works over HTTPS or on localhost. To test on a real phone, deploy it or tunnel with something like ngrok.

Checking it works

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

The API

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

Deploying to Vercel

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.

Releases

Packages

Contributors

Languages