Live captioning system for EMF Camp using WhisperLive (Collabora).
EMF Camptions provides real-time speech-to-text captioning for live events. Audio is captured from stage microphones via Raspberry Pi devices, streamed to a central server running WhisperLive for transcription, and distributed to display screens and user devices.
- Real-time speech-to-text using WhisperLive (faster-whisper backend)
- Multiple venue support with independent audio streams
- WebSocket and Server-Sent Events (SSE) for caption distribution
- Large screen display mode for venue monitors
- Mobile-friendly viewer with customizable font size and themes
- Admin interface for session management
- Raspberry Pi setup scripts for audio capture and display
# Start the server
docker compose up --build
# Initialize default venues
curl -X POST http://localhost:8000/api/admin/init-venues
# Open the viewer
open http://localhost:8000/# Install dependencies
pip install -e ".[dev]"
# Run the server (WL must be running separately)
CAMPTIONS_WL_URL=ws://localhost:9090 \
CAMPTIONS_ADMIN_TOKEN=dev \
CAMPTIONS_INGEST_TOKEN=dev \
uvicorn camptions.main:app --reload┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Raspberry Pi │ │ Central Server │ │ Displays │
│ (Audio Capture)│────▶│ (WhisperLive) │────▶│ (WebSocket) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│
▼
┌───────────┐
│ SQLite │
│ Database │
└───────────┘
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ OBS / Encoder │────▶│ MediaMTX │────▶│ Viewer (WHEP) │
│ (WHIP publish) │ │ (WebRTC relay) │ │ in-browser │
└─────────────────┘ └─────────────────┘ └─────────────────┘
WebSocket /api/audio/ingest/{venue_id}?token=<INGEST_TOKEN>— Stream raw PCM audio (16 kHz, 16-bit, mono). 🔒 ingest token required.
WebSocket /api/captions/stream/{venue_id}— Real-time caption streamGET /api/captions/stream/{venue_id}/sse— Server-Sent Events streamGET /api/captions/history/{venue_id}— Historical captions
GET /api/venues— List all venues (public)GET /api/venues/{venue_id}— Get venue details (public)POST /api/venues— Create a venue 🔒PATCH /api/venues/{venue_id}— Update a venue 🔒
GET /api/schedule/now-and-next— Now/next talks for all venuesGET /api/schedule/now-and-next/{venue_id}— Now/next for one venue
GET /api/admin/stats— System statistics (public)GET /api/admin/sessions— List recent sessions 🔒POST /api/admin/init-venues— Initialize default venues 🔒POST /api/admin/cleanup— Clean up old data 🔒
🔒 = requires Authorization: Bearer <ADMIN_TOKEN>
Environment variables (prefix with CAMPTIONS_):
| Variable | Default | Description |
|---|---|---|
HOST |
0.0.0.0 |
Server bind address |
PORT |
8000 |
Server port |
DEBUG |
false |
Enable debug logging and SQLAlchemy echo |
DATABASE_URL |
sqlite+aiosqlite:///./camptions.db |
Database connection string |
WL_URL |
ws://wl:9090 |
WhisperLive WebSocket URL |
WHISPER_MODEL |
small.en |
Whisper model passed in the WL handshake |
WHISPER_LANGUAGE |
en |
Whisper language passed in the WL handshake |
WHISPER_USE_VAD |
false |
Enable WL's VAD filter |
WL_RECONNECT_INTERVAL |
3300 |
Seconds before the send loop drops & reconnects WL (must be < --max_connection_time) |
ADMIN_TOKEN |
(required) | Bearer token for admin and venue-write endpoints |
INGEST_TOKEN |
(required) | Token for Pi audio-ingest WebSocket (?token=) |
DEFAULT_VENUES |
["stage-a", "stage-b", "stage-c"] |
Default venue IDs created by init-venues |
CAPTION_RETENTION_HOURS |
72 |
Hours to retain caption data |
RATE_LIMIT_PER_MINUTE |
120 |
Max HTTP requests per client IP per minute on public API endpoints (0 = disabled) |
WS_CONNECTIONS_PER_IP |
10 |
Max simultaneous WebSocket connections per client IP |
Generate camptions tokens with: python3 -c "import secrets; print(secrets.token_hex(32))"
MediaMTX provides WebRTC ingest (WHIP) from OBS and egress (WHEP) to browsers.
| Variable | Default | Description |
|---|---|---|
WHIP_TOKEN |
(required) | Shared bearer password for OBS → MediaMTX WHIP publish. Generate with openssl rand -hex 32. |
MTX_WEBRTCADDITIONALHOSTS |
(required) | Public hostname/IP browsers can reach MediaMTX on — needed because MediaMTX can only see its own Docker/host interfaces. Set to 127.0.0.1 for local dev; the server's public DNS name in production. |
OBS setup — Service: WHIP, Server: https://captions.emf.camp/stage-a/whip, Bearer token: publisher:<WHIP_TOKEN>.
WHEP stream URLs (set per-venue in the admin under "Presentation Stream"):
| Stage | WHEP URL |
|---|---|
| Stage A | https://captions.emf.camp/stage-a/whep |
| Stage B | https://captions.emf.camp/stage-b/whep |
| Stage C | https://captions.emf.camp/stage-c/whep |
HTTPS requirement — browsers block mixed-content WebRTC: if the viewer is served over HTTPS, the WHEP URL must also be HTTPS. The included
nginx.confalready proxies/stage-*/whipand/stage-*/whepto MediaMTX atlocalhost:8889, so the public WHEP URLs above work as-is once nginx is deployed.
We use DietPi as the base OS. The repo ships a dietpi.txt at the root with the first-boot automation settings we use for capture Pis (hostname stage-pi-001, ethernet enabled, SSH pubkey login only, automated install of ALSA / Git / Python 3 pip).
- Download the DietPi image for your Pi from dietpi.com/#downloadinfo and flash it to an SD card with Raspberry Pi Imager or balenaEtcher.
- Mount the
bootpartition on your workstation and copy dietpi.txt over the file already there:cp dietpi.txt /media/$USER/boot/dietpi.txt - Before booting, edit the copy on the SD card:
- Set
AUTO_SETUP_NET_HOSTNAMEto a unique name per Pi (e.g.stage-a-pi,stage-b-pi). - Replace
AUTO_SETUP_GLOBAL_PASSWORDwith your own password. - Replace
AUTO_SETUP_SSH_PUBKEYwith your own public key, or add additionalAUTO_SETUP_SSH_PUBKEY=lines. - If using WiFi, set
AUTO_SETUP_NET_WIFI_ENABLED=1and editdietpi-wifi.txton the boot partition with your SSID/PSK.
- Set
- Eject the SD card, boot the Pi on the venue network, and wait ~5–10 minutes for first-run automation to finish (the Pi will reboot itself a couple of times).
- SSH in as
rootusing your key:ssh root@stage-pi-001.local
- Create an unprivileged user to run the capture service (the setup script refuses to install for
root):adduser camptions usermod -aG sudo camptions mkdir -p /home/camptions/.ssh cp /root/.ssh/authorized_keys /home/camptions/.ssh/ chown -R camptions:camptions /home/camptions/.ssh chmod 700 /home/camptions/.ssh
- Copy this repo's
raspberry-pi/directory onto the Pi and run the relevant setup script below as thecamptionsuser.
cd raspberry-pi
sudo ./setup-audio-capture.shAfter the script finishes, edit /opt/camptions/config.env to set CAMPTIONS_SERVER and CAMPTIONS_VENUE, then enable the service:
sudo systemctl enable --now camptions-capturecd raspberry-pi
sudo ./setup-display.shcd raspberry-pi
sudo ./setup-full.sh| URL | Description |
|---|---|
/ |
Mobile viewer — venue tabs, live captions, embedded stream toggle |
/v/{venue_id} |
Viewer pre-selected to a specific venue |
/display/{venue_id} |
Large-screen caption display |
/admin |
Admin interface — venue controls, stream URL config |
/status |
Public status board — venue live/offline state, subscriber counts, now/next schedule. Pass ?token=<ADMIN_TOKEN> to also show segment totals from the admin stats API. |
| Parameter | Values | Description |
|---|---|---|
venue |
venue ID | Which venue to display |
mode |
dark, light, high-contrast |
Color scheme |
fontSize |
CSS value | Font size (e.g., 4vw, 48px) |
camptions/
├── src/camptions/ # Backend Python code
│ ├── main.py # FastAPI application
│ ├── config.py # Configuration
│ ├── models.py # Database models
│ ├── routers/ # API endpoints
│ └── services/ # Business logic
├── static/ # Frontend HTML/CSS/JS
├── raspberry-pi/ # Pi setup scripts
├── Dockerfile # Container build
└── docker-compose.yml # Container orchestration
MIT License - see LICENSE for details.