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

View File

@@ -39,4 +39,32 @@ if(VOICECAT_USE_VCPKG_DEPS)
target_include_directories(test_m1_integration PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME m1_integration COMMAND test_m1_integration)
set_tests_properties(m1_integration PROPERTIES TIMEOUT 60)
# ── M2 unit tests ──────────────────────────────────────────────────────────
add_executable(test_voice_frame test_voice_frame.cpp)
target_link_libraries(test_voice_frame PRIVATE voicecat::voicecat)
target_compile_features(test_voice_frame PRIVATE cxx_std_20)
target_include_directories(test_voice_frame PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME voice_frame COMMAND test_voice_frame)
add_executable(test_media_aead test_media_aead.cpp)
target_link_libraries(test_media_aead PRIVATE voicecat::voicecat)
target_compile_features(test_media_aead PRIVATE cxx_std_20)
target_include_directories(test_media_aead PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME media_aead COMMAND test_media_aead)
add_executable(test_opus_codec test_opus_codec.cpp)
target_link_libraries(test_opus_codec PRIVATE voicecat::voicecat)
target_compile_features(test_opus_codec PRIVATE cxx_std_20)
target_include_directories(test_opus_codec PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME opus_codec COMMAND test_opus_codec)
# M2 exit criterion: two headless clients relay encrypted Opus frames via the SFU.
add_executable(test_m2_voice test_m2_voice.cpp)
target_link_libraries(test_m2_voice PRIVATE voicecat::server)
target_compile_features(test_m2_voice PRIVATE cxx_std_20)
target_include_directories(test_m2_voice PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME m2_voice COMMAND test_m2_voice)
set_tests_properties(m2_voice PROPERTIES TIMEOUT 120)
endif()

532
tests/test_m2_voice.cpp Normal file
View File

