Transmitter Audio Uplink
:::info Status
Implemented. Source: docs/transmitter-audio-uplink-contract.md v1.0.
:::
:::warning Not MQTT This is a dedicated, authenticated WebSocket — not a topic on the broker. It's documented here because it's the other half of a transmitter's protocol surface: everything about identity, claim, and the MQTT topics (status/heartbeat/config/cmd/ack/log) is unchanged and lives in Topic Structure and Device Registration. This page covers only the uplink connection itself. :::
Companion to the transmitter MQTT contract, the same way that contract is a companion to the stream player contract. This document covers the one thing a transmitter can do that the MQTT contract doesn't: send live audio arriving on its amplifier/mic input back to the backend, which identifies the mosque and fans it out exactly the way any other live broadcast fans out — see Azaan Broadcast.
Why a second connection, not MQTT
A transmitter already has an open MQTT session (connection parameters). Continuous audio doesn't go over it:
- EMQX's per-device rate/message-size limits
(
emqx/emqx.conf.template, see Device Deployment) are sized for status/heartbeat/cmd traffic — a "chatty device" ceiling, not sustained audio. - The one precedent for audio-over-MQTT in this codebase was the admin panel's own mic mirrored as base64 JSON chunks on the (now-retired) mosque-wide trigger topic — unauthenticated, and not the pattern to extend.
Instead: a dedicated, authenticated WebSocket, the same shape the admin
panel's own live broadcast already uses (main.py, see
Azaan Broadcast) — reusing the exact same
FFmpeg → Icecast pipeline and the exact same started/ended fan-out, not a
second implementation of either.
Trigger — device-initiated
The transmitter's own hardware decides when to start (VOX/level-detection
on the amplifier input, or a physical button) and opens the connection on
its own. The backend's job is identify and fan out, not command a start —
there is no MQTT cmd that tells a unit to begin capturing.
Config — opt-in, per unit
TransmitterConfig.live_audio_uplink, part of the same retained config
document delivered over MQTT (see Topic Structure — config):
{"live_audio_uplink": {"enabled": true, "room_id": "azaan"}}
| Field | Type | Notes |
|---|---|---|
enabled | bool | Default false. Wiring a mic/line-in is a per-site hardware decision — most units never have one. |
room_id | string | Which Icecast room this unit's uplink feeds — one of icecast_helper.ROOMS (azaan, taraweeh, waaz, announcement). Default azaan. |
Identity and credential
The connection authenticates with the same per-device secret every
transmitter already has: core/device_claim.py:claim() mints and
bcrypt-hashes one for every device kind at claim time
(devices.device_secret_hash) — not a second credential system. It's
returned as broadcast_secret in the POST /mosque-devices/claim and
/announce responses (present once, same rule as every other secret in
this contract — see Device Registration), and
recoverable via POST /mosque-devices/{device_id}/regenerate-broadcast-secret
(superadmin) for a unit claimed before this existed.
Connect
wss://host/ws/transmitter-broadcast?device_uid={device_uid}&device_secret={broadcast_secret}&room_id=azaan
device_uid here is devices.device_uid — the same identifier already
reported in this unit's MQTT status/heartbeat payloads, not the
topic-path device_id.
The backend resolves mosque_id only from this credential
(core/device_authz.py:authorize_and_locate) — there is no mosque_id
parameter on this endpoint at all, and none would be trusted if there were.
Reconnect: exponential backoff 1s → 60s max, jittered — the same posture as the MQTT contract's own reconnect rule.
Rejected (socket closed, no retained state changes)
| Close code | Reason |
|---|---|
4404 | room_id isn't a known room |
4401 | Unknown device, wrong secret, or the device is not ACTIVE-and-bound (same eight-step chain the stream-gate uses — core/device_authz.py:evaluate) |
Audio format
Raw PCM, 16-bit signed little-endian, 16 kHz, mono
(icecast_helper.TRANSMITTER_PCM_SAMPLE_RATE/TRANSMITTER_PCM_CHANNELS) —
sent as the WebSocket binary frame payload, no container, no codec. Chosen
because an ESP32 I2S ADC has no on-board encoder to produce anything else;
the backend's FFmpeg transcodes to the Icecast mount's MP3, same as it
already does for the admin panel's WebM/Opus.
On connect the server replies once with:
{"type": "connected", "mosque_id": "...", "room_id": "azaan", "stream_url": "https://.../{mosqueId}_azaan"}
then accepts binary frames indefinitely. No audio for
BROADCAST_IDLE_TIMEOUT_SECS (default 60s) ends the session — the same
idle rule the admin panel's own broadcast connection uses.
Fan-out — identical to any other live broadcast
Once connected, this is a live broadcast in every sense
Azaan Broadcast describes: the Icecast mount
goes live, every other stream player and transmitter bound to the mosque
gets a per-device play (device_trigger.publish_azaan /
publish_play_to_transmitters — see Commands), and
mobile push fires (VoIP/FCM for azaan, alert/notification otherwise) — all
already authorization-gated per recipient. Nothing new was built for this;
it's the same function the admin panel's own mic triggers,
core/live_broadcast.py:notify_icecast_broadcast_started.
The source transmitter is excluded from its own fan-out — it isn't told
to play the broadcast it's currently sourcing, which would loop its own
amplifier output back through itself.
No explicit "stop." Same as every other live broadcast on this platform: disconnecting ends the Icecast mount, and every receiving device notices the stream ended on its own. There is no message telling anyone the uplink stopped.
Firmware acceptance checklist
- Only opens this connection when
live_audio_uplink.enabledistruein the retained config - Uses
device_uid+ thebroadcast_secretfrom claim/announce — never a hardcoded value - Sends raw 16-bit LE PCM, 16 kHz, mono — no container, no encoding
- Reconnects with jittered exponential backoff on drop, same as the MQTT connection
- Does not assume any response frame from the server beyond the one
connectedmessage at connect time — the server never sends anything else on this socket - Treats a
4401/4404close as terminal for that attempt, not something to retry immediately in a tight loop