How BitChat Implements Push-to-Talk Voice Communication: A Technical Deep Dive
BitChat implements push-to-talk (PTT) as a live voice streaming system that transmits AAC audio frames in real-time to a single peer while the user holds the record button, then seamlessly transitions the finished burst into a regular voice-note file.
Push-to-talk voice communication in BitChat combines the immediacy of live audio with the reliability of persistent messaging. According to the permissionlesstech/bitchat source code, the implementation spans capture engines, custom packet protocols, and BLE transmission layers. This article examines the complete architecture from microphone input through final message delivery.
Core Architecture: Three-Stage Pipeline
The PTT system operates through three coordinated components: capture, packetization/transmission, and reception/assembly.
Stage 1: Audio Capture with Dual Output
The PTTCaptureEngine handles microphone input and produces two simultaneous outputs: live AAC frames for streaming and a persistent .m4a file for the final voice-note. This dual-output design ensures that even if the live stream fails, the complete recording survives.
In PTTCaptureEngine.swift, the engine runs on a dedicated capture queue and invokes its onFrames callback for each batch of encoded audio. The PTTLiveVoiceSession (conforming to VoiceCaptureSession) manages this flow:
// Start a live PTT session when the user presses the mic button
let liveSession = PTTLiveVoiceSession(
sendPacket: { data in meshService.sendVoiceBurst(data) }
)
await liveSession.start() // begins capture & streaming
The start() method creates a temporary output URL using VoiceBurstPacket.makeBurstID() to generate a unique burst identifier. This ID links the live stream to its eventual voice-note counterpart.
Stage 2: Packetization and BLE Transmission
Raw AAC frames cannot traverse BLE efficiently. The VoiceBurstPacketizer groups frames into VoiceBurstPacket structures with a defined wire format specified in VoiceBurstPacket.swift:
| Packet Type | Flag Value | Payload Contents |
|---|---|---|
| START | 0x01 |
[codec] — advertises aacLC16kMono |
| DATA | 0x00 |
[len][AAC-frame]... (up to 8 frames) |
| END | 0x02 |
[totalDataPackets][durationMs] |
| CANCELED | 0x04 |
empty — signals receivers to discard |
The PTTLiveVoiceSession uses StreamState to guarantee exactly one START packet, then feeds each frame batch to VoiceBurstPacketizer.add and flushes completed packets. The sendPacket closure—provided by the chat coordinator—injects packets into BLEService for Noise-encrypted transmission.
Packet size respects TransportConfig.pttMaxBurstContentBytes to ensure single-BLE-frame delivery after encryption overhead.
Stage 3: Reception, Assembly, and UI Integration
The ChatLiveVoiceCoordinator handles inbound voice bursts. Registered for both private DMs (NoisePayloadType.voiceFrame) and public mesh traffic (MessageType.voiceFrame), it performs four critical functions:
- Decodes packets via
VoiceBurstPacket.decode - Buffers out-of-order packets with a 0.5-second gap timeout (
gapSkipSeconds) - Progressively writes AAC data to a temporary
.aacfile - Plays live audio through
PTTBurstPlayer
When the END packet arrives, the coordinator creates a BitchatMessage representing the live burst. If the sender later delivers the finalized .m4a voice-note (voice_<burstID>.m4a), the coordinator silently absorbs it—preventing duplicate bubbles in the chat UI.
// Receiving a voice frame in the coordinator
func handlePublicVoiceFramePayload(
from peerID: PeerID,
nickname: String,
payload: Data,
timestamp: Date
) {
// Decode packet, assemble burst, write to temporary file, play live
chatLiveVoiceCoordinator.handlePublicVoiceFramePayload(
from: peerID,
nickname: nickname,
payload: payload,
timestamp: timestamp
)
}
Session Lifecycle: From Press to Release
The complete user interaction follows a strict state machine:
Press to start:
let liveSession = PTTLiveVoiceSession(
sendPacket: { data in meshService.sendVoiceBurst(data) }
)
await liveSession.start()
// START packet emitted, UI shows setActivePublicVoiceTalker indicator
Hold to stream:
PTTCaptureEnginedelivers AAC frames viaonFrames- Each batch packetized and transmitted
PTTBurstPlayerplays inbound streams from peer
Release to finish:
if let noteURL = await liveSession.finish() {
// noteURL is the finalized .m4a file
viewModel.sendVoiceNote(fileURL: noteURL)
}
// END packet emitted, voice-note enters normal message pipeline
Key Implementation Files
| File | Purpose |
|---|---|
VoiceCaptureSession.swift |
Protocol definition and concrete session implementations including PTTLiveVoiceSession |
PTTCaptureEngine.swift |
Low-level audio engine for recording, AAC encoding, and frame emission |
VoiceBurstPacket.swift |
Wire-format packet structures and VoiceBurstPacketizer logic |
ChatLiveVoiceCoordinator.swift |
Inbound packet handling, burst assembly, and UI integration |
PTTBurstPlayer.swift |
Real-time playback of incoming live voice frames |
PTTSettings.swift |
User preference toggle for enabling/disabling live PTT |
Performance and Reliability Considerations
The BitChat PTT system prioritizes low latency over guaranteed delivery. The 0.5-second gap tolerance in ChatLiveVoiceCoordinator prevents stalls from minor packet loss, while the dual-output capture ensures message persistence regardless of streaming success.
Codec selection (aacLC16kMono at 16 kHz) balances quality against BLE bandwidth constraints. The 8-frame maximum per DATA packet and pttMaxBurstContentBytes limit keep transmissions within single BLE frames, avoiding fragmentation latency.
Summary
- Push-to-talk in BitChat streams AAC audio live while recording, then promotes the recording to a standard voice-note
PTTLiveVoiceSessionorchestrates capture throughPTTCaptureEngineand packetization viaVoiceBurstPacketizerVoiceBurstPacketdefines START/DATA/END/CANCELED states with compact binary encoding for BLE efficiencyChatLiveVoiceCoordinatorhandles reception, assembly, live playback, and deduplication with final voice-notes- The burst ID ties live streams to their persistent counterparts, ensuring seamless UX across network conditions
Frequently Asked Questions
What happens if the BLE connection drops during a push-to-talk transmission?
The live stream terminates without an END packet, and receivers discard the partial burst after the 0.5-second gap timeout. However, the sender's local .m4a recording completes normally and can be sent as a regular voice-note once connectivity returns.
How does BitChat prevent duplicate voice messages in the chat UI?
The ChatLiveVoiceCoordinator tracks burst IDs. When it receives both a completed live burst (via PTT packets) and its matching voice-note file (via the standard message pipeline), it absorbs the file silently and presents only the unified message bubble.
Can users disable push-to-talk while keeping standard voice notes?
Yes. PTTSettings.swift provides a user-facing toggle that disables the live streaming pathway without affecting VoiceNoteCaptureSession or the standard voice-note recording flow.
Why does BitChat use AAC instead of Opus for voice compression?
The aacLC16kMono codec selection in VoiceBurstPacket.swift reflects hardware encoder availability on iOS devices and predictable frame sizes for BLE packetization. The 16 kHz mono configuration provides sufficient voice quality within the byte budget of pttMaxBurstContentBytes.
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 →