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
- 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.
- They report a bang. When a phone hears a sudden, sharp sound, it sends only when it heard it and how loud it was.
- 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.
- 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.
- Open a phone tab, choose Room 202 and press Start listening (allow the microphone).
- Do the same in two more tabs: one at Room 204, one at Hallway Center.
- Open the dashboard in another tab.
- Press Simulate shot in all three phone tabs, within the same 10 seconds (each tab counts down).
- 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
- On this computer, open Try demo and create a space. It shows a QR code.
- 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.
- 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
- No recordings leave the phone. Only the time, loudness and a confidence score of a detected sound are sent. Phones listen only after someone presses Start listening.
- The detector is a placeholder. It accepts any short, sharp sound, which is why a clap works for the demo. A real deployment needs a trained gunshot classifier.
- The location is an estimate. It depends on knowing where each phone is, on how many phones heard the shot, and on echoes. Three or more phones around the area give the best results.
- Not for real emergencies. Use it to evaluate the idea, not to make safety decisions.
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
- 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.
- 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.
- 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.
- 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/current | The demo space this request is in (cookie from /join/CODE, ?space=CODE or an x-space header; otherwise the shared space) |
| POST | /api/spaces/leave | Go back to the shared demo space |
| GET | /api/time | Server 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/alert | Raise a manual alert (creates an incident if none is active) |
| POST | /api/all-clear | Clear the active incident |
| GET | /api/incidents/current | Active incident with heatmap, trail, detections, status reports and recently active phones, or null (dashboard) |
| GET | /api/incidents/summary | Just the active incident's id, status and estimated place, or null (what phones poll) |
| GET | /api/phones | Recently 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-cal | Running 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/finish | Compute each phone's delay and apply it to future detections |
| POST | /api/timing-cal/cancel | Stop 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.
201Room 201 (0, 0)202Room 202 (6, 0)203Room 203 (12, 0)204Room 204 (18, 0)205Room 205 (24, 0)hall-wHallway West (3, 4)hall-cHallway Center (12, 4)hall-eHallway East (21, 4)