Skip to content

Detroit Pulse

Detroit Pulse listens to public scanner radio for metro Detroit and turns it into a live map of incidents. I built it on my own for my Deep Learning class at Lawrence Tech in spring 2026. Whisper transcribes the audio, a local language model reads each transmission for its address and what kind of call it is, and a correlation engine decides whether it starts a new incident or belongs to one already on the map. For the address step I trained a LoRA adapter and ran the fine-tuned model.

  • Type Class project, solo
  • Class Deep Learning, Lawrence Tech
  • When Spring 2026
  • Area Metro Detroit
  • Models Whisper large-v3 · Qwen2.5-7B-Instruct
  • Ran on RTX 4070 Ti Super
  • Fine-tuning Four LoRA adapter versions, each shadow-tested
  • Stack Python · Whisper · Local LLMs · QLoRA · React
A drawing of the Detroit Pulse pipeline: scanner radio pulled with ffmpeg and cut at pauses, Whisper large-v3 transcribing on the GPU, Qwen2.5-7B finding the address, Photon then Google geocoding it inside metro Detroit, the model tagging the type, priority and units, and a correlation engine that opens a new incident under a score of 0.20, lets the model judge from 0.20 to 0.85 and merges from 0.85 up, with a model veto and incompatible types scoring zero, before PostGIS stores it and Redis and WebSockets push it to a React and Mapbox map

A drawing of the pipeline, not a screenshot of the app.

What it is

Scanner radio is not natural language. It is full of unit numbers, signal codes, floor numbers and round counts, and one channel carries several calls at once. Detroit Pulse listens to public scanner feeds, transcribes them, pulls out the address, the kind of call, its priority and the units sent, and groups transmissions about the same event into one incident. New incidents and updates reach the map in the browser as they happen.

The speech and language models ran locally on one RTX 4070 Ti Super: Whisper through faster-whisper, and Qwen through Ollama. It is non-commercial and not affiliated with any agency.

The hardest part was the grouping: working out that two transmissions a few seconds apart are about the same structure fire.

Pipeline

From radio to map

  1. Audio. Each feed gets its own worker, which pulls a little over 30 seconds at a time with ffmpeg as 16 kHz mono. Silero VAD looks for a pause within 4 seconds of the 25-second mark and cuts there, so a chunk ends in a pause rather than at a fixed length. The last 3 seconds of each pull are carried into the start of the next chunk. Chunks with no speech are dropped before transcription. When a pull fails, the worker backs off, doubling its wait up to 2 minutes. The config lists 17 feeds across Wayne, Oakland, Washtenaw and Livingston counties, with 7 Wayne County feeds switched on.
  2. Speech. Whisper large-v3 transcribes each chunk on the GPU through faster-whisper. Its prompt starts with a list of local street and city names and the last 30 words heard on the same feed, kept for 5 minutes, to steer it toward local names and give it the transmission before.
  3. Address. Qwen2.5-7B-Instruct, served locally by Ollama, turns the transcript into one address that can be geocoded, or NO_LOCATION. The prompt carries a short guide to metro Detroit roads and one firm rule: numbers for rounds, floors, units, times and head counts are never part of an address. A check then throws the answer out if the street name it gives is not in the transcript. A setting picks the model: the base model alone, shadow mode, which also runs my fine-tuned model and logs where the two differ, or the fine-tuned model in production, which falls back to the base model’s answer if its own call fails. A failed call comes back as NO_LOCATION, and a circuit breaker opens after three failures in a row: for the next 60 seconds the step answers NO_LOCATION without calling the model.
  4. Place. Photon, a free geocoder, goes first, biased toward the area the feed covers. If its answer lands outside that area, the query is retried with the feed’s main city added. A table of local landmarks and nicknames catches places like hospitals and arenas. Google’s geocoder is the fallback, rate-limited to one call every 2 seconds with bursts of up to 5. Every result has to fall inside a box around metro Detroit, and results are cached in Redis for 30 days. If geocoding fails, Detroit Fire engine and ladder numbers can stand in: their firehouses give a rough location to match against.
  5. Incident details. A second call to the same model gets the transcript, the geocoded location and the incidents already open, up to 20 on the same feed and 5 on others for mutual aid. It returns JSON: whether this is an incident at all, one of 19 incident types, a priority, the units sent and the units clearing. The prompt tells it to mark radio tests and chatter as no incident, and a reply that is not valid JSON is treated as no incident, so one bad reply does not stop the pipeline.
  6. Correlation. The engine decides whether the transmission starts a new incident or updates one already open. More on that below.
  7. Storage and live updates. Incidents, and every transcript the model marks as an incident, are written to PostgreSQL with PostGIS. Each stored transcript keeps how it was matched, where its location came from, which models handled it and how long it took. Redis holds the live state: the queues between stages, the open incidents, which units are on which call, and the caches. Each new incident, update or resolution is published through Redis and pushed to every open map over a WebSocket.

