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:

  1. Decodes packets via VoiceBurstPacket.decode
  2. Buffers out-of-order packets with a 0.5-second gap timeout (gapSkipSeconds)
  3. Progressively writes AAC data to a temporary .aac file
  4. 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:

  • PTTCaptureEngine delivers AAC frames via onFrames
  • Each batch packetized and transmitted
  • PTTBurstPlayer plays 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
  • PTTLiveVoiceSession orchestrates capture through PTTCaptureEngine and packetization via VoiceBurstPacketizer
  • VoiceBurstPacket defines START/DATA/END/CANCELED states with compact binary encoding for BLE efficiency
  • ChatLiveVoiceCoordinator handles 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →