From e155e342f445144219d4ba94ab56335b3a034548 Mon Sep 17 00:00:00 2001 From: Talon Date: Mon, 22 Jun 2026 16:56:49 +0200 Subject: [PATCH] feat(audio): channel sample_rate caps Opus bandwidth (narrowband/wideband) The per-channel sample_rate field was inert after pinning the codec to 48 kHz. Make it meaningful without changing the 48 kHz clock: carry it as OpusParams::max_bandwidth_hz and apply OPUS_SET_MAX_BANDWIDTH in OpusEncoder::init (8000->narrowband, 16000->wideband, 24000->super-wideband, 48000->full). A low-bitrate room can now shed out-of-band content while every endpoint keeps a single 48 kHz clock. Make sample_rate channel-authoritative on the server: conn_session no longer overrides effective sample_rate with the client's always-48000 request (it now behaves like frame_ms/mode). vc_get_stream_audio_config reports the channel's configured rate for own streams too. New ctest channel_samplerate: a 7 kHz tone is attenuated ~1000x on an 8 kHz (narrowband) channel vs a 48 kHz (full-band) channel, proving the cap is in effect. ctest --preset dev 26/26. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 2 +- PROGRESS.md | 14 +- core/src/codec/opus_codec.cpp | 14 ++ core/src/codec/opus_codec.h | 5 + core/src/core/client.cpp | 12 +- docs/voice.md | 9 +- server/src/conn_session.cpp | 13 +- tests/CMakeLists.txt | 9 + tests/test_channel_samplerate.cpp | 316 ++++++++++++++++++++++++++++++ 9 files changed, 379 insertions(+), 15 deletions(-) create mode 100644 tests/test_channel_samplerate.cpp diff --git a/CLAUDE.md b/CLAUDE.md index dd5fb52..761a4e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ and what's next* read [`PROGRESS.md`](PROGRESS.md); for *design* read [`docs/`]( > server-mute, channel CRUD, in-app account management, disconnect/keepalive/reaper. Windows > WinForms C# client shipped (M4). **macOS AppKit client shipped** — `VoiceCatMac.xcodeproj` > at `clients/apple/macOS/`. **iOS SwiftUI client shipped** — `VoiceCatiOS.xcodeproj` at -> `clients/apple/iOS/`. `ctest --preset dev` green — 25/25 tests. +> `clients/apple/iOS/`. `ctest --preset dev` green — 26/26 tests. > External PCM feed/tap API (`vc_stream_feed_pcm` + `vc_set_pcm_sink`) shipped. > **Screen-audio sharing shipped on macOS (ScreenCaptureKit) and iOS (ReplayKit Broadcast > Upload Extension → host App Group ring → `vc_stream_feed_pcm`).** See [`PROGRESS.md`](PROGRESS.md). diff --git a/PROGRESS.md b/PROGRESS.md index 1243cb2..b26e4aa 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -442,7 +442,19 @@ up instantly. Newest status at the top. ## Recent completed work -All items below are `[x]` done; `ctest --preset dev` 25/25 on Windows after all. +All items below are `[x]` done; `ctest --preset dev` 26/26 on Windows after all. + +- **Per-channel sample_rate as a bandwidth cap** (2026-06-22): the channel `sample_rate` field + was inert (the codec is pinned to 48 kHz). Made it meaningful without changing the 48 kHz + clock: it's carried as `OpusParams::max_bandwidth_hz` and applied via `OPUS_SET_MAX_BANDWIDTH` + in `OpusEncoder::init` (8000→narrowband … 48000→full). Made it **channel-authoritative** on + the server (`conn_session.cpp` no longer overrides effective `sample_rate` with the client's + always-48000 request — like `frame_ms`/`mode`). `vc_get_stream_audio_config` now reports the + channel's configured rate for own streams too. New ctest `channel_samplerate`: a 7 kHz tone is + attenuated ~1000× on an 8 kHz channel vs a 48 kHz channel. Files: `opus_codec.{h,cpp}`, + `client.cpp`, `server/src/conn_session.cpp`, `docs/voice.md`, `tests/test_channel_samplerate.cpp`, + `tests/CMakeLists.txt`. (Future: a true non-48k stack is possible but unnecessary — 48 kHz is + what nearly all hard/software runs at; the bandwidth cap covers the narrowband use case.) - **Non-20ms channel frame_ms fix** (2026-06-22): the AudioEngine capture clock is fixed at 48 kHz / 20 ms (960-sample frames), but a channel may set any Opus `frame_ms` (2.5…60 ms, diff --git a/core/src/codec/opus_codec.cpp b/core/src/codec/opus_codec.cpp index 4b2250e..abdfd2a 100644 --- a/core/src/codec/opus_codec.cpp +++ b/core/src/codec/opus_codec.cpp @@ -4,6 +4,19 @@ namespace voicecat::codec { #ifdef VOICECAT_HAS_OPUS +// Map an intended channel/capture sample rate (Hz) to the Opus max-bandwidth constant. The +// codec always runs at 48 kHz internally (docs/voice.md §3); this caps the bandwidth the +// encoder will select so a channel can request narrowband/wideband audio for low-bitrate rooms. +// 0 (unset) and >= 48000 map to FULLBAND, which is the encoder default (i.e. a no-op cap). +static opus_int32 opus_max_bandwidth_for(uint32_t rate_hz) { + if (rate_hz == 0) return OPUS_BANDWIDTH_FULLBAND; + if (rate_hz <= 8000) return OPUS_BANDWIDTH_NARROWBAND; // ~4 kHz audio + if (rate_hz <= 12000) return OPUS_BANDWIDTH_MEDIUMBAND; // ~6 kHz + if (rate_hz <= 16000) return OPUS_BANDWIDTH_WIDEBAND; // ~8 kHz + if (rate_hz <= 24000) return OPUS_BANDWIDTH_SUPERWIDEBAND; // ~12 kHz + return OPUS_BANDWIDTH_FULLBAND; // ~20 kHz +} + // ── OpusEncoder ────────────────────────────────────────────────────────────── bool OpusEncoder::init(const OpusParams& p) { @@ -27,6 +40,7 @@ bool OpusEncoder::init(const OpusParams& p) { } opus_encoder_ctl(enc_, OPUS_SET_BITRATE(static_cast(p.bitrate_bps))); + opus_encoder_ctl(enc_, OPUS_SET_MAX_BANDWIDTH(opus_max_bandwidth_for(p.max_bandwidth_hz))); opus_encoder_ctl(enc_, OPUS_SET_COMPLEXITY(static_cast(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)); diff --git a/core/src/codec/opus_codec.h b/core/src/codec/opus_codec.h index e306dbe..df8ec9b 100644 --- a/core/src/codec/opus_codec.h +++ b/core/src/codec/opus_codec.h @@ -26,6 +26,11 @@ enum class OpusApplication { struct OpusParams { uint32_t sample_rate = 48000; + // Intended channel/capture sample rate (Hz), used ONLY to cap the encoder's audio bandwidth + // (narrowband/wideband/…) via OPUS_SET_MAX_BANDWIDTH. The codec always runs at 48 kHz + // internally (docs/voice.md §3); this lets a low-bitrate channel constrain encoded bandwidth + // without changing the PCM clock. 0 = unset → full band. Decoder ignores it. + uint32_t max_bandwidth_hz = 0; uint32_t bitrate_bps = 24000; uint32_t frame_ms = 20; bool stereo = false; diff --git a/core/src/core/client.cpp b/core/src/core/client.cpp index b4042cc..88cf91c 100644 --- a/core/src/core/client.cpp +++ b/core/src/core/client.cpp @@ -946,10 +946,12 @@ voicecat::codec::OpusParams opus_params_from_audio_config(const voicecat::v1::Au voicecat::codec::OpusParams p; // Opus always runs at 48 kHz internally (docs/voice.md §3): the whole AudioEngine clock is // 48 kHz and external PCM is fed at 48 kHz, so the codec must match regardless of what a - // channel advertises. Honoring a non-48k effective sample_rate here would create an encoder - // expecting e.g. 16k PCM while being fed 48k frames — wrong pitch/duration. The wire - // sample_rate field mainly tags narrowband intent; Opus handles that via bitrate at 48k. + // channel advertises. Honoring a non-48k effective sample_rate as the codec rate would + // create an encoder expecting e.g. 16k PCM while being fed 48k frames — wrong pitch/duration. + // Instead the channel's sample_rate is carried as max_bandwidth_hz and caps the encoder's + // selected audio bandwidth (narrowband/wideband/…) — a low-bitrate room still benefits. p.sample_rate = 48000; + p.max_bandwidth_hz = a.sample_rate(); // 0 = unset → full band p.bitrate_bps = a.bitrate_bps() ? a.bitrate_bps() : 24000; p.frame_ms = a.frame_ms() ? a.frame_ms() : 20; p.stereo = (a.mode() == voicecat::v1::MODE_STEREO); @@ -1524,7 +1526,9 @@ vc_result vc_client::get_stream_audio_config(uint32_t user_id, uint32_t stream_i const auto& p = ls->effective_params; out->codec = 0; out->mode = p.stereo ? 1u : 0u; - out->sample_rate = p.sample_rate; + // Report the channel's configured sample_rate (carried as max_bandwidth_hz), not the + // fixed 48 kHz codec clock — matches what the remote-stream path below reports. + out->sample_rate = p.max_bandwidth_hz ? p.max_bandwidth_hz : p.sample_rate; out->bitrate_bps = p.bitrate_bps; out->frame_ms = p.frame_ms; out->application = static_cast(p.application); diff --git a/docs/voice.md b/docs/voice.md index e70f166..5994b28 100644 --- a/docs/voice.md +++ b/docs/voice.md @@ -101,8 +101,13 @@ message AudioConfig { Guidance baked into defaults / docs: - **Sample rate: always run Opus at 48 kHz internally.** Opus resamples internally anyway; - 48 kHz avoids surprises. The `sample_rate` field mainly constrains capture/narrowband - modes for very low bitrate channels. Default **48000**. + 48 kHz avoids surprises, and the whole audio stack (capture, `vc_stream_feed_pcm`, mixing, + playback) runs at 48 kHz. The per-channel `sample_rate` field is **channel-authoritative** + (not a client request) and does *not* change the codec/PCM clock — it caps the encoder's + audio bandwidth via `OPUS_SET_MAX_BANDWIDTH` (8000 → narrowband ~4 kHz, 16000 → wideband + ~8 kHz, 24000 → super-wideband ~12 kHz, 48000 → full ~20 kHz). This lets a low-bitrate room + shed out-of-band content while every endpoint keeps a single 48 kHz clock. Default **48000** + (full band). See `OpusEncoder::init` and `vc_client::opus_params_from_audio_config`. - **Frame size: 20 ms default.** Smaller (10 ms) lowers latency at the cost of more per-packet overhead and CPU; larger (40/60 ms) improves efficiency and loss resilience at the cost of latency. Expose it per channel for "low-latency talk" vs "stable music" rooms. diff --git a/server/src/conn_session.cpp b/server/src/conn_session.cpp index c892bfe..f4a615f 100644 --- a/server/src/conn_session.cpp +++ b/server/src/conn_session.cpp @@ -531,21 +531,20 @@ void ConnSession::handle_stream_announce(uint64_t req_id, res->set_ssrc(ssrc); // Per-channel AudioConfig is authoritative (docs/voice.md §3): the channel's mode/ - // frame_ms/application/fec/dtx/complexity/expected_packet_loss apply to every stream - // announced into it, regardless of kind. bitrate_bps is clamped (not overridden) to the - // channel's ceiling so a client may still request less. sample_rate stays - // client-requested-or-48000 — everything runs at 48kHz internally per voice.md §3. + // sample_rate/frame_ms/application/fec/dtx/complexity/expected_packet_loss apply to every + // stream announced into it, regardless of kind. bitrate_bps is clamped (not overridden) to + // the channel's ceiling so a client may still request less. sample_rate is taken from the + // channel verbatim (copied via *eff below) — the codec always runs at 48kHz internally, but + // a sub-48k value caps the encoder's audio bandwidth (narrowband/wideband; see + // OpusEncoder::init), so it's a channel policy, not a client choice. auto* eff = res->mutable_effective_audio(); auto chan_cfg = registry_->channel_audio_config(registry_->user_channel(user_id_.load())); uint32_t requested_bps = msg.has_requested_audio() ? msg.requested_audio().bitrate_bps() : 0; - uint32_t requested_rate = - msg.has_requested_audio() ? msg.requested_audio().sample_rate() : 0; if (chan_cfg) { *eff = *chan_cfg; eff->set_bitrate_bps(requested_bps > 0 ? std::min(requested_bps, chan_cfg->bitrate_bps()) : chan_cfg->bitrate_bps()); - eff->set_sample_rate(requested_rate > 0 ? requested_rate : 48000); } else if (msg.has_requested_audio()) { *eff = msg.requested_audio(); } else { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f890e01..f73a540 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -211,4 +211,13 @@ if(VOICECAT_USE_VCPKG_DEPS) target_include_directories(test_frame_ms_reframe PRIVATE ${VC_TEST_INTERNAL_INCLUDES}) add_test(NAME frame_ms_reframe COMMAND test_frame_ms_reframe) set_tests_properties(frame_ms_reframe PROPERTIES TIMEOUT 90) + + # Per-channel sample_rate as an Opus bandwidth cap (OPUS_SET_MAX_BANDWIDTH): a 7 kHz tone is + # attenuated on an 8 kHz (narrowband) channel vs a 48 kHz (full-band) channel. + add_executable(test_channel_samplerate test_channel_samplerate.cpp) + target_link_libraries(test_channel_samplerate PRIVATE voicecat::server) + target_compile_features(test_channel_samplerate PRIVATE cxx_std_20) + target_include_directories(test_channel_samplerate PRIVATE ${VC_TEST_INTERNAL_INCLUDES}) + add_test(NAME channel_samplerate COMMAND test_channel_samplerate) + set_tests_properties(channel_samplerate PROPERTIES TIMEOUT 90) endif() diff --git a/tests/test_channel_samplerate.cpp b/tests/test_channel_samplerate.cpp new file mode 100644 index 0000000..e2a4e3b --- /dev/null +++ b/tests/test_channel_samplerate.cpp @@ -0,0 +1,316 @@ +/* + * test_channel_samplerate — per-channel sample_rate as an Opus bandwidth cap. + * + * The codec always runs at 48 kHz internally (docs/voice.md §3); a channel's sample_rate is + * carried as OPUS_SET_MAX_BANDWIDTH so a low-bitrate / narrowband room can constrain the encoded + * audio bandwidth without changing the PCM clock. This verifies the cap is actually in effect: + * + * - A channel at sample_rate = 8000 (NARROWBAND, ~4 kHz audio) and a channel at 48000 + * (FULLBAND) are each fed an identical 7 kHz tone (well above the narrowband edge). + * - The narrowband channel's decoded energy must be substantially lower — the only difference + * between the two runs is the channel's sample_rate, so a lower energy proves the bandwidth + * cap filtered the out-of-band tone. + * - vc_get_stream_audio_config reports the channel's configured sample_rate (not 48000). + */ +#include + +#ifdef VOICECAT_HAS_NET + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "voicecat.h" +#include "server.h" +#include "db.h" + +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) + +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 bool connect_guest(vc_client*& client, const char* name, const char* label, + uint16_t port, EventStore& ev) { + vc_callbacks cb{on_event, nullptr, &ev}; + vc_config cfg{label, "0.1", VC_LOG_OFF}; + client = vc_client_create(&cfg, cb); + if (!client) return false; + ev.client = client; + ev.label = label; + if (vc_connect(client, "127.0.0.1", port) != VC_OK) return false; + if (vc_authenticate_guest(client, name) != VC_OK) return false; + if (!wait_for(ev, [](EventStore& s) { return s.auth_ok; }, 8000)) return false; + if (!wait_for(ev, [](EventStore& s) { return s.channel_list_received; }, 3000)) return false; + return true; +} + +// 7 kHz tone — above the NARROWBAND (~4 kHz) edge, within FULLBAND. +static std::vector make_tone(int n, float hz) { + std::vector pcm(static_cast(n)); + for (int i = 0; i < n; ++i) { + float t = static_cast(i) / 48000.0f; + pcm[i] = static_cast(std::sin(2.0f * 3.14159265f * hz * t) * 16000.0f); + } + return pcm; +} + +struct SinkData { + std::mutex mu; + std::condition_variable cv; + std::atomic call_count{0}; + int64_t total_energy = 0; +}; + +static void pcm_sink(void* user, uint32_t, uint32_t, + const int16_t* pcm, size_t n, uint32_t channels, uint32_t) { + auto* d = static_cast(user); + std::lock_guard lk(d->mu); + for (size_t i = 0; i < n * channels; ++i) + d->total_energy += std::abs(static_cast(pcm[i])); + d->call_count.fetch_add(1, std::memory_order_relaxed); + d->cv.notify_all(); +} + +static bool sink_wait(SinkData& d, int timeout_ms) { + auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms); + std::unique_lock lk(d.mu); + return d.cv.wait_until(lk, deadline, [&] { return d.call_count.load() > 0; }); +} + +static uint32_t make_channel(vc_client* admin, EventStore& evAdmin, const char* name, + uint32_t sample_rate) { + vc_channel_info ch{}; + ch.name = name; + ch.audio.codec = 0; // OPUS + ch.audio.mode = 0; // mono + ch.audio.sample_rate = sample_rate; + ch.audio.bitrate_bps = 32000; + ch.audio.frame_ms = 20; + ch.audio.fec = 1; + ch.audio.complexity = 10; + + { std::lock_guard lk(evAdmin.mu); evAdmin.generic_result_received = false; } + if (vc_create_channel(admin, &ch) != VC_OK) return 0; + if (!wait_for(evAdmin, [](EventStore& s) { return s.generic_result_received; }, 5000)) return 0; + { std::lock_guard lk(evAdmin.mu); if (!evAdmin.generic_ok) return 0; } + + vc_channel_list cl{}; + if (vc_list_channels(admin, &cl) != VC_OK) return 0; + uint32_t id = 0; + for (size_t i = 0; i < cl.count; ++i) + if (cl.items[i].name && std::string(cl.items[i].name) == name) { id = cl.items[i].id; break; } + vc_free_channel_list(&cl); + return id; +} + +// Feed a 7 kHz tone through `channel_id` and return the decoded energy the sink observed. +// Also asserts vc_get_stream_audio_config reports `expect_sr`. +static int64_t run_case(uint16_t port, vc_client* admin, EventStore& evAdmin, + uint32_t channel_id, uint32_t expect_sr, const char* tag) { + EventStore evA, evB; + vc_client *clientA = nullptr, *clientB = nullptr; + CHECK(connect_guest(clientA, "SrA", "sr-a", port, evA)); + CHECK(connect_guest(clientB, "SrB", "sr-b", port, evB)); + int64_t energy = -1; + if (!clientA || !clientB) goto cleanup; + + { + uint32_t a_uid = 0, b_uid = 0; + { std::lock_guard lk(evA.mu); a_uid = evA.self_user_id; } + { std::lock_guard lk(evB.mu); b_uid = evB.self_user_id; } + + CHECK(vc_move_user(admin, a_uid, channel_id) == VC_OK); + CHECK(vc_move_user(admin, b_uid, channel_id) == VC_OK); + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + + SinkData sink; + CHECK(vc_set_pcm_sink(clientB, pcm_sink, &sink) == VC_OK); + + vc_stream_desc desc{}; + desc.kind = VC_STREAM_MIC; + uint32_t a_sid = 0; + CHECK(vc_stream_start(clientA, &desc, &a_sid) == VC_OK); + + bool b_saw_a = wait_for(evB, [&](EventStore& s) { + for (auto& [uid, sid] : s.streams_started) + if (uid == a_uid) return true; + return false; + }, 5000); + CHECK(b_saw_a); + std::this_thread::sleep_for(std::chrono::milliseconds(400)); + + vc_audio_config ac{}; + CHECK(vc_get_stream_audio_config(clientA, a_uid, a_sid, &ac) == VC_OK); + CHECK(ac.sample_rate == expect_sr); + + auto tone = make_tone(960, 7000.0f); + for (int i = 0; i < 300; ++i) + CHECK(vc_stream_feed_pcm(clientA, a_sid, tone.data(), 960, 1) == VC_OK); + + CHECK(sink_wait(sink, 5000)); + std::this_thread::sleep_for(std::chrono::milliseconds(1500)); + + energy = sink.total_energy; + std::printf("test_channel_samplerate[%s]: sr=%u calls=%d energy=%lld\n", + tag, expect_sr, sink.call_count.load(), static_cast(energy)); + CHECK(sink.call_count.load() > 0); + + vc_stream_stop(clientA, a_sid); + } + +cleanup: + if (clientA) { vc_disconnect(clientA); vc_client_destroy(clientA); } + if (clientB) { vc_disconnect(clientB); vc_client_destroy(clientB); } + return energy; +} + +int main() { + auto tmp = std::filesystem::temp_directory_path() / + ("vctest_chansr_" + std::to_string( + std::chrono::steady_clock::now().time_since_epoch().count())); + std::filesystem::create_directories(tmp); + std::string data_dir = tmp.string(); + + { + voicecat::server::Database db(data_dir + "/voicecat.db"); + std::string err; + if (!db.open(err) || !db.create_account("admin", "pass", true, err)) { + std::printf("FAIL: provision admin: %s\n", err.c_str()); + std::filesystem::remove_all(tmp); + return 1; + } + } + + 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-ChanSrTest"; + 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); + if (!ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; })) { + 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("channel_samplerate: server ready on :%u\n", port); + + EventStore evAdmin; + evAdmin.label = "admin"; + vc_callbacks cbAdmin{on_event, nullptr, &evAdmin}; + vc_config cfgAdmin{"chansr-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)); + + uint32_t ch_full = make_channel(admin, evAdmin, "FullBand", 48000); + uint32_t ch_narrow = make_channel(admin, evAdmin, "NarrowBand", 8000); + CHECK(ch_full != 0); + CHECK(ch_narrow != 0); + + int64_t full_energy = (ch_full ? run_case(port, admin, evAdmin, ch_full, 48000, "full") : -1); + int64_t narrow_energy = (ch_narrow ? run_case(port, admin, evAdmin, ch_narrow, 8000, "narrow") : -1); + + // The 7 kHz tone is above the narrowband (~4 kHz) cutoff: the narrowband channel must filter + // most of it out, so its decoded energy is far below the full-band channel's. Generous margin + // (< 50%) to stay robust across Opus versions while still proving the cap is in effect. + CHECK(full_energy > 0); + CHECK(narrow_energy >= 0); + std::printf("channel_samplerate: full=%lld narrow=%lld ratio=%.3f\n", + static_cast(full_energy), static_cast(narrow_energy), + full_energy > 0 ? static_cast(narrow_energy) / static_cast(full_energy) + : 0.0); + CHECK(narrow_energy < full_energy / 2); + + vc_disconnect(admin); + vc_client_destroy(admin); + server.stop(); + server_thread.join(); + std::filesystem::remove_all(tmp); + + if (g_failures == 0) { + std::printf("channel_samplerate: all checks passed\n"); + return 0; + } + std::printf("channel_samplerate: %d failure(s)\n", g_failures); + return 1; +} + +#else // !VOICECAT_HAS_NET + +int main() { + std::printf("channel_samplerate: SKIP (VOICECAT_HAS_NET not defined)\n"); + return 0; +} + +#endif // VOICECAT_HAS_NET