Files
buzz/mobile/lib/shared/huddle/huddle_wire.dart
klopez4212 8c0f42e187 Add mobile Huddles voice MVP (#6056)
## Summary
- add foreground mobile Huddles on Android and iOS with native Opus
capture/playback, mute, speaker routing, participants, lifecycle, and
minimized drawer UI
- keep mobile Huddle cards and roster state live, including ended rooms,
relay-resolved profiles, and agents
- broadcast desktop agent TTS through the existing Huddle audio protocol

## Scope
Foreground human-to-human voice MVP only. Agent setup/transcripts,
background calling, recording, and advanced device controls remain out
of scope.

## Validation
- `just mobile-check`
- `just mobile-test` — 1,500 passed
- `just desktop-check` and `just desktop-test` — 4,957 passed
- desktop typecheck, strict Clippy, and Tauri tests — 2,445 passed, 15
ignored
- mobile worktree identity contract checks
- physical Pixel/iPhone behavior reviewed during development

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: leader <71e9f2c44a6932b6772caaaccda1911d010463c3e2c6c40410b8329956046801@buzz.block.builderlab.xyz>
Co-authored-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz>
2026-08-22 05:28:16 -07:00

194 lines
6.1 KiB
Dart

import 'package:flutter/foundation.dart';
/// Fixed audio framing contract shared with Buzz Desktop and `buzz-relay`.
///
/// Huddle audio rooms pin the first participant's protocol version. New mobile
/// clients request v3 because relay-to-client frames add an occupancy epoch to
/// v2's one-byte peer prefix. They must not silently fall back: the relay
/// rejects mixed-version rooms with `upgrade_required`.
abstract final class HuddleWireV3 {
static const protocolVersion = 3;
static const sampleRateHz = 48000;
static const channels = 1;
static const frameSamples = 960;
static const frameDuration = Duration(milliseconds: 20);
static const headerLength = 8;
static const relayPeerPrefixLength = 2;
static const dtxFlag = 0x01;
static const maxBinaryFrameLength = 4096;
/// Encodes a client-to-relay frame: `header | opus payload`.
static Uint8List encodeClientFrame(
HuddleAudioHeader header,
Uint8List opusPayload,
) {
if (opusPayload.isEmpty) {
throw const HuddleWireException('Opus payload must not be empty.');
}
final length = headerLength + opusPayload.length;
if (length > maxBinaryFrameLength) {
throw HuddleWireException(
'Huddle audio frame is $length bytes; maximum is '
'$maxBinaryFrameLength.',
);
}
return Uint8List.fromList([...header.encode(), ...opusPayload]);
}
/// Decodes a relay-to-client frame:
/// `peer index | epoch | header | opus payload`.
static HuddleRemoteAudioFrame decodeRelayFrame(Uint8List bytes) {
final minimumLength = relayPeerPrefixLength + headerLength + 1;
if (bytes.length < minimumLength) {
throw HuddleWireException(
'Relay audio frame is ${bytes.length} bytes; expected at least '
'$minimumLength.',
);
}
if (bytes.length > maxBinaryFrameLength + relayPeerPrefixLength) {
throw HuddleWireException(
'Relay audio frame is ${bytes.length} bytes; maximum is '
'${maxBinaryFrameLength + relayPeerPrefixLength}.',
);
}
return HuddleRemoteAudioFrame(
peerIndex: bytes[0],
epoch: bytes[1],
header: HuddleAudioHeader.decode(bytes, offset: relayPeerPrefixLength),
opusPayload: Uint8List.fromList(
bytes.sublist(relayPeerPrefixLength + headerLength),
),
);
}
}
/// The fixed eight-byte v2 header, encoded in network byte order.
///
/// Layout:
/// - bytes 0-1: wrapping sequence number (`u16`)
/// - bytes 2-5: sender media timestamp at 48 kHz (`u32`)
/// - byte 6: sender-authored audio level in dBov (`i8`, `-127...0`)
/// - byte 7: flags (`bit 0 = Opus DTX`; unknown bits are ignored)
@immutable
final class HuddleAudioHeader {
final int sequence;
final int timestamp48k;
final int levelDbov;
final int flags;
const HuddleAudioHeader({
required this.sequence,
required this.timestamp48k,
required this.levelDbov,
required this.flags,
}) : assert(sequence >= 0 && sequence <= 0xffff),
assert(timestamp48k >= 0 && timestamp48k <= 0xffffffff),
assert(levelDbov >= -127 && levelDbov <= 0),
assert(flags >= 0 && flags <= 0xff);
/// Builds a header while enforcing the wire ranges in release builds too.
factory HuddleAudioHeader.checked({
required int sequence,
required int timestamp48k,
required int levelDbov,
required int flags,
}) {
_validateValues(sequence, timestamp48k, levelDbov, flags);
return HuddleAudioHeader(
sequence: sequence,
timestamp48k: timestamp48k,
levelDbov: levelDbov,
flags: flags,
);
}
bool get isDtx => flags & HuddleWireV3.dtxFlag != 0;
Uint8List encode() {
_validate();
final bytes = Uint8List(HuddleWireV3.headerLength);
final view = ByteData.sublistView(bytes);
view.setUint16(0, sequence, Endian.big);
view.setUint32(2, timestamp48k, Endian.big);
view.setInt8(6, levelDbov);
view.setUint8(7, flags);
return bytes;
}
static HuddleAudioHeader decode(Uint8List bytes, {int offset = 0}) {
if (offset < 0 || bytes.length - offset < HuddleWireV3.headerLength) {
throw const HuddleWireException(
'Huddle audio header requires eight bytes.',
);
}
final view = ByteData.sublistView(
bytes,
offset,
offset + HuddleWireV3.headerLength,
);
final rawLevel = view.getInt8(6);
// Match the desktop contract: malformed VU telemetry becomes the silent
// floor but never causes an otherwise valid Opus packet to be dropped.
final level = rawLevel >= -127 && rawLevel <= 0 ? rawLevel : -127;
return HuddleAudioHeader(
sequence: view.getUint16(0, Endian.big),
timestamp48k: view.getUint32(2, Endian.big),
levelDbov: level,
flags: view.getUint8(7),
);
}
void _validate() => _validateValues(sequence, timestamp48k, levelDbov, flags);
static void _validateValues(
int sequence,
int timestamp48k,
int levelDbov,
int flags,
) {
if (sequence < 0 || sequence > 0xffff) {
throw RangeError.range(sequence, 0, 0xffff, 'sequence');
}
if (timestamp48k < 0 || timestamp48k > 0xffffffff) {
throw RangeError.range(timestamp48k, 0, 0xffffffff, 'timestamp48k');
}
if (levelDbov < -127 || levelDbov > 0) {
throw RangeError.range(levelDbov, -127, 0, 'levelDbov');
}
if (flags < 0 || flags > 0xff) {
throw RangeError.range(flags, 0, 0xff, 'flags');
}
}
}
@immutable
final class HuddleRemoteAudioFrame {
final int peerIndex;
/// Occupancy epoch of `peerIndex` this frame was authored under. Fenced by
/// the transport against the current roster occupant's epoch so a frame from
/// a departed occupant that arrives after its slot is reused is dropped
/// rather than mis-attributed to the new occupant.
final int epoch;
final HuddleAudioHeader header;
final Uint8List opusPayload;
const HuddleRemoteAudioFrame({
required this.peerIndex,
required this.epoch,
required this.header,
required this.opusPayload,
});
}
final class HuddleWireException implements Exception {
final String message;
const HuddleWireException(this.message);
@override
String toString() => 'HuddleWireException: $message';
}