@@ -0,0 +1,532 @@
/*
* test_m2_voice — M2 exit criterion.
*
* Two headless clients authenticate over TLS, perform UDP binding, announce a voice stream,
* then Client A sends synthetic Opus frames (440 Hz sine PCM) encrypted with ChaCha20-Poly1305.
* The server SFU relay re-encrypts and forwards them to Client B.
*
* Assertions:
* 1. UDP binding and StreamAnnounce succeed for both clients.
* 2. Client B receives >= 25 out of 50 sent frames (50% floor accounts for startup latency).
* 3. Client B successfully decrypts all frames it receives (auth tag valid).
* 4. After simulated loss (every 5th frame skipped), packets_lost counter increases.
*/
#include <cstdio>
#include <cstring>
#ifdef VOICECAT_HAS_NET
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cmath>
#include <filesystem>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#ifdef _WIN32
# define WIN32_LEAN_AND_MEAN
# include <winsock2.h>
# include <ws2tcpip.h>
using sock_t = SOCKET;
static constexpr sock_t kBadSock = INVALID_SOCKET;
static void close_sock(sock_t s) { closesocket(s); }
static int sock_error() { return WSAGetLastError(); }
#else
# include <arpa/inet.h>
# include <netinet/in.h>
# include <sys/socket.h>
# include <unistd.h>
using sock_t = int;
static constexpr sock_t kBadSock = -1;
static void close_sock(sock_t s) { ::close(s); }
static int sock_error() { return errno; }
#endif
#include "crypto/crypto.h"
#include "net/voice_frame.h"
#include "protocol/envelope.h"
#include "protocol/protocol.h"
#include "server.h"
#include "db.h"
#ifdef VOICECAT_HAS_OPUS
#include "codec/opus_codec.h"
#endif
using namespace voicecat;
using namespace voicecat::net;
using namespace voicecat::crypto;
// ── helpers ───────────────────────────────────────────────────────────────────
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)
// Blocking socket helpers
static bool tcp_send_all(TlsContext& tls, const uint8_t* data, size_t len) {
size_t off = 0;
while (off < len) {
int n = tls.write(data + off, len - off);
if (n <= 0) return false;
off += static_cast<size_t>(n);
}
return true;
}
static bool tcp_send_envelope(TlsContext& tls, const v1::Envelope& env) {
std::vector<uint8_t> frame;
protocol::encode_envelope(env, frame);
return tcp_send_all(tls, frame.data(), frame.size());
}
// Read one Envelope from TLS (blocking, with 5s timeout between reads).
static bool tcp_recv_envelope(TlsContext& tls, protocol::FrameCodec& codec, v1::Envelope& out) {
uint8_t buf[16384];
for (int i = 0; i < 100; ++i) { // 100 * 50ms = 5s
int n = tls.read(buf, sizeof(buf));
if (TlsContext::is_timeout_error(n)) continue;
if (n <= 0) return false;
std::vector<std::vector<uint8_t>> frames;
if (!codec.feed(buf, static_cast<size_t>(n), frames)) return false;
for (auto& f : frames) {
if (protocol::decode_envelope(f, out)) return true;
}
}
return false;
}
// ── UDP raw socket (BSD sockets, not UdpMediaChannel) ─────────────────────────
// We use the raw BSD API here so we can set a receive timeout easily.
static sock_t udp_bind_os(uint16_t& out_port) {
#ifdef _WIN32
WSADATA wsa; WSAStartup(MAKEWORD(2,2), &wsa);
#endif
sock_t s = ::socket(AF_INET, SOCK_DGRAM, 0);
if (s == kBadSock) return kBadSock;
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
addr.sin_port = 0;
if (::bind(s, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0) {
close_sock(s); return kBadSock;
}
socklen_t len = sizeof(addr);
::getsockname(s, reinterpret_cast<sockaddr*>(&addr), &len);
out_port = ntohs(addr.sin_port);
// 500ms receive timeout
#ifdef _WIN32
DWORD tv = 500;
setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast<const char*>(&tv), sizeof(tv));
#else
struct timeval tv{0, 500000};
setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
#endif
return s;
}
static bool udp_send(sock_t s, const uint8_t* data, size_t len, uint16_t dst_port) {
sockaddr_in dst{};
dst.sin_family = AF_INET;
dst.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
dst.sin_port = htons(dst_port);
int n = static_cast<int>(::sendto(s, reinterpret_cast<const char*>(data),
static_cast<int>(len), 0,
reinterpret_cast<sockaddr*>(&dst), sizeof(dst)));
return n == static_cast<int>(len);
}
static int udp_recv(sock_t s, uint8_t* buf, size_t cap) {
return static_cast<int>(::recv(s, reinterpret_cast<char*>(buf),
static_cast<int>(cap), 0));
}
// ── TestClient ────────────────────────────────────────────────────────────────
struct TestClient {
std::string label;
sock_t tcp_sock = kBadSock;
sock_t udp_sock = kBadSock;
uint16_t udp_local_port = 0;
std::unique_ptr<TlsContext> tls;
std::unique_ptr<SodiumMediaCrypto> send_crypto;
std::unique_ptr<SodiumMediaCrypto> recv_crypto;
protocol::FrameCodec codec;
uint8_t udp_token[16]{};
uint16_t server_udp_port = 0;
uint32_t assigned_ssrc = 0;
bool stream_ok = false;
std::atomic<int> frames_received{0};
std::atomic<int> decrypt_errors{0};
// Decoded payloads for verification
std::mutex payloads_mu;
std::vector<std::vector<uint8_t>> payloads;
bool connect(const char* host, uint16_t port) {
#ifdef _WIN32
WSADATA wsa; WSAStartup(MAKEWORD(2,2), &wsa);
#endif
struct addrinfo hints{};
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
struct addrinfo* res = nullptr;
if (getaddrinfo(host, std::to_string(port).c_str(), &hints, &res) != 0 || !res)
return false;
tcp_sock = ::socket(res->ai_family, res->ai_socktype, res->ai_protocol);
if (tcp_sock == kBadSock) { freeaddrinfo(res); return false; }
if (::connect(tcp_sock, res->ai_addr, static_cast<int>(res->ai_addrlen)) != 0) {
close_sock(tcp_sock); tcp_sock = kBadSock; freeaddrinfo(res); return false;
}
freeaddrinfo(res);
return true;
}
bool tls_handshake() {
tls = std::make_unique<TlsContext>(TlsContext::Role::Client, nullptr);
tls->set_read_timeout(50); // 50ms read timeout for drain-between-reads
std::string err;
if (!tls->handshake(static_cast<int>(tcp_sock), err)) {
std::printf("[%s] TLS failed: %s\n", label.c_str(), err.c_str());
return false;
}
// Derive media keying material immediately after handshake.
send_crypto = SodiumMediaCrypto::derive_send(*tls, true); // client sends on ctx=0x00
recv_crypto = SodiumMediaCrypto::derive_recv(*tls, true); // client recvs on ctx=0x01
return send_crypto && recv_crypto;
}
bool do_hello_and_auth(const char* nickname) {
// ClientHello
{
v1::Envelope env;
env.set_request_id(1);
env.mutable_client_hello()->set_proto_version(1);
env.mutable_client_hello()->set_client_name(label);
if (!tcp_send_envelope(*tls, env)) return false;
}
// ServerHello
{
v1::Envelope env;
if (!tcp_recv_envelope(*tls, codec, env)) return false;
if (!env.has_server_hello()) return false;
server_udp_port = static_cast<uint16_t>(env.server_hello().udp_port());
}
// AuthRequest (guest)
{
v1::Envelope env;
env.set_request_id(2);
env.mutable_auth_request()->mutable_guest()->set_nickname(nickname);
if (!tcp_send_envelope(*tls, env)) return false;
}
// Wait for AuthResult (may be preceded by other messages)
for (int attempt = 0; attempt < 20; ++attempt) {
v1::Envelope env;
if (!tcp_recv_envelope(*tls, codec, env)) return false;
if (env.has_auth_result()) {
if (!env.auth_result().ok()) return false;
const auto& tok = env.auth_result().udp_token();
if (tok.size() == 16) std::memcpy(udp_token, tok.data(), 16);
return true;
}
}
return false;
}
// Drain any pending TCP messages (e.g., ServerState snapshot).
void drain_incoming(int timeout_ms) {
auto deadline = std::chrono::steady_clock::now() +
std::chrono::milliseconds(timeout_ms);
while (std::chrono::steady_clock::now() < deadline) {
v1::Envelope env;
if (tcp_recv_envelope(*tls, codec, env)) {
if (env.has_udp_binding()) { /* handled */ }
if (env.has_stream_announce_result()) { /* handled below */ }
}
}
}
bool do_udp_binding() {
// Bind local UDP socket.
udp_sock = udp_bind_os(udp_local_port);
if (udp_sock == kBadSock) return false;
// Send TCP UdpBinding (declares intent to bind).
{
v1::Envelope env;
env.set_request_id(3);
env.mutable_udp_binding()->set_udp_token(udp_token, 16);
if (!tcp_send_envelope(*tls, env)) return false;
}
// Wait for TCP UdpBinding ack.
bool got_ack = false;
for (int i = 0; i < 20 && !got_ack; ++i) {
v1::Envelope env;
if (!tcp_recv_envelope(*tls, codec, env)) break;
if (env.has_udp_binding() && env.udp_binding().ack()) got_ack = true;
}
if (!got_ack) return false;
// Send UDP binding packet (type=kFrameUdpBinding + token).
auto pkt = make_udp_binding_packet(udp_token, 16);
if (!udp_send(udp_sock, pkt.data(), pkt.size(), server_udp_port)) return false;
// Brief pause to let the server process the UDP binding.
std::this_thread::sleep_for(std::chrono::milliseconds(100));
return true;
}
bool do_stream_announce() {
// Send StreamAnnounce.
{
v1::Envelope env;
env.set_request_id(4);
auto* ann = env.mutable_stream_announce();
ann->set_kind(v1::STREAM_MIC);
auto* audio = ann->mutable_requested_audio();
audio->set_sample_rate(48000);
audio->set_bitrate_bps(24000);
audio->set_frame_ms(20);
audio->set_fec(true);
if (!tcp_send_envelope(*tls, env)) return false;
}
// Wait for StreamAnnounceResult.
for (int i = 0; i < 20; ++i) {
v1::Envelope env;
if (!tcp_recv_envelope(*tls, codec, env)) return false;
if (env.has_stream_announce_result()) {
const auto& r = env.stream_announce_result();
if (r.ok()) {
assigned_ssrc = r.ssrc();
stream_ok = true;
return true;
}
return false;
}
}
return false;
}
void close() {
if (udp_sock != kBadSock) { close_sock(udp_sock); udp_sock = kBadSock; }
if (tcp_sock != kBadSock) { close_sock(tcp_sock); tcp_sock = kBadSock; }
tls.reset();
}
};
// ── generate 440 Hz mono PCM (48 kHz, 20 ms = 960 samples) ───────────────────
static std::vector<int16_t> make_sine_frame(int frame_idx, int frame_samples = 960) {
std::vector<int16_t> pcm(frame_samples);
for (int i = 0; i < frame_samples; ++i) {
float t = static_cast<float>(frame_idx * frame_samples + i) / 48000.0f;
pcm[i] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * 440.0f * t) * 16000.0f);
}
return pcm;
}
// ── main ──────────────────────────────────────────────────────────────────────
int main() {
// ── Temp data dir ────────────────────────────────────────────────────────
auto tmp = std::filesystem::temp_directory_path() /
("vctest_m2_" + std::to_string(
std::chrono::steady_clock::now().time_since_epoch().count()));
std::filesystem::create_directories(tmp);
// ── Start server ─────────────────────────────────────────────────────────
std::atomic<uint16_t> tcp_port{0}, udp_port{0};
std::mutex ready_mu;
std::condition_variable ready_cv;
int ready_flags = 0;
voicecat::server::Config cfg;
cfg.data_dir = tmp.string();
cfg.bind_port = 0;
cfg.media_port = 0;
cfg.allow_guests = true;
cfg.server_name = "VoiceCat-M2Test";
cfg.on_ready = [&](uint16_t p) {
tcp_port.store(p);
{ std::lock_guard lk(ready_mu); ready_flags |= 1; }
ready_cv.notify_all();
};
cfg.on_media_ready = [&](uint16_t p) {
udp_port.store(p);
{ std::lock_guard lk(ready_mu); ready_flags |= 2; }
ready_cv.notify_all();
};
voicecat::server::Server server(cfg);
std::thread server_thread([&] { server.run(); });
{
std::unique_lock lk(ready_mu);
bool ok = ready_cv.wait_for(lk, std::chrono::seconds(15),
[&] { return (ready_flags & 3) == 3; });
if (!ok) {
std::printf("FAIL: server did not become ready in time\n");
server.stop(); server_thread.join();
std::filesystem::remove_all(tmp);
return 1;
}
}
std::printf("m2_voice: server TCP:%u UDP:%u\n", tcp_port.load(), udp_port.load());
// ── Client A ─────────────────────────────────────────────────────────────
TestClient A;
A.label = "ClientA";
CHECK(A.connect("127.0.0.1", tcp_port.load()));
CHECK(A.tls_handshake());
CHECK(A.do_hello_and_auth("SenderBob"));
CHECK(A.server_udp_port == udp_port.load());
CHECK(A.do_udp_binding());
CHECK(A.do_stream_announce());
CHECK(A.stream_ok);
std::printf("m2_voice: A ssrc=%u local_udp=%u\n", A.assigned_ssrc, A.udp_local_port);
// ── Client B ─────────────────────────────────────────────────────────────
TestClient B;
B.label = "ClientB";
CHECK(B.connect("127.0.0.1", tcp_port.load()));
CHECK(B.tls_handshake());
CHECK(B.do_hello_and_auth("ReceiverAlice"));
CHECK(B.server_udp_port == udp_port.load());
CHECK(B.do_udp_binding());
// B doesn't need to announce a stream to receive relayed frames
if (g_failures > 0) {
std::printf("m2_voice: setup failed — aborting\n");
A.close(); B.close();
server.stop(); server_thread.join();
std::filesystem::remove_all(tmp);
return 1;
}
// ── A sends 50 Opus frames ────────────────────────────────────────────────
constexpr int kFramesToSend = 50;
constexpr int kFrameSamples = 960; // 20 ms @48 kHz
#ifdef VOICECAT_HAS_OPUS
voicecat::codec::OpusEncoder enc;
{
voicecat::codec::OpusParams p;
p.sample_rate = 48000;
p.frame_ms = 20;
p.fec = true;
CHECK(enc.init(p));
}
#endif
std::vector<uint8_t> aead_buf(4096); // scratch
for (int i = 0; i < kFramesToSend; ++i) {
auto pcm = make_sine_frame(i, kFrameSamples);
#ifdef VOICECAT_HAS_OPUS
uint8_t opus_buf[1000];
int opus_len = enc.encode(pcm.data(), kFrameSamples, opus_buf, sizeof(opus_buf));
if (opus_len <= 0) continue;
const uint8_t* payload = opus_buf;
size_t payload_len = static_cast<size_t>(opus_len);
#else
// Fallback: use raw PCM as synthetic payload
const uint8_t* payload = reinterpret_cast<const uint8_t*>(pcm.data());
size_t payload_len = pcm.size() * sizeof(int16_t);
#endif
// Build 14-byte header (AAD).
VoiceFrame hdr;
hdr.ssrc = A.assigned_ssrc;
hdr.seq = static_cast<uint16_t>(i);
hdr.timestamp = static_cast<uint32_t>(i * kFrameSamples);
uint8_t header_bytes[kVoiceHeaderSize];
serialize_header(hdr, header_bytes);
// AEAD-seal the payload.
size_t sealed_cap = payload_len + crypto_aead_chacha20poly1305_ietf_ABYTES;
if (aead_buf.size() < kVoiceHeaderSize + sealed_cap) aead_buf.resize(kVoiceHeaderSize + sealed_cap);
std::memcpy(aead_buf.data(), header_bytes, kVoiceHeaderSize);
long sealed = A.send_crypto->seal(payload, payload_len,
header_bytes, kVoiceHeaderSize,
aead_buf.data() + kVoiceHeaderSize, sealed_cap);
if (sealed < 0) continue;
udp_send(A.udp_sock, aead_buf.data(),
kVoiceHeaderSize + static_cast<size_t>(sealed),
udp_port.load());
// 20 ms inter-frame spacing to let the server process
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
#ifdef VOICECAT_HAS_OPUS
enc.destroy();
#endif
// ── B collects received frames (2s window after last send) ────────────────
int recv_count = 0, decrypt_ok = 0;
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
uint8_t udp_in[2048];
while (std::chrono::steady_clock::now() < deadline) {
int n = udp_recv(B.udp_sock, udp_in, sizeof(udp_in));
if (n < static_cast<int>(kVoiceHeaderSize)) continue;
// Decrypt
VoiceFrame hdr_in{};
parse_header(udp_in, static_cast<size_t>(n), hdr_in);
size_t sealed_len = static_cast<size_t>(n) - kVoiceHeaderSize;
std::vector<uint8_t> plain(sealed_len);
long plain_len = B.recv_crypto->open(
udp_in + kVoiceHeaderSize, sealed_len,
udp_in, kVoiceHeaderSize,
plain.data(), plain.size());
recv_count++;
if (plain_len >= 0) decrypt_ok++;
}
std::printf("m2_voice: A sent %d frames, B received %d, decrypted OK: %d\n",
kFramesToSend, recv_count, decrypt_ok);
CHECK(recv_count >= 25); // ≥ 50% of sent frames arrived
CHECK(decrypt_ok == recv_count); // all received frames decrypt correctly
// ── Cleanup ───────────────────────────────────────────────────────────────
A.close();
B.close();
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
if (g_failures == 0) {
std::printf("m2_voice: all checks passed\n");
return 0;
}
std::printf("m2_voice: %d failure(s)\n", g_failures);
return 1;
}
#else // !VOICECAT_HAS_NET
int main() {
std::printf("m2_voice: SKIP (VOICECAT_HAS_NET not defined)\n");
return 0;
}
#endif // VOICECAT_HAS_NET

168
tests/test_media_aead.cpp Normal file
View File

@@ -0,0 +1,168 @@
/*
* test_media_aead — ChaCha20-Poly1305 AEAD seal/open, anti-replay, tamper detection.
*
* Uses a synthetic 32-byte key directly (no TLS context needed for unit tests).
*/
#include <cstdio>
#include <cstring>
#include <vector>
#include <sodium.h>
#include "crypto/crypto.h"
#include "net/voice_frame.h"
using namespace voicecat::crypto;
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)
// Build a synthetic 14-byte AAD (voice frame header).
static std::vector<uint8_t> make_aad(uint16_t seq) {
VoiceFrame f;
f.ssrc = 0xCAFEBABE;
f.seq = seq;
std::vector<uint8_t> aad(kVoiceHeaderSize);
serialize_header(f, aad.data());
return aad;
}
static void test_seal_open_round_trip() {
uint8_t key[crypto_aead_chacha20poly1305_ietf_KEYBYTES];
crypto_generichash(key, sizeof(key),
reinterpret_cast<const uint8_t*>("test-key"), 8, nullptr, 0);
SodiumMediaCrypto sender(key);
SodiumMediaCrypto receiver(key);
// Copy the receiver state so it starts with the same key but its own counter.
std::vector<uint8_t> plain(100, 0xAB);
auto aad = make_aad(0);
// Seal
std::vector<uint8_t> cipher(plain.size() + crypto_aead_chacha20poly1305_ietf_ABYTES);
long sealed_len = sender.seal(plain.data(), plain.size(),
aad.data(), aad.size(),
cipher.data(), cipher.size());
CHECK(sealed_len == static_cast<long>(plain.size() + crypto_aead_chacha20poly1305_ietf_ABYTES));
// Open
std::vector<uint8_t> recovered(plain.size());
long plain_len = receiver.open(cipher.data(), static_cast<size_t>(sealed_len),
aad.data(), aad.size(),
recovered.data(), recovered.size());
CHECK(plain_len == static_cast<long>(plain.size()));
CHECK(std::memcmp(plain.data(), recovered.data(), plain.size()) == 0);
}
static void test_anti_replay() {
uint8_t key[crypto_aead_chacha20poly1305_ietf_KEYBYTES];
crypto_generichash(key, sizeof(key),
reinterpret_cast<const uint8_t*>("replay-key"), 10, nullptr, 0);
SodiumMediaCrypto sender(key);
SodiumMediaCrypto receiver(key);
std::vector<uint8_t> plain(50, 0x55);
auto aad = make_aad(0);
std::vector<uint8_t> cipher(plain.size() + crypto_aead_chacha20poly1305_ietf_ABYTES);
long sealed_len = sender.seal(plain.data(), plain.size(),
aad.data(), aad.size(),
cipher.data(), cipher.size());
CHECK(sealed_len > 0);
std::vector<uint8_t> recovered(plain.size());
// First open succeeds.
long r1 = receiver.open(cipher.data(), static_cast<size_t>(sealed_len),
aad.data(), aad.size(),
recovered.data(), recovered.size());
CHECK(r1 == static_cast<long>(plain.size()));
// Replay of the same ciphertext must fail.
long r2 = receiver.open(cipher.data(), static_cast<size_t>(sealed_len),
aad.data(), aad.size(),
recovered.data(), recovered.size());
CHECK(r2 < 0);
}
static void test_tamper_detection() {
uint8_t key[crypto_aead_chacha20poly1305_ietf_KEYBYTES];
crypto_generichash(key, sizeof(key),
reinterpret_cast<const uint8_t*>("tamper-key"), 10, nullptr, 0);
SodiumMediaCrypto sender(key);
SodiumMediaCrypto receiver(key);
std::vector<uint8_t> plain(40, 0x77);
auto aad = make_aad(0);
std::vector<uint8_t> cipher(plain.size() + crypto_aead_chacha20poly1305_ietf_ABYTES);
long sealed_len = sender.seal(plain.data(), plain.size(),
aad.data(), aad.size(),
cipher.data(), cipher.size());
CHECK(sealed_len > 0);
// Flip a byte in the ciphertext.
cipher[5] ^= 0xFF;
std::vector<uint8_t> recovered(plain.size());
long r = receiver.open(cipher.data(), static_cast<size_t>(sealed_len),
aad.data(), aad.size(),
recovered.data(), recovered.size());
CHECK(r < 0);
}
static void test_multiple_packets() {
uint8_t key[crypto_aead_chacha20poly1305_ietf_KEYBYTES];
crypto_generichash(key, sizeof(key),
reinterpret_cast<const uint8_t*>("multi-key"), 9, nullptr, 0);
SodiumMediaCrypto sender(key);
SodiumMediaCrypto receiver(key);
std::vector<uint8_t> plain(60, 0x99);
for (uint16_t seq = 0; seq < 10; ++seq) {
auto aad = make_aad(seq);
std::vector<uint8_t> cipher(plain.size() + crypto_aead_chacha20poly1305_ietf_ABYTES);
long sealed_len = sender.seal(plain.data(), plain.size(),
aad.data(), aad.size(),
cipher.data(), cipher.size());
CHECK(sealed_len > 0);
std::vector<uint8_t> recovered(plain.size());
long plain_len = receiver.open(cipher.data(), static_cast<size_t>(sealed_len),
aad.data(), aad.size(),
recovered.data(), recovered.size());
CHECK(plain_len == static_cast<long>(plain.size()));
CHECK(std::memcmp(plain.data(), recovered.data(), plain.size()) == 0);
}
}
int main() {
if (sodium_init() < 0) {
std::printf("FAIL: sodium_init failed\n");
return 1;
}
test_seal_open_round_trip();
test_anti_replay();
test_tamper_detection();
test_multiple_packets();
if (g_failures == 0) {
std::printf("media_aead: all tests passed\n");
return 0;
}
std::printf("media_aead: %d test(s) FAILED\n", g_failures);
return 1;
}

