How Push-to-Talk Voice Communication Operates Over BitChat's Mesh Network
BitChat implements a "smart" push-to-talk system that converts tap-and-hold mic gestures into live AAC-LC audio streams, transmitting encrypted VoiceBurstPackets over BLE mesh with approximately 470ms end-to-end latency while maintaining compatibility with traditional offline voice notes.
BitChat's open-source mesh messaging platform transforms standard voice note capture into walkie-talkie-style live communication by streaming compressed audio frames through its existing decentralized network. This architecture enables real-time voice transmission between peers without centralized servers, leveraging the same cryptographic sessions and relay infrastructure used for text messages. Understanding how push-to-talk voice communication operates over BitChat's mesh requires examining the three-stage pipeline: capture and encoding on the sender, encrypted transport across the mesh, and frame assembly with jitter-buffered playback on the receiver.
Audio Capture and Frame Encoding
The sender-side pipeline begins when a user holds the mic button in ContentComposerView.micButtonView, triggering a live capture path that runs parallel to the standard voice note recorder. An AVAudioEngine input tap feeds 16kHz mono PCM audio into PTTFrameEncoder, an AVAudioConverter instance that produces AAC-LC frames approximately 130 bytes each, representing 64ms of audio per frame.
The VoiceBurstPacketizer class defined in bitchat/Protocols/VoiceBurstPacket.swift (lines 69-82) batches one or two frames into a VoiceBurstPacket while enforcing the constraint that total payload size remains under TransportConfig.pttMaxBurstContentBytes (approximately 210 bytes). This size limitation ensures that after Noise encryption and padding, each packet fits within a single BLE frame. Each burst receives an 8-byte random burstID generated via VoiceBurstPacket.makeBurstID() (lines 48-56), uniquely identifying the stream for the sender-peer-scope triple.
When the user releases the button, the system emits an END packet, writes the accumulated PCM to an .m4a file for the traditional file-transfer pipeline, and deletes the temporary buffer if the gesture was cancelled instead.
// Live capture encoder workflow
let frameData: Data = // AAC-LC frame from AVAudioConverter
var packetizer = VoiceBurstPacketizer(burstID: myBurstID)
let packets = packetizer.add(frameData) // Returns completed packets
for pkt in packets {
// BLEService.sendVoiceFrame wraps the packet in a Noise payload
await BLEService.shared.sendVoiceFrame(pkt, to: peerID)
}
Wire Protocol and Transport Layer
BitChat differentiates between direct messages and public mesh broadcasts through distinct wire formats while maintaining consistent payload structures. For direct messages, the system uses NoisePayloadType.voiceFrame (hex 0x08), embedding the VoiceBurstPacket inside an existing Noise session with the same confidentiality and integrity guarantees as text messages. The RelayController forwards these encrypted frames exactly like other DM traffic.
Public mesh transmissions utilize MessageType.voiceFrame (hex 0x29), sending signed broadcast packets with no additional padding to stay under the BLE MTU limit. These packets relay using the same fragment-style jitter and clamp mechanisms as other mesh traffic. According to bitchat/Protocols/VoiceBurstPacket.swift (lines 25-34), the binary layout follows [burstID:8][seq:2][flags:1][payload…], where flags indicate START, END, CANCELED, or data frames.
Security varies by transport: DM packets inherit Noise protocol encryption automatically, while public packets require signature verification against the sender's announce before decoding. Unknown or malformed packets undergo immediate dropping at the transport layer.
Assembly and Playback on the Receiver
The ChatLiveVoiceCoordinator class in bitchat/ViewModels/ChatLiveVoiceCoordinator.swift serves as the core runtime component managing inbound voice streams. It exposes handleVoiceFramePayload for DMs and handlePublicVoiceFramePayload for public mesh, both delegating to handle(_:from:scope:nickname:timestamp:) (lines 75-87) for unified processing.
Upon receiving the first packet, the coordinator creates a new Assembly instance that opens a temporary .aac file prefixed with voice_live_. If the user currently views the conversation with live voice enabled, the assembly registers a PTTBurstPlayer instance. The coordinator implements a jitter buffer delaying playback start until approximately 350ms of audio accumulates, with gap handling that skips silent periods exceeding 0.5 seconds (gapSkipSeconds).
The PTTBurstPlayer receives decoded AAC frames and enqueues them for AVAudioPlayerNode, continuing playback until the buffer drains after finalization. When an END packet arrives—or an idle timeout fires—the temporary file closes and renames from voice_live_ to voice_, preventing quota-sweep deletion. The finalized .m4a voice note sent via the normal file-transfer path later triggers absorbFinalizedVoiceNote() (lines 56-84), which replaces the live bubble without creating duplicate chat rows.
func handleVoiceFramePayload(from peerID: PeerID, payload: Data, timestamp: Date) {
// Forward to live-voice coordinator for assembly
chatLiveVoiceCoordinator.handleVoiceFramePayload(
from: peerID,
payload: payload,
timestamp: timestamp
)
}
// Absorption of finalized voice note
if chatLiveVoiceCoordinator.absorbFinalizedVoiceNote(message) {
// Live bubble replaced; no duplicate row created
}
Resource protection mechanisms include maximum concurrent assembly limits, byte caps, and burst-age limits to prevent mesh abuse, maintaining the network's approximately 15KB/s per-link bandwidth budget. Codec validation restricts incoming frames to AAC-LC 16kHz only, with size caps and rate-limiting guarding against malicious payloads.
Latency Budget and Bandwidth Constraints
The mouth-to-ear latency for single-hop direct messages totals approximately 470ms, breaking down as follows:
- Frame accumulation: 64ms (single frame duration)
- Encoding and packetization: approximately 5ms
- BLE write and delivery: 30-60ms per hop
- Jitter buffer: 350ms initial buffering
Additional relay hops add 10-40ms each, keeping two-to-three-hop paths comfortably under 800ms total latency. This performance profile suits the "walkie-talkie" use case while respecting the mesh network's bandwidth limitations and BLE frame constraints.
Summary
- BitChat's push-to-talk system streams live AAC-LC audio at 16kHz mono, packetizing frames into VoiceBurstPackets under 210 bytes to fit single BLE frames after encryption.
- Transport security leverages existing Noise sessions for DMs (payload type
0x08) and signed broadcasts for public mesh (message type0x29), with strict validation rejecting malformed packets. - The ChatLiveVoiceCoordinator manages assembly, jitter buffering (~350ms), and playback via PTTBurstPlayer, while absorbing finalized voice notes to prevent chat duplication.
- End-to-end latency averages 470ms for direct connections, scaling to under 800ms for multi-hop mesh relays.
- Resource limits including concurrent burst caps and 15KB/s per-link budgets protect the mesh infrastructure from audio stream abuse.
Frequently Asked Questions
What audio codec and sample rate does BitChat use for push-to-talk?
BitChat uses AAC-LC (Advanced Audio Coding Low Complexity) at 16kHz mono for all push-to-talk transmissions. The PTTFrameEncoder converts raw PCM from AVAudioEngine into AAC-LC frames approximately 130 bytes each, representing 64ms of compressed audio. This codec choice balances compression efficiency with computational overhead suitable for BLE transport constraints.
How does BitChat handle packet loss during live voice transmission?
The system employs a jitter buffer in ChatLiveVoiceCoordinator that delays playback start until approximately 350ms of audio accumulates, accommodating out-of-order packet arrival. For gaps exceeding 0.5 seconds (gapSkipSeconds), the coordinator skips ahead rather than accumulating silence. Missing packets within a burst do not halt playback; the decoder continues with subsequent frames, relying on AAC's error concealment capabilities.
What is the difference between DM and public mesh voice transmission?
Direct messages use NoisePayloadType.voiceFrame (hex 0x08) transported inside encrypted Noise sessions, providing confidentiality and integrity. Public mesh broadcasts use MessageType.voiceFrame (hex 0x29) as signed packets without padding, verified against the sender's announce. DMs route through existing peer sessions, while public mesh packets flood through the network using standard relay mechanisms with fragment-style jitter control.
How does the system prevent abuse of live voice resources?
Multiple safeguards protect the mesh: rate limiting and byte caps restrict per-peer bandwidth usage, maximum concurrent assembly limits prevent memory exhaustion, and burst-age timeouts discard stale partial transmissions. The 15KB/s per-link budget reserved for mesh traffic ensures voice streams cannot starve text messages or control traffic. Additionally, codec validation rejects any non-AAC-LC payloads, and size checks enforce the ~210 byte maximum per-packet limit.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →