feat: external PCM feed/tap API (vc_stream_feed_pcm + vc_set_pcm_sink)
Promotes vc_test_inject_capture (mono-only, TEST-ONLY) to a public,
stereo-capable production API and adds a symmetric PCM tap on the
receive side. Enables ReplayKit (iOS), ScreenCaptureKit (macOS), bots,
soundboards, and custom clients — all without a hardware audio device.
Core C++:
- voicecat.h: new vc_stream_feed_pcm, vc_pcm_sink_cb typedef,
vc_set_pcm_sink; vc_test_inject_capture kept as deprecated alias
- audio_engine: stereo-aware inject_capture (channels param + ring
reset on channel-count change); atomic pcm_sink_ fired per decoded
frame in on_playback; RemoteStream carries user_id/stream_id for
RT-safe sink metadata; init_recv_stream takes user_id+stream_id
- client.cpp: stream_feed_pcm / set_pcm_sink implementations;
sync_remote_streams passes user_id/stream_id to init_recv_stream
- voicecat.cpp: trampolines + channels=1/2 validation
Tests: test_external_pcm (headless, 3 sub-tests: mono round-trip,
stereo feed L≠R, sink metadata+disable). ctest 23/23.
Swift: feedPcm / setPcmSink in VoiceCatClient.swift + 4 XCTest
smoke tests (ExternalPcmTests.swift).
C#: StreamFeedPcm / SetPcmSink in VoiceCatClient.cs + NativeMethods.cs
(vc_stream_feed_pcm unsafe P/Invoke, VcPcmSinkCallback delegate,
vc_set_pcm_sink via nint) + 4 xUnit smoke tests (ExternalPcmTests.cs).
Docs: architecture.md §4 new subsection, voice.md §9 updated
(macOS/iOS now reference vc_stream_feed_pcm), protocol.md §8 explicit
no-protocol-change note, roadmap.md M5 entry.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:52:09 +02:00
|
|
|
/*
|
|
|
|
|
* 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>
|
|
|
|
|
|
|
|
|
|
#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};
|
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};
|
feat: external PCM feed/tap API (vc_stream_feed_pcm + vc_set_pcm_sink)
Promotes vc_test_inject_capture (mono-only, TEST-ONLY) to a public,
stereo-capable production API and adds a symmetric PCM tap on the
receive side. Enables ReplayKit (iOS), ScreenCaptureKit (macOS), bots,
soundboards, and custom clients — all without a hardware audio device.
Core C++:
- voicecat.h: new vc_stream_feed_pcm, vc_pcm_sink_cb typedef,
vc_set_pcm_sink; vc_test_inject_capture kept as deprecated alias
- audio_engine: stereo-aware inject_capture (channels param + ring
reset on channel-count change); atomic pcm_sink_ fired per decoded
frame in on_playback; RemoteStream carries user_id/stream_id for
RT-safe sink metadata; init_recv_stream takes user_id+stream_id
- client.cpp: stream_feed_pcm / set_pcm_sink implementations;
sync_remote_streams passes user_id/stream_id to init_recv_stream
- voicecat.cpp: trampolines + channels=1/2 validation
Tests: test_external_pcm (headless, 3 sub-tests: mono round-trip,
stereo feed L≠R, sink metadata+disable). ctest 23/23.
Swift: feedPcm / setPcmSink in VoiceCatClient.swift + 4 XCTest
smoke tests (ExternalPcmTests.swift).
C#: StreamFeedPcm / SetPcmSink in VoiceCatClient.cs + NativeMethods.cs
(vc_stream_feed_pcm unsafe P/Invoke, VcPcmSinkCallback delegate,
vc_set_pcm_sink via nint) + 4 xUnit smoke tests (ExternalPcmTests.cs).
Docs: architecture.md §4 new subsection, voice.md §9 updated
(macOS/iOS now reference vc_stream_feed_pcm), protocol.md §8 explicit
no-protocol-change note, roadmap.md M5 entry.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:52:09 +02:00
|
|
|
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;
|
feat: external PCM feed/tap API (vc_stream_feed_pcm + vc_set_pcm_sink)
Promotes vc_test_inject_capture (mono-only, TEST-ONLY) to a public,
stereo-capable production API and adds a symmetric PCM tap on the
receive side. Enables ReplayKit (iOS), ScreenCaptureKit (macOS), bots,
soundboards, and custom clients — all without a hardware audio device.
Core C++:
- voicecat.h: new vc_stream_feed_pcm, vc_pcm_sink_cb typedef,
vc_set_pcm_sink; vc_test_inject_capture kept as deprecated alias
- audio_engine: stereo-aware inject_capture (channels param + ring
reset on channel-count change); atomic pcm_sink_ fired per decoded
frame in on_playback; RemoteStream carries user_id/stream_id for
RT-safe sink metadata; init_recv_stream takes user_id+stream_id
- client.cpp: stream_feed_pcm / set_pcm_sink implementations;
sync_remote_streams passes user_id/stream_id to init_recv_stream
- voicecat.cpp: trampolines + channels=1/2 validation
Tests: test_external_pcm (headless, 3 sub-tests: mono round-trip,
stereo feed L≠R, sink metadata+disable). ctest 23/23.
Swift: feedPcm / setPcmSink in VoiceCatClient.swift + 4 XCTest
smoke tests (ExternalPcmTests.swift).
C#: StreamFeedPcm / SetPcmSink in VoiceCatClient.cs + NativeMethods.cs
(vc_stream_feed_pcm unsafe P/Invoke, VcPcmSinkCallback delegate,
vc_set_pcm_sink via nint) + 4 xUnit smoke tests (ExternalPcmTests.cs).
Docs: architecture.md §4 new subsection, voice.md §9 updated
(macOS/iOS now reference vc_stream_feed_pcm), protocol.md §8 explicit
no-protocol-change note, roadmap.md M5 entry.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:52:09 +02:00
|
|
|
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;
|
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;
|
feat: external PCM feed/tap API (vc_stream_feed_pcm + vc_set_pcm_sink)
Promotes vc_test_inject_capture (mono-only, TEST-ONLY) to a public,
stereo-capable production API and adds a symmetric PCM tap on the
receive side. Enables ReplayKit (iOS), ScreenCaptureKit (macOS), bots,
soundboards, and custom clients — all without a hardware audio device.
Core C++:
- voicecat.h: new vc_stream_feed_pcm, vc_pcm_sink_cb typedef,
vc_set_pcm_sink; vc_test_inject_capture kept as deprecated alias
- audio_engine: stereo-aware inject_capture (channels param + ring
reset on channel-count change); atomic pcm_sink_ fired per decoded
frame in on_playback; RemoteStream carries user_id/stream_id for
RT-safe sink metadata; init_recv_stream takes user_id+stream_id
- client.cpp: stream_feed_pcm / set_pcm_sink implementations;
sync_remote_streams passes user_id/stream_id to init_recv_stream
- voicecat.cpp: trampolines + channels=1/2 validation
Tests: test_external_pcm (headless, 3 sub-tests: mono round-trip,
stereo feed L≠R, sink metadata+disable). ctest 23/23.
Swift: feedPcm / setPcmSink in VoiceCatClient.swift + 4 XCTest
smoke tests (ExternalPcmTests.swift).
C#: StreamFeedPcm / SetPcmSink in VoiceCatClient.cs + NativeMethods.cs
(vc_stream_feed_pcm unsafe P/Invoke, VcPcmSinkCallback delegate,
vc_set_pcm_sink via nint) + 4 xUnit smoke tests (ExternalPcmTests.cs).
Docs: architecture.md §4 new subsection, voice.md §9 updated
(macOS/iOS now reference vc_stream_feed_pcm), protocol.md §8 explicit
no-protocol-change note, roadmap.md M5 entry.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:52:09 +02:00
|
|
|
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;
|
|
|
|
|
}
|