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:
@@ -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
|
||||
Reference in New Issue
Block a user