mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
## 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>
194 lines
6.1 KiB
Dart
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';
|
|
}
|