Gunshot Locator

Ordinary phones in classrooms listen for a gunshot. When several of them hear one, the system works out where it came from, from how loud it was and how soon each phone heard it, and alerts everyone in the building within seconds.

A hackathon prototype (HackTX 2026), not a validated gunshot detector or emergency system. Please test with claps or the Simulate shot button only.

How it works

  1. Phones listen. Each phone runs a web page that listens to its microphone. The audio is analyzed on the phone itself and never leaves it.
  2. They report a bang. When a phone hears a sudden, sharp sound, it sends only when it heard it and how loud it was.
  3. The server compares. Sound travels about 1 m every 3 ms and gets quieter with distance, so phones closer to the shot hear it sooner and louder. The server finds the spot that best explains every phone's report.
  4. Everyone is alerted. The dashboard shows the estimated location, and every listening phone gets the alert and can report "I'm safe", "Need help" or "Injured".

Try it in 2 minutes, on this computer

Each browser tab can act as a separate phone. Put three "phones" in three rooms and fire a pretend shot that they all hear equally loud, at the same moment. The only place that fits is the spot equally far from all three, so the system should find the middle.

  1. Open a phone tab, choose Room 202 and press Start listening (allow the microphone).
  2. Do the same in two more tabs: one at Room 204, one at Hallway Center.
  3. Open the dashboard in another tab.
  4. Press Simulate shot in all three phone tabs, within the same 10 seconds (each tab counts down).
  5. The dashboard turns Active and marks the shot in Room 203, in the middle of the three phones. Press All clear to reset.

Sharing this site with other people? Press Try demo first and create your own space, so their tests don't mix with yours.

Try it with your phone

  1. On this computer, open Try demo and create a space. It shows a QR code.
  2. Scan it with your phone's camera, choose a room and press Start listening. Keep the screen on: phones stop listening when the screen sleeps.
  3. Open the dashboard here, then clap once, sharply, about 1 m from the phone.

Phones only allow the microphone on secure (https://) sites. If you're running this on your own laptop, DEMO.md in the project's repository shows how to make it reachable from a phone.

Reading the dashboard

Blue shading
Where the shot could have come from: the darker, the better that spot explains what the phones heard. A large shaded area means the phones couldn't narrow it down.
Red ringed dot
The best estimate for the latest shot, with the nearest room named above the map.
Numbered dashed line
Earlier shots in order, so you can see if a shooter is moving.
Solid / hollow dots
Phones that heard the latest shot (with how loud) / phones that are listening but didn't.
Status reports
People on the phones reporting safe, need help or injured, and where they are.
Detections table
Every phone's report, grouped by shot: how long after the first phone it heard it, and how loud.

Privacy and limits

Nearby buildings

Buildings around the latest estimate, when phones are on the real map.

For developers

Simulate without any phones

With the dev server running, fake phones in every room report a shot at (9, 2) m:

npx tsx scripts/simulate.ts 9 2

A shooter moving 6 m between 3 shots, or phones on the real map:

npx tsx scripts/simulate.ts 2 -2 --shots 3 --move 6
npx tsx scripts/simulate.ts 9 2 --geo 30.2849,-97.7341

The simulator clears the previous incident first; add --keep to append, --rooms 202,204 to use fewer phones, --parallel to have every phone report at the same instant, or --space CODE to run in a demo space.

How localization works

  1. Line up the reports. Each phone sends its own clock time of the sound's onset. The server adds the phone's clock offset (re-synced every minute) and subtracts its calibrated audio delay; loudness gets the mic calibration.
  2. Group into shots. Reports within ±300 ms are one shot. One phone can only say "near this phone"; two or more go to the grid search.
  3. Score every grid cell. Loudness should fall 6 dB per doubling of distance, and each phone should hear the shot distance ÷ 343 m/s after it was fired. The gun's loudness and firing time are unknown, so they're fitted per cell; only differences between phones matter. Each phone counts by how accurate its position and timing are, and the shading is the likelihood relative to the best cell.
  4. Pick the best fit. The lowest-cost cell is the estimate. Two phones leave two mirror-image answers; a third phone off their line settles it.

Tunables live in lib/localize.ts (and lib/geo.ts for the real map). Phone settings are managed in the admin panel; per-phone audio delays are measured with a timing calibration.

API

POST/api/spaces{ name? }: start a demo space and switch this browser into it → { id, code, name }
GET/api/spaces/currentThe demo space this request is in (cookie from /join/CODE, ?space=CODE or an x-space header; otherwise the shared space)
POST/api/spaces/leaveGo back to the shared demo space
GET/api/timeServer clock. Phones sample it 12× (after a warm-up request) and keep the fastest round trip to compute their clock offset.
POST/api/checkin{ deviceId?, roomId OR x/y in meters OR lat/lng + accuracyM (+ label), clockOffsetMs, clockRttMs? } → { deviceId, frame, roomId }. With x/y, the room is the nearest one.
POST/api/calibrate{ deviceId, peakDb } from a reference clap → stores the mic gain offset
POST/api/detection{ deviceId, localSpikeTsMs, peakDb, confidence, label } → { incidentId, estimate: { x, y, lat, lng, roomId, label, deviceCount } }
POST/api/status{ deviceId, status: safe | need_help | injured }
POST/api/alertRaise a manual alert (creates an incident if none is active)
POST/api/all-clearClear the active incident
GET/api/incidents/currentActive incident with heatmap, trail, detections, status reports and recently active phones, or null (dashboard)
GET/api/incidents/summaryJust the active incident's id, status and estimated place, or null (what phones poll)
GET/api/phonesRecently active phones with where each is checked in and its settings (admin panel)
GET/api/phones/settings?deviceId= → the settings a phone should follow (phones poll this)
POST/api/phones/settings{ target: deviceId | "all", patch }: change phone settings from the admin panel
POST/api/phones/calibrate-mic{ deviceId }: ask a phone to calibrate its mic on the next clap
GET/api/timing-calRunning timing calibration (with phones that have reported) and the last finished one
POST/api/timing-cal/start{ x, y } or { lat, lng }: where the reference sound will be made
POST/api/timing-cal/report{ deviceId, localSpikeTsMs, peakDb }: a phone heard the reference (loudest kept)
POST/api/timing-cal/finishCompute each phone's delay and apply it to future detections
POST/api/timing-cal/cancelStop a running calibration
GET/api/nearby?lat&lng&q: nearby buildings for the real-map view (needs SERPAPI_KEY)

Rooms

Use these IDs as roomId at check-in. Coordinates are meters on the demo floor plan, which runs x −3 to 30, y −4 to 8.

  • 201 Room 201 (0, 0)
  • 202 Room 202 (6, 0)
  • 203 Room 203 (12, 0)
  • 204 Room 204 (18, 0)
  • 205 Room 205 (24, 0)
  • hall-w Hallway West (3, 4)
  • hall-c Hallway Center (12, 4)
  • hall-e Hallway East (21, 4)