Three bugs fixed across the full stack (proto/server/core/ABI/Win/macOS/iOS): 1. Join/Leave Voice now truly subscribes/unsubscribes from the voice plane. Previously the button only toggled the local mic — receiving was always on (gated by channel membership alone). Added a protocol-level voice subscription concept: new SubscribeVoiceRequest/UnsubscribeVoiceRequest/VoiceSubscriptionResult proto messages, User.voice_subscribed field, vc_join_voice/vc_leave_voice C ABI functions, VC_EVENT_VOICE_STATE event, server-side voice_subscribed flag checked by the SFU relay recipient filter, and core-client gating of remote-stream decoder setup. All three clients rewired to subscribe+mic on Join / unsubscribe on Leave. Text chat works regardless of voice subscription. 2. Channel edit dialog now shows the channel's actual current settings. The read struct vc_channel was missing sort_order and audio fields — only the write struct vc_channel_info had them. Extended vc_channel with both (additive, no ABI break), updated the session model and list_channels marshaling to populate them, and updated all three clients' edit callers to use actual channel info instead of hardcoded defaults. 3. Channel parameter updates now automatically restart everyone's streams. Previously editing a channel's audio config persisted and broadcast a ChannelEvent::UPDATED, but no layer restarted streams — encoders/decoders are frozen at announce time. handle_channel_event now detects audio-config changes on the user's current channel and stop->starts each active local stream. The server reads the updated config on re-announce; peers wire up fresh decoders at the new ssrc. All 29 CTest tests pass; Windows DLL + C# client build clean. Apple clients not yet compile-verified (Windows environment).
321 lines
12 KiB
C++
321 lines
12 KiB
C++
/*
|
|
* 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 <cstdio>
|
|
|
|
#ifdef VOICECAT_HAS_NET
|
|
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <condition_variable>
|
|
#include <cstring>
|
|
#include <filesystem>
|
|
#include <mutex>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
#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};
|
|
bool voice_subscribed{false};
|
|
std::vector<std::pair<uint32_t, uint32_t>> 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<EventStore*>(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_VOICE_STATE: s->voice_subscribed = (ev->u32a == 1); 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 <typename Pred>
|
|
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;
|
|
if (vc_join_voice(client) != VC_OK) return false;
|
|
if (!wait_for(ev, [](EventStore& s) { return s.voice_subscribed; }, 5000)) return false;
|
|
return true;
|
|
}
|
|
|
|
// 7 kHz tone — above the NARROWBAND (~4 kHz) edge, within FULLBAND.
|
|
static std::vector<int16_t> make_tone(int n, float hz) {
|
|
std::vector<int16_t> pcm(static_cast<size_t>(n));
|
|
for (int i = 0; i < n; ++i) {
|
|
float t = static_cast<float>(i) / 48000.0f;
|
|
pcm[i] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * hz * t) * 16000.0f);
|
|
}
|
|
return pcm;
|
|
}
|
|
|
|
struct SinkData {
|
|
std::mutex mu;
|
|
std::condition_variable cv;
|
|
std::atomic<int> 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<SinkData*>(user);
|
|
std::lock_guard lk(d->mu);
|
|
for (size_t i = 0; i < n * channels; ++i)
|
|
d->total_energy += std::abs(static_cast<int>(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<long long>(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<uint16_t> 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<long long>(full_energy), static_cast<long long>(narrow_energy),
|
|
full_energy > 0 ? static_cast<double>(narrow_energy) / static_cast<double>(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
|