mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:25:18 +00:00
* Live push-to-talk voice for DMs: stream while you talk, voice note as fallback Holding the mic in a DM now streams AAC frames live over the Noise session (walkie-talkie style, ~0.5s mouth-to-ear at one hop) while recording the same audio as a normal voice note. On release the note ships through the existing fileTransfer pipeline; receivers that heard the live stream absorb it silently into the same bubble (matched by the burst ID embedded in the file name), so reliability comes for free and nobody sees duplicates. Protocol: - NoisePayloadType.voiceFrame = 0x08 carrying VoiceBurstPacket (burstID + seq + START/data/END/CANCELED, length-prefixed AAC frames) - 210-byte burst-content budget keeps each Noise packet inside the 256-byte padding bucket: one BLE frame, never the fragment scheduler - fire-and-forget: frames are dropped (never queued) without an established session; live is only offered when the peer is mesh-reachable Receive: - ChatLiveVoiceCoordinator assembles bursts (jitter-ordered, 0.5s gap skip, 3s idle end, flood/size caps), persists progressively as ADTS .aac so even a partial burst is a replayable bubble - live autoplay only when the conversation is on screen, app active, and the new app-info "live voice messages" toggle is on (also gates live sending) - one-playback-at-a-time via a shared ExclusivePlayback slot Capture: - PTTCaptureEngine taps AVAudioEngine, dual-encodes: live AAC frames + the finalized .m4a (same 16kHz/mono/16kbps settings as VoiceRecorder) - VoiceRecordingViewModel now drives a pluggable VoiceCaptureSession; the composer HUD shows a pulsing LIVE treatment when streaming Includes the push-to-talk design doc, 6 new localization keys across all 29 locales, and unit tests for framing, packetizer budget, ADTS output, codec round-trip, and the assembly/absorb lifecycle. Public-mesh PTT (MessageType 0x29) lands separately on top of this. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Public-mesh push-to-talk: signed live voice bursts in the mesh channel Extends live PTT from DMs to the public mesh timeline. Holding the mic in the mesh channel now broadcasts the burst live as signed voiceFrame packets (MessageType 0x29) while the finalized voice note still ships on release — new clients hear you as you speak and absorb the note silently into the live bubble; old clients (and late joiners) keep receiving the note exactly as before, so mixed-version meshes lose nothing. Wire/relay: - MessageType.voiceFrame = 0x29: ephemeral signed broadcast, never gossip-synced (SyncTypeFlags maps it to no bit), never padded (padding to the 512 block would push every ~490-byte signed packet into fragmentation) - RelayController treats voiceFrame like media fragments: dense-graph TTL clamp contains the sustained ~15 pkt/s per-talker stream, tight 8-25 ms jitter keeps multi-hop latency inside the receiver's 350 ms jitter buffer - inbound gate mirrors public messages: broadcast-only, 30 s freshness cap, packet signature verified against the claimed sender's announce before any audio reaches the UI App: - ChatLiveVoiceCoordinator gains burst scopes: public bubbles land in the mesh timeline, autoplay only while that timeline is on screen, and the finalized-note absorb is scope-bound (a public note can't replace a DM burst or vice versa) - floor courtesy: while someone talks live in the public channel the composer mic tints red and pulses, with an accessibility value naming the talker ("%@ is speaking", localized in all 29 locales); holding still works — a decentralized mesh has no floor arbiter, the tint just discourages talk-over Tests: relay policy (sparse cap + dense clamp), public bubble + talker indicator lifecycle, note absorption into the mesh store, and scope-binding rejection; full suite green (1382 tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * PTT follow-ups from review + field test: peer-ID normalization, toggle gates inbound, drop-path diagnostics Codex review fixes (#1403): - makeVoiceCaptureSession normalizes the selected peer with toShort() before the reachability/session checks and binds the send target to that same routing ID — a conversation selected under the stable 64-hex Noise key no longer silently falls back to a classic note while the short-ID session is established - the live-voice toggle now gates inbound bursts too: off means classic-notes-only in both directions (no live bubble, partial file, or early notification; the finalized note still arrives), with a test Field-test diagnostics (first device run: DM frames decrypted but no bubble appeared, with no log evidence of which guard dropped them): - coordinator logs undecodable frames (size + hex prefix) and blocked drops - makeAssembly logs directory/file-handle failures instead of returning nil silently - PTTLiveVoiceSession logs capture start and finish (packet/frame/duration counts); PTTCaptureEngine logs engine start success/failure with the input format; BLEService.sendVoiceFrame logs no-session drops Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix iPhone live-capture failure: dead input unit (AURemoteIO -10851, 0 Hz) Field testing showed the phone's live capture failing at mic enable with AURemoteIO -10851 and an input format of 0 Hz / 2 ch — an input unit bound to an earlier (playback-only or settling) audio session. The Mac, which has no session lifecycle, captured fine, which is why public bursts from the Mac worked while phone-side sends degraded from working (first hold) to sporadic to dead across holds. Three layers of defense: - PTTCaptureEngine recreates its AVAudioEngine on every start(), after the session is configured, so the input unit binds to the session that is active now; a dead input (0 Hz or 0 channels) is now a distinct, logged error instead of a silent setup failure - PTTLiveVoiceSession retries the capture start once after a 150 ms route-settle pause - VoiceRecordingViewModel falls back to the classic VoiceRecorder within the same hold if the live engine still cannot start — a route glitch now costs the live stream, never the voice note Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Blue mic when the hold will stream live The mic button now shows readiness at a glance — and doubles as a build marker for device testing: - blue: holding will stream live (DM peer reachable with an established Noise session, or the public mesh channel) - accent (orange in DMs): holding records a classic voice note (no session yet, peer unreachable, or live voice toggled off) - red states unchanged (recording, floor busy) Refactors capture-backend selection into a single liveVoiceTarget() so the indicator and makeVoiceCaptureSession can never disagree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Revert the blue live-ready mic to the normal accent color The build-verification marker did its job; idle mic color goes back to the accent. The LIVE recording HUD remains the signal for whether a hold is streaming. Keeps the liveVoiceTarget() refactor so backend selection stays in one place. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Leave a trace on every mic press and every inbound-frame drop Field testing read "tap does nothing" as breakage: the mic start is async (permission check + engine spin-up), so releasing before recording begins has always been a silent cancel — for classic voice notes too. Every press now logs which backend it chose and, for quick presses, that it released before recording started. Also logs the two remaining silent drops: inbound voice frames rejected by the live-voice toggle (the one unlogged guard left in the receive path) and the classic-note fallback now includes the toggle state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix mic hold dying instantly in DMs: sheet swipe gesture starved the composer Field logs showed every DM mic hold ending 3-10 ms after it began, on both platforms, while public-channel holds worked — the private sheet wraps its entire content (composer included) in a high-priority swipe-right-to-leave DragGesture, and a high-priority ancestor drag cancels the mic button's press-and-hold within milliseconds. Same starvation mechanism as the DM image-reveal bug (#1402), hitting a drag instead of a tap. The swipe-to-leave gesture now lives on the message list only, so the composer's gestures (mic hold, text field, buttons) are out of its reach and the swipe still works where users actually swipe. Also stops touching the capture engine when a hold cancels before the engine ever started: probing inputNode on a never-started engine instantiates its input unit against whatever session is active and spams benign-but-alarming AURemoteIO -10851 errors into field logs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Reorder app info sheet: usage first, then settings, then reference New section order: HOW TO USE, then the adjustable bits (appearance, voice, network), then the reference material (features, privacy, symbols legend). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * App info: flow HOW TO USE into one paragraph; "list" and "person" wording The six how-to-use bullets now read as a single comma-separated paragraph (same instruction strings, legacy bullet prefix stripped at render). Two wording updates across all 29 locales: the people icon opens the "list" (not "sidebar"), and you tap a "person's" name (not a "peer's") to start a DM. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
163 lines
14 KiB
Markdown
163 lines
14 KiB
Markdown
# Smart Push-to-Talk (PTT) — Design
|
||
|
||
**Status:** Draft for review — no code yet
|
||
**Scope:** Live voice bursts over the BLE mesh, in public chat and Noise DMs, with graceful degradation to the existing voice-note pipeline.
|
||
|
||
## 1. Goal and core idea
|
||
|
||
Today, holding the mic button records an AAC `.m4a` and ships it as a `fileTransfer` (0x22) after you release — the receiver hears nothing until the whole file arrives. PTT makes the same gesture *live*: encoded audio frames stream over the mesh while you speak, and nearby receivers hear you with sub-second delay, walkie-talkie style.
|
||
|
||
The "smart" part is that PTT is not a separate mode the user must choose. It is a **delivery strategy**, picked automatically per conversation:
|
||
|
||
| Context | Delivery |
|
||
|---|---|
|
||
| DM, peer connected/reachable on mesh | **Live stream** (Noise-encrypted frames) + finalized voice note for reliability |
|
||
| DM, peer only reachable via Nostr | Existing voice-note recording only (no live; media doesn't ride Nostr today) |
|
||
| Public mesh chat | **Live broadcast stream** (signed) + finalized voice note, same dedup as DMs |
|
||
| Geohash (Nostr) channels | PTT unavailable (matches existing `canSendMediaInCurrentContext` media policy) |
|
||
|
||
*(Public originally speced as live-only to avoid doubling bandwidth, but dropping the note broadcast would regress mixed-version meshes — old clients that can't decode live bursts would stop receiving public voice entirely — and late joiners/out-of-range peers would get nothing. The note stays; live receivers absorb it silently.)*
|
||
|
||
One gesture (hold mic), one mental model ("talk"), and the system degrades from live → reliable-note → unavailable based on what the transport can actually do.
|
||
|
||
**Bandwidth reality check:** the mesh moves ~15 KB/s per link (469 B fragments at 30 ms spacing); our voice codec needs ~2 KB/s. Live voice fits with a wide margin, even relayed.
|
||
|
||
## 2. What already exists (reused, not rebuilt)
|
||
|
||
- **Capture UX:** `ContentComposerView.micButtonView` already implements hold-to-record / release-to-send via `DragGesture`, backed by `VoiceRecordingViewModel`'s state machine and `VoiceRecorder` (AAC-LC, 16 kHz mono, 16 kbps). Mic permission string is already in Info.plist.
|
||
- **Reliable delivery:** `BitchatFilePacket` TLV + `fileTransfer` (0x22) + fragmentation + transfer progress + `ChatMediaTransferCoordinator`.
|
||
- **Playback:** `VoiceNotePlaybackController` + `VoiceNotePlaybackCoordinator` (single active playback), `WaveformView`, `VoiceNoteView`.
|
||
- **Transport:** signed public packets, Noise sessions with typed inner payloads, `RelayController` flood control, `MessageDeduplicator`, `MessageRouter.canDeliverPromptly`.
|
||
|
||
New work is the **streaming path**: a frame encoder/packetizer, a wire format for bursts, a jitter-buffered receiver/player, relay policy, and the live-bubble UI.
|
||
|
||
## 3. Wire protocol
|
||
|
||
### 3.1 Burst framing (shared inner format)
|
||
|
||
A talk burst is a sequence of packets sharing an 8-byte random `burstID`:
|
||
|
||
```
|
||
[burstID: 8][seq: UInt16 BE][flags: 1][payload…]
|
||
```
|
||
|
||
`flags` bits:
|
||
- `0x01 START` — payload is a header TLV: codec (1 B enum, 0x01 = AAC-LC/16kHz/mono), frame duration ms, batch size. Sent as seq 0 and re-attached (piggybacked TLV) every ~2 s so mid-burst joiners can sync.
|
||
- `0x02 END` — payload: total data-packet count (UInt16), duration ms (UInt32). Lets receivers detect tail loss.
|
||
- `0x04 CANCELED` — sender slid-to-cancel; receivers stop playback, discard buffered audio, and drop the bubble.
|
||
- `0x00` (data) — payload is N length-prefixed AAC frames: `[len: UInt16 BE][ADTS-less raw AAC frame]…`
|
||
|
||
Audio math: AAC-LC at 16 kHz has 1024-sample frames = **64 ms/frame ≈ 130 B at 16 kbps**. A greedy packetizer batches frames up to a **210-byte burst-content budget** (`pttMaxBurstContentBytes`), chosen so the Noise ciphertext (content + 1 inner-type byte + 16-byte tag) stays inside `MessagePadding`'s 256-byte bucket — the whole directed packet is 288 bytes and voice **never enters the fragment scheduler** (which caps concurrent transfers at 2 and would starve file sends). At 16 kbps that works out to **1 frame/packet, ~15.6 pkt/s, ~5.3 KB/s wire** for DMs; public bursts (unpadded, planned for phase 2) can batch 3 frames.
|
||
|
||
### 3.2 Public mesh: new `MessageType.voiceFrame = 0x29`
|
||
|
||
- Broadcast (no recipient), **signed** like public messages; unsigned or signature-mismatched frames are dropped on receive.
|
||
- **No padding** (add to `BLEOutboundPacketPolicy` alongside `fileTransfer`) — padding to the 512 block would push every packet over MTU into fragmentation.
|
||
- TTL: `messageTTL` (7) at origin; `RelayController` gets a `voiceFrame` case mirroring the fragment policy — dense clamp to 5, jitter 8–25 ms — so live audio floods like fragments, not like announces. Per-hop cost is ~10–40 ms; 3 hops stays comfortably inside the jitter buffer.
|
||
- Dedup: existing `MessageDeduplicator` (timestamp ms + seq make each packet unique).
|
||
- 0x29 is the next free code after `nostrCarrier = 0x28`; unknown types are ignored by older clients (iOS and Android), so rollout is compatible — old clients simply don't hear live bursts.
|
||
|
||
### 3.3 DM: new `NoisePayloadType.voiceFrame = 0x08`
|
||
|
||
- Inner payload = the same burst framing, wrapped in `noiseEncrypted` (0x11) directed packets — wire-indistinguishable from other DM traffic by size/type (0x04/0x05 are reserved per the comment in `BitchatProtocol.swift`; 0x08 is the first free slot after `groupKeyUpdate = 0x07`).
|
||
- Directed encrypted packets are already always-relayed by `RelayController`, so multi-hop DMs work unchanged.
|
||
- Requires an established session; PTT-live is only offered when `noiseService.hasEstablishedSession` (otherwise first hold triggers the normal handshake + falls back to voice-note for that burst).
|
||
- Fire-and-forget: **no delivery acks, no retransmit** for frames. Late audio is worthless; reliability comes from the finalized note (§5).
|
||
|
||
### 3.4 Known traffic-analysis tradeoff
|
||
|
||
A steady ~8 pkt/s cadence for the duration of a burst is a timing fingerprint even under Noise (observers can infer "someone is speaking to someone", not content). This is inherent to live voice; the doc records it as accepted for v1. Block padding is skipped for voiceFrame (size classes would leak little beyond what cadence already does).
|
||
|
||
## 4. Sender pipeline
|
||
|
||
`VoiceRecorder` (AVAudioRecorder → file) can't stream, so PTT capture uses a parallel path in a new `PTTStreamEncoder` actor:
|
||
|
||
```
|
||
AVAudioEngine input tap (native format)
|
||
→ PTTInputResampler → 16 kHz mono PCM
|
||
→ PTTFrameEncoder (AVAudioConverter) → AAC-LC frames → packetizer → BLEService.sendVoiceFrame
|
||
→ the same PCM is simultaneously written to an AVAudioFile .m4a (identical settings
|
||
to VoiceRecorder), so finalization needs no remux step
|
||
```
|
||
|
||
- On release: emit `END`, then hand the finalized `.m4a` to the **existing** `sendVoiceNote` flow. The note's `fileName` embeds the burst ID (`voice_<burstID hex>.m4a`) — that links note↔burst with **zero changes** to `BitchatFilePacket`/Android interop.
|
||
- On slide-to-cancel: stop tap, emit `CANCELED`, delete local file, skip finalization. UX note: unlike voice notes, live audio already played on the far side cannot be unsent — the recording UI must make "you are live" unmistakable (see §7).
|
||
- Caps: max burst 120 s (matching the voice-note recorder's cap), exactly one outbound burst at a time.
|
||
- Audio session: reuse `VoiceRecorder`'s `.playAndRecord` config; add `.duckOthers`.
|
||
|
||
## 5. Receiver pipeline
|
||
|
||
New `VoiceBurstAssembler` (keyed by sender + burstID) feeding a `PTTBurstPlayer`:
|
||
|
||
- **Jitter buffer:** start playback after 350 ms of buffered audio or 500 ms wall-clock, whichever first. Frames decode via `AVAudioConverter` → `AVAudioPlayerNode`.
|
||
- **Loss handling:** gap in seq → insert silence for the missing frames (64 ms each) and keep going. No PLC in v1; at these frame sizes brief dropouts are acceptable.
|
||
- **Burst end:** on `END`, or 3 s with no frames (talker walked out of range).
|
||
- **Persistence:** frames append to an incoming ADTS `.aac` file (already an allowed `MimeType`), so every burst becomes a replayable voice-note bubble containing whatever was captured — even a partial one.
|
||
- **Dedup with the finalized note:** when a `fileTransfer` arrives whose fileName carries a burstID we already assembled (DM or public), it silently *replaces* the partial file behind the existing bubble (no new message row, no second notification). Receivers that heard everything live just get a lossless copy.
|
||
- **Resource caps:** ≤ 8 concurrent assemblies, ≤ 256 KB per burst (60 s × 2 KB/s + slack), 30 s stale cleanup, and drop inbound frames beyond ~2× realtime per sender (spam/flood guard).
|
||
|
||
## 6. Playback policy — when does it actually make sound?
|
||
|
||
Auto-playing strangers' audio is the fastest way to make this feature hated. Rules:
|
||
|
||
1. **Live autoplay** only when *all* hold: app foregrounded, the burst's conversation is the one currently on screen, and the **"live voice messages"** toggle is on (app-level, in the app-info sheet, default on; per-conversation overrides are a v2 refinement). The same toggle gates live *sending* — off means voice behaves exactly like classic notes in both directions.
|
||
2. Otherwise the burst appears as a **live bubble**: pulsing waveform + `LIVE` badge + sender name; tapping it joins playback at the live edge. When the burst ends it becomes a normal voice-note bubble.
|
||
3. **One voice at a time:** route through `VoiceNotePlaybackCoordinator`. If two people talk simultaneously, the first burst holds the floor; the second shows as a tappable live bubble. No mixing in v1.
|
||
4. Notifications: a live burst in a non-focused DM fires the normal message notification once (at START), not per-frame.
|
||
|
||
**Floor courtesy (public mesh):** while someone else's burst is live in the current channel, the mic button tints "busy" with the talker's name. Holding it still works — a decentralized mesh has no floor arbiter, and rejecting sends would desync under partitions — but the UI discourages talk-over. Hard floor control (token passing) is explicitly out of scope.
|
||
|
||
## 7. UX
|
||
|
||
- **Same gesture:** hold mic = talk. When the live path is active, the recording HUD shows a red pulsing **LIVE** treatment (vs. the current neutral recording UI) so the sender knows audio is leaving in real time, not on release. When live isn't available (Nostr-only peer, no session yet), the HUD looks like today's and behavior is unchanged.
|
||
- Slide-to-cancel keeps working in both modes, with the §4 caveat surfaced simply: cancel stops and discards; it can't unplay what was heard.
|
||
- VoiceOver: mirror the existing `accessibilityAction` toggle-record pattern on the mic button.
|
||
- Foreground-only in v1: no `audio` UIBackgroundMode (App Store review + battery implications). If the app backgrounds mid-burst, capture stops cleanly with END. Background listen/talk is a v2 candidate.
|
||
|
||
## 8. Latency budget (1 hop, DM)
|
||
|
||
| Stage | Cost |
|
||
|---|---|
|
||
| Frame accumulation (1 × 64 ms) | 64 ms |
|
||
| Encode + packetize | ~5 ms |
|
||
| BLE write + delivery | 30–60 ms |
|
||
| Jitter buffer | 350 ms |
|
||
| **Mouth-to-ear** | **~470 ms** |
|
||
|
||
Each relay hop adds ~10–40 ms jitter + radio time. 2–3 hops stays under ~800 ms — solidly in walkie-talkie territory (commercial PoC apps run 300 ms–1 s+).
|
||
|
||
## 9. Security & privacy summary
|
||
|
||
- Public frames signed with the existing packet signature; verified against the sender's announce before decode. Unsigned → dropped.
|
||
- DM frames ride inside Noise; content confidentiality/integrity as any DM.
|
||
- Codec input is validated by frame-length prefixes and total-size caps before touching `AVAudioConverter` (malformed frames dropped, assembly aborted over cap).
|
||
- Timing fingerprint accepted per §3.4. No PTT in geohash channels (would put voice on public Nostr relays).
|
||
- Mic capture only while the button is held; recording state is always visible.
|
||
|
||
## 10. New components & touch points
|
||
|
||
| Piece | Where |
|
||
|---|---|
|
||
| `PTTStreamEncoder` (tap → AAC → packets) | `bitchat/Features/voice/` |
|
||
| `PTTBurstPlayer` (jitter buffer → decode → engine) | `bitchat/Features/voice/` |
|
||
| `VoiceBurstFramer` / `VoiceBurstAssembler` (wire encode/decode, caps) | `bitchat/Protocols/` + `bitchat/Services/PTT/` |
|
||
| `MessageType.voiceFrame = 0x29` | `localPackages/BitFoundation/.../MessageType.swift` + `BLEReceivePipeline` / `BLEService.handleReceivedPacket` |
|
||
| `NoisePayloadType.voiceFrame = 0x08` | `BitchatProtocol.swift` + `ChatTransportEventCoordinator` dispatch |
|
||
| Relay policy case | `RelayController` (fragment-like clamp/jitter) |
|
||
| No-padding rule | `BLEOutboundPacketPolicy` |
|
||
| Pacing/cap constants | `TransportConfig` |
|
||
| Live bubble + LIVE HUD + busy mic tint + settings toggle | `VoiceNoteView`/`MediaMessageView`, `ContentComposerView`, conversation settings |
|
||
| Delivery-mode selection | `ChatViewModel` / `ChatMediaTransferCoordinator` (reuse `isPeerConnected` / `canDeliverPromptly` / `hasEstablishedSession`) |
|
||
|
||
## 11. Phasing
|
||
|
||
1. **Phase 1 — DM live (highest value, lowest blast radius):** encoder, framer, Noise inner type, assembler/player, live bubble, finalize-as-note dedup. Single-hop DMs are the dominant real-world case.
|
||
2. **Phase 2 — public mesh:** `voiceFrame` 0x29, signing/verification, relay policy, floor-courtesy UI, autoplay defaults.
|
||
3. **Phase 3 (v2 candidates):** background audio mode, mid-burst repair requests (piggyback on REQUEST_SYNC), talk-over mixing, AAC-ELD low-delay profile, dedicated walkie-mode screen, media-over-Nostr (unlocks live-ish PTT for internet peers), Android protocol spec sync.
|
||
|
||
## 12. Testing
|
||
|
||
- Unit: framer round-trip (START/data/END/CANCELED, seq gaps, oversize frames), assembler caps + stale cleanup + burstID note dedup, packetizer batch sizing vs. MTU (assert no fragmentation), relay-policy clamps.
|
||
- Integration: two-simulator loopback via the existing mesh test harness; loss injection (drop every Nth frame) → verify silence-fill + partial-file persistence.
|
||
- Device: two-phone live DM (latency measurement vs. §8 budget), three-phone relay chain, talk-over behavior, cancel semantics, mic-permission-denied path.
|