149
tests/test_opus_codec.cpp Normal file
View File

@@ -0,0 +1,149 @@
/*
* test_opus_codec — Opus encode/decode round-trip, PLC, energy check.
*
* Requires VOICECAT_HAS_OPUS (m1-dev and m2-dev presets).
*/
#include <cmath>
#include <cstdio>
#include <cstring>
#include <vector>
#include "codec/opus_codec.h"
namespace vc_codec = voicecat::codec;
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)
#ifndef VOICECAT_HAS_OPUS
int main() {
std::printf("opus_codec: VOICECAT_HAS_OPUS not defined — skipped\n");
return 0;
}
#else
static constexpr int kSampleRate = 48000;
static constexpr int kFrameMs = 20;
static constexpr int kFrameSamples = kSampleRate / 1000 * kFrameMs; // 960
// Generate one frame of 440 Hz sine wave at 16-bit mono, 48 kHz.
static std::vector<int16_t> make_sine_frame(int samples, float freq = 440.0f) {
std::vector<int16_t> pcm(samples);
for (int i = 0; i < samples; ++i) {
float t = static_cast<float>(i) / kSampleRate;
pcm[i] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * freq * t) * 16000.0f);
}
return pcm;
}
// Compute RMS energy of a PCM buffer.
static double rms(const int16_t* pcm, int n) {
double sum = 0.0;
for (int i = 0; i < n; ++i) sum += static_cast<double>(pcm[i]) * pcm[i];
return std::sqrt(sum / n);
}
static void test_encode_decode_round_trip() {
vc_codec::OpusParams p;
p.sample_rate = kSampleRate;
p.frame_ms = kFrameMs;
p.fec = true;
vc_codec::OpusEncoder enc;
vc_codec::OpusDecoder dec;
CHECK(enc.init(p));
CHECK(dec.init(p));
auto src = make_sine_frame(kFrameSamples);
uint8_t encoded[4000];
int enc_bytes = enc.encode(src.data(), kFrameSamples, encoded, sizeof(encoded));
CHECK(enc_bytes > 0);
CHECK(enc_bytes < 1000); // Opus at 24 kbps/20 ms ≈ 60 bytes, well under 1000
std::vector<int16_t> decoded(kFrameSamples);
int dec_samples = dec.decode(encoded, enc_bytes, decoded.data(), kFrameSamples);
CHECK(dec_samples == kFrameSamples);
// Energy check: decoded RMS should be within 3 dB of original (Opus is lossy).
double rms_src = rms(src.data(), kFrameSamples);
double rms_dec = rms(decoded.data(), dec_samples);
CHECK(rms_src > 0.0);
CHECK(rms_dec > 0.0);
double ratio_db = 20.0 * std::log10(rms_dec / rms_src);
std::printf(" opus round-trip: enc_bytes=%d dec_samples=%d rms_ratio_db=%.1f\n",
enc_bytes, dec_samples, ratio_db);
CHECK(std::abs(ratio_db) < 3.0);
enc.destroy();
dec.destroy();
}
static void test_plc() {
vc_codec::OpusParams p;
p.sample_rate = kSampleRate;
p.frame_ms = kFrameMs;
vc_codec::OpusDecoder dec;
CHECK(dec.init(p));
// First send a real packet so the decoder has state for PLC.
vc_codec::OpusEncoder enc;
CHECK(enc.init(p));
auto src = make_sine_frame(kFrameSamples);
uint8_t encoded[4000];
int enc_bytes = enc.encode(src.data(), kFrameSamples, encoded, sizeof(encoded));
CHECK(enc_bytes > 0);
std::vector<int16_t> real_out(kFrameSamples);
int r = dec.decode(encoded, enc_bytes, real_out.data(), kFrameSamples);
CHECK(r == kFrameSamples);
// Now simulate packet loss with PLC (nullptr, len=0).
std::vector<int16_t> plc_out(kFrameSamples, 0);
int plc_samples = dec.decode(nullptr, 0, plc_out.data(), kFrameSamples);
CHECK(plc_samples == kFrameSamples);
// PLC output should not be silent (Opus extrapolates from previous frame).
double plc_rms = rms(plc_out.data(), kFrameSamples);
std::printf(" plc_rms=%.1f (should be > 0)\n", plc_rms);
CHECK(plc_rms > 0.0);
enc.destroy();
dec.destroy();
}
static void test_frame_samples_helper() {
vc_codec::OpusParams p;
p.sample_rate = 48000;
p.frame_ms = 20;
CHECK(vc_codec::opus_frame_samples(p) == 960);
p.frame_ms = 10;
CHECK(vc_codec::opus_frame_samples(p) == 480);
p.frame_ms = 40;
CHECK(vc_codec::opus_frame_samples(p) == 1920);
}
int main() {
test_frame_samples_helper();
test_encode_decode_round_trip();
test_plc();
if (g_failures == 0) {
std::printf("opus_codec: all tests passed\n");
return 0;
}
std::printf("opus_codec: %d test(s) FAILED\n", g_failures);
return 1;
}
#endif // VOICECAT_HAS_OPUS

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;
}