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

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

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

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

View File

@@ -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

View 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

View File

@@ -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 §811.
// ── 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

View File

@@ -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.02.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

View File

@@ -2,6 +2,81 @@
namespace voicecat::codec {
// M0 stub. Brought up in M2. See docs/voice.md §34.
#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

View File

@@ -3,35 +3,105 @@
*
* Design: docs/voice.md §34. 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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