feat(M2): UDP voice/media plane -- SFU relay, Opus, AEAD, jitter buffer

Adds the full voice pipeline: 14-byte binary frame header, ChaCha20-Poly1305
AEAD keyed from the TLS exporter, libopus encode/decode with FEC/PLC/DTX,
an adaptive per-ssrc jitter buffer, a miniaudio capture/playback engine, an
APM passthrough stub, and the UdpBinding/StreamAnnounce signaling chain
wired through ConnSession/SessionRegistry into a new server-side SFU
(MediaRelay) that decrypts and re-encrypts frames per channel member.

Exit criterion verified: test_m2_voice — two headless clients relay 50
encrypted Opus frames through the server; ctest --preset m1-dev is 9/9
green. Also corrects protocol.md's UdpBinding diagram, which described the
UDP-side binding packet as AEAD-sealed when it is in fact a plaintext
bootstrap frame (separate from the TCP/TLS UdpBinding ack).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 01:31:14 +02:00
parent 63f457fc54
commit 694494a5be
29 changed files with 2548 additions and 86 deletions

128
tests/test_voice_frame.cpp Normal file
View File

@@ -0,0 +1,128 @@
/*
* test_voice_frame — serialize/parse round-trips for the 14-byte UDP media header.
*/
#include <cassert>
#include <cstdio>
#include <cstring>
#include <vector>
#include "net/voice_frame.h"
using namespace voicecat::net;
static int g_failures = 0;
#define CHECK(cond) \
do { if (!(cond)) { \
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
++g_failures; \
}} while (0)
static void test_header_round_trip() {
VoiceFrame f;
f.type = kFrameVoice;
f.flags = kFlagMarker | kFlagFecPresent;
f.codec = kCodecOpus;
f.ssrc = 0xDEADBEEF;
f.seq = 0xAB12;
f.timestamp = 0x12345678;
uint8_t buf[kVoiceHeaderSize];
serialize_header(f, buf);
VoiceFrame out{};
CHECK(parse_header(buf, kVoiceHeaderSize, out));
CHECK(out.type == f.type);
CHECK(out.flags == f.flags);
CHECK(out.codec == f.codec);
CHECK(out.ssrc == f.ssrc);
CHECK(out.seq == f.seq);
CHECK(out.timestamp == f.timestamp);
}
static void test_empty_payload_packet() {
VoiceFrame f;
f.type = kFrameKeepalive;
f.ssrc = 42;
uint8_t buf[kVoiceHeaderSize];
serialize_header(f, buf);
VoiceFrame out{};
CHECK(parse_header(buf, kVoiceHeaderSize, out));
CHECK(out.type == kFrameKeepalive);
CHECK(out.ssrc == 42);
}
static void test_payload_packet() {
std::vector<uint8_t> payload(60, 0xAB);
VoiceFrame f;
f.ssrc = 0x00000001;
f.seq = 0x0001;
f.timestamp = 960;
f.payload = payload;
// Serialize full wire packet
std::vector<uint8_t> wire(kVoiceHeaderSize + payload.size());
serialize_header(f, wire.data());
std::memcpy(wire.data() + kVoiceHeaderSize, payload.data(), payload.size());
VoiceFrame out{};
CHECK(parse_header(wire.data(), wire.size(), out));
CHECK(out.ssrc == f.ssrc);
CHECK(out.seq == f.seq);
CHECK(out.timestamp == f.timestamp);
// Payload starts at kVoiceHeaderSize
CHECK(wire.size() - kVoiceHeaderSize == 60);
CHECK(wire[kVoiceHeaderSize] == 0xAB);
}
static void test_udp_binding_packet() {
uint8_t token[16];
for (int i = 0; i < 16; ++i) token[i] = static_cast<uint8_t>(i);
auto pkt = make_udp_binding_packet(token, 16);
CHECK(pkt.size() == kVoiceHeaderSize + 16);
CHECK(pkt[0] == kFrameUdpBinding);
CHECK(std::memcmp(pkt.data() + kVoiceHeaderSize, token, 16) == 0);
}
static void test_parse_too_short() {
uint8_t buf[10] = {};
VoiceFrame out{};
CHECK(!parse_header(buf, 10, out));
}
static void test_big_endian_layout() {
VoiceFrame f;
f.ssrc = 0x01020304;
f.seq = 0x0506;
f.timestamp = 0x0708090A;
uint8_t buf[kVoiceHeaderSize];
serialize_header(f, buf);
// ssrc at [4..7]
CHECK(buf[4] == 0x01 && buf[5] == 0x02 && buf[6] == 0x03 && buf[7] == 0x04);
// seq at [8..9]
CHECK(buf[8] == 0x05 && buf[9] == 0x06);
// timestamp at [10..13]
CHECK(buf[10] == 0x07 && buf[11] == 0x08 && buf[12] == 0x09 && buf[13] == 0x0A);
}
int main() {
test_header_round_trip();
test_empty_payload_packet();
test_payload_packet();
test_udp_binding_packet();
test_parse_too_short();
test_big_endian_layout();
if (g_failures == 0) {
std::printf("voice_frame: all tests passed\n");
return 0;
}
std::printf("voice_frame: %d test(s) FAILED\n", g_failures);
return 1;
}