Correlation

Same call, or a new one?

The same event comes up again and again as units respond. The correlation engine scores each new transmission against every open incident, and the score decides whether the code or the model settles it.

  • Weighted signals. Overlap between the two addresses counts for up to 0.30, how close their geocodes are for up to 0.25, shared units for 0.20, the same incident type for 0.10, arriving within five minutes of the incident opening for 0.08, and the same feed for 0.05. Sharing the main street name adds a bonus. Locations more than 5 km apart, incidents with no update for 30 minutes, and unit matches on busy feeds lose points. The weights are set by hand and were tuned empirically.
  • 0.85 and up. The transmission merges into the incident, after a quick check in which the model can only veto an obvious mistake, like a different city or incompatible types.
  • 0.20 to 0.85. The model judges. It sees up to four candidates with their scores and last three transcripts, and answers MATCH, NEW or RETRY. The prompt tells it that one channel carries many calls and to answer NEW when unsure, and a MATCH only counts if the score is at least 0.50.
  • RETRY. A transmission too ambiguous to place waits in a queue for its feed, and runs again with fresher context when the next transmission on that feed arrives. It is re-run up to three times within 10 minutes, and from the second re-run the model is told to choose MATCH or NEW.
  • Under 0.20. A new incident.
  • Types that cannot match. Fourteen pairs of incident types, such as a structure fire and a medical call, score zero against each other, so the scoring never merges them.
  • Busy feeds. The Detroit feeds and the county-wide and regional ones are the busiest. On those, a shared unit counts for less and never merges a transmission on its own: a recent incident with the same unit and type is lifted to just over 0.20, so the model judges it. A transmission there with no address, and no location from its units, is set aside.
  • Quieter feeds. A unit already on an open incident moves that incident to the top of the list with extra weight, unless it is more than 3 km away. An incident resolves when its last unit clears.
  • Agencies. Unit IDs are prefixed with their agency, so units from two departments that share a number are kept apart.
  • No address. When a transmission has no address, an open incident of the same type, on the same feed, opened in the last five minutes, is lifted the same way instead of being passed over.
  • Closing. By default, a background thread stops matching new transmissions to incidents that have had no update for four hours, and the API closes them in the database and on the map after 48 hours without one.

Architecture

How it runs

One Python process runs the pipeline: a thread for each feed, one Whisper thread, four workers for the language model steps (the number is a setting) and a thread that retires stale incidents from matching. Redis queues sit between the stages. A thread that crashes restarts itself after a pause, and every minute the process logs which threads are alive.

Loading models from several threads at once caused segfaults, because PyTorch’s CUDA initialization is not thread-safe. The fix was to load Whisper and Silero VAD on the main thread before any worker starts, keep VAD on the CPU behind a lock, and start the feed workers 2 seconds apart.

FastAPI serves the REST API, the WebSocket and the built React app. PostgreSQL with PostGIS and Redis run in Docker Compose, Ollama serves the language model locally, and a start script waits for the databases, builds the frontend and starts the rest.

Frontend

The map

The map is React 19 and Mapbox GL JS, built with Vite. Incidents appear as dots colored by type, and the incident list sorts them by latest update, newest, priority or number of updates, and filters them by type.

