Files
voice-cat/tests/test_frame_ms_reframe.cpp

330 lines
13 KiB
C++
Raw Normal View History

/*
* test_frame_ms_reframe channels whose frame_ms differs from the engine's 20 ms.
*
* The AudioEngine capture clock is fixed at 48 kHz / 20 ms, so capture and vc_stream_feed_pcm
* always deliver 960-sample frames. A channel, however, may set any Opus frame_ms (docs/voice.md
* §3: 2.560 ms). Before the reframe fix, on_capture_frame handed the engine's 960-sample frame
* straight to an encoder configured for the channel's frame_ms: for frame_ms < 20 the receiver
* sized its decode buffer too small and dropped every frame (dead audio); for frame_ms > 20 the
* channel's setting was silently ignored. on_capture_frame now reframes to ls.frame_samples.
*
* Two end-to-end cases (admin creates the channel, two guests do a feedencoderelaydecodesink
* round trip in it). Both assert the sink receives non-zero decoded energy at 48 kHz:
*
* frame_ms = 40 larger window: two 960-sample engine frames accumulate into one 1920 encode.
* frame_ms = 10 smaller window: each 960-sample engine frame splits into two 480 encodes.
* This is the case that was fully broken (OPUS_BUFFER_TOO_SMALL on decode).
*/
#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"
// ── 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 generic_result_received{false};
bool generic_ok{false};
feat: fix voice join/leave, channel edit defaults, channel-update stream restart 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).
2026-06-24 14:29:39 +02:00
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;
feat: fix voice join/leave, channel edit defaults, channel-update stream restart 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).
2026-06-24 14:29:39 +02:00
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;
feat: fix voice join/leave, channel edit defaults, channel-update stream restart 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).
2026-06-24 14:29:39 +02:00
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;
}
struct SinkData {
std::mutex mu;
std::condition_variable cv;
std::atomic<int> call_count{0};
uint32_t last_sample_rate = 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 sr) {
auto* d = static_cast<SinkData*>(user);
std::lock_guard lk(d->mu);
d->last_sample_rate = sr;
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; });
}
// Admin creates a mono channel with the given frame_ms; returns its id (0 on failure).
static uint32_t make_channel(vc_client* admin, EventStore& evAdmin, const char* name,
uint32_t frame_ms) {
vc_channel_info ch{};
ch.name = name;
ch.audio.codec = 0; // OPUS
ch.audio.mode = 0; // mono
ch.audio.sample_rate = 48000;
ch.audio.bitrate_bps = 24000;
ch.audio.frame_ms = frame_ms;
ch.audio.fec = 1;
ch.audio.complexity = 5;
{ 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;
}
// Full feed→encode→relay→decode→sink round trip inside `channel_id`, asserting the channel's
// frame_ms is in effect and the sink hears decoded energy.
static void run_case(uint16_t port, vc_client* admin, EventStore& evAdmin,
uint32_t channel_id, uint32_t expect_frame_ms, const char* tag) {
std::printf("test_frame_ms_reframe[%s]: channel=%u frame_ms=%u\n", tag, channel_id,
expect_frame_ms);
EventStore evA, evB;
vc_client *clientA = nullptr, *clientB = nullptr;
CHECK(connect_guest(clientA, "ReframeA", "rf-a", port, evA));
CHECK(connect_guest(clientB, "ReframeB", "rf-b", port, evB));
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; }
// Move both guests into the target channel so the relay is channel-scoped to them.
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));
// The channel's frame_ms must be the effective window on A's stream — proves the encoder
// (and hence the reframe target) is configured for the non-20ms window.
vc_audio_config ac{};
CHECK(vc_get_stream_audio_config(clientA, a_uid, a_sid, &ac) == VC_OK);
CHECK(ac.frame_ms == expect_frame_ms);
CHECK(ac.sample_rate == 48000);
// Feed 300 engine-shaped (960-sample / 20 ms / 48 kHz) frames. on_capture_frame reframes
// them to the channel's window before encoding.
auto sine = make_sine_mono(960);
for (int i = 0; i < 300; ++i)
CHECK(vc_stream_feed_pcm(clientA, a_sid, sine.data(), 960, 1) == VC_OK);
CHECK(sink_wait(sink, 5000));
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
int calls = sink.call_count.load();
int64_t energy = sink.total_energy;
std::printf("test_frame_ms_reframe[%s]: sink calls=%d energy=%lld sr=%u\n",
tag, calls, static_cast<long long>(energy), sink.last_sample_rate);
CHECK(calls > 0);
CHECK(energy > 0); // decoded audio actually arrived
CHECK(sink.last_sample_rate == 48000);
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); }
std::printf("test_frame_ms_reframe[%s]: done\n", tag);
}
int main() {
auto tmp = std::filesystem::temp_directory_path() /
("vctest_reframe_" + 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 so we can create channels with custom frame_ms.
{
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-ReframeTest";
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("frame_ms_reframe: server ready on :%u\n", port);
// Admin client creates the two test channels.
EventStore evAdmin;
evAdmin.label = "admin";
vc_callbacks cbAdmin{on_event, nullptr, &evAdmin};
vc_config cfgAdmin{"reframe-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 ch40 = make_channel(admin, evAdmin, "Reframe40", 40);
uint32_t ch10 = make_channel(admin, evAdmin, "Reframe10", 10);
CHECK(ch40 != 0);
CHECK(ch10 != 0);
if (ch40) run_case(port, admin, evAdmin, ch40, 40, "40ms");
if (ch10) run_case(port, admin, evAdmin, ch10, 10, "10ms");
vc_disconnect(admin);
vc_client_destroy(admin);
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
if (g_failures == 0) {
std::printf("frame_ms_reframe: all checks passed\n");
return 0;
}
std::printf("frame_ms_reframe: %d failure(s)\n", g_failures);
return 1;
}
#else // !VOICECAT_HAS_NET
int main() {
std::printf("frame_ms_reframe: SKIP (VOICECAT_HAS_NET not defined)\n");
return 0;
}
#endif // VOICECAT_HAS_NET