From fdcd8d1427078def2435ac56a94845217087776f Mon Sep 17 00:00:00 2001 From: Talon Date: Sat, 20 Jun 2026 13:40:47 +0200 Subject: [PATCH] feat: DRED (Deep REDundancy) per-channel toggle Adds Opus 1.6 DRED support end-to-end: encoder embeds 20 ms of ML redundancy in every packet when enabled; decoder recovers lost frames from the next buffered packet's DRED extension rather than falling back to PLC comfort noise. Protocol: bool dred = 11 added to AudioConfig (backward-compatible, defaults false). C ABI: int dred added to vc_audio_config. Encoder: OPUS_SET_DRED_DURATION(2) when dred=true. Decoder: OpusDREDDecoder + per-stream OpusDRED scratch pre-allocated off the RT thread; JitterBuffer::try_copy_front_payload peeks at the next packet without popping on every PLC step; opus_decoder_dred_decode reconstructs the lost frame if DRED data is present, otherwise falls back to PLC. New test: test_dred_toggle (22/22 ctest green). Co-Authored-By: Claude Sonnet 4.6 --- PROGRESS.md | 17 +- core/include/voicecat.h | 1 + core/proto/voicecat.proto | 1 + core/src/audio/audio_engine.cpp | 67 +++++++- core/src/audio/audio_engine.h | 16 ++ core/src/codec/opus_codec.cpp | 10 ++ core/src/codec/opus_codec.h | 9 ++ core/src/core/client.cpp | 4 + core/src/session/session.cpp | 1 + core/src/session/session.h | 1 + docs/voice.md | 11 +- tests/CMakeLists.txt | 9 ++ tests/test_dred_toggle.cpp | 272 ++++++++++++++++++++++++++++++++ 13 files changed, 412 insertions(+), 7 deletions(-) create mode 100644 tests/test_dred_toggle.cpp diff --git a/PROGRESS.md b/PROGRESS.md index de24c5c..ac1ac9e 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -39,7 +39,8 @@ up instantly. Newest status at the top. `allowBluetoothA2DP` and the output route must remain the headphones/A2DP device — NOT flip to `…Record`. If confirmed, delete the `logSessionState` calls + method and the prior band-aid comments in `IOSAudioRouter`/`audio_engine.cpp` can be trimmed. - - **Verified on Windows:** `cmake --build --preset dev` clean, `ctest --preset dev` 21/21. + - **Verified on Windows:** `cmake --build --preset dev` clean, `ctest --preset dev` 22/22 + (21/21 prior + new `test_dred_toggle`). iOS build & on-device run still to be done by the user on the Mac. - **Planned (not started):** **External PCM feed/tap API (`vc_stream_feed_pcm` + @@ -393,7 +394,19 @@ iOS 18.0 deployment target. App Group `group.cat.voice.VoiceCat` for Keychain sh - [x] **All three client UIs** (Windows WinForms, macOS AppKit, iOS SwiftUI) expose the full M5 moderation and admin surface. - [x] **Docs** — `docs/protocol.md`, `docs/security.md` kept in sync. -- [ ] **DRED/audio-quality polish** — not started. +- [x] **DRED/audio-quality polish** — done (2026-06-20). `bool dred` added to `AudioConfig` + proto (field 11) and `vc_audio_config` C ABI. Encoder: `OPUS_SET_DRED_DURATION(2)` when + enabled (20 ms of ML redundancy per packet). Decoder: `OpusDREDDecoder` + per-stream + `OpusDRED` scratch pre-allocated; `JitterBuffer::try_copy_front_payload` peeks at the next + buffered packet on every PLC step; if DRED data is present, `opus_decoder_dred_decode` + reconstructs the lost frame — otherwise falls back to standard PLC. New test: + `test_dred_toggle` (ctest 22/22). Files: `voicecat.proto`, `voicecat.h`, + `opus_codec.{h,cpp}`, `audio_engine.{h,cpp}`, `client.cpp`, `session.{h,cpp}`. +- [ ] **DRED toggle in client UIs** — expose the `dred` flag in all three channel-config UIs + so admins can enable it per channel. Windows: `ChannelEditForm` / `vc_channel_info.audio.dred` + checkbox. macOS AppKit: channel-edit sheet. iOS SwiftUI: channel-edit form. All three UIs + already have full channel CRUD wired; this is an additive checkbox on the existing audio-config + section. (Core/protocol/ABI all done — this is UI-only work.) - [ ] **macOS ScreenCaptureKit screen-audio** — `startStream(.screenAudio)` in macOS client announces the stream but `start_loopback_capture()` returns false (no `VOICECAT_HAS_LOOPBACK` on macOS). Implement via `vc_stream_feed_pcm` + `SCStream` once the feed API ships. diff --git a/core/include/voicecat.h b/core/include/voicecat.h index ba70491..3986fa4 100644 --- a/core/include/voicecat.h +++ b/core/include/voicecat.h @@ -221,6 +221,7 @@ typedef struct vc_audio_config { uint32_t expected_packet_loss; /* % 0..100 */ int dtx; /* bool */ uint32_t complexity; /* 0..10 */ + int dred; /* bool — Deep REDundancy (Opus 1.6), off by default */ } vc_audio_config; /* M5: permission bitset (mirrors protocol Permissions). */ diff --git a/core/proto/voicecat.proto b/core/proto/voicecat.proto index c0a6bf8..b562129 100644 --- a/core/proto/voicecat.proto +++ b/core/proto/voicecat.proto @@ -87,6 +87,7 @@ message AudioConfig { uint32 expected_packet_loss = 8; // % bool dtx = 9; uint32 complexity = 10; // 0..10 + bool dred = 11; // Deep REDundancy (Opus 1.6); off by default } message StreamInfo { diff --git a/core/src/audio/audio_engine.cpp b/core/src/audio/audio_engine.cpp index 40522d2..41e6681 100644 --- a/core/src/audio/audio_engine.cpp +++ b/core/src/audio/audio_engine.cpp @@ -127,6 +127,17 @@ std::optional JitterBuffer::peek_front_ts() const { return buf_.begin()->first; } +size_t JitterBuffer::try_copy_front_payload(uint32_t expected_ts, uint8_t* out, size_t max_sz) { + std::unique_lock lk(mu_, std::try_to_lock); + if (!lk || buf_.empty()) return 0; + auto it = buf_.begin(); + if (it->first != expected_ts) return 0; + const auto& payload = it->second.payload; + size_t n = std::min(payload.size(), max_sz); + std::memcpy(out, payload.data(), n); + return n; +} + void JitterBuffer::reset() { std::lock_guard lk(mu_); buf_.clear(); @@ -147,6 +158,9 @@ AudioEngine::~AudioEngine() { context_inited_ = false; } #endif +#ifdef VOICECAT_HAS_OPUS + if (dred_dec_) { opus_dred_decoder_destroy(dred_dec_); dred_dec_ = nullptr; } +#endif } #ifdef VOICECAT_HAS_AUDIO @@ -167,6 +181,12 @@ bool AudioEngine::start(const AudioParams& p, CaptureCallback capture_cb) { capture_cb_ = std::move(capture_cb); frame_samples_ = static_cast(p.sample_rate / 1000 * p.frame_ms); running_.store(true, std::memory_order_release); +#ifdef VOICECAT_HAS_OPUS + if (!dred_dec_) { + int err = 0; + dred_dec_ = opus_dred_decoder_create(&err); // null on failure — DRED silently disabled + } +#endif #ifdef VOICECAT_HAS_AUDIO // Pre-allocate capture accumulators before the devices start so on_capture / on_loopback @@ -412,7 +432,16 @@ bool AudioEngine::get_stream_state(uint32_t ssrc, float& gain, bool& mute, bool& void AudioEngine::remove_stream(uint32_t ssrc) { std::lock_guard lk(streams_mu_); - streams_.erase(ssrc); + auto it = streams_.find(ssrc); + if (it != streams_.end()) { +#ifdef VOICECAT_HAS_OPUS + if (it->second.dred_state_) { + opus_dred_free(it->second.dred_state_); + it->second.dred_state_ = nullptr; + } +#endif + streams_.erase(it); + } } std::vector> AudioEngine::poll_talk_transitions() { @@ -453,6 +482,12 @@ void AudioEngine::init_recv_stream(uint32_t ssrc, const codec::OpusParams& p) { int frame_samples = stream.decoder.frame_samples(); if (frame_samples <= 0) frame_samples = static_cast(p.sample_rate / 1000 * p.frame_ms); stream.init_ring(channels, frame_samples); + // DRED: pre-allocate per-stream scratch (no RT-thread allocation). 4000 bytes > max Opus pkt. + stream.dred_payload_scratch_.assign(4000, 0); + if (!stream.dred_state_) { + int err = 0; + stream.dred_state_ = opus_dred_alloc(&err); // null on failure — falls back to PLC + } } #endif @@ -560,7 +595,35 @@ void AudioEngine::on_playback(int16_t* out, ma_uint32 frames) { sizeof(int16_t)); n = frame_samples; } else { - n = stream.decoder.decode(nullptr, 0, stream.decode_scratch.data(), frame_samples); + // Try DRED recovery: if the next packet is already in the jitter buffer, + // parse its DRED extension and reconstruct the lost frame with it — producing + // better quality than PLC comfort noise. Falls back to PLC on any failure. + n = -1; +#ifdef VOICECAT_HAS_OPUS + if (dred_dec_ && stream.dred_state_) { + uint32_t next_ts = stream.playout_ts + static_cast(frame_samples); + size_t psz = stream.jitter.try_copy_front_payload( + next_ts, stream.dred_payload_scratch_.data(), + stream.dred_payload_scratch_.size()); + if (psz > 0) { + int dred_end = 0; + int ret = opus_dred_parse( + dred_dec_, stream.dred_state_, + stream.dred_payload_scratch_.data(), + static_cast(psz), + frame_samples, static_cast(params_.sample_rate), + &dred_end, 0); + if (ret > 0) + n = stream.decoder.decode_dred(stream.dred_state_, 0, + stream.decode_scratch.data(), + frame_samples); + } + } +#endif + if (n <= 0) { + n = stream.decoder.decode(nullptr, 0, stream.decode_scratch.data(), + frame_samples); + } if (n > 0) stream.plc_samples_since_real += n; // track PLC streak } if (n <= 0) break; // decoder error/exhausted PLC; rest of this period stays silent diff --git a/core/src/audio/audio_engine.h b/core/src/audio/audio_engine.h index 9bb6d3c..d8cd553 100644 --- a/core/src/audio/audio_engine.h +++ b/core/src/audio/audio_engine.h @@ -60,6 +60,11 @@ class JitterBuffer { // AudioEngine::on_playback). Uses try_lock — never blocks the real-time callback. std::optional peek_front_ts() const; + // If the front frame's timestamp == expected_ts, copies its raw Opus payload into out + // (caller-allocated, max_sz bytes). Returns bytes copied, or 0 (lock miss / wrong ts / + // empty). Caller pre-allocates out to avoid RT-thread allocation. Uses try_lock. + size_t try_copy_front_payload(uint32_t expected_ts, uint8_t* out, size_t max_sz); + uint32_t target_depth_ms()const { return target_depth_ms_.load(); } uint32_t packets_lost() const { return lost_.load(); } void reset(); @@ -389,6 +394,13 @@ class AudioEngine { bool noise_reduction_enabled = false; std::unique_ptr recv_ns; + // DRED: pre-allocated scratch for loss recovery. dred_state_ is per-stream; see + // AudioEngine::dred_dec_ (shared). Allocated in init_recv_stream(); freed in remove_stream(). +#ifdef VOICECAT_HAS_OPUS + ::OpusDRED* dred_state_ = nullptr; +#endif + std::vector dred_payload_scratch_; // pre-sized to 4000 bytes + // M3: talk-indicator edge detection (docs/voice.md §7) — updated by push_recv_frame // (already off the real-time audio thread), polled by poll_talk_transitions(). std::atomic last_voice_ms{0}; @@ -452,6 +464,10 @@ class AudioEngine { int frame_samples_ = 960; // 20 ms @48 kHz +#ifdef VOICECAT_HAS_OPUS + ::OpusDREDDecoder* dred_dec_ = nullptr; // shared DRED decoder; null if unsupported +#endif + static constexpr int64_t kTalkHangoverMs = 300; }; diff --git a/core/src/codec/opus_codec.cpp b/core/src/codec/opus_codec.cpp index 1b82fd0..4b2250e 100644 --- a/core/src/codec/opus_codec.cpp +++ b/core/src/codec/opus_codec.cpp @@ -32,6 +32,8 @@ bool OpusEncoder::init(const OpusParams& p) { opus_encoder_ctl(enc_, OPUS_SET_DTX(p.dtx ? 1 : 0)); opus_encoder_ctl(enc_, OPUS_SET_PACKET_LOSS_PERC( static_cast(p.expected_packet_loss))); + // DRED: 2 × 10ms frames of redundancy covers one 20ms frame loss with ML reconstruction. + opus_encoder_ctl(enc_, OPUS_SET_DRED_DURATION(p.dred ? 2 : 0)); return true; } @@ -70,6 +72,14 @@ int OpusDecoder::decode(const uint8_t* opus_data, int len, int16_t* out_pcm, int return n; } +int OpusDecoder::decode_dred(::OpusDRED* dred, int32_t dred_offset, + int16_t* out_pcm, int max_samples) { + if (!dec_ || !dred) return -1; + int n = opus_decoder_dred_decode(dec_, dred, dred_offset, out_pcm, max_samples); + if (n < 0) { err_ = opus_strerror(n); return -1; } + return n; +} + void OpusDecoder::destroy() { if (dec_) { opus_decoder_destroy(dec_); dec_ = nullptr; } } diff --git a/core/src/codec/opus_codec.h b/core/src/codec/opus_codec.h index 84ff541..e306dbe 100644 --- a/core/src/codec/opus_codec.h +++ b/core/src/codec/opus_codec.h @@ -34,6 +34,7 @@ struct OpusParams { uint32_t complexity = 10; uint32_t expected_packet_loss = 0; // % 0..100 OpusApplication application = OpusApplication::Voip; + bool dred = false; }; // Returns frame_samples for a given sample_rate + frame_ms. @@ -95,6 +96,14 @@ class OpusDecoder { int decode(const uint8_t* opus_data, int len, int16_t* out_pcm, int max_samples, bool fec = false); + // Decode a lost frame using pre-parsed DRED state from the next received packet. + // dred_offset=0 means the frame immediately before the next packet. + // Returns frame_samples on success, -1 if DRED unavailable or decode failed. +#ifdef VOICECAT_HAS_OPUS + int decode_dred(::OpusDRED* dred, int32_t dred_offset, int16_t* out_pcm, int max_samples); + ::OpusDecoder* raw() const { return dec_; } +#endif + void destroy(); bool valid() const { return dec_ != nullptr; } diff --git a/core/src/core/client.cpp b/core/src/core/client.cpp index b2b3bbf..d090a4e 100644 --- a/core/src/core/client.cpp +++ b/core/src/core/client.cpp @@ -953,6 +953,7 @@ voicecat::codec::OpusParams opus_params_from_audio_config(const voicecat::v1::Au p.complexity = a.complexity() ? a.complexity() : 10; p.expected_packet_loss = a.expected_packet_loss(); p.application = static_cast(a.application()); + p.dred = a.dred(); return p; } @@ -968,6 +969,7 @@ voicecat::v1::AudioConfig audio_config_from_vc(const vc_audio_config& c) { a.set_expected_packet_loss(c.expected_packet_loss); a.set_dtx(c.dtx != 0); a.set_complexity(c.complexity); + a.set_dred(c.dred != 0); return a; } @@ -1458,6 +1460,7 @@ vc_result vc_client::get_stream_audio_config(uint32_t user_id, uint32_t stream_i out->expected_packet_loss = p.expected_packet_loss; out->dtx = p.dtx ? 1 : 0; out->complexity = p.complexity; + out->dred = p.dred ? 1 : 0; return VC_OK; } @@ -1476,6 +1479,7 @@ vc_result vc_client::get_stream_audio_config(uint32_t user_id, uint32_t stream_i out->expected_packet_loss = s.expected_packet_loss; out->dtx = s.dtx ? 1 : 0; out->complexity = s.complexity; + out->dred = s.dred ? 1 : 0; return VC_OK; } return VC_ERR_INVALID_ARG; diff --git a/core/src/session/session.cpp b/core/src/session/session.cpp index 09d556f..a964db1 100644 --- a/core/src/session/session.cpp +++ b/core/src/session/session.cpp @@ -45,6 +45,7 @@ std::vector copy_streams( s.expected_packet_loss = pb.audio().expected_packet_loss(); s.dtx = pb.audio().dtx(); s.complexity = pb.audio().complexity(); + s.dred = pb.audio().dred(); out.push_back(std::move(s)); } return out; diff --git a/core/src/session/session.h b/core/src/session/session.h index 1cdf954..90a5f94 100644 --- a/core/src/session/session.h +++ b/core/src/session/session.h @@ -45,6 +45,7 @@ struct Stream { uint32_t expected_packet_loss{0}; bool dtx{false}; uint32_t complexity{0}; + bool dred{false}; }; struct User { diff --git a/docs/voice.md b/docs/voice.md index 17e5139..4501bdf 100644 --- a/docs/voice.md +++ b/docs/voice.md @@ -111,9 +111,14 @@ Layered, all configurable per channel: unrecovered loss; always on, free. 3. **DTX** — sender stops transmitting during silence and sends sparse comfort-noise updates; cuts bandwidth and is bandwidth-friendly on busy channels. -4. **DRED (Deep REDundancy, optional/feature-gated)** — Opus 1.6's ML redundancy carries - acoustic features so the decoder can reconstruct longer loss bursts. Heavier CPU; gate - behind a `features` flag and per-channel toggle, off by default. +4. **DRED (Deep REDundancy, per-channel toggle)** — Opus 1.6's ML redundancy: the encoder + embeds 20 ms of acoustic features in every packet (`bool dred` in `AudioConfig`, off by + default). When a packet is lost, the receiver peeks at the next already-buffered packet, + parses its DRED extension (`opus_dred_parse`), and reconstructs the lost frame with + `opus_decoder_dred_decode` — producing significantly better audio than PLC comfort noise + for single-frame gaps. Falls back silently to PLC if the next packet has not arrived yet + or if the sender did not embed DRED. Heavier CPU on the encoder (~5–10 % at 24 kbps); + minimal overhead on the decoder (parse is a fast header check on non-DRED packets). ## 5. Jitter buffer diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a932e1a..584abf3 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -174,4 +174,13 @@ if(VOICECAT_USE_VCPKG_DEPS) target_include_directories(test_reaper_timeout PRIVATE ${VC_TEST_INTERNAL_INCLUDES}) add_test(NAME reaper_timeout COMMAND test_reaper_timeout) set_tests_properties(reaper_timeout PROPERTIES TIMEOUT 30) + + # DRED toggle: per-channel dred flag round-trips through protocol; encoder initialises + # with DRED; PCM injection through DRED-enabled encode path runs without crash. + add_executable(test_dred_toggle test_dred_toggle.cpp) + target_link_libraries(test_dred_toggle PRIVATE voicecat::server) + target_compile_features(test_dred_toggle PRIVATE cxx_std_20) + target_include_directories(test_dred_toggle PRIVATE ${VC_TEST_INTERNAL_INCLUDES}) + add_test(NAME dred_toggle COMMAND test_dred_toggle) + set_tests_properties(dred_toggle PROPERTIES TIMEOUT 60) endif() diff --git a/tests/test_dred_toggle.cpp b/tests/test_dred_toggle.cpp new file mode 100644 index 0000000..5e07532 --- /dev/null +++ b/tests/test_dred_toggle.cpp @@ -0,0 +1,272 @@ +/* + * test_dred_toggle — DRED (Deep REDundancy) per-channel toggle. + * + * Verifies: + * 1. A channel created with dred=true in AudioConfig has the flag round-trip through + * the protocol and observable via vc_get_stream_audio_config. + * 2. The encoder initialises with DRED enabled (no crash, valid stream). + * 3. PCM can be injected through the encode path when DRED is active. + */ +#include + +#ifdef VOICECAT_HAS_NET + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "voicecat.h" +#include "server.h" +#include "db.h" + +// ── Event tracking ──────────────────────────────────────────────────────────── + +struct EventStore { + std::mutex mu; + std::condition_variable cv; + bool auth_ok{false}; + uint32_t self_user_id{0}; + bool channel_list_received{false}; + bool generic_result_received{false}; + bool generic_ok{false}; + std::vector> streams_started; // (user_id, stream_id) + vc_client* client{nullptr}; + const char* label{nullptr}; +}; + +static void on_event(void* user, const vc_event* ev) { + auto* s = static_cast(user); + std::lock_guard lk(s->mu); + switch (ev->type) { + case VC_EVENT_SERVER_IDENTITY: + vc_confirm_server_identity(s->client, 1); + break; + case VC_EVENT_AUTH_RESULT: + s->auth_ok = (ev->result == VC_OK); + s->self_user_id = ev->user_id; + break; + case VC_EVENT_CHANNEL_LIST: + s->channel_list_received = true; + break; + case VC_EVENT_GENERIC_RESULT: + s->generic_result_received = true; + s->generic_ok = (ev->result == VC_OK); + break; + case VC_EVENT_STREAM_STARTED: + s->streams_started.emplace_back(ev->user_id, ev->stream_id); + break; + default: + break; + } + s->cv.notify_all(); +} + +template +static bool wait_for(EventStore& s, Pred pred, int timeout_ms) { + auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms); + std::unique_lock lk(s.mu); + return s.cv.wait_until(lk, deadline, [&] { return pred(s); }); +} + +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) + +int main() { + auto tmp = std::filesystem::temp_directory_path() / + ("vctest_dred_" + std::to_string( + std::chrono::steady_clock::now().time_since_epoch().count())); + std::filesystem::create_directories(tmp); + std::string data_dir = tmp.string(); + + // Pre-provision an admin account so the server doesn't auto-generate one. + { + voicecat::server::Database db(data_dir + "/voicecat.db"); + std::string err; + if (!db.open(err)) { + std::printf("FAIL: db.open: %s\n", err.c_str()); + std::filesystem::remove_all(tmp); + return 1; + } + if (!db.create_account("admin", "pass", true, err)) { + std::printf("FAIL: create admin: %s\n", err.c_str()); + std::filesystem::remove_all(tmp); + return 1; + } + } + + // ── Start server ───────────────────────────────────────────────────────── + std::atomic bound_port{0}; + std::mutex ready_mu; + std::condition_variable ready_cv; + bool ready{false}; + + voicecat::server::Config cfg; + cfg.data_dir = data_dir; + cfg.bind_port = 0; + cfg.media_port = 0; + cfg.server_name = "VoiceCat-DredTest"; + cfg.allow_guests = true; + cfg.on_ready = [&](uint16_t p) { + bound_port.store(p); + { std::lock_guard lk(ready_mu); ready = true; } + 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(10), [&] { return ready; }); + if (!ok) { + std::printf("FAIL: server did not start\n"); + server.stop(); server_thread.join(); + std::filesystem::remove_all(tmp); + return 1; + } + } + uint16_t port = bound_port.load(); + std::printf("dred_toggle: server on :%u\n", port); + + // ── Admin client — create a channel with DRED enabled ──────────────────── + EventStore evAdmin; + evAdmin.label = "admin"; + vc_callbacks cbAdmin{on_event, nullptr, &evAdmin}; + vc_config cfgAdmin{"test-dred-admin", "0.1", VC_LOG_OFF}; + vc_client* admin = vc_client_create(&cfgAdmin, cbAdmin); + CHECK(admin != nullptr); + evAdmin.client = admin; + + CHECK(vc_connect(admin, "127.0.0.1", port) == VC_OK); + CHECK(vc_authenticate_user(admin, "admin", "pass") == VC_OK); + CHECK(wait_for(evAdmin, [](EventStore& s){ return s.auth_ok; }, 8000)); + CHECK(wait_for(evAdmin, [](EventStore& s){ return s.channel_list_received; }, 3000)); + + // Create a channel with DRED enabled. + vc_channel_info ch{}; + ch.name = "DRED Test Channel"; + ch.audio.codec = 0; // OPUS + ch.audio.mode = 0; // mono + ch.audio.sample_rate = 48000; + ch.audio.bitrate_bps = 24000; + ch.audio.frame_ms = 20; + ch.audio.fec = 1; + ch.audio.expected_packet_loss = 5; + ch.audio.complexity = 10; + ch.audio.dred = 1; // ← DRED enabled + + { std::lock_guard lk(evAdmin.mu); evAdmin.generic_result_received = false; } + CHECK(vc_create_channel(admin, &ch) == VC_OK); + CHECK(wait_for(evAdmin, [](EventStore& s){ return s.generic_result_received; }, 5000)); + { std::lock_guard lk(evAdmin.mu); CHECK(evAdmin.generic_ok); } + + // Find the new channel id. + vc_channel_list cl{}; + CHECK(vc_list_channels(admin, &cl) == VC_OK); + CHECK(cl.count >= 3u); + uint32_t dred_channel_id = 0; + for (size_t i = 0; i < cl.count; ++i) { + if (cl.items[i].name && std::string(cl.items[i].name) == "DRED Test Channel") { + dred_channel_id = cl.items[i].id; + break; + } + } + vc_free_channel_list(&cl); + CHECK(dred_channel_id != 0); + std::printf("dred_toggle: DRED channel id=%u\n", dred_channel_id); + + // ── Guest client — join DRED channel and start a stream ────────────────── + EventStore evGuest; + evGuest.label = "guest"; + vc_callbacks cbGuest{on_event, nullptr, &evGuest}; + vc_config cfgGuest{"test-dred-guest", "0.1", VC_LOG_OFF}; + vc_client* guest = vc_client_create(&cfgGuest, cbGuest); + CHECK(guest != nullptr); + evGuest.client = guest; + + CHECK(vc_connect(guest, "127.0.0.1", port) == VC_OK); + CHECK(vc_authenticate_guest(guest, "GuestUser") == VC_OK); + CHECK(wait_for(evGuest, [](EventStore& s){ return s.auth_ok; }, 8000)); + CHECK(wait_for(evGuest, [](EventStore& s){ return s.channel_list_received; }, 3000)); + + // Move the guest into the DRED channel. + uint32_t guest_uid = 0; + { std::lock_guard lk(evGuest.mu); guest_uid = evGuest.self_user_id; } + CHECK(vc_move_user(admin, guest_uid, dred_channel_id) == VC_OK); + std::this_thread::sleep_for(std::chrono::milliseconds(400)); + + // Start a MIC stream in the DRED channel. + vc_stream_desc desc{}; + desc.kind = VC_STREAM_MIC; + uint32_t stream_id = 0; + CHECK(vc_stream_start(guest, &desc, &stream_id) == VC_OK); + CHECK(stream_id != 0); + + bool stream_started = wait_for(evGuest, [&](EventStore& s){ + for (auto& [uid, sid] : s.streams_started) + if (uid == guest_uid) return true; + return false; + }, 5000); + CHECK(stream_started); + + // Allow UDP binding to settle. + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + + // ── Verify DRED flag is visible in effective audio config ───────────────── + vc_audio_config ac{}; + vc_result rc = vc_get_stream_audio_config(guest, guest_uid, stream_id, &ac); + CHECK(rc == VC_OK); + CHECK(ac.dred == 1); + std::printf("dred_toggle: effective dred=%d fec=%d bitrate=%u\n", + ac.dred, ac.fec, ac.bitrate_bps); + + // ── Inject PCM frames through the DRED-enabled encode path ─────────────── + // A 440 Hz sine frame at 48kHz / 20ms (960 samples). + std::vector sine(960); + for (int i = 0; i < 960; ++i) { + float t = static_cast(i) / 48000.0f; + sine[i] = static_cast( + std::sin(2.0f * 3.14159265f * 440.0f * t) * 16000.0f); + } + // Inject 10 frames — no crash = encoder + DRED extension running correctly. + for (int i = 0; i < 10; ++i) + vc_test_inject_capture(guest, stream_id, sine.data(), 960); + + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + + // ── Cleanup ─────────────────────────────────────────────────────────────── + vc_stream_stop(guest, stream_id); + vc_disconnect(guest); + vc_disconnect(admin); + vc_client_destroy(guest); + vc_client_destroy(admin); + server.stop(); + server_thread.join(); + std::filesystem::remove_all(tmp); + + if (g_failures == 0) { + std::printf("dred_toggle: all checks passed\n"); + return 0; + } + std::printf("dred_toggle: %d failure(s)\n", g_failures); + return 1; +} + +#else // !VOICECAT_HAS_NET + +int main() { + std::printf("dred_toggle: SKIP (VOICECAT_HAS_NET not defined)\n"); + return 0; +} + +#endif // VOICECAT_HAS_NET