Opening an incident shows a short plain-English summary that the model rewrites every few updates, the units on the call with cleared ones marked, and the transcripts behind it. Inside Detroit it also shows the police precinct and fire battalion the incident falls in, and for fires and hazmat calls, the three nearest Detroit Fire firehouses and how far away each one is. The pipeline works those out when an incident opens or an update moves it, with a point-in-polygon test against the precinct and battalion boundaries and a distance to every firehouse, from the same GeoJSON the map layers use.

Optional layers add Detroit police precincts, Detroit Fire battalions and firehouses from GeoJSON bundled with the app, county lines, the last 48 hours of the city’s crime reports from its open data portal, and DTE Energy outage areas. The browser keeps one WebSocket open, pings it every 25 seconds, and reconnects 3 seconds after it drops.

Fine-tuning

Fine-tuning the address step

Address extraction is where I spent the most time. A general model reads unit numbers, floors and round counts as house numbers and mile roads, and will sometimes give an address when there is none. I built a QLoRA pipeline to fine-tune Qwen2.5-7B-Instruct for this one step, with checks between the stages before a model goes live. I trained four versions of the LoRA adapter with it, mostly on the RTX 4070 Ti Super and, when a run was taking a long time, on a server with two A100s. Each version went through shadow mode before I promoted it, and I ran the fine-tuned model in Detroit Pulse. The training examples use the exact prompt the pipeline sends, in Qwen’s chat format.

  1. Labeling. Claude labels transcripts from four queues: mile-road answers, the ones most prone to being made up; medium-confidence geocodes; NO_LOCATION answers on the four Detroit feeds; and high-confidence answers as positives. It sees Qwen’s answer and the geocode confidence, and returns the right address or NO_LOCATION with a reason. A cleanup script clears Claude’s address labels on transmissions whose geocode failed or came back low confidence, since those cannot be checked.
  2. Review. A terminal tool for labeling by hand works through the same queues. It shows the transcript and Qwen’s answer, with keys to keep it, correct it or mark it NO_LOCATION.
  3. Dataset. The export combines the human and Claude labels, NO_LOCATION examples from transmissions that were part of an incident but had no address, and 21 hand-written examples: 16 where the numbers are rounds, unit numbers, floors, signal codes or head counts and there is no address, and 5 where a number really is part of one. It leaves out unreviewed answers that already geocoded with high confidence, on the reasoning that the base model handles those, tops NO_LOCATION up to about 35 percent when it runs short, and holds out 10 percent for evaluation. Training stops to ask unless the set has at least 1,000 examples, 300 reviewed ones and 15 percent NO_LOCATION.
  4. Training. The base model loads in 4-bit NF4, and LoRA adapters train on every attention and MLP projection for 3 epochs, keeping the checkpoint with the lowest eval loss. The script detects the GPU and sets the rank and batch size to fit: rank 64 on an A100, and rank 16 with an effective batch of 16 on a 16 GB card.
  5. Export. The adapter is merged into the base weights, converted to GGUF with llama.cpp, quantized (Q4_K_M by default), registered with Ollama and smoke-tested.
  6. Offline evaluation. Both models run on the held-out set and are compared on exact match, street match, city match, NO_LOCATION precision, recall and F1, and hallucination rate: how often a model gives an address when the answer is NO_LOCATION. To pass, the fine-tuned model has to beat the base by 5 points on exact match and 3 on street match, with no lower NO_LOCATION F1 and no higher hallucination rate.
  7. Shadow mode. Both models answer every live transmission. The pipeline uses the base answer and logs both, with their geocodes. Live traffic has no answer key, so the geocoder is the referee: when the two disagree, the answer that geocodes with higher confidence wins. The gates include at least 500 comparisons, more wins than the base, no more likely hallucinations and at least as many high-confidence geocodes.
  8. Promotion and rollback. Promotion checks that Ollama can serve the model, then reads the latest shadow report and stops unless the fine-tuned model passed every gate, or I confirm an override by hand. It then records the version in a model registry table, retires the old one and switches the pipeline’s settings over. Rolling back is one command that returns it to the base model.

Questions about Detroit Pulse?

I am happy to go deeper on any part of the pipeline. nathanpaulgamache@gmail.com

Ask me about it