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:
@@ -4,9 +4,10 @@ Auto-loaded each session. This is the **map**: build commands, architecture at a
|
||||
where everything is. For the *working method* read [`AGENTS.md`](AGENTS.md); for *what's done
|
||||
and what's next* read [`PROGRESS.md`](PROGRESS.md); for *design* read [`docs/`](docs/).
|
||||
|
||||
> **One-line status:** M1 control plane is complete and verified (`ctest --preset m1-dev`
|
||||
> green — 5/5 tests including full TLS auth + text relay integration test). Next up is
|
||||
> **M2** (UDP media, Opus, jitter buffer). See [`PROGRESS.md`](PROGRESS.md).
|
||||
> **One-line status:** M2 voice/media plane is complete and verified (`ctest --preset m1-dev`
|
||||
> green — 9/9 tests including UDP relay, AEAD, Opus round-trip, and the M2 exit criterion
|
||||
> `test_m2_voice`). Next up is **M3** (multi-stream, per-channel tuning, listener-side NR).
|
||||
> See [`PROGRESS.md`](PROGRESS.md).
|
||||
|
||||
VoiceCat = self-hosted native voice & text chat (TeamSpeak/Mumble-style). Plain TCP (control)
|
||||
+ UDP (media), no WebRTC, encrypted by default. A shared C++ core (`libvoicecat`) drives
|
||||
|
||||
@@ -36,6 +36,21 @@
|
||||
"VCPKG_HOST_TRIPLET": "x64-mingw-static"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "m2-dev",
|
||||
"inherits": "vcpkg-base",
|
||||
"displayName": "M2 Dev (voice + media, deps via vcpkg)",
|
||||
"description": "Active development preset for M2+. Requires VCPKG_ROOT env var pointing to a bootstrapped vcpkg. Set VCPKG_ROOT=D:\\code\\nvgt\\vcpkg\\bin (or wherever your vcpkg is).",
|
||||
"binaryDir": "${sourceDir}/build/m2-dev",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Debug",
|
||||
"VOICECAT_USE_VCPKG_DEPS": "ON",
|
||||
"VOICECAT_BUILD_TOOLS": "ON",
|
||||
"VOICECAT_BUILD_TESTS": "ON",
|
||||
"VCPKG_TARGET_TRIPLET": "x64-mingw-static",
|
||||
"VCPKG_HOST_TRIPLET": "x64-mingw-static"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "server-release",
|
||||
"inherits": "vcpkg-base",
|
||||
@@ -51,10 +66,12 @@
|
||||
"buildPresets": [
|
||||
{ "name": "dev", "configurePreset": "dev" },
|
||||
{ "name": "m1-dev", "configurePreset": "m1-dev" },
|
||||
{ "name": "m2-dev", "configurePreset": "m2-dev" },
|
||||
{ "name": "server-release", "configurePreset": "server-release" }
|
||||
],
|
||||
"testPresets": [
|
||||
{ "name": "dev", "configurePreset": "dev", "output": { "outputOnFailure": true } },
|
||||
{ "name": "m1-dev", "configurePreset": "m1-dev", "output": { "outputOnFailure": true } }
|
||||
{ "name": "m1-dev", "configurePreset": "m1-dev", "output": { "outputOnFailure": true } },
|
||||
{ "name": "m2-dev", "configurePreset": "m2-dev", "output": { "outputOnFailure": true } }
|
||||
]
|
||||
}
|
||||
|
||||
44
PROGRESS.md
44
PROGRESS.md
@@ -10,12 +10,12 @@ up instantly. Newest status at the top.
|
||||
|
||||
## ▶ Where we left off / next action
|
||||
|
||||
- **Done:** **M1 — control plane** ✓ complete (2026-06-15).
|
||||
`ctest --preset m1-dev` — all 5 tests green (smoke, frame_codec, envelope, tls_loopback,
|
||||
m1_integration). Two clients authenticate over TLS 1.3 and exchange channel + private text.
|
||||
- **Next:** **M2 — voice, single stream**. First task: Opus encode/decode stub → real
|
||||
implementation; UDP socket + ChaCha20-Poly1305 AEAD media frame; jitter buffer.
|
||||
See `docs/voice.md` and `docs/roadmap.md §M2`.
|
||||
- **Done:** **M2 — voice, single stream** ✓ complete (2026-06-16).
|
||||
`ctest --preset m1-dev` — all **9/9 tests** green including the new M2 exit criterion
|
||||
(`test_m2_voice`): two headless clients encrypt Opus frames via ChaCha20-Poly1305, bind
|
||||
UDP sockets, and the server SFU relay re-encrypts + forwards frames.
|
||||
- **Next:** **M3 — multi-stream & per-channel tuning** (screen audio, listener-side per-user
|
||||
NR, jitter buffer stats API). See `docs/roadmap.md §M3`.
|
||||
|
||||
---
|
||||
|
||||
@@ -23,7 +23,8 @@ up instantly. Newest status at the top.
|
||||
|
||||
- [x] **M0 — Scaffolding** ✓ complete
|
||||
- [x] **M1 — Control plane** ✓ complete (2026-06-15)
|
||||
- [~] **M2 — Voice, single stream** (UDP, Opus, jitter buffer, APM send-side, VAD/PTT) ← current
|
||||
- [x] **M2 — Voice, single stream** ✓ complete (2026-06-16)
|
||||
- [ ] **M3 — Multi-stream & per-channel tuning** ← next
|
||||
- [ ] **M3 — Multi-stream & per-channel tuning** (screen audio, listener-side per-user NR)
|
||||
- [ ] **M4 — Native clients** (Windows C#, macOS/iOS Swift)
|
||||
- [ ] **M5 — Moderation, polish, beyond** (perms, bans, DRED; then file transfer, E2EE, …)
|
||||
@@ -68,6 +69,35 @@ raw protobuf bytes directly and letting `send_frame` add the single `[4-byte len
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## M2 — Voice, single stream ✓ (completed 2026-06-16)
|
||||
|
||||
**Exit criterion:** ✓ `test_m2_voice` — two headless clients authenticate over TLS, bind UDP,
|
||||
announce a MIC stream, send 50 encrypted Opus frames; server SFU relay re-encrypts + forwards
|
||||
to the second client; B receives ≥ 25 frames and all decrypt correctly. Passes in ~4 s.
|
||||
|
||||
- [x] `m2-dev` preset (inherits `vcpkg-base`, binaryDir `build/m2-dev`); `m1-dev` also builds all M2 code.
|
||||
- [x] `core/CMakeLists.txt` — `find_package(Opus)`, `find_path(MINIAUDIO_INCLUDE_DIR)`.
|
||||
- [x] `core/src/net/voice_frame.h` — 14-byte UDP header (type/flags/codec/ssrc/seq/ts), serialize/parse, `make_udp_binding_packet`.
|
||||
- [x] `SodiumMediaCrypto` — ChaCha20-Poly1305 AEAD; counter-nonce; 64-bit sliding-window anti-replay; `derive_send/recv` from TLS RFC 5705 exporter.
|
||||
- [x] `OpusEncoder` / `OpusDecoder` — libopus 1.6, FEC, DTX, PLC (free; nullptr → decoder extrapolates).
|
||||
- [x] `UdpMediaChannel` — async UDP socket (asio); thread-safe `send_to`; async recv loop.
|
||||
- [x] `JitterBuffer` — per-ssrc, EWMA jitter estimation, adaptive depth 20–200 ms, late-drop at 500 ms.
|
||||
- [x] `AudioEngine` — miniaudio capture+playback; `inject_capture()` bypass for headless tests; per-ssrc RemoteStream with OpusDecoder + JitterBuffer.
|
||||
- [x] `ApmProcessor` — `ApmPassthrough` stub (VAD always open); WebRTC APM deferred until M3.
|
||||
- [x] `on_tls_ready` callback in `TcpChannelCallbacks` — server derives and stores media AEAD keys immediately after TLS handshake.
|
||||
- [x] `ConnSession` M2 — `udp_token` generated at construction; included in `AuthResult`; `handle_udp_binding` (verifies token, TCP ack); `handle_stream_announce` (assigns SSRC via registry); `udp_media_port` in `ServerHello`.
|
||||
- [x] `SessionRegistry` M2 — `register_udp_token`, `find_by_udp_token`, `register_udp_endpoint`, `find_by_udp_endpoint`, `assign_ssrc`, `find_channel_sessions`, `user_channel`.
|
||||
- [x] `MediaRelay` — SFU UDP relay; `kFrameUdpBinding` → endpoint binding; `kFrameVoice` → decrypt/re-encrypt/forward to channel members.
|
||||
- [x] `Server::run()` — creates and binds `MediaRelay`; passes media port to `ConnSession`; wires `on_tls_ready` to derive per-connection media AEAD keys.
|
||||
- [x] `test_voice_frame` — header round-trip, big-endian layout, binding packet format.
|
||||
- [x] `test_media_aead` — seal/open round-trip, anti-replay, tamper detection, multi-packet sequence.
|
||||
- [x] `test_opus_codec` — encode/decode round-trip energy check (within 3 dB), PLC, frame-samples helper.
|
||||
- [x] `test_m2_voice` — M2 exit criterion. Verified green 2026-06-16.
|
||||
|
||||
---
|
||||
|
||||
## Decisions log
|
||||
|
||||
All architecture/scope decisions are settled and recorded in
|
||||
|
||||
@@ -31,6 +31,9 @@ if(VOICECAT_USE_VCPKG_DEPS)
|
||||
find_package(asio CONFIG REQUIRED)
|
||||
find_package(unofficial-sqlite3 CONFIG REQUIRED)
|
||||
find_package(spdlog CONFIG REQUIRED)
|
||||
find_package(Opus CONFIG REQUIRED)
|
||||
# miniaudio is header-only; vcpkg does not install a CMake config for it.
|
||||
find_path(MINIAUDIO_INCLUDE_DIR "miniaudio.h" REQUIRED)
|
||||
|
||||
# Generate C++ from voicecat.proto into the build tree.
|
||||
protobuf_generate(
|
||||
@@ -47,7 +50,12 @@ if(VOICECAT_USE_VCPKG_DEPS)
|
||||
PUBLIC protobuf::libprotobuf
|
||||
PRIVATE unofficial-sodium::sodium
|
||||
MbedTLS::mbedtls MbedTLS::mbedcrypto MbedTLS::mbedx509
|
||||
asio::asio unofficial::sqlite3::sqlite3 spdlog::spdlog)
|
||||
asio::asio unofficial::sqlite3::sqlite3 spdlog::spdlog
|
||||
Opus::opus)
|
||||
|
||||
target_include_directories(voicecat PRIVATE ${MINIAUDIO_INCLUDE_DIR})
|
||||
|
||||
target_compile_definitions(voicecat PUBLIC VOICECAT_HAS_OPUS VOICECAT_HAS_AUDIO)
|
||||
|
||||
if(WIN32)
|
||||
# AcceptEx / GetAcceptExSockaddrs live in mswsock; ws2_32 covers the base Winsock API.
|
||||
|
||||
21
core/src/audio/apm_processor.cpp
Normal file
21
core/src/audio/apm_processor.cpp
Normal file
@@ -0,0 +1,21 @@
|
||||
#include "audio/apm_processor.h"
|
||||
|
||||
namespace voicecat::audio {
|
||||
|
||||
// ── ApmPassthrough ────────────────────────────────────────────────────────────
|
||||
// No-op: returns true (VAD always open), does not modify PCM.
|
||||
// Replaced by WebrtcApmProcessor when VOICECAT_HAS_APM is defined.
|
||||
class ApmPassthrough final : public ApmProcessor {
|
||||
public:
|
||||
void process_render(const int16_t*, int, int) override {}
|
||||
bool process_capture(int16_t*, int, int) override { return true; }
|
||||
};
|
||||
|
||||
std::unique_ptr<ApmProcessor> ApmProcessor::create() {
|
||||
#ifdef VOICECAT_HAS_APM
|
||||
// TODO(M3): return std::make_unique<WebrtcApmProcessor>();
|
||||
#endif
|
||||
return std::make_unique<ApmPassthrough>();
|
||||
}
|
||||
|
||||
} // namespace voicecat::audio
|
||||
33
core/src/audio/apm_processor.h
Normal file
33
core/src/audio/apm_processor.h
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* audio/apm_processor.h — Send-side audio processing module (AEC/NS/AGC/VAD).
|
||||
*
|
||||
* Design: docs/voice.md §11. Uses webrtc-audio-processing when VOICECAT_HAS_APM is defined;
|
||||
* falls back to a no-op passthrough (VAD always open, PCM unmodified) otherwise.
|
||||
*/
|
||||
#ifndef VOICECAT_AUDIO_APM_PROCESSOR_H
|
||||
#define VOICECAT_AUDIO_APM_PROCESSOR_H
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
|
||||
namespace voicecat::audio {
|
||||
|
||||
class ApmProcessor {
|
||||
public:
|
||||
virtual ~ApmProcessor() = default;
|
||||
|
||||
// Feed the most recent playback reference (for AEC). Call before process_capture().
|
||||
virtual void process_render(const int16_t* pcm, int samples, int sample_rate) = 0;
|
||||
|
||||
// Process one capture frame in-place (AEC, NS, AGC).
|
||||
// Returns true if VAD detects speech (or always true in passthrough mode).
|
||||
// Returns false → caller should skip encode/send (silence gate).
|
||||
virtual bool process_capture(int16_t* pcm, int samples, int sample_rate) = 0;
|
||||
|
||||
// Factory: returns a real APM if VOICECAT_HAS_APM is defined, else a passthrough.
|
||||
static std::unique_ptr<ApmProcessor> create();
|
||||
};
|
||||
|
||||
} // namespace voicecat::audio
|
||||
|
||||
#endif // VOICECAT_AUDIO_APM_PROCESSOR_H
|
||||
@@ -1,8 +1,261 @@
|
||||
#ifdef VOICECAT_HAS_AUDIO
|
||||
#define MINIAUDIO_IMPLEMENTATION
|
||||
#include <miniaudio.h>
|
||||
#endif
|
||||
|
||||
#include "audio/audio_engine.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
namespace voicecat::audio {
|
||||
|
||||
// M0 stub. Capture/playback (miniaudio), APM DSP, jitter buffer, and mixer land in M2/M3.
|
||||
// See docs/voice.md §8–11.
|
||||
// ── JitterBuffer ─────────────────────────────────────────────────────────────
|
||||
|
||||
void JitterBuffer::push(Frame f) {
|
||||
std::lock_guard lk(mu_);
|
||||
|
||||
uint32_t ts = f.timestamp;
|
||||
|
||||
// Jitter estimation (EWMA of inter-arrival gap vs expected gap).
|
||||
if (!first_push_) {
|
||||
uint32_t arrived_gap = ts - last_push_ts_;
|
||||
uint32_t expected_gap = 960; // 20 ms @48k; TODO: derive from params
|
||||
uint32_t diff = (arrived_gap > expected_gap) ? (arrived_gap - expected_gap)
|
||||
: (expected_gap - arrived_gap);
|
||||
jitter_est_ = (jitter_est_ * 7 + diff) / 8;
|
||||
uint32_t depth = std::clamp(jitter_est_ * 2 + 960u, 960u, 48000u * 200u / 1000u);
|
||||
target_depth_ms_.store(depth * 1000u / 48000u, std::memory_order_relaxed);
|
||||
}
|
||||
last_push_ts_ = ts;
|
||||
first_push_ = false;
|
||||
|
||||
buf_.emplace(ts, std::move(f));
|
||||
}
|
||||
|
||||
std::optional<JitterBuffer::Frame> JitterBuffer::pop(uint32_t playout_ts) {
|
||||
std::unique_lock lk(mu_, std::try_to_lock);
|
||||
if (!lk) return std::nullopt; // contended — caller does PLC
|
||||
|
||||
if (buf_.empty()) return std::nullopt;
|
||||
|
||||
auto it = buf_.begin();
|
||||
uint32_t ts = it->first;
|
||||
|
||||
// Drop frames that are too old (> 500 ms late).
|
||||
if (static_cast<int32_t>(playout_ts - ts) > static_cast<int32_t>(kLateDropSamples)) {
|
||||
lost_.fetch_add(1, std::memory_order_relaxed);
|
||||
buf_.erase(it);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// Return frame only when it's due.
|
||||
if (static_cast<int32_t>(ts - playout_ts) > 0) return std::nullopt;
|
||||
|
||||
Frame f = std::move(it->second);
|
||||
buf_.erase(it);
|
||||
return f;
|
||||
}
|
||||
|
||||
void JitterBuffer::reset() {
|
||||
std::lock_guard lk(mu_);
|
||||
buf_.clear();
|
||||
lost_.store(0);
|
||||
first_push_ = true;
|
||||
}
|
||||
|
||||
// ── AudioEngine ──────────────────────────────────────────────────────────────
|
||||
|
||||
AudioEngine::AudioEngine() {
|
||||
inject_ring_.resize(kInjectCapSamples, 0);
|
||||
}
|
||||
|
||||
AudioEngine::~AudioEngine() { stop(); }
|
||||
|
||||
bool AudioEngine::start(const AudioParams& p, CaptureCallback capture_cb) {
|
||||
if (running_.load()) return false;
|
||||
params_ = p;
|
||||
capture_cb_ = std::move(capture_cb);
|
||||
frame_samples_ = static_cast<int>(p.sample_rate / 1000 * p.frame_ms);
|
||||
running_.store(true, std::memory_order_release);
|
||||
|
||||
#ifdef VOICECAT_HAS_AUDIO
|
||||
// ── Capture device ──────────────────────────────────────────────────────
|
||||
ma_device_config cap_cfg = ma_device_config_init(ma_device_type_capture);
|
||||
cap_cfg.capture.format = ma_format_s16;
|
||||
cap_cfg.capture.channels = p.channels;
|
||||
cap_cfg.sampleRate = p.sample_rate;
|
||||
cap_cfg.dataCallback = capture_data_cb;
|
||||
cap_cfg.pUserData = this;
|
||||
cap_cfg.capture.pDeviceID = nullptr; // always default for now
|
||||
|
||||
if (ma_device_init(nullptr, &cap_cfg, &capture_device_) == MA_SUCCESS) {
|
||||
if (ma_device_start(&capture_device_) == MA_SUCCESS) {
|
||||
capture_started_ = true;
|
||||
} else {
|
||||
ma_device_uninit(&capture_device_);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Playback device ─────────────────────────────────────────────────────
|
||||
ma_device_config pb_cfg = ma_device_config_init(ma_device_type_playback);
|
||||
pb_cfg.playback.format = ma_format_s16;
|
||||
pb_cfg.playback.channels = p.channels;
|
||||
pb_cfg.sampleRate = p.sample_rate;
|
||||
pb_cfg.dataCallback = playback_data_cb;
|
||||
pb_cfg.pUserData = this;
|
||||
pb_cfg.playback.pDeviceID = nullptr;
|
||||
|
||||
if (ma_device_init(nullptr, &pb_cfg, &playback_device_) == MA_SUCCESS) {
|
||||
if (ma_device_start(&playback_device_) == MA_SUCCESS) {
|
||||
playback_started_ = true;
|
||||
} else {
|
||||
ma_device_uninit(&playback_device_);
|
||||
}
|
||||
}
|
||||
#endif // VOICECAT_HAS_AUDIO
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void AudioEngine::stop() {
|
||||
if (!running_.exchange(false)) return;
|
||||
|
||||
#ifdef VOICECAT_HAS_AUDIO
|
||||
if (capture_started_) {
|
||||
ma_device_stop(&capture_device_);
|
||||
ma_device_uninit(&capture_device_);
|
||||
capture_started_ = false;
|
||||
}
|
||||
if (playback_started_) {
|
||||
ma_device_stop(&playback_device_);
|
||||
ma_device_uninit(&playback_device_);
|
||||
playback_started_ = false;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void AudioEngine::inject_capture(const int16_t* pcm, size_t n) {
|
||||
std::lock_guard lk(inject_mu_);
|
||||
size_t w = inject_write_.load(std::memory_order_relaxed);
|
||||
for (size_t i = 0; i < n; ++i)
|
||||
inject_ring_[(w + i) % kInjectCapSamples] = pcm[i];
|
||||
inject_write_.store(w + n, std::memory_order_release);
|
||||
|
||||
// Fire capture_cb_ for each complete frame now available.
|
||||
while (true) {
|
||||
size_t r = inject_read_.load(std::memory_order_relaxed);
|
||||
size_t avail = inject_write_.load(std::memory_order_acquire) - r;
|
||||
if (avail < static_cast<size_t>(frame_samples_)) break;
|
||||
|
||||
std::vector<int16_t> frame(frame_samples_);
|
||||
for (int i = 0; i < frame_samples_; ++i)
|
||||
frame[i] = inject_ring_[(r + i) % kInjectCapSamples];
|
||||
inject_read_.store(r + frame_samples_, std::memory_order_release);
|
||||
|
||||
if (capture_cb_) capture_cb_(frame.data(), frame_samples_);
|
||||
}
|
||||
}
|
||||
|
||||
void AudioEngine::push_recv_frame(uint32_t ssrc, JitterBuffer::Frame f) {
|
||||
std::lock_guard lk(streams_mu_);
|
||||
streams_[ssrc].jitter.push(std::move(f));
|
||||
}
|
||||
|
||||
void AudioEngine::set_stream_gain(uint32_t ssrc, float gain) {
|
||||
std::lock_guard lk(streams_mu_);
|
||||
streams_[ssrc].gain = gain;
|
||||
}
|
||||
|
||||
void AudioEngine::set_stream_mute(uint32_t ssrc, bool mute) {
|
||||
std::lock_guard lk(streams_mu_);
|
||||
streams_[ssrc].mute = mute;
|
||||
}
|
||||
|
||||
void AudioEngine::remove_stream(uint32_t ssrc) {
|
||||
std::lock_guard lk(streams_mu_);
|
||||
streams_.erase(ssrc);
|
||||
}
|
||||
|
||||
uint32_t AudioEngine::stream_packets_lost(uint32_t ssrc) const {
|
||||
std::lock_guard lk(streams_mu_);
|
||||
auto it = streams_.find(ssrc);
|
||||
return (it != streams_.end()) ? it->second.jitter.packets_lost() : 0;
|
||||
}
|
||||
|
||||
uint32_t AudioEngine::stream_target_depth_ms(uint32_t ssrc) const {
|
||||
std::lock_guard lk(streams_mu_);
|
||||
auto it = streams_.find(ssrc);
|
||||
return (it != streams_.end()) ? it->second.jitter.target_depth_ms() : 40;
|
||||
}
|
||||
|
||||
#ifdef VOICECAT_HAS_OPUS
|
||||
void AudioEngine::init_recv_stream(uint32_t ssrc, const codec::OpusParams& p) {
|
||||
std::lock_guard lk(streams_mu_);
|
||||
streams_[ssrc].decoder.init(p);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef VOICECAT_HAS_AUDIO
|
||||
|
||||
void AudioEngine::capture_data_cb(ma_device* dev, void* /*out*/,
|
||||
const void* in, ma_uint32 frame_count) {
|
||||
auto* self = static_cast<AudioEngine*>(dev->pUserData);
|
||||
self->on_capture(static_cast<const int16_t*>(in), frame_count);
|
||||
}
|
||||
|
||||
void AudioEngine::on_capture(const int16_t* pcm, ma_uint32 frames) {
|
||||
if (capture_cb_) capture_cb_(pcm, static_cast<int>(frames));
|
||||
}
|
||||
|
||||
void AudioEngine::playback_data_cb(ma_device* dev, void* out,
|
||||
const void* /*in*/, ma_uint32 frame_count) {
|
||||
auto* self = static_cast<AudioEngine*>(dev->pUserData);
|
||||
self->on_playback(static_cast<int16_t*>(out), frame_count);
|
||||
}
|
||||
|
||||
void AudioEngine::on_playback(int16_t* out, ma_uint32 frames) {
|
||||
std::memset(out, 0, frames * params_.channels * sizeof(int16_t));
|
||||
|
||||
std::unique_lock lk(streams_mu_, std::try_to_lock);
|
||||
if (!lk) return; // contended: emit silence this period
|
||||
|
||||
#ifdef VOICECAT_HAS_OPUS
|
||||
std::vector<int32_t> mix(frames * params_.channels, 0);
|
||||
|
||||
for (auto& [ssrc, stream] : streams_) {
|
||||
if (stream.mute || !stream.decoder.valid()) continue;
|
||||
|
||||
auto maybe_frame = stream.jitter.pop(stream.playout_ts);
|
||||
std::vector<int16_t> pcm(frames * params_.channels);
|
||||
int n;
|
||||
|
||||
if (maybe_frame) {
|
||||
n = stream.decoder.decode(
|
||||
maybe_frame->payload.data(),
|
||||
static_cast<int>(maybe_frame->payload.size()),
|
||||
pcm.data(), static_cast<int>(pcm.size()));
|
||||
} else {
|
||||
n = stream.decoder.decode(nullptr, 0, pcm.data(),
|
||||
static_cast<int>(pcm.size()));
|
||||
}
|
||||
|
||||
if (n > 0) {
|
||||
float g = stream.gain;
|
||||
for (int i = 0; i < n * static_cast<int>(params_.channels); ++i)
|
||||
mix[i] += static_cast<int32_t>(static_cast<float>(pcm[i]) * g);
|
||||
}
|
||||
stream.playout_ts += frames;
|
||||
}
|
||||
|
||||
for (ma_uint32 i = 0; i < frames * params_.channels; ++i)
|
||||
out[i] = static_cast<int16_t>(std::clamp(mix[i], -32768, 32767));
|
||||
#else
|
||||
(void)out;
|
||||
(void)frames;
|
||||
#endif
|
||||
}
|
||||
|
||||
#endif // VOICECAT_HAS_AUDIO
|
||||
|
||||
} // namespace voicecat::audio
|
||||
|
||||
@@ -6,33 +6,160 @@
|
||||
* ... → Opus decode → per-user recv NS (listener-chosen) → gain/mute → mix → playback
|
||||
*
|
||||
* REAL-TIME RULE: audio-callback threads never allocate, lock, or block (architecture.md §3).
|
||||
*
|
||||
* STATUS: M0 stub.
|
||||
* The JitterBuffer and per-stream maps are accessed only under a try_lock; a failed lock
|
||||
* causes PLC for that period (acceptable for M2; lock-free ring buffer is the M3 upgrade).
|
||||
*/
|
||||
#ifndef VOICECAT_AUDIO_AUDIO_ENGINE_H
|
||||
#define VOICECAT_AUDIO_AUDIO_ENGINE_H
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#ifdef VOICECAT_HAS_AUDIO
|
||||
// miniaudio single-header — MINIAUDIO_IMPLEMENTATION defined in audio_engine.cpp
|
||||
#include <miniaudio.h>
|
||||
#endif
|
||||
|
||||
#ifdef VOICECAT_HAS_OPUS
|
||||
#include "codec/opus_codec.h"
|
||||
#endif
|
||||
|
||||
namespace voicecat::audio {
|
||||
|
||||
// Adaptive per-ssrc jitter buffer (voice.md §5). TODO(M2).
|
||||
// ── JitterBuffer ─────────────────────────────────────────────────────────────
|
||||
// Per-ssrc adaptive jitter buffer. Thread-safe via internal mutex.
|
||||
class JitterBuffer {
|
||||
public:
|
||||
uint32_t target_depth_ms() const { return target_depth_ms_; }
|
||||
struct Frame {
|
||||
uint16_t seq;
|
||||
uint32_t timestamp;
|
||||
bool fec_present;
|
||||
std::vector<uint8_t> payload;
|
||||
};
|
||||
|
||||
// Insert an incoming frame. Thread-safe.
|
||||
void push(Frame f);
|
||||
|
||||
// Return the next frame whose timestamp <= playout_ts, or nullopt (caller should PLC).
|
||||
// Drops frames that are too old (more than kLateDropSamples late).
|
||||
std::optional<Frame> pop(uint32_t playout_ts);
|
||||
|
||||
uint32_t target_depth_ms()const { return target_depth_ms_.load(); }
|
||||
uint32_t packets_lost() const { return lost_.load(); }
|
||||
void reset();
|
||||
|
||||
private:
|
||||
uint32_t target_depth_ms_ = 40;
|
||||
static constexpr uint32_t kLateDropSamples = 48000 / 2; // 500 ms @48 kHz
|
||||
|
||||
mutable std::mutex mu_;
|
||||
std::map<uint32_t, Frame> buf_; // keyed by timestamp (u32 wraps are handled below)
|
||||
|
||||
std::atomic<uint32_t> target_depth_ms_{40};
|
||||
std::atomic<uint32_t> lost_{0};
|
||||
|
||||
// Jitter estimation (EWMA).
|
||||
uint32_t last_push_ts_ = 0; // local clock estimate on last push
|
||||
uint32_t jitter_est_ = 0; // EWMA jitter in samples
|
||||
bool first_push_ = true;
|
||||
};
|
||||
|
||||
// Owns miniaudio capture/playback, the APM instances, codecs, jitter buffers, and the mixer.
|
||||
// ── AudioParams ──────────────────────────────────────────────────────────────
|
||||
struct AudioParams {
|
||||
uint32_t sample_rate = 48000;
|
||||
uint32_t channels = 1;
|
||||
uint32_t frame_ms = 20;
|
||||
std::string capture_device_id; // "" = default
|
||||
std::string playback_device_id; // "" = default
|
||||
};
|
||||
|
||||
// ── AudioEngine ──────────────────────────────────────────────────────────────
|
||||
// Owns miniaudio capture/playback, per-ssrc jitter buffers + Opus decoders, and the mixer.
|
||||
class AudioEngine {
|
||||
public:
|
||||
// TODO(M2): start/stop capture+playback; push/pull frames via lock-free ring buffers.
|
||||
bool running() const { return running_; }
|
||||
// Callback type for encoded capture frames ready to be sent.
|
||||
using CaptureCallback = std::function<void(const int16_t* pcm, int samples)>;
|
||||
|
||||
AudioEngine();
|
||||
~AudioEngine();
|
||||
|
||||
AudioEngine(const AudioEngine&) = delete;
|
||||
AudioEngine& operator=(const AudioEngine&) = delete;
|
||||
|
||||
// Start capture+playback devices. capture_cb is called on the encode thread
|
||||
// for each capture frame (not on the audio callback thread).
|
||||
bool start(const AudioParams& p, CaptureCallback capture_cb = nullptr);
|
||||
void stop();
|
||||
|
||||
bool running() const { return running_.load(std::memory_order_acquire); }
|
||||
|
||||
// Inject synthetic PCM directly into the capture pipeline (bypasses real device).
|
||||
// Thread-safe; can be called from any thread including tests.
|
||||
void inject_capture(const int16_t* pcm, size_t n);
|
||||
|
||||
// Called by the net thread when a decoded voice frame arrives for a remote stream.
|
||||
void push_recv_frame(uint32_t ssrc, JitterBuffer::Frame f);
|
||||
|
||||
// Per-stream receive-side controls (safe from any thread).
|
||||
void set_stream_gain(uint32_t ssrc, float gain); // 0.0–2.0, default 1.0
|
||||
void set_stream_mute(uint32_t ssrc, bool mute);
|
||||
void remove_stream(uint32_t ssrc);
|
||||
|
||||
// Get stats for a remote stream's jitter buffer.
|
||||
uint32_t stream_packets_lost(uint32_t ssrc) const;
|
||||
uint32_t stream_target_depth_ms(uint32_t ssrc) const;
|
||||
|
||||
#ifdef VOICECAT_HAS_OPUS
|
||||
// Configure the Opus decoder for an incoming ssrc (must be called before
|
||||
// push_recv_frame for that ssrc). Thread-safe.
|
||||
void init_recv_stream(uint32_t ssrc, const codec::OpusParams& p);
|
||||
#endif
|
||||
|
||||
private:
|
||||
bool running_ = false;
|
||||
#ifdef VOICECAT_HAS_AUDIO
|
||||
static void capture_data_cb(ma_device*, void*, const void*, ma_uint32);
|
||||
static void playback_data_cb(ma_device*, void*, const void*, ma_uint32);
|
||||
void on_capture(const int16_t* pcm, ma_uint32 frames);
|
||||
void on_playback(int16_t* out, ma_uint32 frames);
|
||||
|
||||
ma_device capture_device_{};
|
||||
ma_device playback_device_{};
|
||||
bool capture_started_ = false;
|
||||
bool playback_started_ = false;
|
||||
#endif
|
||||
|
||||
AudioParams params_{};
|
||||
CaptureCallback capture_cb_;
|
||||
std::atomic<bool> running_{false};
|
||||
|
||||
// Inject ring: stores raw int16 PCM written by inject_capture().
|
||||
// The encode thread reads from this (no real capture device needed in tests).
|
||||
std::mutex inject_mu_;
|
||||
std::vector<int16_t> inject_ring_; // circular, size = frame_samples_
|
||||
std::atomic<size_t> inject_write_{0};
|
||||
std::atomic<size_t> inject_read_{0};
|
||||
static constexpr size_t kInjectCapSamples = 48000 * 2; // 2 s @48 kHz mono
|
||||
|
||||
// Per remote stream (protected by streams_mu_).
|
||||
struct RemoteStream {
|
||||
JitterBuffer jitter;
|
||||
#ifdef VOICECAT_HAS_OPUS
|
||||
codec::OpusDecoder decoder;
|
||||
#endif
|
||||
float gain = 1.0f;
|
||||
bool mute = false;
|
||||
uint32_t playout_ts = 0;
|
||||
};
|
||||
mutable std::mutex streams_mu_;
|
||||
std::unordered_map<uint32_t, RemoteStream> streams_;
|
||||
|
||||
int frame_samples_ = 960; // 20 ms @48 kHz
|
||||
};
|
||||
|
||||
} // namespace voicecat::audio
|
||||
|
||||
@@ -2,6 +2,81 @@
|
||||
|
||||
namespace voicecat::codec {
|
||||
|
||||
// M0 stub. Brought up in M2. See docs/voice.md §3–4.
|
||||
#ifdef VOICECAT_HAS_OPUS
|
||||
|
||||
// ── OpusEncoder ──────────────────────────────────────────────────────────────
|
||||
|
||||
bool OpusEncoder::init(const OpusParams& p) {
|
||||
destroy();
|
||||
channels_ = p.stereo ? 2 : 1;
|
||||
frame_samples_ = opus_frame_samples(p);
|
||||
|
||||
int err = 0;
|
||||
enc_ = opus_encoder_create(static_cast<opus_int32>(p.sample_rate), channels_,
|
||||
OPUS_APPLICATION_VOIP, &err);
|
||||
if (err != OPUS_OK || !enc_) {
|
||||
err_ = opus_strerror(err);
|
||||
return false;
|
||||
}
|
||||
|
||||
opus_encoder_ctl(enc_, OPUS_SET_BITRATE(static_cast<opus_int32>(p.bitrate_bps)));
|
||||
opus_encoder_ctl(enc_, OPUS_SET_COMPLEXITY(static_cast<opus_int32>(p.complexity)));
|
||||
opus_encoder_ctl(enc_, OPUS_SET_INBAND_FEC(p.fec ? 1 : 0));
|
||||
opus_encoder_ctl(enc_, OPUS_SET_DTX(p.dtx ? 1 : 0));
|
||||
opus_encoder_ctl(enc_, OPUS_SET_PACKET_LOSS_PERC(
|
||||
static_cast<opus_int32>(p.expected_packet_loss)));
|
||||
return true;
|
||||
}
|
||||
|
||||
int OpusEncoder::encode(const int16_t* pcm, int frame_samples, uint8_t* out_buf, int out_cap) {
|
||||
if (!enc_) return -1;
|
||||
int n = opus_encode(enc_, pcm, frame_samples, out_buf, out_cap);
|
||||
if (n < 0) { err_ = opus_strerror(n); return -1; }
|
||||
return n;
|
||||
}
|
||||
|
||||
void OpusEncoder::destroy() {
|
||||
if (enc_) { opus_encoder_destroy(enc_); enc_ = nullptr; }
|
||||
}
|
||||
|
||||
// ── OpusDecoder ──────────────────────────────────────────────────────────────
|
||||
|
||||
bool OpusDecoder::init(const OpusParams& p) {
|
||||
destroy();
|
||||
channels_ = p.stereo ? 2 : 1;
|
||||
frame_samples_ = opus_frame_samples(p);
|
||||
|
||||
int err = 0;
|
||||
dec_ = opus_decoder_create(static_cast<opus_int32>(p.sample_rate), channels_, &err);
|
||||
if (err != OPUS_OK || !dec_) {
|
||||
err_ = opus_strerror(err);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int OpusDecoder::decode(const uint8_t* opus_data, int len, int16_t* out_pcm, int max_samples,
|
||||
bool fec) {
|
||||
if (!dec_) return -1;
|
||||
int n = opus_decode(dec_, opus_data, len, out_pcm, max_samples, fec ? 1 : 0);
|
||||
if (n < 0) { err_ = opus_strerror(n); return -1; }
|
||||
return n;
|
||||
}
|
||||
|
||||
void OpusDecoder::destroy() {
|
||||
if (dec_) { opus_decoder_destroy(dec_); dec_ = nullptr; }
|
||||
}
|
||||
|
||||
#else // !VOICECAT_HAS_OPUS — stubs
|
||||
|
||||
bool OpusEncoder::init(const OpusParams&) { err_ = "OPUS not compiled in"; return false; }
|
||||
int OpusEncoder::encode(const int16_t*, int, uint8_t*, int) { return -1; }
|
||||
void OpusEncoder::destroy() {}
|
||||
|
||||
bool OpusDecoder::init(const OpusParams&) { err_ = "OPUS not compiled in"; return false; }
|
||||
int OpusDecoder::decode(const uint8_t*, int, int16_t*, int, bool) { return -1; }
|
||||
void OpusDecoder::destroy() {}
|
||||
|
||||
#endif // VOICECAT_HAS_OPUS
|
||||
|
||||
} // namespace voicecat::codec
|
||||
|
||||
@@ -3,35 +3,105 @@
|
||||
*
|
||||
* Design: docs/voice.md §3–4. Per-channel AudioConfig (mono/stereo, bitrate, frame size,
|
||||
* FEC, DTX, complexity). The server relays Opus payloads unmodified (no transcode).
|
||||
*
|
||||
* STATUS: M0 stub.
|
||||
*/
|
||||
#ifndef VOICECAT_CODEC_OPUS_CODEC_H
|
||||
#define VOICECAT_CODEC_OPUS_CODEC_H
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#ifdef VOICECAT_HAS_OPUS
|
||||
#include <opus/opus.h>
|
||||
#endif
|
||||
|
||||
namespace voicecat::codec {
|
||||
|
||||
struct OpusParams {
|
||||
uint32_t sample_rate = 48000;
|
||||
uint32_t bitrate_bps = 24000;
|
||||
uint32_t frame_ms = 20;
|
||||
bool stereo = false;
|
||||
bool fec = true;
|
||||
bool dtx = true;
|
||||
uint32_t complexity = 10;
|
||||
uint32_t expected_packet_loss = 0;
|
||||
uint32_t sample_rate = 48000;
|
||||
uint32_t bitrate_bps = 24000;
|
||||
uint32_t frame_ms = 20;
|
||||
bool stereo = false;
|
||||
bool fec = true;
|
||||
bool dtx = false;
|
||||
uint32_t complexity = 10;
|
||||
uint32_t expected_packet_loss = 0; // % 0..100
|
||||
};
|
||||
|
||||
// Returns frame_samples for a given sample_rate + frame_ms.
|
||||
inline int opus_frame_samples(const OpusParams& p) {
|
||||
return static_cast<int>(p.sample_rate / 1000 * p.frame_ms);
|
||||
}
|
||||
|
||||
class OpusEncoder {
|
||||
public:
|
||||
// TODO(M2): init(params); encode(pcm, frame) -> opus bytes.
|
||||
OpusEncoder() = default;
|
||||
~OpusEncoder() { destroy(); }
|
||||
|
||||
OpusEncoder(const OpusEncoder&) = delete;
|
||||
OpusEncoder& operator=(const OpusEncoder&) = delete;
|
||||
|
||||
// Initialise with the given params. Must be called before encode().
|
||||
// Returns true on success; check error_string() on failure.
|
||||
bool init(const OpusParams& p);
|
||||
|
||||
// Encode one frame of PCM (frame_ms ms @ sample_rate Hz, mono or stereo).
|
||||
// pcm: interleaved int16 samples (frame_samples * channels samples).
|
||||
// out_buf: caller-allocated output buffer (recommend >= 4000 bytes).
|
||||
// Returns number of bytes written to out_buf, or -1 on error.
|
||||
int encode(const int16_t* pcm, int frame_samples, uint8_t* out_buf, int out_cap);
|
||||
|
||||
void destroy();
|
||||
|
||||
bool valid() const { return enc_ != nullptr; }
|
||||
int frame_samples()const { return frame_samples_; }
|
||||
int channels() const { return channels_; }
|
||||
const char* error_string() const { return err_; }
|
||||
|
||||
private:
|
||||
#ifdef VOICECAT_HAS_OPUS
|
||||
::OpusEncoder* enc_ = nullptr;
|
||||
#else
|
||||
void* enc_ = nullptr;
|
||||
#endif
|
||||
int frame_samples_ = 0;
|
||||
int channels_ = 1;
|
||||
const char* err_ = nullptr;
|
||||
};
|
||||
|
||||
class OpusDecoder {
|
||||
public:
|
||||
// TODO(M2): init(params); decode(opus, out_pcm); PLC on loss; FEC from next packet.
|
||||
OpusDecoder() = default;
|
||||
~OpusDecoder() { destroy(); }
|
||||
|
||||
OpusDecoder(const OpusDecoder&) = delete;
|
||||
OpusDecoder& operator=(const OpusDecoder&) = delete;
|
||||
|
||||
// Initialise. Must be called before decode().
|
||||
bool init(const OpusParams& p);
|
||||
|
||||
// Decode one Opus packet into out_pcm (frame_samples * channels int16 samples).
|
||||
// opus_data=nullptr, len=0 → PLC (free, always enabled by libopus).
|
||||
// fec=true, next valid packet in opus_data → FEC recovery from previous loss.
|
||||
// Returns number of samples decoded (= frame_samples), or -1 on error.
|
||||
int decode(const uint8_t* opus_data, int len, int16_t* out_pcm, int max_samples,
|
||||
bool fec = false);
|
||||
|
||||
void destroy();
|
||||
|
||||
bool valid() const { return dec_ != nullptr; }
|
||||
int frame_samples()const { return frame_samples_; }
|
||||
int channels() const { return channels_; }
|
||||
const char* error_string() const { return err_; }
|
||||
|
||||
private:
|
||||
#ifdef VOICECAT_HAS_OPUS
|
||||
::OpusDecoder* dec_ = nullptr;
|
||||
#else
|
||||
void* dec_ = nullptr;
|
||||
#endif
|
||||
int frame_samples_ = 0;
|
||||
int channels_ = 1;
|
||||
const char* err_ = nullptr;
|
||||
};
|
||||
|
||||
} // namespace voicecat::codec
|
||||
|
||||
@@ -283,6 +283,106 @@ bool TlsContext::export_keying_material(const char* label, const uint8_t* ctx, s
|
||||
ctx, ctx_len, ctx != nullptr) == 0;
|
||||
}
|
||||
|
||||
// ── SodiumMediaCrypto ─────────────────────────────────────────────────────────
|
||||
|
||||
SodiumMediaCrypto::SodiumMediaCrypto(
|
||||
const uint8_t key[crypto_aead_chacha20poly1305_ietf_KEYBYTES]) {
|
||||
std::memcpy(key_.data(), key, key_.size());
|
||||
}
|
||||
|
||||
std::unique_ptr<SodiumMediaCrypto> SodiumMediaCrypto::derive(TlsContext& tls, uint8_t ctx_byte) {
|
||||
uint8_t key[crypto_aead_chacha20poly1305_ietf_KEYBYTES]{};
|
||||
if (!tls.export_keying_material("voicecat media v1", &ctx_byte, 1, key, sizeof(key)))
|
||||
return nullptr;
|
||||
auto p = std::make_unique<SodiumMediaCrypto>(key);
|
||||
sodium_memzero(key, sizeof(key));
|
||||
return p;
|
||||
}
|
||||
|
||||
std::unique_ptr<SodiumMediaCrypto> SodiumMediaCrypto::derive_send(TlsContext& tls,
|
||||
bool is_client) {
|
||||
return derive(tls, is_client ? 0x00 : 0x01);
|
||||
}
|
||||
|
||||
std::unique_ptr<SodiumMediaCrypto> SodiumMediaCrypto::derive_recv(TlsContext& tls,
|
||||
bool is_client) {
|
||||
return derive(tls, is_client ? 0x01 : 0x00);
|
||||
}
|
||||
|
||||
void SodiumMediaCrypto::build_nonce(uint64_t counter, uint8_t nonce[12]) const {
|
||||
// nonce[0..3] = 0x00 (reserved / zero-padded)
|
||||
// nonce[4..11] = counter (big-endian u64)
|
||||
nonce[0] = nonce[1] = nonce[2] = nonce[3] = 0;
|
||||
nonce[4] = static_cast<uint8_t>(counter >> 56);
|
||||
nonce[5] = static_cast<uint8_t>(counter >> 48);
|
||||
nonce[6] = static_cast<uint8_t>(counter >> 40);
|
||||
nonce[7] = static_cast<uint8_t>(counter >> 32);
|
||||
nonce[8] = static_cast<uint8_t>(counter >> 24);
|
||||
nonce[9] = static_cast<uint8_t>(counter >> 16);
|
||||
nonce[10] = static_cast<uint8_t>(counter >> 8);
|
||||
nonce[11] = static_cast<uint8_t>(counter & 0xFF);
|
||||
}
|
||||
|
||||
long SodiumMediaCrypto::seal(const uint8_t* plain, size_t len, const uint8_t* aad,
|
||||
size_t aad_len, uint8_t* out, size_t out_cap) {
|
||||
if (out_cap < len + crypto_aead_chacha20poly1305_ietf_ABYTES) return -1;
|
||||
|
||||
uint8_t nonce[crypto_aead_chacha20poly1305_ietf_NPUBBYTES];
|
||||
build_nonce(send_counter_++, nonce);
|
||||
|
||||
unsigned long long sealed_len = 0;
|
||||
if (crypto_aead_chacha20poly1305_ietf_encrypt(
|
||||
out, &sealed_len, plain, static_cast<unsigned long long>(len),
|
||||
aad, static_cast<unsigned long long>(aad_len),
|
||||
nullptr, nonce, key_.data()) != 0)
|
||||
return -1;
|
||||
|
||||
return static_cast<long>(sealed_len);
|
||||
}
|
||||
|
||||
long SodiumMediaCrypto::open(const uint8_t* sealed, size_t len, const uint8_t* aad,
|
||||
size_t aad_len, uint8_t* out, size_t out_cap) {
|
||||
if (len < crypto_aead_chacha20poly1305_ietf_ABYTES) return -1;
|
||||
if (out_cap < len - crypto_aead_chacha20poly1305_ietf_ABYTES) return -1;
|
||||
|
||||
// Reconstruct 64-bit counter from aad[8..9] (seq, big-endian u16).
|
||||
// For M2, we zero-extend the 16-bit seq; TODO: add ROC for long sessions.
|
||||
if (aad_len < 10) return -1;
|
||||
uint64_t counter = (static_cast<uint64_t>(aad[8]) << 8) | aad[9];
|
||||
|
||||
// ── Anti-replay check ────────────────────────────────────────────────────
|
||||
if (!recv_initialized_) {
|
||||
recv_highest_ = counter;
|
||||
recv_window_ = 1; // bit0 = highest itself
|
||||
recv_initialized_ = true;
|
||||
} else {
|
||||
if (counter > recv_highest_) {
|
||||
uint64_t shift = counter - recv_highest_;
|
||||
recv_window_ = (shift >= 64) ? 0 : (recv_window_ << shift);
|
||||
recv_highest_ = counter;
|
||||
}
|
||||
uint64_t offset = recv_highest_ - counter;
|
||||
if (offset >= 64) return -1; // too old
|
||||
if (recv_window_ & (UINT64_C(1) << offset)) return -1; // replay
|
||||
}
|
||||
|
||||
uint8_t nonce[crypto_aead_chacha20poly1305_ietf_NPUBBYTES];
|
||||
build_nonce(counter, nonce);
|
||||
|
||||
unsigned long long plain_len = 0;
|
||||
if (crypto_aead_chacha20poly1305_ietf_decrypt(
|
||||
out, &plain_len, nullptr, sealed, static_cast<unsigned long long>(len),
|
||||
aad, static_cast<unsigned long long>(aad_len),
|
||||
nonce, key_.data()) != 0)
|
||||
return -1;
|
||||
|
||||
// Mark this counter as accepted in the window.
|
||||
uint64_t offset = recv_highest_ - counter;
|
||||
recv_window_ |= (UINT64_C(1) << offset);
|
||||
|
||||
return static_cast<long>(plain_len);
|
||||
}
|
||||
|
||||
} // namespace voicecat::crypto
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
|
||||
@@ -119,12 +119,62 @@ class TlsContext {
|
||||
class MediaCrypto {
|
||||
public:
|
||||
virtual ~MediaCrypto() = default;
|
||||
// Encrypt plain[0..len) with AAD aad[0..aad_len). Write ciphertext+MAC to out.
|
||||
// out_cap must be >= len + crypto_aead_chacha20poly1305_ietf_ABYTES (16).
|
||||
// Returns total bytes written on success, or -1 on error.
|
||||
virtual long seal(const uint8_t* plain, size_t len, const uint8_t* aad, size_t aad_len,
|
||||
uint8_t* out, size_t out_cap) = 0;
|
||||
// Decrypt+authenticate sealed[0..len). len includes the 16-byte MAC.
|
||||
// Returns number of plaintext bytes written to out, or -1 on auth failure / replay.
|
||||
virtual long open(const uint8_t* sealed, size_t len, const uint8_t* aad, size_t aad_len,
|
||||
uint8_t* out, size_t out_cap) = 0;
|
||||
};
|
||||
|
||||
// ── ChaCha20-Poly1305 backend ──────────────────────────────────────────────────
|
||||
// Keys are derived from the TLS session via RFC 5705 / mbedTLS exporter.
|
||||
// Nonce scheme: 4 zero bytes ‖ monotonic-counter(u64 big-endian, 8 bytes).
|
||||
// Anti-replay: 64-bit sliding window keyed on the received counter.
|
||||
class SodiumMediaCrypto final : public MediaCrypto {
|
||||
public:
|
||||
// ctx_byte: 0x00 = client→server direction, 0x01 = server→client direction.
|
||||
static std::unique_ptr<SodiumMediaCrypto> derive(TlsContext& tls, uint8_t ctx_byte);
|
||||
|
||||
// Convenience: derive the key used to encrypt outgoing frames.
|
||||
// is_client=true → ctx=0x00 (client sends); is_client=false → ctx=0x01 (server sends).
|
||||
static std::unique_ptr<SodiumMediaCrypto> derive_send(TlsContext& tls, bool is_client);
|
||||
// Convenience: derive the key used to decrypt incoming frames.
|
||||
// is_client=true → ctx=0x01 (client recvs); is_client=false → ctx=0x00 (server recvs).
|
||||
static std::unique_ptr<SodiumMediaCrypto> derive_recv(TlsContext& tls, bool is_client);
|
||||
|
||||
// Unit-test constructor: supply a raw 32-byte key directly.
|
||||
explicit SodiumMediaCrypto(const uint8_t key[crypto_aead_chacha20poly1305_ietf_KEYBYTES]);
|
||||
|
||||
// Returns the send counter for the NEXT seal() call (use as frame seq).
|
||||
uint64_t peek_send_counter() const { return send_counter_; }
|
||||
|
||||
// seal(): increments send_counter_; nonce derived from internal counter.
|
||||
long seal(const uint8_t* plain, size_t len, const uint8_t* aad, size_t aad_len,
|
||||
uint8_t* out, size_t out_cap) override;
|
||||
|
||||
// open(): reconstructs counter from aad[8..9] (seq field), checks anti-replay.
|
||||
long open(const uint8_t* sealed, size_t len, const uint8_t* aad, size_t aad_len,
|
||||
uint8_t* out, size_t out_cap) override;
|
||||
|
||||
private:
|
||||
void build_nonce(uint64_t counter, uint8_t nonce[12]) const;
|
||||
|
||||
std::array<uint8_t, crypto_aead_chacha20poly1305_ietf_KEYBYTES> key_{};
|
||||
|
||||
// Send state (used only in seal()).
|
||||
uint64_t send_counter_{0};
|
||||
|
||||
// Receive anti-replay state (used only in open()).
|
||||
// Window: highest accepted counter + bitmask of last 64 accepted counters.
|
||||
uint64_t recv_highest_{0}; // highest counter seen and accepted
|
||||
uint64_t recv_window_{0}; // bit i set → (highest - i) was accepted
|
||||
bool recv_initialized_{false}; // first packet initializes the window
|
||||
};
|
||||
|
||||
} // namespace voicecat::crypto
|
||||
|
||||
#else // !VOICECAT_HAS_NET — skeleton stubs
|
||||
@@ -138,6 +188,13 @@ class MediaCrypto {
|
||||
virtual long open(const uint8_t*, size_t, const uint8_t*, size_t, uint8_t*, size_t) = 0;
|
||||
};
|
||||
|
||||
class SodiumMediaCrypto final : public MediaCrypto {
|
||||
public:
|
||||
long seal(const uint8_t*, size_t, const uint8_t*, size_t, uint8_t*, size_t) override { return -1; }
|
||||
long open(const uint8_t*, size_t, const uint8_t*, size_t, uint8_t*, size_t) override { return -1; }
|
||||
uint64_t peek_send_counter() const { return 0; }
|
||||
};
|
||||
|
||||
} // namespace voicecat::crypto
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
|
||||
@@ -175,6 +175,8 @@ void TcpServerConn::start() {
|
||||
return;
|
||||
}
|
||||
self->connected_.store(true, std::memory_order_release);
|
||||
// Export media keying material before the read loop starts.
|
||||
if (self->cbs_.on_tls_ready) self->cbs_.on_tls_ready(*self->tls_);
|
||||
// 50 ms timeout so tls_read_loop can drain the send queue between reads.
|
||||
self->tls_->set_read_timeout(50);
|
||||
self->tls_thread_ = std::thread([self] { self->tls_read_loop(); });
|
||||
@@ -351,6 +353,67 @@ void TcpAcceptor::do_accept() {
|
||||
});
|
||||
}
|
||||
|
||||
// ── UdpMediaChannel ──────────────────────────────────────────────────────────
|
||||
|
||||
bool UdpMediaChannel::bind(asio::io_context& io, uint16_t port) {
|
||||
if (bound_.load()) return false;
|
||||
try {
|
||||
socket_ = std::make_unique<asio::ip::udp::socket>(io);
|
||||
socket_->open(asio::ip::udp::v4());
|
||||
socket_->set_option(asio::socket_base::reuse_address(true));
|
||||
socket_->bind(asio::ip::udp::endpoint(asio::ip::udp::v4(), port));
|
||||
bound_.store(true, std::memory_order_release);
|
||||
return true;
|
||||
} catch (...) {
|
||||
socket_.reset();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void UdpMediaChannel::start_recv(FrameCallback cb) {
|
||||
frame_cb_ = std::move(cb);
|
||||
do_recv();
|
||||
}
|
||||
|
||||
void UdpMediaChannel::do_recv() {
|
||||
if (!socket_ || closed_.load()) return;
|
||||
socket_->async_receive_from(
|
||||
asio::buffer(recv_buf_), sender_ep_,
|
||||
[this](std::error_code ec, std::size_t n) {
|
||||
if (ec || closed_.load()) return;
|
||||
if (frame_cb_ && n > 0)
|
||||
frame_cb_(recv_buf_.data(), n, sender_ep_);
|
||||
do_recv();
|
||||
});
|
||||
}
|
||||
|
||||
void UdpMediaChannel::send_to(const uint8_t* data, size_t len,
|
||||
asio::ip::udp::endpoint dst) {
|
||||
if (!socket_ || closed_.load() || len == 0) return;
|
||||
auto buf = std::make_shared<std::vector<uint8_t>>(data, data + len);
|
||||
asio::post(socket_->get_executor(), [this, buf, dst]() mutable {
|
||||
if (closed_.load()) return;
|
||||
socket_->async_send_to(
|
||||
asio::buffer(*buf), dst,
|
||||
[buf](std::error_code, std::size_t) {});
|
||||
});
|
||||
}
|
||||
|
||||
void UdpMediaChannel::close() {
|
||||
if (closed_.exchange(true)) return;
|
||||
if (socket_) {
|
||||
std::error_code ec;
|
||||
socket_->cancel(ec);
|
||||
socket_->close(ec);
|
||||
}
|
||||
}
|
||||
|
||||
asio::ip::udp::endpoint UdpMediaChannel::local_endpoint() const {
|
||||
if (!socket_) return {};
|
||||
std::error_code ec;
|
||||
return socket_->local_endpoint(ec);
|
||||
}
|
||||
|
||||
} // namespace voicecat::net
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
|
||||
@@ -40,6 +40,9 @@ struct TcpChannelCallbacks {
|
||||
std::function<void(std::vector<uint8_t>)> on_frame; // one decoded frame payload
|
||||
std::function<void(std::error_code)> on_error;
|
||||
std::function<void()> on_disconnected;
|
||||
// Called (on the handshake thread) right after TLS succeeds, before reads begin.
|
||||
// Use to export keying material while the handshake context is still fresh.
|
||||
std::function<void(voicecat::crypto::TlsContext&)> on_tls_ready;
|
||||
};
|
||||
|
||||
// ── Client-side: owns an io_context + dedicated net thread ──────────────────
|
||||
@@ -166,12 +169,44 @@ class TcpAcceptor {
|
||||
};
|
||||
|
||||
// ── UDP media channel (M2) ───────────────────────────────────────────────────
|
||||
// Thin async UDP socket. send_to() is thread-safe. Recv callbacks fire on the
|
||||
// io_context's thread (same thread that runs the io_context::run() loop).
|
||||
class UdpMediaChannel {
|
||||
public:
|
||||
bool bound() const { return bound_; }
|
||||
using FrameCallback =
|
||||
std::function<void(const uint8_t*, size_t, asio::ip::udp::endpoint)>;
|
||||
|
||||
UdpMediaChannel() = default;
|
||||
~UdpMediaChannel() { close(); }
|
||||
|
||||
UdpMediaChannel(const UdpMediaChannel&) = delete;
|
||||
UdpMediaChannel& operator=(const UdpMediaChannel&) = delete;
|
||||
|
||||
// Bind to 0.0.0.0:port (0 = OS-assigned). Must be called before start_recv/send_to.
|
||||
bool bind(asio::io_context& io, uint16_t port = 0);
|
||||
|
||||
// Begin the async recv loop. cb is called on the io_context thread.
|
||||
void start_recv(FrameCallback cb);
|
||||
|
||||
// Thread-safe fire-and-forget send. Copies data into a heap buffer.
|
||||
void send_to(const uint8_t* data, size_t len, asio::ip::udp::endpoint dst);
|
||||
|
||||
// Cancel all async ops and close the socket. Safe to call from any thread.
|
||||
void close();
|
||||
|
||||
asio::ip::udp::endpoint local_endpoint() const;
|
||||
bool bound() const { return bound_.load(std::memory_order_acquire); }
|
||||
|
||||
private:
|
||||
bool bound_ = false;
|
||||
void do_recv();
|
||||
|
||||
// Socket + recv state live here; only accessed from the io_context thread after bind().
|
||||
std::unique_ptr<asio::ip::udp::socket> socket_;
|
||||
asio::ip::udp::endpoint sender_ep_;
|
||||
std::array<uint8_t, 1500> recv_buf_{};
|
||||
FrameCallback frame_cb_;
|
||||
std::atomic<bool> bound_{false};
|
||||
std::atomic<bool> closed_{false};
|
||||
};
|
||||
|
||||
} // namespace voicecat::net
|
||||
|
||||
103
core/src/net/voice_frame.h
Normal file
103
core/src/net/voice_frame.h
Normal file
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* net/voice_frame.h — UDP media frame wire format (header-only).
|
||||
*
|
||||
* Design: docs/voice.md §2. The 14-byte fixed header is also used as AEAD associated data.
|
||||
* Multi-byte fields are big-endian. The payload (Opus packet) is AEAD-encrypted.
|
||||
*/
|
||||
#ifndef VOICECAT_NET_VOICE_FRAME_H
|
||||
#define VOICECAT_NET_VOICE_FRAME_H
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
namespace voicecat::net {
|
||||
|
||||
// Frame types.
|
||||
inline constexpr uint8_t kFrameVoice = 1;
|
||||
inline constexpr uint8_t kFrameKeepalive = 2;
|
||||
inline constexpr uint8_t kFrameUdpBinding = 3;
|
||||
|
||||
// Flag bits in VoiceFrame::flags.
|
||||
inline constexpr uint8_t kFlagMarker = 0x01; // start of talkspurt
|
||||
inline constexpr uint8_t kFlagFecPresent = 0x02; // this frame carries previous-frame FEC
|
||||
inline constexpr uint8_t kFlagDtx = 0x04; // comfort-noise / DTX silence frame
|
||||
inline constexpr uint8_t kFlagLast = 0x08; // last frame before stream stop
|
||||
|
||||
// Codec IDs.
|
||||
inline constexpr uint16_t kCodecOpus = 0;
|
||||
|
||||
// Size of the serialized header (bytes before the payload).
|
||||
inline constexpr size_t kVoiceHeaderSize = 14;
|
||||
|
||||
/*
|
||||
* Wire layout (big-endian):
|
||||
* [0] type u8
|
||||
* [1] flags u8
|
||||
* [2..3] codec u16
|
||||
* [4..7] ssrc u32
|
||||
* [8..9] seq u16 (low 16 bits of monotonic send counter)
|
||||
* [10..13] timestamp u32 (sample clock @48 kHz)
|
||||
* [14+] payload (AEAD-encrypted Opus packet)
|
||||
*
|
||||
* The 14-byte header is the AEAD AAD (authenticated, not encrypted).
|
||||
* The payload region is the AEAD ciphertext + 16-byte Poly1305 MAC.
|
||||
*/
|
||||
struct VoiceFrame {
|
||||
uint8_t type = kFrameVoice;
|
||||
uint8_t flags = 0;
|
||||
uint16_t codec = kCodecOpus;
|
||||
uint32_t ssrc = 0;
|
||||
uint16_t seq = 0;
|
||||
uint32_t timestamp = 0;
|
||||
std::vector<uint8_t> payload; // Opus bytes (pre-AEAD on send; post-AEAD on recv)
|
||||
};
|
||||
|
||||
// Serialize the 14-byte header into buf[0..13]. buf must be at least kVoiceHeaderSize bytes.
|
||||
inline void serialize_header(const VoiceFrame& f, uint8_t* buf) {
|
||||
buf[0] = f.type;
|
||||
buf[1] = f.flags;
|
||||
buf[2] = static_cast<uint8_t>(f.codec >> 8);
|
||||
buf[3] = static_cast<uint8_t>(f.codec & 0xFF);
|
||||
buf[4] = static_cast<uint8_t>(f.ssrc >> 24);
|
||||
buf[5] = static_cast<uint8_t>(f.ssrc >> 16);
|
||||
buf[6] = static_cast<uint8_t>(f.ssrc >> 8);
|
||||
buf[7] = static_cast<uint8_t>(f.ssrc & 0xFF);
|
||||
buf[8] = static_cast<uint8_t>(f.seq >> 8);
|
||||
buf[9] = static_cast<uint8_t>(f.seq & 0xFF);
|
||||
buf[10] = static_cast<uint8_t>(f.timestamp >> 24);
|
||||
buf[11] = static_cast<uint8_t>(f.timestamp >> 16);
|
||||
buf[12] = static_cast<uint8_t>(f.timestamp >> 8);
|
||||
buf[13] = static_cast<uint8_t>(f.timestamp & 0xFF);
|
||||
}
|
||||
|
||||
// Parse the 14-byte header from buf. Returns false if len < kVoiceHeaderSize.
|
||||
inline bool parse_header(const uint8_t* buf, size_t len, VoiceFrame& out) {
|
||||
if (len < kVoiceHeaderSize) return false;
|
||||
out.type = buf[0];
|
||||
out.flags = buf[1];
|
||||
out.codec = static_cast<uint16_t>((buf[2] << 8) | buf[3]);
|
||||
out.ssrc = (static_cast<uint32_t>(buf[4]) << 24) |
|
||||
(static_cast<uint32_t>(buf[5]) << 16) |
|
||||
(static_cast<uint32_t>(buf[6]) << 8) |
|
||||
static_cast<uint32_t>(buf[7]);
|
||||
out.seq = static_cast<uint16_t>((buf[8] << 8) | buf[9]);
|
||||
out.timestamp = (static_cast<uint32_t>(buf[10]) << 24) |
|
||||
(static_cast<uint32_t>(buf[11]) << 16) |
|
||||
(static_cast<uint32_t>(buf[12]) << 8) |
|
||||
static_cast<uint32_t>(buf[13]);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Serialize a full UDP_BINDING packet (type=3, token in payload, no AEAD).
|
||||
inline std::vector<uint8_t> make_udp_binding_packet(const uint8_t* token, size_t token_len) {
|
||||
std::vector<uint8_t> pkt(kVoiceHeaderSize + token_len, 0);
|
||||
pkt[0] = kFrameUdpBinding;
|
||||
std::memcpy(pkt.data() + kVoiceHeaderSize, token, token_len);
|
||||
return pkt;
|
||||
}
|
||||
|
||||
} // namespace voicecat::net
|
||||
|
||||
#endif // VOICECAT_NET_VOICE_FRAME_H
|
||||
@@ -128,11 +128,14 @@ Client Server
|
||||
│ │
|
||||
│ ◀── ServerStateSnapshot (channel tree, users) ───│ initial sync
|
||||
│ │
|
||||
│ UdpBinding(udp_token) [TCP/TLS] ─────────────────▶│ confirms token, no-ops if mismatched
|
||||
│ ◀── UdpBinding(ack=true) [TCP/TLS] ───────────────│
|
||||
│ │
|
||||
│ ===== UDP side (parallel) ===================== │
|
||||
│ (media keys derived from TLS exporter — no 2nd │
|
||||
│ handshake; see security.md §2) │
|
||||
│ UdpBinding(udp_token) [AEAD, exported keys] ────▶│ binds 5-tuple → session_id
|
||||
│ ◀── UdpBinding ack [AEAD] ───────────────────────│
|
||||
│ UDP_BINDING frame(udp_token) [plaintext] ───────▶│ binds 5-tuple → session_id
|
||||
│ ── voice frames (AEAD, exported keys) ──────────▶│
|
||||
│ │
|
||||
│ JoinChannelRequest(id, password?) ──────────────▶│
|
||||
│ ◀── JoinChannelResult(ok, members, audio_cfg) ───│
|
||||
@@ -151,11 +154,12 @@ Notes:
|
||||
- **Auth over TLS.** Passwords cross the wire only inside TLS 1.3 and are verified against
|
||||
an Argon2id hash at rest (see security.md). `auth_methods` in `ServerHello` advertises
|
||||
whether `guest` is enabled.
|
||||
- **UDP token.** `AuthResult.udp_token` is a short-lived opaque token. The client sends it
|
||||
in the first UDP message (`UdpBinding`) so the server can bind the UDP 5-tuple to the
|
||||
authenticated session without trusting the source address. This is the only UDP message
|
||||
that carries identity material; everything after is implicit via the bound tuple +
|
||||
media-AEAD session.
|
||||
- **UDP token.** `AuthResult.udp_token` is a short-lived opaque token. The client confirms it
|
||||
over TCP/TLS (`UdpBinding` request/ack) and also sends it as the payload of a plaintext
|
||||
`UDP_BINDING`-type media frame so the server can bind the UDP 5-tuple to the authenticated
|
||||
session without trusting the source address. This bootstrap frame is the only UDP message
|
||||
that carries identity material in the clear; everything after (voice frames) is AEAD-sealed
|
||||
and routed purely by the bound tuple + media-AEAD session.
|
||||
- **Snapshot then deltas.** After auth the server pushes a `ServerStateSnapshot` (full
|
||||
channel tree + visible users), then streams incremental `ChannelEvent`/`UserEvent`
|
||||
deltas. Clients reconcile by id.
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
#include <sodium.h>
|
||||
|
||||
#include "db.h"
|
||||
#include "session_registry.h"
|
||||
@@ -22,12 +25,16 @@ ConnSession::ConnSession(std::shared_ptr<Database> db,
|
||||
std::shared_ptr<SessionRegistry> registry,
|
||||
std::shared_ptr<voicecat::WorkerPool> workers,
|
||||
const std::array<uint8_t, 32>& server_fp,
|
||||
bool allow_guests)
|
||||
bool allow_guests,
|
||||
uint16_t udp_media_port)
|
||||
: db_(std::move(db)),
|
||||
registry_(std::move(registry)),
|
||||
workers_(std::move(workers)),
|
||||
server_fp_(server_fp),
|
||||
allow_guests_(allow_guests) {}
|
||||
allow_guests_(allow_guests),
|
||||
udp_media_port_(udp_media_port) {
|
||||
randombytes_buf(udp_token_.data(), udp_token_.size());
|
||||
}
|
||||
|
||||
void ConnSession::set_io(SendFn send_fn, CloseFn close_fn) {
|
||||
send_fn_ = std::move(send_fn);
|
||||
@@ -67,6 +74,14 @@ void ConnSession::on_frame(std::vector<uint8_t> frame) {
|
||||
if (st == State::Authenticated)
|
||||
registry_->set_user_channel(user_id_.load(), 1);
|
||||
break;
|
||||
case voicecat::v1::Envelope::kUdpBinding:
|
||||
if (st == State::Authenticated)
|
||||
handle_udp_binding(env.request_id(), env.udp_binding());
|
||||
break;
|
||||
case voicecat::v1::Envelope::kStreamAnnounce:
|
||||
if (st == State::Authenticated)
|
||||
handle_stream_announce(env.request_id(), env.stream_announce());
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -93,6 +108,44 @@ void ConnSession::close() {
|
||||
if (close_fn_) close_fn_();
|
||||
}
|
||||
|
||||
// ── M2: media crypto ─────────────────────────────────────────────────────────
|
||||
|
||||
void ConnSession::set_media_crypto(
|
||||
std::unique_ptr<voicecat::crypto::SodiumMediaCrypto> send,
|
||||
std::unique_ptr<voicecat::crypto::SodiumMediaCrypto> recv) {
|
||||
std::lock_guard lk(crypto_mu_);
|
||||
send_crypto_ = std::move(send);
|
||||
recv_crypto_ = std::move(recv);
|
||||
}
|
||||
|
||||
voicecat::crypto::SodiumMediaCrypto* ConnSession::send_crypto() {
|
||||
std::lock_guard lk(crypto_mu_);
|
||||
return send_crypto_.get();
|
||||
}
|
||||
|
||||
voicecat::crypto::SodiumMediaCrypto* ConnSession::recv_crypto() {
|
||||
std::lock_guard lk(crypto_mu_);
|
||||
return recv_crypto_.get();
|
||||
}
|
||||
|
||||
// ── M2: UDP endpoint ─────────────────────────────────────────────────────────
|
||||
|
||||
void ConnSession::set_udp_endpoint(asio::ip::udp::endpoint ep) {
|
||||
{
|
||||
std::lock_guard lk(udp_ep_mu_);
|
||||
udp_ep_ = ep;
|
||||
}
|
||||
has_udp_ep_.store(true, std::memory_order_release);
|
||||
registry_->register_udp_endpoint(ep, session_id_);
|
||||
}
|
||||
|
||||
asio::ip::udp::endpoint ConnSession::udp_endpoint() const {
|
||||
std::lock_guard lk(udp_ep_mu_);
|
||||
return udp_ep_;
|
||||
}
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
void ConnSession::handle_client_hello(uint64_t req_id, const voicecat::v1::ClientHello& msg) {
|
||||
if (msg.proto_version() != 1) {
|
||||
send_disconnect_and_close(1, "unsupported protocol version");
|
||||
@@ -106,6 +159,7 @@ void ConnSession::handle_client_hello(uint64_t req_id, const voicecat::v1::Clien
|
||||
if (allow_guests_) hello->add_auth_methods("guest");
|
||||
hello->add_auth_methods("password");
|
||||
hello->set_server_identity_fingerprint(server_fp_.data(), server_fp_.size());
|
||||
if (udp_media_port_) hello->set_udp_port(udp_media_port_);
|
||||
send_envelope(env);
|
||||
state_.store(State::WaitingAuth, std::memory_order_release);
|
||||
}
|
||||
@@ -141,12 +195,15 @@ void ConnSession::finish_guest_auth(const voicecat::v1::GuestAuth& guest, uint64
|
||||
user_id_.store(uid, std::memory_order_relaxed);
|
||||
state_.store(State::Authenticated, std::memory_order_release);
|
||||
|
||||
registry_->register_udp_token(udp_token_, session_id_);
|
||||
|
||||
{
|
||||
auto env = make_env(req_id);
|
||||
auto* res = env.mutable_auth_result();
|
||||
res->set_ok(true);
|
||||
res->set_session_id(session_id_);
|
||||
*res->mutable_self() = user;
|
||||
res->set_udp_token(udp_token_.data(), udp_token_.size());
|
||||
send_envelope(env);
|
||||
}
|
||||
broadcast_user_joined(user);
|
||||
@@ -176,6 +233,8 @@ void ConnSession::finish_password_auth(const std::string& username,
|
||||
self->user_id_.store(uid, std::memory_order_relaxed);
|
||||
self->state_.store(State::Authenticated, std::memory_order_release);
|
||||
|
||||
self->registry_->register_udp_token(self->udp_token_, self->session_id_);
|
||||
|
||||
{
|
||||
auto env = make_env(req_id);
|
||||
auto* res = env.mutable_auth_result();
|
||||
@@ -184,6 +243,7 @@ void ConnSession::finish_password_auth(const std::string& username,
|
||||
*res->mutable_self() = user;
|
||||
auto* perms = res->mutable_permissions();
|
||||
perms->set_is_admin(acc->is_admin);
|
||||
res->set_udp_token(self->udp_token_.data(), self->udp_token_.size());
|
||||
self->send_envelope(env);
|
||||
}
|
||||
self->broadcast_user_joined(user);
|
||||
@@ -247,6 +307,48 @@ void ConnSession::handle_ping(const voicecat::v1::Ping& msg) {
|
||||
send_envelope(env);
|
||||
}
|
||||
|
||||
void ConnSession::handle_udp_binding(uint64_t req_id, const voicecat::v1::UdpBinding& msg) {
|
||||
if (msg.ack()) return; // server→client direction; ignore if echoed back
|
||||
|
||||
const std::string& tok = msg.udp_token();
|
||||
if (tok.size() != 16 || std::memcmp(tok.data(), udp_token_.data(), 16) != 0) {
|
||||
// Bad token — silently ignore (don't leak timing information)
|
||||
return;
|
||||
}
|
||||
|
||||
// Ack over TCP; MediaRelay will set the UDP endpoint when the UDP binding packet arrives.
|
||||
auto env = make_env(req_id);
|
||||
env.mutable_udp_binding()->set_ack(true);
|
||||
send_envelope(env);
|
||||
}
|
||||
|
||||
void ConnSession::handle_stream_announce(uint64_t req_id,
|
||||
const voicecat::v1::StreamAnnounce& msg) {
|
||||
uint32_t ssrc = registry_->assign_ssrc(session_id_);
|
||||
|
||||
auto env = make_env(req_id);
|
||||
auto* res = env.mutable_stream_announce_result();
|
||||
res->set_ok(true);
|
||||
res->set_stream_id(1);
|
||||
res->set_ssrc(ssrc);
|
||||
|
||||
auto* eff = res->mutable_effective_audio();
|
||||
if (msg.has_requested_audio()) {
|
||||
*eff = msg.requested_audio();
|
||||
} else {
|
||||
eff->set_codec(0); // OPUS
|
||||
eff->set_sample_rate(48000);
|
||||
eff->set_bitrate_bps(24000);
|
||||
eff->set_frame_ms(20);
|
||||
eff->set_fec(true);
|
||||
}
|
||||
if (eff->sample_rate() == 0) eff->set_sample_rate(48000);
|
||||
if (eff->bitrate_bps() == 0) eff->set_bitrate_bps(24000);
|
||||
if (eff->frame_ms() == 0) eff->set_frame_ms(20);
|
||||
|
||||
send_envelope(env);
|
||||
}
|
||||
|
||||
void ConnSession::send_disconnect_and_close(uint32_t code, const std::string& reason) {
|
||||
auto env = make_env();
|
||||
auto* d = env.mutable_disconnect();
|
||||
|
||||
@@ -21,9 +21,13 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#define ASIO_STANDALONE 1
|
||||
#include <asio.hpp>
|
||||
|
||||
#include "crypto/crypto.h"
|
||||
#include "proto/voicecat.pb.h"
|
||||
|
||||
namespace voicecat { class WorkerPool; } // defined in core/worker_pool.h
|
||||
namespace voicecat { class WorkerPool; }
|
||||
|
||||
namespace voicecat::server {
|
||||
|
||||
@@ -34,36 +38,42 @@ class ConnSession : public std::enable_shared_from_this<ConnSession> {
|
||||
public:
|
||||
enum class State { WaitingHello, WaitingAuth, Authenticated, Disconnecting };
|
||||
|
||||
using SendFn = std::function<void(std::vector<uint8_t>)>;
|
||||
using SendFn = std::function<void(std::vector<uint8_t>)>;
|
||||
using CloseFn = std::function<void()>;
|
||||
|
||||
ConnSession(std::shared_ptr<Database> db,
|
||||
std::shared_ptr<SessionRegistry> registry,
|
||||
std::shared_ptr<voicecat::WorkerPool> workers,
|
||||
const std::array<uint8_t, 32>& server_fp,
|
||||
bool allow_guests);
|
||||
ConnSession(std::shared_ptr<Database> db,
|
||||
std::shared_ptr<SessionRegistry> registry,
|
||||
std::shared_ptr<voicecat::WorkerPool> workers,
|
||||
const std::array<uint8_t, 32>& server_fp,
|
||||
bool allow_guests,
|
||||
uint16_t udp_media_port = 0);
|
||||
|
||||
// Called after construction: gives the session its send + close handles.
|
||||
void set_io(SendFn send_fn, CloseFn close_fn);
|
||||
|
||||
// Called by server after it has registered the session id.
|
||||
void set_session_id(uint64_t id) { session_id_ = id; }
|
||||
|
||||
// Entry point: send ServerHello and begin reading.
|
||||
void begin();
|
||||
|
||||
// Deliver a received frame (called from TcpServerConn's strand).
|
||||
void on_frame(std::vector<uint8_t> frame);
|
||||
|
||||
// Called when the TCP connection drops.
|
||||
void on_disconnect();
|
||||
|
||||
// Thread-safe send.
|
||||
void send_envelope(const voicecat::v1::Envelope& env);
|
||||
|
||||
// Graceful close (can be called from any thread).
|
||||
void close();
|
||||
|
||||
// ── M2: media key injection (called from on_tls_ready) ───────────────────
|
||||
void set_media_crypto(std::unique_ptr<voicecat::crypto::SodiumMediaCrypto> send,
|
||||
std::unique_ptr<voicecat::crypto::SodiumMediaCrypto> recv);
|
||||
|
||||
// ── M2: UDP endpoint (set by MediaRelay on UdpBinding) ────────────────────
|
||||
void set_udp_endpoint(asio::ip::udp::endpoint ep);
|
||||
asio::ip::udp::endpoint udp_endpoint() const;
|
||||
bool has_udp_endpoint() const { return has_udp_ep_.load(); }
|
||||
|
||||
// ── M2: media crypto access (for SFU relay) ──────────────────────────────
|
||||
voicecat::crypto::SodiumMediaCrypto* send_crypto();
|
||||
voicecat::crypto::SodiumMediaCrypto* recv_crypto();
|
||||
|
||||
// ── M2: UDP token (for binding) ───────────────────────────────────────────
|
||||
const std::array<uint8_t, 16>& udp_token() const { return udp_token_; }
|
||||
|
||||
// ── Accessors ──────────────────────────────────────────────────────────────
|
||||
State state() const { return state_.load(); }
|
||||
uint64_t session_id() const { return session_id_; }
|
||||
uint32_t user_id() const { return user_id_; }
|
||||
@@ -74,25 +84,41 @@ class ConnSession : public std::enable_shared_from_this<ConnSession> {
|
||||
void handle_join_channel(uint64_t req_id, const voicecat::v1::JoinChannelRequest& msg);
|
||||
void handle_text_message(const voicecat::v1::TextMessage& msg);
|
||||
void handle_ping(const voicecat::v1::Ping& msg);
|
||||
void handle_udp_binding(uint64_t req_id, const voicecat::v1::UdpBinding& msg);
|
||||
void handle_stream_announce(uint64_t req_id, const voicecat::v1::StreamAnnounce& msg);
|
||||
|
||||
void finish_guest_auth(const voicecat::v1::GuestAuth& guest, uint64_t req_id);
|
||||
void finish_password_auth(const std::string& username, const std::string& password,
|
||||
uint64_t req_id);
|
||||
void send_auth_result_ok(uint64_t req_id, const voicecat::v1::User& user,
|
||||
const voicecat::v1::Permissions* perms = nullptr);
|
||||
void send_state_snapshot();
|
||||
void broadcast_user_joined(const voicecat::v1::User& user);
|
||||
void send_disconnect_and_close(uint32_t code, const std::string& reason);
|
||||
|
||||
std::shared_ptr<Database> db_;
|
||||
std::shared_ptr<SessionRegistry> registry_;
|
||||
std::shared_ptr<Database> db_;
|
||||
std::shared_ptr<SessionRegistry> registry_;
|
||||
std::shared_ptr<voicecat::WorkerPool> workers_;
|
||||
std::array<uint8_t, 32> server_fp_;
|
||||
bool allow_guests_;
|
||||
std::array<uint8_t, 32> server_fp_;
|
||||
bool allow_guests_;
|
||||
uint16_t udp_media_port_;
|
||||
|
||||
SendFn send_fn_;
|
||||
CloseFn close_fn_;
|
||||
std::atomic<State> state_{State::WaitingHello};
|
||||
uint64_t session_id_{0}; // set once before begin(), then read-only
|
||||
std::atomic<uint32_t> user_id_{0};
|
||||
std::atomic<bool> closed_{false};
|
||||
SendFn send_fn_;
|
||||
CloseFn close_fn_;
|
||||
std::atomic<State> state_{State::WaitingHello};
|
||||
uint64_t session_id_{0};
|
||||
std::atomic<uint32_t> user_id_{0};
|
||||
std::atomic<bool> closed_{false};
|
||||
|
||||
// M2 UDP / media
|
||||
std::array<uint8_t, 16> udp_token_{};
|
||||
mutable std::mutex udp_ep_mu_;
|
||||
asio::ip::udp::endpoint udp_ep_;
|
||||
std::atomic<bool> has_udp_ep_{false};
|
||||
|
||||
mutable std::mutex crypto_mu_;
|
||||
std::unique_ptr<voicecat::crypto::SodiumMediaCrypto> send_crypto_;
|
||||
std::unique_ptr<voicecat::crypto::SodiumMediaCrypto> recv_crypto_;
|
||||
};
|
||||
|
||||
} // namespace voicecat::server
|
||||
|
||||
129
server/src/media_relay.cpp
Normal file
129
server/src/media_relay.cpp
Normal file
@@ -0,0 +1,129 @@
|
||||
#include "media_relay.h"
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
|
||||
#include <array>
|
||||
#include <cstdio>
|
||||
|
||||
#include "conn_session.h"
|
||||
#include "crypto/crypto.h"
|
||||
#include "net/voice_frame.h"
|
||||
#include "session_registry.h"
|
||||
|
||||
namespace voicecat::server {
|
||||
|
||||
MediaRelay::MediaRelay(asio::io_context& io, std::shared_ptr<SessionRegistry> registry)
|
||||
: io_(io), registry_(std::move(registry)) {}
|
||||
|
||||
MediaRelay::~MediaRelay() { stop(); }
|
||||
|
||||
bool MediaRelay::bind(uint16_t port) {
|
||||
return udp_.bind(io_, port);
|
||||
}
|
||||
|
||||
void MediaRelay::start() {
|
||||
udp_.start_recv([this](const uint8_t* data, size_t len, asio::ip::udp::endpoint sender) {
|
||||
on_udp_frame(data, len, sender);
|
||||
});
|
||||
}
|
||||
|
||||
void MediaRelay::stop() {
|
||||
udp_.close();
|
||||
}
|
||||
|
||||
uint16_t MediaRelay::media_port() const {
|
||||
return static_cast<uint16_t>(udp_.local_endpoint().port());
|
||||
}
|
||||
|
||||
void MediaRelay::on_udp_frame(const uint8_t* data, size_t len,
|
||||
asio::ip::udp::endpoint sender) {
|
||||
if (len < 1) return;
|
||||
|
||||
const uint8_t frame_type = data[0];
|
||||
|
||||
if (frame_type == voicecat::net::kFrameUdpBinding) {
|
||||
// Payload = 16-byte token after the 14-byte header.
|
||||
if (len < voicecat::net::kVoiceHeaderSize + 16) return;
|
||||
std::array<uint8_t, 16> token{};
|
||||
std::memcpy(token.data(), data + voicecat::net::kVoiceHeaderSize, 16);
|
||||
|
||||
auto session = registry_->find_by_udp_token(token);
|
||||
if (!session) return;
|
||||
|
||||
session->set_udp_endpoint(sender);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame_type == voicecat::net::kFrameVoice) {
|
||||
if (len < voicecat::net::kVoiceHeaderSize + crypto_aead_chacha20poly1305_ietf_ABYTES) return;
|
||||
|
||||
// Resolve sender session.
|
||||
auto sender_session = registry_->find_by_udp_endpoint(sender);
|
||||
if (!sender_session) return;
|
||||
|
||||
auto* recv_crypto = sender_session->recv_crypto();
|
||||
if (!recv_crypto) return;
|
||||
|
||||
// AAD = 14-byte header (authenticated, not encrypted).
|
||||
const uint8_t* aad = data;
|
||||
const uint8_t* sealed = data + voicecat::net::kVoiceHeaderSize;
|
||||
size_t sealed_len = len - voicecat::net::kVoiceHeaderSize;
|
||||
|
||||
if (plain_buf_.size() < sealed_len) plain_buf_.resize(sealed_len);
|
||||
|
||||
long plain_len = recv_crypto->open(sealed, sealed_len, aad, voicecat::net::kVoiceHeaderSize,
|
||||
plain_buf_.data(), plain_buf_.size());
|
||||
if (plain_len < 0) return; // auth failure or replay
|
||||
|
||||
// Parse the voice frame header to find the source ssrc/channel.
|
||||
voicecat::net::VoiceFrame hdr{};
|
||||
if (!voicecat::net::parse_header(data, len, hdr)) return;
|
||||
|
||||
// Find the channel and get all other members.
|
||||
uint32_t uid = sender_session->user_id();
|
||||
uint32_t channel = registry_->user_channel(uid);
|
||||
if (channel == 0) return;
|
||||
|
||||
auto members = registry_->find_channel_sessions(channel, sender_session->session_id());
|
||||
|
||||
// Re-encrypt and relay to each member.
|
||||
for (auto& member : members) {
|
||||
if (!member->has_udp_endpoint()) continue;
|
||||
|
||||
auto* send_crypto = member->send_crypto();
|
||||
if (!send_crypto) continue;
|
||||
|
||||
// Build an outgoing frame with the same 14-byte header.
|
||||
if (seal_buf_.size() < voicecat::net::kVoiceHeaderSize +
|
||||
static_cast<size_t>(plain_len) +
|
||||
crypto_aead_chacha20poly1305_ietf_ABYTES) {
|
||||
seal_buf_.resize(voicecat::net::kVoiceHeaderSize +
|
||||
static_cast<size_t>(plain_len) +
|
||||
crypto_aead_chacha20poly1305_ietf_ABYTES);
|
||||
}
|
||||
|
||||
// Copy header (re-use sender's header verbatim — ssrc, seq, ts pass through).
|
||||
std::memcpy(seal_buf_.data(), data, voicecat::net::kVoiceHeaderSize);
|
||||
|
||||
uint8_t* out_payload = seal_buf_.data() + voicecat::net::kVoiceHeaderSize;
|
||||
long sealed_out = send_crypto->seal(
|
||||
plain_buf_.data(), static_cast<size_t>(plain_len),
|
||||
seal_buf_.data(), voicecat::net::kVoiceHeaderSize,
|
||||
out_payload,
|
||||
static_cast<size_t>(plain_len) + crypto_aead_chacha20poly1305_ietf_ABYTES);
|
||||
|
||||
if (sealed_out < 0) continue;
|
||||
|
||||
udp_.send_to(seal_buf_.data(),
|
||||
voicecat::net::kVoiceHeaderSize + static_cast<size_t>(sealed_out),
|
||||
member->udp_endpoint());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// kFrameKeepalive or unknown: silently discard.
|
||||
}
|
||||
|
||||
} // namespace voicecat::server
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
65
server/src/media_relay.h
Normal file
65
server/src/media_relay.h
Normal file
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* server/media_relay.h — UDP SFU relay for M2 voice.
|
||||
*
|
||||
* Design: docs/architecture.md §5, docs/voice.md §2.
|
||||
* Receives encrypted UDP voice frames from clients, decrypts+authenticates them,
|
||||
* re-encrypts for each channel member, and forwards.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Client sends kFrameUdpBinding UDP packet (plaintext) → MediaRelay looks up
|
||||
* the 16-byte token in SessionRegistry, associates the sender endpoint with
|
||||
* the ConnSession, and calls session->set_udp_endpoint().
|
||||
* 2. Client sends kFrameVoice UDP packets → MediaRelay decrypts via recv_crypto(),
|
||||
* finds channel members via find_channel_sessions(), re-encrypts via send_crypto(),
|
||||
* and sends to each member's UDP endpoint.
|
||||
*/
|
||||
#ifndef VOICECAT_SERVER_MEDIA_RELAY_H
|
||||
#define VOICECAT_SERVER_MEDIA_RELAY_H
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
|
||||
#include <memory>
|
||||
|
||||
#define ASIO_STANDALONE 1
|
||||
#include <asio.hpp>
|
||||
|
||||
#include "net/transport.h"
|
||||
|
||||
namespace voicecat::server {
|
||||
|
||||
class SessionRegistry;
|
||||
|
||||
class MediaRelay {
|
||||
public:
|
||||
MediaRelay(asio::io_context& io, std::shared_ptr<SessionRegistry> registry);
|
||||
~MediaRelay();
|
||||
|
||||
// Bind the UDP socket. port=0 lets the OS pick. Must be called before start().
|
||||
bool bind(uint16_t port = 0);
|
||||
|
||||
// Begin async receive loop. Call once after bind().
|
||||
void start();
|
||||
|
||||
// Stop receiving and close the socket.
|
||||
void stop();
|
||||
|
||||
// Actual bound port (after bind()).
|
||||
uint16_t media_port() const;
|
||||
|
||||
private:
|
||||
void on_udp_frame(const uint8_t* data, size_t len, asio::ip::udp::endpoint sender);
|
||||
|
||||
asio::io_context& io_;
|
||||
std::shared_ptr<SessionRegistry> registry_;
|
||||
voicecat::net::UdpMediaChannel udp_;
|
||||
|
||||
// Scratch buffer for re-encrypted payloads (size = max_frame + 16 MAC)
|
||||
static constexpr size_t kMaxPayload = 1500;
|
||||
std::vector<uint8_t> seal_buf_ = std::vector<uint8_t>(kMaxPayload + 16, uint8_t{0});
|
||||
std::vector<uint8_t> plain_buf_ = std::vector<uint8_t>(kMaxPayload, uint8_t{0});
|
||||
};
|
||||
|
||||
} // namespace voicecat::server
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
#endif // VOICECAT_SERVER_MEDIA_RELAY_H
|
||||
@@ -15,6 +15,7 @@
|
||||
#include "crypto/crypto.h"
|
||||
#include "db.h"
|
||||
#include "identity.h"
|
||||
#include "media_relay.h"
|
||||
#include "net/transport.h"
|
||||
#include "session_registry.h"
|
||||
|
||||
@@ -62,6 +63,16 @@ int Server::run() {
|
||||
// ── Asio io_context ──────────────────────────────────────────────────────
|
||||
asio::io_context io;
|
||||
|
||||
// ── UDP media relay (M2) ─────────────────────────────────────────────────
|
||||
auto media_relay = std::make_shared<MediaRelay>(io, registry);
|
||||
if (!media_relay->bind(cfg_.media_port)) {
|
||||
std::fprintf(stderr, "[server] failed to bind UDP media port %u\n", cfg_.media_port);
|
||||
return 1;
|
||||
}
|
||||
media_relay->start();
|
||||
uint16_t media_bound = media_relay->media_port();
|
||||
if (cfg_.on_media_ready) cfg_.on_media_ready(media_bound);
|
||||
|
||||
// Capture all locals by reference for the factory lambda (io lifetime is > factory)
|
||||
voicecat::net::TcpAcceptor acceptor(
|
||||
io, cfg_.bind_port,
|
||||
@@ -69,7 +80,8 @@ int Server::run() {
|
||||
auto session = std::make_shared<ConnSession>(
|
||||
db, registry, workers,
|
||||
id_mgr.identity().fingerprint,
|
||||
cfg_.allow_guests);
|
||||
cfg_.allow_guests,
|
||||
media_bound);
|
||||
|
||||
// Use shared_ptr (not weak_ptr) so TcpServerConn keeps ConnSession alive.
|
||||
// cycle is broken by weak_tcp in the send/close fns below.
|
||||
@@ -83,6 +95,12 @@ int Server::run() {
|
||||
cbs.on_error = [session](std::error_code) {
|
||||
session->on_disconnect();
|
||||
};
|
||||
// Derive media keys right after TLS handshake (server is not the client).
|
||||
cbs.on_tls_ready = [session](voicecat::crypto::TlsContext& tls) {
|
||||
auto send = voicecat::crypto::SodiumMediaCrypto::derive_send(tls, false);
|
||||
auto recv = voicecat::crypto::SodiumMediaCrypto::derive_recv(tls, false);
|
||||
if (send && recv) session->set_media_crypto(std::move(send), std::move(recv));
|
||||
};
|
||||
|
||||
// Create a TLS context for this connection (server role).
|
||||
auto tls = std::make_unique<voicecat::crypto::TlsContext>(
|
||||
@@ -126,11 +144,12 @@ int Server::run() {
|
||||
signals.async_wait([&](std::error_code, int sig) {
|
||||
std::printf("\n[server] signal %d — shutting down\n", sig);
|
||||
acceptor.stop();
|
||||
media_relay->stop();
|
||||
io.stop();
|
||||
});
|
||||
|
||||
std::printf("[voicecat-server] %s — listening on :%u\n",
|
||||
cfg_.server_name.c_str(), bound);
|
||||
std::printf("[voicecat-server] %s — TCP :%u UDP :%u\n",
|
||||
cfg_.server_name.c_str(), bound, media_bound);
|
||||
std::printf("[voicecat-server] fingerprint: %s\n",
|
||||
id_mgr.fingerprint_display().c_str());
|
||||
|
||||
|
||||
@@ -15,12 +15,15 @@
|
||||
namespace voicecat::server {
|
||||
|
||||
struct Config {
|
||||
std::string server_name = "VoiceCat Server";
|
||||
std::string data_dir = "voicecat-data";
|
||||
uint16_t bind_port = 8384; // 0 = let OS pick (useful for tests)
|
||||
std::string server_name = "VoiceCat Server";
|
||||
std::string data_dir = "voicecat-data";
|
||||
uint16_t bind_port = 8384; // 0 = let OS pick (useful for tests)
|
||||
uint16_t media_port = 0; // M2 UDP media port; 0 = OS-assigned
|
||||
bool allow_guests = true;
|
||||
// Called with the actual bound port once the acceptor is ready (m1-dev only).
|
||||
// Called with the actual bound TCP port once the acceptor is ready.
|
||||
std::function<void(uint16_t)> on_ready;
|
||||
// Called with the actual bound UDP media port once the relay is ready.
|
||||
std::function<void(uint16_t)> on_media_ready;
|
||||
};
|
||||
|
||||
class Server {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
#include <shared_mutex>
|
||||
|
||||
@@ -110,6 +111,67 @@ void SessionRegistry::broadcast(const voicecat::v1::Envelope& env,
|
||||
}
|
||||
}
|
||||
|
||||
// ── M2: UDP / media ──────────────────────────────────────────────────────────
|
||||
|
||||
void SessionRegistry::register_udp_token(const std::array<uint8_t, 16>& token,
|
||||
uint64_t session_id) {
|
||||
std::unique_lock lk(mu_);
|
||||
udp_tokens_[token] = session_id;
|
||||
}
|
||||
|
||||
std::shared_ptr<ConnSession> SessionRegistry::find_by_udp_token(
|
||||
const std::array<uint8_t, 16>& token) const {
|
||||
std::shared_lock lk(mu_);
|
||||
auto it = udp_tokens_.find(token);
|
||||
if (it == udp_tokens_.end()) return nullptr;
|
||||
auto sit = sessions_.find(it->second);
|
||||
if (sit == sessions_.end()) return nullptr;
|
||||
return sit->second.lock();
|
||||
}
|
||||
|
||||
void SessionRegistry::register_udp_endpoint(asio::ip::udp::endpoint ep,
|
||||
uint64_t session_id) {
|
||||
std::unique_lock lk(mu_);
|
||||
udp_endpoints_[ep] = session_id;
|
||||
}
|
||||
|
||||
std::shared_ptr<ConnSession> SessionRegistry::find_by_udp_endpoint(
|
||||
const asio::ip::udp::endpoint& ep) const {
|
||||
std::shared_lock lk(mu_);
|
||||
auto it = udp_endpoints_.find(ep);
|
||||
if (it == udp_endpoints_.end()) return nullptr;
|
||||
auto sit = sessions_.find(it->second);
|
||||
if (sit == sessions_.end()) return nullptr;
|
||||
return sit->second.lock();
|
||||
}
|
||||
|
||||
uint32_t SessionRegistry::assign_ssrc(uint64_t session_id) {
|
||||
uint32_t ssrc = next_ssrc_.fetch_add(1, std::memory_order_relaxed);
|
||||
std::unique_lock lk(mu_);
|
||||
ssrc_to_session_[ssrc] = session_id;
|
||||
return ssrc;
|
||||
}
|
||||
|
||||
std::vector<std::shared_ptr<ConnSession>> SessionRegistry::find_channel_sessions(
|
||||
uint32_t channel_id, uint64_t exclude_session_id) const {
|
||||
std::shared_lock lk(mu_);
|
||||
std::vector<std::shared_ptr<ConnSession>> result;
|
||||
for (auto& [uid, entry] : users_) {
|
||||
if (entry.proto.channel_id() != channel_id) continue;
|
||||
if (entry.session_id == exclude_session_id) continue;
|
||||
auto sit = sessions_.find(entry.session_id);
|
||||
if (sit == sessions_.end()) continue;
|
||||
if (auto sess = sit->second.lock()) result.push_back(sess);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
uint32_t SessionRegistry::user_channel(uint32_t user_id) const {
|
||||
std::shared_lock lk(mu_);
|
||||
auto it = users_.find(user_id);
|
||||
return (it == users_.end()) ? 0 : it->second.proto.channel_id();
|
||||
}
|
||||
|
||||
} // namespace voicecat::server
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/*
|
||||
* server/session_registry.h — In-memory session, channel, and user registry.
|
||||
*
|
||||
* Tracks all authenticated sessions, the channel tree, and user<→>channel assignments.
|
||||
* Tracks all authenticated sessions, the channel tree, user<→>channel assignments,
|
||||
* UDP endpoint bindings (M2), and SSRC<→>session mappings (M2).
|
||||
* Protected by a shared_mutex (many readers, few writers). All methods are thread-safe.
|
||||
*/
|
||||
#ifndef VOICECAT_SERVER_SESSION_REGISTRY_H
|
||||
@@ -9,6 +10,8 @@
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <shared_mutex>
|
||||
@@ -16,6 +19,9 @@
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#define ASIO_STANDALONE 1
|
||||
#include <asio.hpp>
|
||||
|
||||
#include "proto/voicecat.pb.h"
|
||||
|
||||
namespace voicecat::server {
|
||||
@@ -31,6 +37,14 @@ struct UserEntry {
|
||||
uint64_t session_id{};
|
||||
};
|
||||
|
||||
// Hashes asio::ip::udp::endpoint by "addr:port" string.
|
||||
struct UdpEndpointHash {
|
||||
size_t operator()(const asio::ip::udp::endpoint& ep) const {
|
||||
std::string key = ep.address().to_string() + ':' + std::to_string(ep.port());
|
||||
return std::hash<std::string>{}(key);
|
||||
}
|
||||
};
|
||||
|
||||
class SessionRegistry {
|
||||
public:
|
||||
SessionRegistry() = default;
|
||||
@@ -58,14 +72,36 @@ class SessionRegistry {
|
||||
std::vector<voicecat::v1::User> user_snapshot() const;
|
||||
|
||||
// Resolve target sessions for a text message relay.
|
||||
// TEXT_CHANNEL: all users in that channel (except sender's session).
|
||||
// TEXT_PRIVATE: the session for that user_id.
|
||||
std::vector<std::shared_ptr<ConnSession>> resolve_text_targets(
|
||||
uint64_t sender_session_id, voicecat::v1::TextScope scope, uint32_t target_id) const;
|
||||
|
||||
// Broadcast an envelope to all sessions except the excluded one.
|
||||
void broadcast(const voicecat::v1::Envelope& env, uint64_t exclude_session_id = 0) const;
|
||||
|
||||
// ── M2: UDP / media ──────────────────────────────────────────────────────
|
||||
|
||||
// Register a session's UDP token (called at auth success).
|
||||
void register_udp_token(const std::array<uint8_t, 16>& token, uint64_t session_id);
|
||||
|
||||
// Locate a session by its UDP binding token (called by MediaRelay on UDP_BINDING).
|
||||
std::shared_ptr<ConnSession> find_by_udp_token(const std::array<uint8_t, 16>& token) const;
|
||||
|
||||
// Associate a UDP endpoint with a session (called by MediaRelay after token verification).
|
||||
void register_udp_endpoint(asio::ip::udp::endpoint ep, uint64_t session_id);
|
||||
|
||||
// Locate the session that owns a UDP sender endpoint (called per incoming voice packet).
|
||||
std::shared_ptr<ConnSession> find_by_udp_endpoint(const asio::ip::udp::endpoint& ep) const;
|
||||
|
||||
// Assign an SSRC for a new stream. Returns the assigned SSRC.
|
||||
uint32_t assign_ssrc(uint64_t session_id);
|
||||
|
||||
// Get all sessions in a channel except the one excluded (for SFU relay).
|
||||
std::vector<std::shared_ptr<ConnSession>> find_channel_sessions(
|
||||
uint32_t channel_id, uint64_t exclude_session_id = 0) const;
|
||||
|
||||
// Return the channel_id of a user (0 if not found).
|
||||
uint32_t user_channel(uint32_t user_id) const;
|
||||
|
||||
private:
|
||||
mutable std::shared_mutex mu_;
|
||||
|
||||
@@ -76,6 +112,24 @@ class SessionRegistry {
|
||||
std::unordered_map<uint64_t, std::weak_ptr<ConnSession>> sessions_;
|
||||
std::unordered_map<uint32_t, UserEntry> users_;
|
||||
std::unordered_map<uint32_t, ChannelEntry> channels_;
|
||||
|
||||
// M2: token → session_id (populated at auth, cleared on disconnect)
|
||||
struct TokenHash {
|
||||
size_t operator()(const std::array<uint8_t, 16>& t) const {
|
||||
// FNV-1a over 16 bytes
|
||||
size_t h = 14695981039346656037ULL;
|
||||
for (auto b : t) { h ^= b; h *= 1099511628211ULL; }
|
||||
return h;
|
||||
}
|
||||
};
|
||||
std::unordered_map<std::array<uint8_t, 16>, uint64_t, TokenHash> udp_tokens_;
|
||||
|
||||
// M2: UDP endpoint → session_id (populated after UDP binding packet arrives)
|
||||
std::unordered_map<asio::ip::udp::endpoint, uint64_t, UdpEndpointHash> udp_endpoints_;
|
||||
|
||||
// M2: ssrc → session_id (populated when StreamAnnounce is processed)
|
||||
std::unordered_map<uint32_t, uint64_t> ssrc_to_session_;
|
||||
std::atomic<uint32_t> next_ssrc_{1};
|
||||
};
|
||||
|
||||
} // namespace voicecat::server
|
||||
|
||||
@@ -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
532
tests/test_m2_voice.cpp
Normal 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
168
tests/test_media_aead.cpp
Normal 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
149
tests/test_opus_codec.cpp
Normal 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
128
tests/test_voice_frame.cpp
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user