When the induction loop proposes a new vocabulary term, it now records
which sampled call(s) most likely produced the suggestion. Admins see
a collapsible "▶ source" player under each pending term showing the
audio clip and transcript, so they can hear what was actually said
before approving or dismissing.
- vocabulary_learner: track sampled call docs, attach source_call_ids
to each pending term via word-overlap search with fallback
- types: VocabularyPendingTerm.source_call_ids?: string[]
- c2api: add getCall(id) using existing GET /calls/{call_id} endpoint
- VocabularyPanel: SourceCallPlayer component — lazy-loads call on
first expand, shows audio controls + transcript snippet
DRB Server
Full-stack backend for the DRB (Distributed Radio Bot) platform — a community-powered, real-time situational awareness system for public safety radio monitoring. Receives telemetry and audio from SDR edge nodes, runs an AI intelligence pipeline on every call, correlates calls into incidents, dispatches alerts, and serves a live operational dashboard.
What DRB Is
DRB turns radio scanners into a shared geographic intelligence picture. Anyone can contribute coverage by running an edge node (an RTL-SDR dongle on a small Linux machine). All nodes feed into a single platform where operators can:
- Monitor in real time — see active incidents on a map as they unfold, with calls grouped by incident as they come in
- Listen live — stream audio to Discord voice channels or directly in the browser
- Get alerted — receive push notifications when significant events are detected
- Investigate after the fact — search transcribed call history, replay audio, and review incident timelines
The intended deployment is a Tactical Operations Center (TOC) display: a map showing active incidents across all covered jurisdictions, with live audio and alert feeds for operators monitoring an area during an event (severe weather, major incident, etc.).
System Overview
DRB is a distributed SDR (Software Defined Radio) monitoring platform. Edge nodes (small Linux machines with RTL-SDR dongles) decode radio systems and stream audio. The server coordinates those nodes, runs the intelligence pipeline on each call, manages Discord voice bot assignments, and serves the operational dashboard.
Edge Node (client machine)
│
├── MQTT checkin/status/metadata ──► mosquitto ──► c2-core ──► Firestore
│
└── MQTT commands ◄─────────────────────────────── c2-core
│
└── discord_join ──► edge node joins Discord voice + streams PulseAudio
Discord User
└── /join /leave /status /help ──► discord-bot ──► c2-core ──► MQTT ──► edge node
Browser (admin)
└── frontend ──► Firestore (real-time reads, Firebase Auth)
└── c2-core REST API (writes, commands, token pool)
Services
| Service | Container | Description | Port |
|---|---|---|---|
mosquitto |
eclipse-mosquitto |
MQTT broker — receives telemetry from edge nodes, dispatches commands | 1883 |
mosquitto |
eclipse-mosquitto |
MQTT broker — receives telemetry from edge nodes, dispatches commands | 1883 |
c2-core |
Python 3.11 / FastAPI | Command & control API — MQTT handler, intelligence pipeline, incident correlator, alert dispatch, node/system/token management | 8000 |
discord-bot |
Python 3.11 / discord.py | Server-side Discord slash command bot — /join, /leave, /status, /help |
— |
frontend |
Node.js / Next.js 14 | Operational web dashboard — live map, incident feed, call history, node and system management | 3000 |
Directory Structure
Server/
├── docker-compose.yml # Orchestrates all four services
├── Makefile # Convenience targets (setup/build/up/down/logs)
│
├── drb-c2-core/ # FastAPI command & control backend
│ ├── app/
│ │ ├── main.py # App entry point — router registration, MQTT startup, node sweeper
│ │ ├── config.py # Pydantic settings (reads from .env)
│ │ ├── models.py # Shared Pydantic models (CommandPayload, etc.)
│ │ ├── routers/
│ │ │ ├── nodes.py # Node CRUD, approve/reject, command dispatch, system assignment
│ │ │ ├── systems.py # Radio system CRUD (P25/DMR/NBFM configs)
│ │ │ ├── calls.py # Call log retrieval (read-only — calls created by MQTT handler)
│ │ │ ├── tokens.py # Discord bot token pool — add/delete/list; assign_token/release_token helpers
│ │ │ ├── upload.py # Audio file upload endpoint (called by edge nodes); triggers intelligence pipeline
│ │ │ ├── incidents.py # Incident CRUD, manual link/unlink calls, resolve
│ │ │ └── alerts.py # Alert rule CRUD
│ │ └── internal/
│ │ ├── auth.py # Auth — Firebase ID token OR service key (Bearer)
│ │ ├── firestore.py # Async Firestore wrappers (doc_get, doc_set, doc_update, collection_list)
│ │ ├── mqtt_handler.py # MQTT subscriber — call_start/call_end handlers, node checkin/status
│ │ ├── node_sweeper.py # Background task — marks nodes offline after 90s without heartbeat
│ │ ├── storage.py # GCS audio file uploads
│ │ ├── transcription.py # STT pipeline — OpenAI Whisper / GPT-4o transcribe
│ │ ├── intelligence.py # GPT scene extraction: tags, incident type, location, units, vehicles; geocoding
│ │ ├── incident_correlator.py # Hybrid correlator — matches calls to incidents or creates new ones
│ │ └── alerter.py # Alert dispatch — evaluates rules, sends notifications
│ ├── scripts/
│ │ └── set_admin.py # CLI to grant/revoke Firebase admin custom claim
│ ├── gcp-key.json # GCP service account key — place here, NOT committed to git
│ └── .env.example
│
├── drb-server-discord-bot/ # Discord slash command bot (server-side)
│ ├── app/
│ │ ├── main.py # Bot startup, cog loading, guild sync
│ │ ├── config.py # Settings (Discord token, C2 URL, service key)
│ │ ├── commands/
│ │ │ └── radio.py # /join (with token autocomplete), /leave, /status, /help
│ │ └── internal/
│ │ └── c2_client.py # Async HTTP client to c2-core — includes service key auth header
│ └── .env.example
│
└── drb-frontend/ # Next.js 14 admin dashboard
├── app/
│ ├── dashboard/ # Overview: active node grid + live call feed
│ ├── map/ # Leaflet map — node locations, active incident pins
│ ├── calls/ # Call history — talkgroup, duration, audio playback, transcript
│ ├── nodes/ # Node list + per-node detail (approve, reject, assign system)
│ ├── systems/ # Radio system CRUD (P25 form with talkgroup editor, DMR/NBFM JSON)
│ ├── tokens/ # Discord bot token pool management
│ ├── incidents/ # Incident list/detail — linked calls, location, summary, resolve
│ ├── alerts/ # Alert rule configuration
│ └── login/ # Firebase Auth login page
├── components/
│ ├── MapView.tsx # react-leaflet map with status-colored markers
│ ├── NodeCard.tsx # Node status card with action buttons
│ ├── NodeConfigModal.tsx # Modal for assigning a radio system to a node
│ ├── CallRow.tsx # Call history row — talkgroup, duration, audio player
│ ├── AuthProvider.tsx # Firebase Auth context + middleware guard
│ ├── Nav.tsx # Sidebar navigation
│ └── StatusBadge.tsx # Color-coded status pill (online/recording/offline/unconfigured)
├── lib/
│ ├── firebase.ts # Firebase JS SDK initialization
│ ├── c2api.ts # Fetch wrapper for c2-core REST API (attaches Firebase ID token)
│ ├── types.ts # Shared TypeScript types (NodeRecord, CallRecord, SystemRecord, etc.)
│ ├── useNodes.ts # Firestore real-time subscription to nodes collection
│ ├── useCalls.ts # Firestore real-time subscription to calls (active + history)
│ └── useSystems.ts # Firestore real-time subscription to systems collection
├── next.config.mjs # Next.js config — standalone output, transpilePackages for Leaflet
└── .env.example
Prerequisites
- Docker + Docker Compose
- A Firebase project with:
- Firestore enabled (Native mode)
- Firebase Authentication enabled (Email/Password provider)
- A GCP service account key with Firestore + Firebase Auth Admin SDK permissions
- A Discord application with:
- One bot token for the server command bot (handles
/join,/leave, etc.) - One or more additional bot tokens for the pool (edge nodes use these to join voice channels)
- One bot token for the server command bot (handles
Setup
# 1. Copy all env files
make setup
# 2. Place your GCP service account key
cp /path/to/your-service-account-key.json drb-c2-core/gcp-key.json
# 3. Fill in secrets — see tables below
nano drb-c2-core/.env
nano drb-server-discord-bot/.env
nano drb-frontend/.env
# 4. Build and start
make build
make up
# 5. Grant admin access to your Firebase user
cd drb-c2-core
python scripts/set_admin.py grant your@email.com
# Sign out and back in to the frontend for the claim to take effect
Environment Variables
drb-c2-core/.env
| Variable | Required | Description |
|---|---|---|
MQTT_BROKER |
Yes | Hostname of the MQTT broker (default: mosquitto) |
MQTT_PORT |
No | MQTT broker port (default: 1883) |
FIRESTORE_DATABASE |
No | Firestore database name (default: (default)) |
GCP_CREDENTIALS_PATH |
Yes | Path to the service account key inside the container (e.g. /secrets/gcp-key.json) |
GCS_BUCKET |
No | GCS bucket name for storing call audio recordings |
SERVICE_KEY |
Yes* | Shared secret for internal service-to-service auth. Must match C2_SERVICE_KEY in the discord-bot env |
*Required for the server Discord bot to authenticate against the C2 API.
drb-server-discord-bot/.env
| Variable | Required | Description |
|---|---|---|
DISCORD_TOKEN |
Yes | Bot token for the server-side slash command bot |
C2_URL |
Yes | Internal URL of c2-core (e.g. http://c2-core:8000) |
C2_SERVICE_KEY |
Yes* | Must match SERVICE_KEY in drb-c2-core — sent as a Bearer token on all C2 API calls |
DEV_GUILD_ID |
No | Discord guild ID to sync slash commands instantly during development |
drb-frontend/.env
| Variable | Required | Description |
|---|---|---|
NEXT_PUBLIC_FIREBASE_API_KEY |
Yes | Firebase web API key |
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN |
Yes | Firebase auth domain |
NEXT_PUBLIC_FIREBASE_PROJECT_ID |
Yes | GCP/Firebase project ID |
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET |
Yes | Firebase storage bucket |
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID |
Yes | Firebase messaging sender ID |
NEXT_PUBLIC_FIREBASE_APP_ID |
Yes | Firebase app ID |
NEXT_PUBLIC_FIRESTORE_DATABASE |
No | Firestore database name — must match c2-core (default: (default)) |
NEXT_PUBLIC_C2_URL |
Yes | C2 API URL reachable from the user's browser |
Authentication
Two auth mechanisms are supported on all C2 routes:
- Firebase ID tokens — issued by Firebase Auth to browser users. The frontend attaches these automatically via
c2api.ts. Required for the dashboard to call the C2 API. - Service key — a static shared secret for internal service calls. The server Discord bot sends this as a
Bearertoken. SetSERVICE_KEYin c2-core andC2_SERVICE_KEYin the discord-bot to the same value.
Admin-only routes (node approve/reject) additionally require the Firebase user to have an admin: true custom claim:
python drb-c2-core/scripts/set_admin.py grant your@email.com
python drb-c2-core/scripts/set_admin.py revoke your@email.com
Discord Bot Token Pool
Edge nodes join Discord voice channels using bot tokens managed by the server. Add tokens in the Tokens page of the frontend (or POST /tokens). Each token has:
- A friendly name
- The actual Discord bot token string (masked in list views)
in_useflag and the node it's currently assigned to
Flow when a user runs /join:
- Discord bot sends a
discord_joincommand to c2-core with the target guild and channel IDs. - c2-core picks a free token (optionally the one requested via autocomplete), marks it
in_use, and forwards the token + channel info to the target edge node over MQTT. - The edge node starts a
discord.pybot with that token and joins the voice channel. - The bot streams live audio continuously from PulseAudio (
drb_sink.monitoron the edge node). Icecast is for browser listening only. - On
/leave, c2-core releases the token back to the pool.
Node Lifecycle
- Edge node starts → sends MQTT
checkin→ appears as pending in the dashboard - Admin clicks Approve → c2-core generates a random API key, stores it in
node_keys, publishes it to the node over MQTT, updates node toapproved - Admin assigns a radio system via Config → c2-core pushes the full system config (frequencies, talkgroups, Icecast settings) over MQTT
- Edge node writes the config and (re)starts OP25
- Node sends
online/recording/unconfiguredstatus via 30s MQTT heartbeats node_sweeperbackground task marks any node offline after 90s without a heartbeat
Call & Intelligence Pipeline
Every radio call flows through a four-stage pipeline triggered when the edge node uploads its recording:
Edge node ──► MQTT call_start ──► CallRecord created (active)
│
Edge node ──► audio upload ──► GCS storage
│
▼
[1] TRANSCRIPTION
OpenAI Whisper / GPT-4o transcribe
→ CallRecord.transcript
│
▼
[2] INTELLIGENCE EXTRACTION (GPT-4o-mini)
Scene detection — splits multi-incident recordings
Speaker role inference — dispatch vs. unit patterns
used to correctly attribute locations (dispatch-
provided address vs. unit position report) and
units (being dispatched vs. acknowledging)
Entity extraction: tags, incident_type, location,
units, vehicles, severity, resolved flag
+ geocoding (Google Maps)
+ embedding (text-embedding-3-small)
→ CallRecord.tags, .location, .units, etc.
│
▼
[3] INCIDENT CORRELATION (hybrid engine)
Fast path — talkgroup + recency
Unit path — same officer continuity
Location — proximity match
Cross-TG — multi-agency / channel hop
Slow path — embedding similarity
→ IncidentRecord created or updated
│
▼
[4] ALERT DISPATCH
Evaluate alert rules (keywords, talkgroups)
→ notifications sent
Frontend Pages
| Page | URL | Description |
|---|---|---|
| Dashboard | /dashboard |
Live node grid + active call stream |
| Map | /map |
Leaflet map — nodes color-coded by status, active call popups |
| Calls | /calls |
Full call history — talkgroup, duration, audio playback, transcript |
| Nodes | /nodes |
Node list; per-node detail for approve/reject/assign system |
| Systems | /systems |
Create and manage P25/DMR/NBFM radio system configurations |
| Tokens | /tokens |
Discord bot token pool management |
| Incidents | /incidents |
Incident list/detail — linked calls, location, summary, tags, resolve |
| Alerts | /alerts |
Not built |
Makefile Targets
make setup — copy .env.example files to .env
make build — docker compose build
make up — docker compose up -d
make down — docker compose down
make logs — follow all service logs
make logs-c2 — c2-core logs only
make logs-bot — discord-bot logs only
make logs-frontend — frontend logs only