Files
buzz/mobile/HUDDLES.md
T
2026-08-17 08:32:01 +01:00

5.9 KiB

Mobile Huddles MVP

This foreground-only product slice preserves the existing Desktop/relay contract. Android and iOS clients can start a human Huddle, open a recent Desktop-started Huddle card, join with the microphone on, hear multiple remote participants, send microphone audio, mute, and leave.

The parent channel carries creator-signed kind 48100 start and 48103 end events. The private stream backing channel uses kind 9007, ttl=3600, and is hidden from the ordinary channel list only after a matching start event links it from an accessible parent. The top-right hang-up follows Desktop's rule: it fetches the current kind 39002 backing-channel snapshot and counts non-bot members before disconnecting, submits kind 9022 when another human remains, and otherwise publishes kind 48103 and archives with kind 9002. A failed count safely assumes another human is present so a transient relay error cannot end the Huddle. Creator-only explicit end support remains in the controller, but the current mobile UI does not foreground a separate “End for everyone” control. On relaunch, parent event history reconstructs the visible card without silently reopening a microphone.

Control plane

  1. Connect to WS /huddle/{ephemeral_channel_id}/audio.
  2. Receive {"type":"challenge","challenge":"..."}.
  3. Sign NIP-42 kind 22242 with relay=<base relay WebSocket URL> and the challenge. The relay tag is not the Huddle endpoint URL.
  4. Send an object envelope containing type=auth, the signed event, parent_channel_id, and protocol_version=2.
  5. Treat the connection as usable only after a joined response. error, unexpected close, handshake timeout, and protocol failures are explicit states. An established session retries only its media socket with bounded backoff (0, 100, 250, 500, 1000, 2000, 2000 ms) and keeps native media alive while reconnecting.

The main Nostr socket is intentionally not shared: it accepts Nostr JSON arrays and drops binary frames, while Huddle control messages are JSON objects.

Emoji reactions remain on the shared Nostr control plane. Mobile publishes the same ephemeral kind 24810 event as Desktop, scoped to the backing channel with h, reaction, and sender_name tags (plus the optional NIP-30 emoji tag for custom emoji). While a Huddle session is active, Mobile also subscribes to that same backing-channel event stream and bursts reactions from other identities; the sender's relay echo is ignored because its burst already plays locally. When the sender's full-screen participant avatar is visible, that avatar is the burst origin so authorship is spatially clear; minimized calls fall back to the available Huddle surface. Reaction events never enter the ordinary channel timeline.

Media plane: protocol v2

Audio is 48 kHz, mono Opus in 20 ms (960-sample) frames.

Client to relay:

8-byte header | Opus payload

Relay to client:

peer_index u8 | 8-byte header | Opus payload

The header is network byte order: sequence u16, 48 kHz timestamp u32, level dBov i8 in -127...0, and flags u8 where bit 0 marks DTX. Unknown flag bits are ignored. Frames are capped at 4096 bytes before the relay peer prefix.

The dBov header also drives the participant speaking treatment. Values above the existing -55 dBov activity threshold are normalized for a 50 ms visual update cadence and continuously scale a 7%-opacity primary-color halo from just beyond the avatar to 2.55 times its base diameter. Level increases use a responsive eased attack while decreases and silence use a softer eased release; silence begins that release after the existing 600 ms hold.

Native boundary

buzz/huddle_media owns microphone permission and the foreground voice audio session. Each platform probes its native Opus encoder and decoder before advertising support. On Android, AudioRecord captures 48 kHz mono PCM, MediaCodec encodes and decodes Opus, and one AudioTrack per remote participant lets the Android communication mixer combine decoded PCM. On iOS, AVAudioEngine owns voice-processed capture and per-peer mixed playout while AVAudioConverter performs native Opus encoding and decoding. Both paths use a three-packet startup jitter buffer with a ten-packet per-peer bound.

Acoustic echo cancellation is enabled through Android's voice communication effects and iOS Voice Processing I/O. Audio focus/session interruptions surface explicitly; media resumes only when the platform permits it. The Flutter speaker control directly switches between the built-in speaker and the normal communication route. Built-in output starts on the earpiece/receiver, while an already-selected external Bluetooth or wired communication route remains under the operating system's routing policy until the user explicitly enables the built-in speaker.

Only compressed localOpusFrame and playRemoteOpusFrame messages cross the Flutter bridge; PCM remains inside the native realtime path. Capture starts unmuted. Debuggable builds also emit aggregate pre-encode RMS/peak dBov histograms plus the active recording route and exposed voice-processing state. They never retain or persist PCM, Opus packets, or speech content. Native capture and compressed ingress queues are bounded so a foreground session cannot accumulate unbounded latency.

This is deliberately not Desktop parity. The app must remain in the foreground; pausing/detaching leaves the room. Mobile agent controls, mobile-originated STT/TTS, recordings, background ringing/calling, and advanced device controls remain outside this slice. Agent-authenticated Opus published by a Desktop Huddle uses the same remote playback path as any other participant.

Physical iOS acceptance must separately verify permission, receiver/speaker and connected-headset routing, interruption recovery, and two-way audio with a Desktop participant. Static codec and Flutter tests do not prove those device behaviors.