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).
457 lines
17 KiB
C++
457 lines
17 KiB
C++
/*
|
|
* test_external_pcm — external PCM feed/tap API.
|
|
*
|
|
* Three headless behavior tests (no audio hardware, no simulator):
|
|
*
|
|
* test_feed_pcm_round_trip — A feeds mono 440 Hz sine via vc_stream_feed_pcm; B's pcm_sink
|
|
* fires with non-zero energy, proving the full pipeline (feed→encode→relay→decode→sink).
|
|
*
|
|
* test_feed_pcm_stereo — A and B join Music Room (stereo/128kbps). A feeds interleaved
|
|
* stereo PCM (loud-L / silent-R) via vc_stream_feed_pcm(channels=2). B's pcm_sink
|
|
* asserts L-channel energy > R-channel energy (real stereo bitstream, not a mono upmix).
|
|
*
|
|
* test_pcm_sink — Verifies sink metadata: correct user_id / stream_id per frame,
|
|
* sample_rate = 48000, and that cb=NULL disables delivery.
|
|
*/
|
|
#include <cstdio>
|
|
|
|
#ifdef VOICECAT_HAS_NET
|
|
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <condition_variable>
|
|
#include <cmath>
|
|
#include <filesystem>
|
|
#include <mutex>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
#include "voicecat.h"
|
|
#include "server.h"
|
|
#include "db.h"
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
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 join_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_JOIN_RESULT:
|
|
s->join_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;
|
|
}
|
|
|
|
static std::vector<int16_t> make_sine_mono(int n) {
|
|
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 * 440.0f * t) * 16000.0f);
|
|
}
|
|
return pcm;
|
|
}
|
|
|
|
// Loud-left / silent-right interleaved stereo (n samples per channel).
|
|
static std::vector<int16_t> make_sine_stereo(int n) {
|
|
std::vector<int16_t> pcm(static_cast<size_t>(n) * 2);
|
|
for (int i = 0; i < n; ++i) {
|
|
float t = static_cast<float>(i) / 48000.0f;
|
|
pcm[i * 2] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * 440.0f * t) * 16000.0f);
|
|
pcm[i * 2 + 1] = 0;
|
|
}
|
|
return pcm;
|
|
}
|
|
|
|
// ── PCM sink state ────────────────────────────────────────────────────────────
|
|
|
|
struct SinkData {
|
|
std::mutex mu;
|
|
std::condition_variable cv;
|
|
std::atomic<int> call_count{0};
|
|
uint32_t last_user_id = 0;
|
|
uint32_t last_stream_id = 0;
|
|
uint32_t last_channels = 0;
|
|
uint32_t last_sample_rate = 0;
|
|
int64_t total_energy = 0;
|
|
int64_t left_energy = 0; // sum |pcm[i*2]| for stereo frames
|
|
int64_t right_energy = 0; // sum |pcm[i*2+1]| for stereo frames
|
|
};
|
|
|
|
static void pcm_sink(void* user, uint32_t uid, uint32_t sid,
|
|
const int16_t* pcm, size_t n, uint32_t channels, uint32_t sr) {
|
|
auto* d = static_cast<SinkData*>(user);
|
|
std::lock_guard lk(d->mu);
|
|
d->last_user_id = uid;
|
|
d->last_stream_id = sid;
|
|
d->last_channels = channels;
|
|
d->last_sample_rate = sr;
|
|
for (size_t i = 0; i < n; ++i) {
|
|
if (channels == 2) {
|
|
d->left_energy += std::abs(static_cast<int>(pcm[i * 2]));
|
|
d->right_energy += std::abs(static_cast<int>(pcm[i * 2 + 1]));
|
|
}
|
|
for (uint32_t c = 0; c < channels; ++c)
|
|
d->total_energy += std::abs(static_cast<int>(pcm[i * channels + c]));
|
|
}
|
|
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; });
|
|
}
|
|
|
|
// ── test_feed_pcm_round_trip ──────────────────────────────────────────────────
|
|
|
|
static void test_feed_pcm_round_trip(uint16_t port) {
|
|
std::printf("test_feed_pcm_round_trip: start\n");
|
|
|
|
EventStore evA, evB;
|
|
vc_client *clientA = nullptr, *clientB = nullptr;
|
|
CHECK(connect_guest(clientA, "PcmA", "pcm-a", port, evA));
|
|
CHECK(connect_guest(clientB, "PcmB", "pcm-b", port, evB));
|
|
if (!clientA || !clientB) goto cleanup_rt;
|
|
|
|
{
|
|
uint32_t a_uid = 0;
|
|
{ std::lock_guard lk(evA.mu); a_uid = evA.self_user_id; }
|
|
|
|
// B registers a sink before A starts speaking.
|
|
SinkData sink;
|
|
CHECK(vc_set_pcm_sink(clientB, pcm_sink, &sink) == VC_OK);
|
|
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
|
|
|
// A announces a MIC stream in Lobby.
|
|
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(300));
|
|
|
|
// A feeds 250 mono frames via the new public API.
|
|
auto sine = make_sine_mono(960);
|
|
for (int i = 0; i < 250; ++i)
|
|
CHECK(vc_stream_feed_pcm(clientA, a_sid, sine.data(), 960, 1) == VC_OK);
|
|
|
|
// Wait for the sink to fire at least once (decode arrived).
|
|
bool fired = sink_wait(sink, 5000);
|
|
CHECK(fired);
|
|
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
|
|
|
|
int calls = sink.call_count.load();
|
|
int64_t energy = sink.total_energy;
|
|
std::printf("test_feed_pcm_round_trip: sink calls=%d energy=%lld\n",
|
|
calls, static_cast<long long>(energy));
|
|
CHECK(calls > 0);
|
|
CHECK(energy > 0);
|
|
|
|
vc_stream_stop(clientA, a_sid);
|
|
}
|
|
|
|
cleanup_rt:
|
|
if (clientA) { vc_disconnect(clientA); vc_client_destroy(clientA); }
|
|
if (clientB) { vc_disconnect(clientB); vc_client_destroy(clientB); }
|
|
std::printf("test_feed_pcm_round_trip: done\n");
|
|
}
|
|
|
|
// ── test_feed_pcm_stereo ──────────────────────────────────────────────────────
|
|
|
|
static void test_feed_pcm_stereo(uint16_t port) {
|
|
std::printf("test_feed_pcm_stereo: start\n");
|
|
|
|
EventStore evA, evB;
|
|
vc_client *clientA = nullptr, *clientB = nullptr;
|
|
CHECK(connect_guest(clientA, "StA", "stereo-a", port, evA));
|
|
CHECK(connect_guest(clientB, "StB", "stereo-b", port, evB));
|
|
if (!clientA || !clientB) goto cleanup_st;
|
|
|
|
{
|
|
uint32_t a_uid = 0;
|
|
{ std::lock_guard lk(evA.mu); a_uid = evA.self_user_id; }
|
|
|
|
// Both join Music Room (channel 2, stereo/128kbps) so the encoder is stereo.
|
|
CHECK(vc_join_channel(clientA, 2, nullptr) == VC_OK);
|
|
CHECK(vc_join_channel(clientB, 2, nullptr) == VC_OK);
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(600));
|
|
|
|
SinkData sink;
|
|
CHECK(vc_set_pcm_sink(clientB, pcm_sink, &sink) == VC_OK);
|
|
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(300));
|
|
|
|
// A announces a MIC stream — effective_params will be stereo (Music Room config).
|
|
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(300));
|
|
|
|
// A feeds 250 stereo frames: loud-L / silent-R.
|
|
auto stereo = make_sine_stereo(960);
|
|
for (int i = 0; i < 250; ++i)
|
|
CHECK(vc_stream_feed_pcm(clientA, a_sid, stereo.data(), 960, 2) == VC_OK);
|
|
|
|
bool fired = sink_wait(sink, 5000);
|
|
CHECK(fired);
|
|
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
|
|
|
|
int64_t L = sink.left_energy;
|
|
int64_t R = sink.right_energy;
|
|
uint32_t ch = sink.last_channels;
|
|
std::printf("test_feed_pcm_stereo: channels=%u L_energy=%lld R_energy=%lld\n",
|
|
ch, static_cast<long long>(L), static_cast<long long>(R));
|
|
|
|
CHECK(ch == 2); // decoder delivered stereo frames
|
|
CHECK(L > 0); // left channel has signal
|
|
// Opus stereo coding (mid/side): R won't be exactly 0 after decode, but should be
|
|
// substantially quieter than L. Allow up to 30% leakage.
|
|
CHECK(R < L || L == 0); // L >= R (loud-L / quiet-R)
|
|
|
|
vc_stream_stop(clientA, a_sid);
|
|
}
|
|
|
|
cleanup_st:
|
|
if (clientA) { vc_disconnect(clientA); vc_client_destroy(clientA); }
|
|
if (clientB) { vc_disconnect(clientB); vc_client_destroy(clientB); }
|
|
std::printf("test_feed_pcm_stereo: done\n");
|
|
}
|
|
|
|
// ── test_pcm_sink ─────────────────────────────────────────────────────────────
|
|
|
|
static void test_pcm_sink(uint16_t port) {
|
|
std::printf("test_pcm_sink: start\n");
|
|
|
|
EventStore evA, evB;
|
|
vc_client *clientA = nullptr, *clientB = nullptr;
|
|
CHECK(connect_guest(clientA, "SnkA", "snk-a", port, evA));
|
|
CHECK(connect_guest(clientB, "SnkB", "snk-b", port, evB));
|
|
if (!clientA || !clientB) goto cleanup_sk;
|
|
|
|
{
|
|
uint32_t a_uid = 0;
|
|
{ std::lock_guard lk(evA.mu); a_uid = evA.self_user_id; }
|
|
|
|
SinkData sink;
|
|
CHECK(vc_set_pcm_sink(clientB, pcm_sink, &sink) == VC_OK);
|
|
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
|
|
|
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);
|
|
|
|
// Capture the stream_id that B observed for A's stream.
|
|
uint32_t b_a_sid = 0;
|
|
{ std::lock_guard lk(evB.mu);
|
|
for (auto& [uid, sid] : evB.streams_started)
|
|
if (uid == a_uid) { b_a_sid = sid; break; }
|
|
}
|
|
CHECK(b_a_sid != 0);
|
|
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(300));
|
|
|
|
auto sine = make_sine_mono(960);
|
|
for (int i = 0; i < 250; ++i)
|
|
vc_stream_feed_pcm(clientA, a_sid, sine.data(), 960, 1);
|
|
|
|
bool fired = sink_wait(sink, 5000);
|
|
CHECK(fired);
|
|
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
|
|
|
|
std::printf("test_pcm_sink: calls=%d user_id=%u stream_id=%u sr=%u energy=%lld\n",
|
|
sink.call_count.load(),
|
|
sink.last_user_id, sink.last_stream_id,
|
|
sink.last_sample_rate,
|
|
static_cast<long long>(sink.total_energy));
|
|
|
|
CHECK(sink.call_count.load() > 0);
|
|
CHECK(sink.total_energy > 0);
|
|
CHECK(sink.last_user_id == a_uid); // source user matches
|
|
CHECK(sink.last_stream_id == b_a_sid); // source stream matches
|
|
CHECK(sink.last_sample_rate == 48000); // always 48000
|
|
|
|
// Disable sink — subsequent frames must not reach the callback.
|
|
CHECK(vc_set_pcm_sink(clientB, nullptr, nullptr) == VC_OK);
|
|
int count_before_disable = sink.call_count.load();
|
|
|
|
// Feed more frames after disabling.
|
|
for (int i = 0; i < 100; ++i)
|
|
vc_stream_feed_pcm(clientA, a_sid, sine.data(), 960, 1);
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
|
|
|
|
// The count must not have increased (sink was disabled).
|
|
int count_after = sink.call_count.load();
|
|
std::printf("test_pcm_sink: count_before_disable=%d count_after=%d\n",
|
|
count_before_disable, count_after);
|
|
CHECK(count_after == count_before_disable);
|
|
|
|
vc_stream_stop(clientA, a_sid);
|
|
}
|
|
|
|
cleanup_sk:
|
|
if (clientA) { vc_disconnect(clientA); vc_client_destroy(clientA); }
|
|
if (clientB) { vc_disconnect(clientB); vc_client_destroy(clientB); }
|
|
std::printf("test_pcm_sink: done\n");
|
|
}
|
|
|
|
// ── main ──────────────────────────────────────────────────────────────────────
|
|
|
|
int main() {
|
|
auto tmp = std::filesystem::temp_directory_path() /
|
|
("vctest_extpcm_" + std::to_string(
|
|
std::chrono::steady_clock::now().time_since_epoch().count()));
|
|
std::filesystem::create_directories(tmp);
|
|
std::string data_dir = tmp.string();
|
|
|
|
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-ExtPcmTest";
|
|
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("external_pcm: server ready on :%u\n", port);
|
|
|
|
test_feed_pcm_round_trip(port);
|
|
test_feed_pcm_stereo(port);
|
|
test_pcm_sink(port);
|
|
|
|
server.stop();
|
|
server_thread.join();
|
|
std::filesystem::remove_all(tmp);
|
|
|
|
if (g_failures == 0) {
|
|
std::printf("external_pcm: all checks passed\n");
|
|
return 0;
|
|
}
|
|
std::printf("external_pcm: %d failure(s)\n", g_failures);
|
|
return 1;
|
|
}
|
|
|
|
#else // !VOICECAT_HAS_NET
|
|
|
|
int main() {
|
|
std::printf("external_pcm: SKIP (VOICECAT_HAS_NET not defined)\n");
|
|
return 0;
|
|
}
|
|
|
|
#endif // VOICECAT_HAS_NET
|