From a9950423ff498c9ada1464932af5a74c5600ed71 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Fri, 20 Mar 2026 15:53:44 -0600 Subject: [PATCH 01/34] DataTracks integration through FFI realsense-livekit: Examples of using DataTracks, realsense and mcap --- CMakeLists.txt | 8 +- README.md | 31 ++ .../livekit_bridge/bridge_data_track.h | 127 +++++ bridge/src/bridge_data_track.cpp | 106 ++++ bridge/src/bridge_room_delegate.cpp | 71 +++ bridge/src/bridge_room_delegate.h | 55 ++ bridge/tests/common/bridge_test_common.h | 311 +++++++++++ .../test_bridge_audio_roundtrip.cpp | 321 ++++++++++++ .../test_bridge_data_roundtrip.cpp | 295 +++++++++++ .../tests/stress/test_bridge_audio_stress.cpp | 276 ++++++++++ .../stress/test_bridge_callback_stress.cpp | 277 ++++++++++ .../tests/stress/test_bridge_data_stress.cpp | 337 ++++++++++++ .../stress/test_bridge_lifecycle_stress.cpp | 298 +++++++++++ .../stress/test_bridge_multi_track_stress.cpp | 440 ++++++++++++++++ bridge/tests/unit/test_bridge_audio_track.cpp | 118 +++++ bridge/tests/unit/test_bridge_data_track.cpp | 132 +++++ bridge/tests/unit/test_bridge_video_track.cpp | 114 ++++ bridge/tests/unit/test_callback_key.cpp | 124 +++++ bridge/tests/unit/test_livekit_bridge.cpp | 138 +++++ examples/bridge_human_robot/human.cpp | 20 +- examples/bridge_human_robot/robot.cpp | 38 +- .../realsense-to-mcap/.gitignore | 3 + .../BuildFileDescriptorSet.cpp | 40 ++ .../BuildFileDescriptorSet.h | 23 + .../realsense-to-mcap/CMakeLists.txt | 143 +++++ .../realsense-to-mcap/README.md | 130 +++++ .../realsense-to-mcap/src/realsense_rgbd.cpp | 495 ++++++++++++++++++ .../src/realsense_to_mcap.cpp | 159 ++++++ .../realsense-to-mcap/src/rgbd_viewer.cpp | 477 +++++++++++++++++ examples/realsense-livekit/setup_realsense.sh | 124 +++++ include/livekit/data_frame.h | 45 ++ include/livekit/data_track_frame.h | 45 ++ include/livekit/data_track_info.h | 40 ++ include/livekit/data_track_subscription.h | 124 +++++ include/livekit/local_data_track.h | 85 +++ include/livekit/local_participant.h | 32 +- include/livekit/remote_data_track.h | 93 ++++ include/livekit/room.h | 2 + include/livekit/room_delegate.h | 13 + include/livekit/room_event_types.h | 26 +- src/data_track_subscription.cpp | 171 ++++++ src/ffi_client.cpp | 106 ++++ src/ffi_client.h | 9 + src/local_data_track.cpp | 65 +++ src/local_participant.cpp | 35 ++ src/remote_data_track.cpp | 68 +++ src/room.cpp | 20 + src/tests/integration/test_room_callbacks.cpp | 360 +++++++++++++ src/tests/stress/test_latency_measurement.cpp | 3 + 49 files changed, 6558 insertions(+), 15 deletions(-) create mode 100644 bridge/include/livekit_bridge/bridge_data_track.h create mode 100644 bridge/src/bridge_data_track.cpp create mode 100644 bridge/src/bridge_room_delegate.cpp create mode 100644 bridge/src/bridge_room_delegate.h create mode 100644 bridge/tests/common/bridge_test_common.h create mode 100644 bridge/tests/integration/test_bridge_audio_roundtrip.cpp create mode 100644 bridge/tests/integration/test_bridge_data_roundtrip.cpp create mode 100644 bridge/tests/stress/test_bridge_audio_stress.cpp create mode 100644 bridge/tests/stress/test_bridge_callback_stress.cpp create mode 100644 bridge/tests/stress/test_bridge_data_stress.cpp create mode 100644 bridge/tests/stress/test_bridge_lifecycle_stress.cpp create mode 100644 bridge/tests/stress/test_bridge_multi_track_stress.cpp create mode 100644 bridge/tests/unit/test_bridge_audio_track.cpp create mode 100644 bridge/tests/unit/test_bridge_data_track.cpp create mode 100644 bridge/tests/unit/test_bridge_video_track.cpp create mode 100644 bridge/tests/unit/test_callback_key.cpp create mode 100644 bridge/tests/unit/test_livekit_bridge.cpp create mode 100644 examples/realsense-livekit/realsense-to-mcap/.gitignore create mode 100644 examples/realsense-livekit/realsense-to-mcap/BuildFileDescriptorSet.cpp create mode 100644 examples/realsense-livekit/realsense-to-mcap/BuildFileDescriptorSet.h create mode 100644 examples/realsense-livekit/realsense-to-mcap/CMakeLists.txt create mode 100644 examples/realsense-livekit/realsense-to-mcap/README.md create mode 100644 examples/realsense-livekit/realsense-to-mcap/src/realsense_rgbd.cpp create mode 100644 examples/realsense-livekit/realsense-to-mcap/src/realsense_to_mcap.cpp create mode 100644 examples/realsense-livekit/realsense-to-mcap/src/rgbd_viewer.cpp create mode 100755 examples/realsense-livekit/setup_realsense.sh create mode 100644 include/livekit/data_frame.h create mode 100644 include/livekit/data_track_frame.h create mode 100644 include/livekit/data_track_info.h create mode 100644 include/livekit/data_track_subscription.h create mode 100644 include/livekit/local_data_track.h create mode 100644 include/livekit/remote_data_track.h create mode 100644 src/data_track_subscription.cpp create mode 100644 src/local_data_track.cpp create mode 100644 src/remote_data_track.cpp create mode 100644 src/tests/integration/test_room_callbacks.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 86eb1e49..30ecbcf8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -76,6 +76,7 @@ set(FFI_PROTO_FILES ${FFI_PROTO_DIR}/e2ee.proto ${FFI_PROTO_DIR}/stats.proto ${FFI_PROTO_DIR}/data_stream.proto + ${FFI_PROTO_DIR}/data_track.proto ${FFI_PROTO_DIR}/rpc.proto ${FFI_PROTO_DIR}/track_publication.proto ) @@ -324,6 +325,7 @@ add_library(livekit SHARED src/audio_source.cpp src/audio_stream.cpp src/data_stream.cpp + src/data_track_subscription.cpp src/e2ee.cpp src/ffi_handle.cpp src/ffi_client.cpp @@ -331,7 +333,9 @@ add_library(livekit SHARED src/livekit.cpp src/logging.cpp src/local_audio_track.cpp + src/local_data_track.cpp src/remote_audio_track.cpp + src/remote_data_track.cpp src/room.cpp src/room_proto_converter.cpp src/room_proto_converter.h @@ -683,10 +687,6 @@ install(FILES # Build the LiveKit C++ bridge before examples (human_robot depends on it) add_subdirectory(bridge) -# ---- Examples ---- -# add_subdirectory(examples) - - if(LIVEKIT_BUILD_EXAMPLES) add_subdirectory(examples) endif() diff --git a/README.md b/README.md index eb473f26..97fc77fc 100644 --- a/README.md +++ b/README.md @@ -447,6 +447,37 @@ CPP SDK is using clang C++ format brew install clang-format ``` + +#### Memory Checks +Run valgrind on various examples or tests to check for memory leaks and other issues. +```bash +valgrind --leak-check=full ./build-debug/bin/BridgeRobot +valgrind --leak-check=full ./build-debug/bin/BridgeHuman +valgrind --leak-check=full ./build-debug/bin/livekit_integration_tests +valgrind --leak-check=full ./build-debug/bin/livekit_stress_tests +``` + +# Running locally +1. Install the livekit-server +https://docs.livekit.io/transport/self-hosting/local/ + +Start the livekit-server with data tracks enabled: +```bash +LIVEKIT_CONFIG="enable_data_tracks: true" livekit-server --dev +``` + +```bash +# generate tokens, do for all participants +lk token create \ + --api-key devkey \ + --api-secret secret \ + -i robot \ + --join \ + --valid-for 99999h \ + --room robo_room \ + --grant '{"canPublish":true,"canSubscribe":true,"canPublishData":true}' +``` +
diff --git a/bridge/include/livekit_bridge/bridge_data_track.h b/bridge/include/livekit_bridge/bridge_data_track.h new file mode 100644 index 00000000..e009f955 --- /dev/null +++ b/bridge/include/livekit_bridge/bridge_data_track.h @@ -0,0 +1,127 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace livekit { +class LocalDataTrack; +class LocalParticipant; +} // namespace livekit + +namespace livekit_bridge { + +namespace test { +class BridgeDataTrackTest; +} // namespace test + +/** + * Handle to a published local data track. + * + * Created via LiveKitBridge::createDataTrack(). The bridge retains a + * reference to every track it creates and will automatically release all + * tracks when disconnect() is called. To unpublish a track mid-session, + * call release() explicitly. + * + * Unlike BridgeAudioTrack / BridgeVideoTrack, data tracks have no + * Source, Publication, mute(), or unmute(). They carry arbitrary binary + * frames via pushFrame(). + * + * All public methods are thread-safe. + * + * Usage: + * auto dt = bridge.createDataTrack("sensor-data"); + * dt->pushFrame({0x01, 0x02, 0x03}); + * dt->release(); // unpublishes mid-session + */ +class BridgeDataTrack { +public: + ~BridgeDataTrack(); + + BridgeDataTrack(const BridgeDataTrack &) = delete; + BridgeDataTrack &operator=(const BridgeDataTrack &) = delete; + + /** + * Push a binary frame to all subscribers of this data track. + * + * @param payload Raw bytes to send. + * @param user_timestamp Optional application-defined timestamp. + * @return true if the frame was pushed, false if the track has been + * released or the push failed (e.g. back-pressure). + */ + bool pushFrame(const std::vector &payload, + std::optional user_timestamp = std::nullopt); + + /** + * Push a binary frame from a raw pointer. + * + * @param data Pointer to raw bytes. + * @param size Number of bytes. + * @param user_timestamp Optional application-defined timestamp. + * @return true on success, false if released or push failed. + */ + bool pushFrame(const std::uint8_t *data, std::size_t size, + std::optional user_timestamp = std::nullopt); + + /// Track name as provided at creation. + const std::string &name() const noexcept { return name_; } + + /// Whether the track is still published in the room. + bool isPublished() const; + + /// Whether this track has been released / unpublished. + bool isReleased() const noexcept; + + /** + * Explicitly unpublish the track and release underlying SDK resources. + * + * After this call, pushFrame() returns false. Called automatically by the + * destructor and by LiveKitBridge::disconnect(). Safe to call multiple + * times (idempotent). + */ + void release(); + +private: + friend class LiveKitBridge; + friend class test::BridgeDataTrackTest; + + BridgeDataTrack(std::string name, + std::shared_ptr track, + livekit::LocalParticipant *participant); + + /** Protects released_ and track_ for thread-safe access. */ + mutable std::mutex mutex_; + + /** Publisher-assigned track name (immutable after construction). */ + std::string name_; + + /** True after release() or disconnect(); prevents further pushFrame(). */ + bool released_ = false; + + /** Underlying SDK data track handle. Nulled on release(). */ + std::shared_ptr track_; + + /** Participant that published this track; used for unpublish. Not owned. */ + livekit::LocalParticipant *participant_ = nullptr; +}; + +} // namespace livekit_bridge diff --git a/bridge/src/bridge_data_track.cpp b/bridge/src/bridge_data_track.cpp new file mode 100644 index 00000000..d69512b5 --- /dev/null +++ b/bridge/src/bridge_data_track.cpp @@ -0,0 +1,106 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "livekit_bridge/bridge_data_track.h" + +#include "livekit/lk_log.h" + +#include "livekit/data_frame.h" +#include "livekit/local_data_track.h" +#include "livekit/local_participant.h" + +namespace livekit_bridge { + +BridgeDataTrack::BridgeDataTrack(std::string name, + std::shared_ptr track, + livekit::LocalParticipant *participant) + : name_(std::move(name)), track_(std::move(track)), + participant_(participant) {} + +BridgeDataTrack::~BridgeDataTrack() { release(); } + +bool BridgeDataTrack::pushFrame(const std::vector &payload, + std::optional user_timestamp) { + std::lock_guard lock(mutex_); + if (released_ || !track_) { + return false; + } + + livekit::DataFrame frame; + frame.payload = payload; + frame.user_timestamp = user_timestamp; + + try { + return track_->tryPush(frame); + } catch (const std::exception &e) { + LK_LOG_ERROR("[BridgeDataTrack] tryPush error: {}", e.what()); + return false; + } +} + +bool BridgeDataTrack::pushFrame(const std::uint8_t *data, std::size_t size, + std::optional user_timestamp) { + std::lock_guard lock(mutex_); + if (released_ || !track_) { + return false; + } + + livekit::DataFrame frame; + frame.payload.assign(data, data + size); + frame.user_timestamp = user_timestamp; + + try { + return track_->tryPush(frame); + } catch (const std::exception &e) { + LK_LOG_ERROR("[BridgeDataTrack] tryPush error: {}", e.what()); + return false; + } +} + +bool BridgeDataTrack::isPublished() const { + std::lock_guard lock(mutex_); + if (released_ || !track_) { + return false; + } + return track_->isPublished(); +} + +bool BridgeDataTrack::isReleased() const noexcept { + std::lock_guard lock(mutex_); + return released_; +} + +void BridgeDataTrack::release() { + std::lock_guard lock(mutex_); + if (released_) { + return; + } + released_ = true; + + if (participant_ && track_) { + try { + participant_->unpublishDataTrack(track_); + } catch (...) { + LK_LOG_ERROR("[BridgeDataTrack] unpublishDataTrack error, continuing " + "with cleanup"); + } + } + + track_.reset(); + participant_ = nullptr; +} + +} // namespace livekit_bridge diff --git a/bridge/src/bridge_room_delegate.cpp b/bridge/src/bridge_room_delegate.cpp new file mode 100644 index 00000000..7dcb4280 --- /dev/null +++ b/bridge/src/bridge_room_delegate.cpp @@ -0,0 +1,71 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// @file bridge_room_delegate.cpp +/// @brief Implementation of BridgeRoomDelegate event forwarding. + +#include "bridge_room_delegate.h" + +#include "livekit/lk_log.h" + +#include "livekit/remote_data_track.h" +#include "livekit/remote_participant.h" +#include "livekit/remote_track_publication.h" +#include "livekit/track.h" +#include "livekit_bridge/livekit_bridge.h" + +namespace livekit_bridge { + +void BridgeRoomDelegate::onTrackSubscribed( + livekit::Room & /*room*/, const livekit::TrackSubscribedEvent &ev) { + if (!ev.track || !ev.participant || !ev.publication) { + return; + } + + const std::string identity = ev.participant->identity(); + const livekit::TrackSource source = ev.publication->source(); + + bridge_.onTrackSubscribed(identity, source, ev.track); +} + +void BridgeRoomDelegate::onTrackUnsubscribed( + livekit::Room & /*room*/, const livekit::TrackUnsubscribedEvent &ev) { + if (!ev.participant || !ev.publication) { + return; + } + + const std::string identity = ev.participant->identity(); + const livekit::TrackSource source = ev.publication->source(); + + bridge_.onTrackUnsubscribed(identity, source); +} + +void BridgeRoomDelegate::onRemoteDataTrackPublished( + livekit::Room & /*room*/, + const livekit::RemoteDataTrackPublishedEvent &ev) { + if (!ev.track) { + LK_LOG_ERROR("[BridgeRoomDelegate] onRemoteDataTrackPublished called " + "with null track."); + return; + } + + LK_LOG_INFO("[BridgeRoomDelegate] onRemoteDataTrackPublished: \"{}\" from " + "\"{}\"", + ev.track->info().name, ev.track->publisherIdentity()); + bridge_.onRemoteDataTrackPublished(ev.track); +} + +} // namespace livekit_bridge diff --git a/bridge/src/bridge_room_delegate.h b/bridge/src/bridge_room_delegate.h new file mode 100644 index 00000000..9c5d0da6 --- /dev/null +++ b/bridge/src/bridge_room_delegate.h @@ -0,0 +1,55 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// @file bridge_room_delegate.h +/// @brief Internal RoomDelegate forwarding SDK events to LiveKitBridge. + +#pragma once + +#include "livekit/room_delegate.h" + +namespace livekit_bridge { + +class LiveKitBridge; + +/** + * Internal RoomDelegate that forwards SDK room events to the LiveKitBridge. + * + * Handles track subscribe/unsubscribe lifecycle. Not part of the public API, + * so its in src/ instead of include/. + */ +class BridgeRoomDelegate : public livekit::RoomDelegate { +public: + explicit BridgeRoomDelegate(LiveKitBridge &bridge) : bridge_(bridge) {} + + /// Forwards a track-subscribed event to LiveKitBridge::onTrackSubscribed(). + void onTrackSubscribed(livekit::Room &room, + const livekit::TrackSubscribedEvent &ev) override; + + /// Forwards a track-unsubscribed event to + /// LiveKitBridge::onTrackUnsubscribed(). + void onTrackUnsubscribed(livekit::Room &room, + const livekit::TrackUnsubscribedEvent &ev) override; + + void onRemoteDataTrackPublished( + livekit::Room &room, + const livekit::RemoteDataTrackPublishedEvent &ev) override; + +private: + LiveKitBridge &bridge_; +}; + +} // namespace livekit_bridge diff --git a/bridge/tests/common/bridge_test_common.h b/bridge/tests/common/bridge_test_common.h new file mode 100644 index 00000000..9389a509 --- /dev/null +++ b/bridge/tests/common/bridge_test_common.h @@ -0,0 +1,311 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace livekit_bridge { +namespace test { + +using namespace std::chrono_literals; + +constexpr int kDefaultTestIterations = 10; +constexpr int kDefaultStressDurationSeconds = 600; + +/** + * Test configuration loaded from the same environment variables used by the + * base SDK tests (see README "RPC Test Environment Variables"). + * + * LIVEKIT_URL - WebSocket URL of the LiveKit server + * LIVEKIT_CALLER_TOKEN - Token for the caller/sender participant + * LIVEKIT_RECEIVER_TOKEN - Token for the receiver participant + * TEST_ITERATIONS - Number of iterations (default: 10) + * STRESS_DURATION_SECONDS - Duration for stress tests (default: 600) + */ +struct BridgeTestConfig { + std::string url; + std::string caller_token; + std::string receiver_token; + int test_iterations; + int stress_duration_seconds; + bool available = false; + + static BridgeTestConfig fromEnv() { + BridgeTestConfig config; + const char *url = std::getenv("LIVEKIT_URL"); + const char *caller_token = std::getenv("LIVEKIT_CALLER_TOKEN"); + const char *receiver_token = std::getenv("LIVEKIT_RECEIVER_TOKEN"); + const char *iterations_env = std::getenv("TEST_ITERATIONS"); + const char *duration_env = std::getenv("STRESS_DURATION_SECONDS"); + + if (url && caller_token && receiver_token) { + config.url = url; + config.caller_token = caller_token; + config.receiver_token = receiver_token; + config.available = true; + } + + config.test_iterations = + iterations_env ? std::atoi(iterations_env) : kDefaultTestIterations; + config.stress_duration_seconds = + duration_env ? std::atoi(duration_env) : kDefaultStressDurationSeconds; + + return config; + } +}; + +/** + * Thread-safe latency statistics collector. + * Identical to livekit::test::LatencyStats but lives in the bridge namespace + * to avoid linking against the base SDK test helpers. + */ +class LatencyStats { +public: + void addMeasurement(double latency_ms) { + std::lock_guard lock(mutex_); + measurements_.push_back(latency_ms); + } + + void printStats(const std::string &title) const { + std::lock_guard lock(mutex_); + + if (measurements_.empty()) { + std::cout << "\n" << title << ": No measurements collected" << std::endl; + return; + } + + std::vector sorted = measurements_; + std::sort(sorted.begin(), sorted.end()); + + double sum = std::accumulate(sorted.begin(), sorted.end(), 0.0); + double avg = sum / sorted.size(); + + std::cout << "\n========================================" << std::endl; + std::cout << " " << title << std::endl; + std::cout << "========================================" << std::endl; + std::cout << "Samples: " << sorted.size() << std::endl; + std::cout << std::fixed << std::setprecision(2); + std::cout << "Min: " << sorted.front() << " ms" << std::endl; + std::cout << "Avg: " << avg << " ms" << std::endl; + std::cout << "P50: " << getPercentile(sorted, 50) << " ms" + << std::endl; + std::cout << "P95: " << getPercentile(sorted, 95) << " ms" + << std::endl; + std::cout << "P99: " << getPercentile(sorted, 99) << " ms" + << std::endl; + std::cout << "Max: " << sorted.back() << " ms" << std::endl; + std::cout << "========================================\n" << std::endl; + } + + size_t count() const { + std::lock_guard lock(mutex_); + return measurements_.size(); + } + + void clear() { + std::lock_guard lock(mutex_); + measurements_.clear(); + } + +private: + static double getPercentile(const std::vector &sorted, + int percentile) { + if (sorted.empty()) + return 0.0; + size_t index = (sorted.size() * percentile) / 100; + if (index >= sorted.size()) + index = sorted.size() - 1; + return sorted[index]; + } + + mutable std::mutex mutex_; + std::vector measurements_; +}; + +/** + * Extended statistics collector for stress tests. + */ +class StressTestStats { +public: + void recordCall(bool success, double latency_ms, size_t payload_size = 0) { + std::lock_guard lock(mutex_); + total_calls_++; + if (success) { + successful_calls_++; + latencies_.push_back(latency_ms); + total_bytes_ += payload_size; + } else { + failed_calls_++; + } + } + + void recordError(const std::string &error_type) { + std::lock_guard lock(mutex_); + error_counts_[error_type]++; + } + + void printStats(const std::string &title = "Stress Test Statistics") const { + std::lock_guard lock(mutex_); + + std::cout << "\n========================================" << std::endl; + std::cout << " " << title << std::endl; + std::cout << "========================================" << std::endl; + std::cout << "Total calls: " << total_calls_ << std::endl; + std::cout << "Successful: " << successful_calls_ << std::endl; + std::cout << "Failed: " << failed_calls_ << std::endl; + std::cout << "Success rate: " << std::fixed << std::setprecision(2) + << (total_calls_ > 0 + ? (100.0 * successful_calls_ / total_calls_) + : 0.0) + << "%" << std::endl; + std::cout << "Total bytes: " << total_bytes_ << " (" + << (total_bytes_ / (1024.0 * 1024.0)) << " MB)" << std::endl; + + if (!latencies_.empty()) { + std::vector sorted = latencies_; + std::sort(sorted.begin(), sorted.end()); + + double sum = + std::accumulate(sorted.begin(), sorted.end(), 0.0); + double avg = sum / sorted.size(); + + std::cout << "\nLatency (ms):" << std::endl; + std::cout << " Min: " << sorted.front() << std::endl; + std::cout << " Avg: " << avg << std::endl; + std::cout << " P50: " + << sorted[sorted.size() * 50 / 100] << std::endl; + std::cout << " P95: " + << sorted[sorted.size() * 95 / 100] << std::endl; + std::cout << " P99: " + << sorted[sorted.size() * 99 / 100] << std::endl; + std::cout << " Max: " << sorted.back() << std::endl; + } + + if (!error_counts_.empty()) { + std::cout << "\nError breakdown:" << std::endl; + for (const auto &pair : error_counts_) { + std::cout << " " << pair.first << ": " << pair.second << std::endl; + } + } + + std::cout << "========================================\n" << std::endl; + } + + int totalCalls() const { + std::lock_guard lock(mutex_); + return total_calls_; + } + + int successfulCalls() const { + std::lock_guard lock(mutex_); + return successful_calls_; + } + + int failedCalls() const { + std::lock_guard lock(mutex_); + return failed_calls_; + } + +private: + mutable std::mutex mutex_; + int total_calls_ = 0; + int successful_calls_ = 0; + int failed_calls_ = 0; + size_t total_bytes_ = 0; + std::vector latencies_; + std::map error_counts_; +}; + +/** + * Base test fixture for bridge E2E tests. + * + * IMPORTANT — SDK lifecycle constraints: + * + * • livekit::initialize() / livekit::shutdown() operate on a process-global + * singleton (FfiClient). shutdown() calls livekit_ffi_dispose() which + * tears down the Rust runtime. Re-initializing after a dispose can leave + * internal Rust state corrupt when done many times in rapid succession. + * + * • Each LiveKitBridge instance independently calls initialize()/shutdown() + * in connect()/disconnect(). With two bridges in the same test the first + * one to disconnect() shuts down the SDK while the second is still alive. + * + * Our strategy: + * 1. Tests should let bridge destructors handle disconnect. Do NOT call + * bridge.disconnect() at the end of a test — just let the bridge go + * out of scope and its destructor will disconnect and (eventually) + * call shutdown. + * 2. If you need to explicitly disconnect mid-test (e.g. to test + * lifecycle), accept that this triggers a shutdown. Add a 1 s sleep + * after destruction before creating the next bridge so the Rust + * runtime fully cleans up. + */ +class BridgeTestBase : public ::testing::Test { +protected: + void SetUp() override { config_ = BridgeTestConfig::fromEnv(); } + + void skipIfNotConfigured() { + if (!config_.available) { + GTEST_SKIP() << "LIVEKIT_URL, LIVEKIT_CALLER_TOKEN, and " + "LIVEKIT_RECEIVER_TOKEN not set"; + } + } + + /** + * Connect two bridges (caller and receiver) and verify both are connected. + * Returns false if either connection fails. + */ + bool connectPair(LiveKitBridge &caller, LiveKitBridge &receiver) { + livekit::RoomOptions options; + options.auto_subscribe = true; + + bool receiver_ok = + receiver.connect(config_.url, config_.receiver_token, options); + if (!receiver_ok) + return false; + + bool caller_ok = + caller.connect(config_.url, config_.caller_token, options); + if (!caller_ok) { + receiver.disconnect(); + return false; + } + + // Allow time for peer discovery + std::this_thread::sleep_for(2s); + return true; + } + + BridgeTestConfig config_; +}; + +} // namespace test +} // namespace livekit_bridge diff --git a/bridge/tests/integration/test_bridge_audio_roundtrip.cpp b/bridge/tests/integration/test_bridge_audio_roundtrip.cpp new file mode 100644 index 00000000..bad61bb7 --- /dev/null +++ b/bridge/tests/integration/test_bridge_audio_roundtrip.cpp @@ -0,0 +1,321 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "../common/bridge_test_common.h" +#include +#include + +namespace livekit_bridge { +namespace test { + +constexpr int kAudioSampleRate = 48000; +constexpr int kAudioChannels = 1; +constexpr int kAudioFrameDurationMs = 10; +constexpr int kSamplesPerFrame = + kAudioSampleRate * kAudioFrameDurationMs / 1000; +constexpr double kHighEnergyThreshold = 0.3; +constexpr int kHighEnergyFramesPerPulse = 5; + +static std::vector generateHighEnergyFrame(int samples) { + std::vector data(samples * kAudioChannels); + const double frequency = 1000.0; + const double amplitude = 30000.0; + for (int i = 0; i < samples; ++i) { + double t = static_cast(i) / kAudioSampleRate; + auto sample = static_cast( + amplitude * std::sin(2.0 * M_PI * frequency * t)); + for (int ch = 0; ch < kAudioChannels; ++ch) { + data[i * kAudioChannels + ch] = sample; + } + } + return data; +} + +static std::vector generateSilentFrame(int samples) { + return std::vector(samples * kAudioChannels, 0); +} + +static double calculateEnergy(const std::vector &samples) { + if (samples.empty()) + return 0.0; + double sum_squared = 0.0; + for (auto s : samples) { + double normalized = static_cast(s) / 32768.0; + sum_squared += normalized * normalized; + } + return std::sqrt(sum_squared / samples.size()); +} + +class BridgeAudioRoundtripTest : public BridgeTestBase {}; + +// --------------------------------------------------------------------------- +// Test 1: Basic audio frame round-trip through the bridge API. +// +// Caller bridge publishes an audio track, receiver bridge receives frames via +// setOnAudioFrameCallback. We send high-energy pulses interleaved with +// silence and verify that the receiver detects them. +// --------------------------------------------------------------------------- +TEST_F(BridgeAudioRoundtripTest, AudioFrameRoundTrip) { + skipIfNotConfigured(); + + std::cout << "\n=== Bridge Audio Frame Round-Trip Test ===" << std::endl; + + LiveKitBridge caller; + LiveKitBridge receiver; + + ASSERT_TRUE(connectPair(caller, receiver)) + << "Failed to connect caller/receiver pair"; + + std::cout << "Both bridges connected." << std::endl; + + auto audio_track = caller.createAudioTrack( + "roundtrip-mic", kAudioSampleRate, kAudioChannels, + livekit::TrackSource::SOURCE_MICROPHONE); + ASSERT_NE(audio_track, nullptr); + + std::cout << "Audio track published." << std::endl; + + std::atomic frames_received{0}; + std::atomic high_energy_frames{0}; + + const std::string caller_identity = "rpc-caller"; + + receiver.setOnAudioFrameCallback( + caller_identity, livekit::TrackSource::SOURCE_MICROPHONE, + [&](const livekit::AudioFrame &frame) { + frames_received++; + double energy = calculateEnergy(frame.data()); + if (energy > kHighEnergyThreshold) { + high_energy_frames++; + } + }); + + std::cout << "Callback registered, sending audio..." << std::endl; + + const int total_frames = 500; + const int frames_between_pulses = 100; + auto next_frame_time = std::chrono::steady_clock::now(); + const auto frame_duration = std::chrono::milliseconds(kAudioFrameDurationMs); + int pulses_sent = 0; + int high_energy_remaining = 0; + + for (int i = 0; i < total_frames; ++i) { + std::this_thread::sleep_until(next_frame_time); + next_frame_time += frame_duration; + + std::vector frame_data; + + if (high_energy_remaining > 0) { + frame_data = generateHighEnergyFrame(kSamplesPerFrame); + high_energy_remaining--; + } else if (i > 0 && i % frames_between_pulses == 0) { + frame_data = generateHighEnergyFrame(kSamplesPerFrame); + high_energy_remaining = kHighEnergyFramesPerPulse - 1; + pulses_sent++; + std::cout << " Sent pulse " << pulses_sent << std::endl; + } else { + frame_data = generateSilentFrame(kSamplesPerFrame); + } + + audio_track->pushFrame(frame_data, kSamplesPerFrame); + } + + std::this_thread::sleep_for(2s); + + std::cout << "\nResults:" << std::endl; + std::cout << " Pulses sent: " << pulses_sent << std::endl; + std::cout << " Frames received: " << frames_received.load() + << std::endl; + std::cout << " High-energy frames rx: " << high_energy_frames.load() + << std::endl; + + EXPECT_GT(frames_received.load(), 0) + << "Receiver should have received at least one audio frame"; + EXPECT_GT(high_energy_frames.load(), 0) + << "Receiver should have detected at least one high-energy pulse"; + + // Clear callback before bridges go out of scope so the reader thread + // is joined while the atomic counters above are still alive. + receiver.clearOnAudioFrameCallback( + caller_identity, livekit::TrackSource::SOURCE_MICROPHONE); +} + +// --------------------------------------------------------------------------- +// Test 2: Audio latency measurement through the bridge. +// +// Same energy-detection approach as the base SDK's AudioLatency test but +// exercises the full bridge pipeline: pushFrame() → SFU → reader thread → +// AudioFrameCallback. +// --------------------------------------------------------------------------- +TEST_F(BridgeAudioRoundtripTest, AudioLatencyMeasurement) { + skipIfNotConfigured(); + + std::cout << "\n=== Bridge Audio Latency Measurement ===" << std::endl; + + LiveKitBridge caller; + LiveKitBridge receiver; + + ASSERT_TRUE(connectPair(caller, receiver)); + + auto audio_track = caller.createAudioTrack( + "latency-mic", kAudioSampleRate, kAudioChannels, + livekit::TrackSource::SOURCE_MICROPHONE); + ASSERT_NE(audio_track, nullptr); + + LatencyStats stats; + std::atomic running{true}; + std::atomic last_send_time_us{0}; + std::atomic waiting_for_echo{false}; + std::atomic missed_pulses{0}; + constexpr uint64_t kEchoTimeoutUs = 2'000'000; + + const std::string caller_identity = "rpc-caller"; + + receiver.setOnAudioFrameCallback( + caller_identity, livekit::TrackSource::SOURCE_MICROPHONE, + [&](const livekit::AudioFrame &frame) { + double energy = calculateEnergy(frame.data()); + if (waiting_for_echo.load() && energy > kHighEnergyThreshold) { + uint64_t rx_us = + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); + uint64_t tx_us = last_send_time_us.load(); + if (tx_us > 0) { + double latency_ms = (rx_us - tx_us) / 1000.0; + if (latency_ms > 0 && latency_ms < 5000) { + stats.addMeasurement(latency_ms); + std::cout << " Latency: " << std::fixed << std::setprecision(2) + << latency_ms << " ms" << std::endl; + } + waiting_for_echo.store(false); + } + } + }); + + const int total_pulses = 10; + const int frames_between_pulses = 100; + int pulses_sent = 0; + int high_energy_remaining = 0; + uint64_t pulse_send_time = 0; + + auto next_frame_time = std::chrono::steady_clock::now(); + const auto frame_duration = std::chrono::milliseconds(kAudioFrameDurationMs); + int frame_count = 0; + + while (running.load() && pulses_sent < total_pulses) { + std::this_thread::sleep_until(next_frame_time); + next_frame_time += frame_duration; + + if (waiting_for_echo.load() && pulse_send_time > 0) { + uint64_t now_us = + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); + if (now_us - pulse_send_time > kEchoTimeoutUs) { + std::cout << " Echo timeout for pulse " << pulses_sent << std::endl; + waiting_for_echo.store(false); + missed_pulses++; + pulse_send_time = 0; + high_energy_remaining = 0; + } + } + + std::vector frame_data; + + if (high_energy_remaining > 0) { + frame_data = generateHighEnergyFrame(kSamplesPerFrame); + high_energy_remaining--; + } else if (frame_count > 0 && + frame_count % frames_between_pulses == 0 && + !waiting_for_echo.load()) { + frame_data = generateHighEnergyFrame(kSamplesPerFrame); + high_energy_remaining = kHighEnergyFramesPerPulse - 1; + + pulse_send_time = + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); + last_send_time_us.store(pulse_send_time); + waiting_for_echo.store(true); + pulses_sent++; + std::cout << "Sent pulse " << pulses_sent << "/" << total_pulses + << std::endl; + } else { + frame_data = generateSilentFrame(kSamplesPerFrame); + } + + audio_track->pushFrame(frame_data, kSamplesPerFrame); + frame_count++; + } + + std::this_thread::sleep_for(2s); + + stats.printStats("Bridge Audio Latency Statistics"); + + if (missed_pulses > 0) { + std::cout << "Missed pulses (timeout): " << missed_pulses << std::endl; + } + + EXPECT_GT(stats.count(), 0u) + << "At least one audio latency measurement should be recorded"; + + receiver.clearOnAudioFrameCallback( + caller_identity, livekit::TrackSource::SOURCE_MICROPHONE); +} + +// --------------------------------------------------------------------------- +// Test 3: Connect → publish → disconnect cycle for audio tracks. +// +// Each cycle creates a bridge in a scoped block so the destructor runs +// before the next cycle begins. A sleep between cycles gives the Rust +// runtime time to fully tear down. +// --------------------------------------------------------------------------- +TEST_F(BridgeAudioRoundtripTest, ConnectPublishDisconnectCycle) { + skipIfNotConfigured(); + + const int cycles = config_.test_iterations; + std::cout << "\n=== Bridge Audio Connect/Disconnect Cycles ===" << std::endl; + std::cout << "Cycles: " << cycles << std::endl; + + for (int i = 0; i < cycles; ++i) { + { + LiveKitBridge bridge; + livekit::RoomOptions options; + options.auto_subscribe = true; + + bool connected = + bridge.connect(config_.url, config_.caller_token, options); + ASSERT_TRUE(connected) << "Cycle " << i << ": connect failed"; + + auto track = bridge.createAudioTrack( + "cycle-mic", kAudioSampleRate, kAudioChannels, + livekit::TrackSource::SOURCE_MICROPHONE); + ASSERT_NE(track, nullptr); + + for (int f = 0; f < 10; ++f) { + auto data = generateSilentFrame(kSamplesPerFrame); + track->pushFrame(data, kSamplesPerFrame); + } + } // bridge destroyed here → disconnect + shutdown + + std::cout << " Cycle " << (i + 1) << "/" << cycles << " OK" << std::endl; + std::this_thread::sleep_for(1s); + } +} + +} // namespace test +} // namespace livekit_bridge diff --git a/bridge/tests/integration/test_bridge_data_roundtrip.cpp b/bridge/tests/integration/test_bridge_data_roundtrip.cpp new file mode 100644 index 00000000..70c1899b --- /dev/null +++ b/bridge/tests/integration/test_bridge_data_roundtrip.cpp @@ -0,0 +1,295 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "../common/bridge_test_common.h" +#include +#include + +namespace livekit_bridge { +namespace test { + +static std::vector generatePayload(size_t size) { + static thread_local std::mt19937 gen(std::random_device{}()); + std::uniform_int_distribution dist(0, 255); + std::vector data(size); + for (auto &b : data) { + b = static_cast(dist(gen)); + } + return data; +} + +class BridgeDataRoundtripTest : public BridgeTestBase {}; + +// --------------------------------------------------------------------------- +// Test 1: Basic data track round-trip. +// +// Caller publishes a data track, receiver registers a callback, caller sends +// frames, receiver verifies payload integrity. +// --------------------------------------------------------------------------- +TEST_F(BridgeDataRoundtripTest, DataFrameRoundTrip) { + skipIfNotConfigured(); + + std::cout << "\n=== Bridge Data Frame Round-Trip Test ===" << std::endl; + + LiveKitBridge caller; + LiveKitBridge receiver; + + ASSERT_TRUE(connectPair(caller, receiver)); + + const std::string track_name = "roundtrip-data"; + const std::string caller_identity = "rpc-caller"; + + auto data_track = caller.createDataTrack(track_name); + ASSERT_NE(data_track, nullptr); + ASSERT_TRUE(data_track->isPublished()); + + std::cout << "Data track published." << std::endl; + + std::mutex rx_mutex; + std::condition_variable rx_cv; + std::vector> received_payloads; + std::vector> received_timestamps; + + receiver.setOnDataFrameCallback( + caller_identity, track_name, + [&](const std::vector &payload, + std::optional user_timestamp) { + std::lock_guard lock(rx_mutex); + received_payloads.push_back(payload); + received_timestamps.push_back(user_timestamp); + rx_cv.notify_all(); + }); + + // Give the subscription time to be established + std::this_thread::sleep_for(3s); + + std::cout << "Sending data frames..." << std::endl; + + const int num_frames = 10; + std::vector> sent_payloads; + std::vector sent_timestamps; + + for (int i = 0; i < num_frames; ++i) { + auto payload = generatePayload(256); + auto ts = static_cast(i * 1000); + sent_payloads.push_back(payload); + sent_timestamps.push_back(ts); + + bool pushed = data_track->pushFrame(payload, ts); + EXPECT_TRUE(pushed) << "pushFrame failed for frame " << i; + + std::this_thread::sleep_for(100ms); + } + + { + std::unique_lock lock(rx_mutex); + rx_cv.wait_for(lock, 10s, [&] { + return received_payloads.size() >= static_cast(num_frames); + }); + } + + std::cout << "\nResults:" << std::endl; + std::cout << " Frames sent: " << num_frames << std::endl; + std::cout << " Frames received: " << received_payloads.size() << std::endl; + + EXPECT_EQ(received_payloads.size(), static_cast(num_frames)) + << "Should receive all sent frames"; + + for (size_t i = 0; + i < std::min(received_payloads.size(), sent_payloads.size()); ++i) { + EXPECT_EQ(received_payloads[i], sent_payloads[i]) + << "Payload mismatch at frame " << i; + ASSERT_TRUE(received_timestamps[i].has_value()) + << "Missing timestamp at frame " << i; + EXPECT_EQ(received_timestamps[i].value(), sent_timestamps[i]) + << "Timestamp mismatch at frame " << i; + } + + receiver.clearOnDataFrameCallback(caller_identity, track_name); +} + +// --------------------------------------------------------------------------- +// Test 2: Data track with callback registered AFTER track is published. +// +// Exercises the bridge's pending_remote_data_tracks_ mechanism: the remote +// data track is published before the receiver registers its callback. +// --------------------------------------------------------------------------- +TEST_F(BridgeDataRoundtripTest, LateCallbackRegistration) { + skipIfNotConfigured(); + + std::cout << "\n=== Bridge Data Late Callback Registration Test ===" + << std::endl; + + LiveKitBridge caller; + LiveKitBridge receiver; + + ASSERT_TRUE(connectPair(caller, receiver)); + + const std::string track_name = "late-callback-data"; + const std::string caller_identity = "rpc-caller"; + + auto data_track = caller.createDataTrack(track_name); + ASSERT_NE(data_track, nullptr); + + std::cout << "Data track published, waiting before registering callback..." + << std::endl; + + std::this_thread::sleep_for(3s); + + std::atomic frames_received{0}; + std::condition_variable rx_cv; + std::mutex rx_mutex; + + receiver.setOnDataFrameCallback( + caller_identity, track_name, + [&](const std::vector &, + std::optional) { + frames_received++; + rx_cv.notify_all(); + }); + + std::cout << "Callback registered (late), sending frames..." << std::endl; + + std::this_thread::sleep_for(2s); + + const int num_frames = 5; + for (int i = 0; i < num_frames; ++i) { + auto payload = generatePayload(128); + data_track->pushFrame(payload); + std::this_thread::sleep_for(100ms); + } + + { + std::unique_lock lock(rx_mutex); + rx_cv.wait_for(lock, 10s, [&] { + return frames_received.load() >= num_frames; + }); + } + + std::cout << "Frames received: " << frames_received.load() << std::endl; + + EXPECT_EQ(frames_received.load(), num_frames) + << "Late callback should still receive all frames"; + + receiver.clearOnDataFrameCallback(caller_identity, track_name); +} + +// --------------------------------------------------------------------------- +// Test 3: Varying payload sizes. +// +// Tests data track with payloads from tiny (1 byte) to large (64KB) to +// verify the bridge handles different frame sizes correctly. +// --------------------------------------------------------------------------- +TEST_F(BridgeDataRoundtripTest, VaryingPayloadSizes) { + skipIfNotConfigured(); + + std::cout << "\n=== Bridge Data Varying Payload Sizes Test ===" << std::endl; + + LiveKitBridge caller; + LiveKitBridge receiver; + + ASSERT_TRUE(connectPair(caller, receiver)); + + const std::string track_name = "size-test-data"; + const std::string caller_identity = "rpc-caller"; + + auto data_track = caller.createDataTrack(track_name); + ASSERT_NE(data_track, nullptr); + + std::mutex rx_mutex; + std::condition_variable rx_cv; + std::vector received_sizes; + + receiver.setOnDataFrameCallback( + caller_identity, track_name, + [&](const std::vector &payload, + std::optional) { + std::lock_guard lock(rx_mutex); + received_sizes.push_back(payload.size()); + rx_cv.notify_all(); + }); + + std::this_thread::sleep_for(3s); + + std::vector test_sizes = {1, 10, 100, 1024, 4096, 16384, 65536}; + + std::cout << "Sending " << test_sizes.size() + << " frames with varying sizes..." << std::endl; + + for (size_t sz : test_sizes) { + auto payload = generatePayload(sz); + bool pushed = data_track->pushFrame(payload); + EXPECT_TRUE(pushed) << "pushFrame failed for size " << sz; + std::this_thread::sleep_for(200ms); + } + + { + std::unique_lock lock(rx_mutex); + rx_cv.wait_for(lock, 15s, [&] { + return received_sizes.size() >= test_sizes.size(); + }); + } + + std::cout << "Received " << received_sizes.size() << "/" + << test_sizes.size() << " frames." << std::endl; + + EXPECT_EQ(received_sizes.size(), test_sizes.size()); + + for (size_t i = 0; + i < std::min(received_sizes.size(), test_sizes.size()); ++i) { + EXPECT_EQ(received_sizes[i], test_sizes[i]) + << "Size mismatch at index " << i; + } + + receiver.clearOnDataFrameCallback(caller_identity, track_name); +} + +// --------------------------------------------------------------------------- +// Test 4: Connect → publish data track → disconnect cycle. +// --------------------------------------------------------------------------- +TEST_F(BridgeDataRoundtripTest, ConnectPublishDisconnectCycle) { + skipIfNotConfigured(); + + const int cycles = config_.test_iterations; + std::cout << "\n=== Bridge Data Connect/Disconnect Cycles ===" << std::endl; + std::cout << "Cycles: " << cycles << std::endl; + + for (int i = 0; i < cycles; ++i) { + { + LiveKitBridge bridge; + livekit::RoomOptions options; + options.auto_subscribe = true; + + bool connected = + bridge.connect(config_.url, config_.caller_token, options); + ASSERT_TRUE(connected) << "Cycle " << i << ": connect failed"; + + auto track = bridge.createDataTrack("cycle-data"); + ASSERT_NE(track, nullptr); + + for (int f = 0; f < 5; ++f) { + auto payload = generatePayload(256); + track->pushFrame(payload); + } + } // bridge destroyed here → disconnect + shutdown + + std::cout << " Cycle " << (i + 1) << "/" << cycles << " OK" << std::endl; + std::this_thread::sleep_for(1s); + } +} + +} // namespace test +} // namespace livekit_bridge diff --git a/bridge/tests/stress/test_bridge_audio_stress.cpp b/bridge/tests/stress/test_bridge_audio_stress.cpp new file mode 100644 index 00000000..88ba97fa --- /dev/null +++ b/bridge/tests/stress/test_bridge_audio_stress.cpp @@ -0,0 +1,276 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "../common/bridge_test_common.h" +#include + +namespace livekit_bridge { +namespace test { + +constexpr int kStressAudioSampleRate = 48000; +constexpr int kStressAudioChannels = 1; +constexpr int kStressFrameDurationMs = 10; +constexpr int kStressSamplesPerFrame = + kStressAudioSampleRate * kStressFrameDurationMs / 1000; + +static std::vector makeSineFrame(int samples, double freq, + int &phase) { + std::vector data(samples * kStressAudioChannels); + const double amplitude = 16000.0; + for (int i = 0; i < samples; ++i) { + double t = static_cast(phase++) / kStressAudioSampleRate; + auto sample = static_cast( + amplitude * std::sin(2.0 * M_PI * freq * t)); + for (int ch = 0; ch < kStressAudioChannels; ++ch) { + data[i * kStressAudioChannels + ch] = sample; + } + } + return data; +} + +class BridgeAudioStressTest : public BridgeTestBase {}; + +// --------------------------------------------------------------------------- +// Sustained audio pushing: sends audio at real-time pace for the configured +// stress duration and tracks frames received, delivery rate, and errors. +// --------------------------------------------------------------------------- +TEST_F(BridgeAudioStressTest, SustainedAudioPush) { + skipIfNotConfigured(); + + std::cout << "\n=== Bridge Sustained Audio Stress Test ===" << std::endl; + std::cout << "Duration: " << config_.stress_duration_seconds << " seconds" + << std::endl; + + LiveKitBridge caller; + LiveKitBridge receiver; + + ASSERT_TRUE(connectPair(caller, receiver)); + + auto audio_track = caller.createAudioTrack( + "stress-mic", kStressAudioSampleRate, kStressAudioChannels, + livekit::TrackSource::SOURCE_MICROPHONE); + ASSERT_NE(audio_track, nullptr); + + const std::string caller_identity = "rpc-caller"; + + std::atomic frames_sent{0}; + std::atomic frames_received{0}; + std::atomic push_failures{0}; + std::atomic running{true}; + + receiver.setOnAudioFrameCallback( + caller_identity, livekit::TrackSource::SOURCE_MICROPHONE, + [&](const livekit::AudioFrame &) { frames_received++; }); + + std::this_thread::sleep_for(3s); + + auto start_time = std::chrono::steady_clock::now(); + auto duration = std::chrono::seconds(config_.stress_duration_seconds); + + std::thread sender([&]() { + int phase = 0; + auto next_frame_time = std::chrono::steady_clock::now(); + const auto frame_duration = + std::chrono::milliseconds(kStressFrameDurationMs); + + while (running.load()) { + std::this_thread::sleep_until(next_frame_time); + next_frame_time += frame_duration; + + auto data = makeSineFrame(kStressSamplesPerFrame, 440.0, phase); + bool ok = audio_track->pushFrame(data, kStressSamplesPerFrame); + if (ok) { + frames_sent++; + } else { + push_failures++; + } + } + }); + + std::thread progress([&]() { + int64_t last_sent = 0; + int64_t last_received = 0; + while (running.load()) { + std::this_thread::sleep_for(30s); + if (!running.load()) + break; + + auto elapsed = std::chrono::steady_clock::now() - start_time; + auto elapsed_s = + std::chrono::duration_cast(elapsed).count(); + int64_t cur_sent = frames_sent.load(); + int64_t cur_received = frames_received.load(); + double send_rate = (cur_sent - last_sent) / 30.0; + double recv_rate = (cur_received - last_received) / 30.0; + last_sent = cur_sent; + last_received = cur_received; + + std::cout << "[" << elapsed_s << "s]" + << " sent=" << cur_sent << " recv=" << cur_received + << " failures=" << push_failures.load() + << " send_rate=" << std::fixed << std::setprecision(1) + << send_rate << "/s" + << " recv_rate=" << recv_rate << "/s" << std::endl; + } + }); + + while (std::chrono::steady_clock::now() - start_time < duration) { + std::this_thread::sleep_for(1s); + } + + running.store(false); + sender.join(); + progress.join(); + + std::this_thread::sleep_for(2s); + + std::cout << "\n========================================" << std::endl; + std::cout << " Sustained Audio Stress Results" << std::endl; + std::cout << "========================================" << std::endl; + std::cout << "Duration: " << config_.stress_duration_seconds << "s" + << std::endl; + std::cout << "Frames sent: " << frames_sent.load() << std::endl; + std::cout << "Frames received: " << frames_received.load() << std::endl; + std::cout << "Push failures: " << push_failures.load() << std::endl; + + double delivery_rate = + frames_sent.load() > 0 + ? (100.0 * frames_received.load() / frames_sent.load()) + : 0.0; + std::cout << "Delivery rate: " << std::fixed << std::setprecision(2) + << delivery_rate << "%" << std::endl; + std::cout << "========================================\n" << std::endl; + + EXPECT_GT(frames_sent.load(), 0) << "Should have sent frames"; + EXPECT_GT(frames_received.load(), 0) << "Should have received frames"; + EXPECT_EQ(push_failures.load(), 0) << "No push failures expected"; + EXPECT_GT(delivery_rate, 50.0) << "Delivery rate below 50%"; + + receiver.clearOnAudioFrameCallback( + caller_identity, livekit::TrackSource::SOURCE_MICROPHONE); +} + +// --------------------------------------------------------------------------- +// Track release under active push: one thread pushes audio continuously +// while another releases the track after a delay. Verifies clean shutdown +// with no crashes or hangs. +// --------------------------------------------------------------------------- +TEST_F(BridgeAudioStressTest, ReleaseUnderActivePush) { + skipIfNotConfigured(); + + std::cout << "\n=== Bridge Audio Release Under Active Push ===" << std::endl; + + const int iterations = config_.test_iterations; + std::cout << "Iterations: " << iterations << std::endl; + + for (int iter = 0; iter < iterations; ++iter) { + { + LiveKitBridge bridge; + livekit::RoomOptions options; + options.auto_subscribe = true; + + bool connected = + bridge.connect(config_.url, config_.caller_token, options); + ASSERT_TRUE(connected) << "Iteration " << iter << ": connect failed"; + + auto track = bridge.createAudioTrack( + "release-stress-mic", kStressAudioSampleRate, kStressAudioChannels, + livekit::TrackSource::SOURCE_MICROPHONE); + + std::atomic pushing{true}; + std::atomic push_count{0}; + + std::thread pusher([&]() { + int phase = 0; + while (pushing.load()) { + auto data = makeSineFrame(kStressSamplesPerFrame, 440.0, phase); + track->pushFrame(data, kStressSamplesPerFrame); + push_count++; + std::this_thread::sleep_for( + std::chrono::milliseconds(kStressFrameDurationMs)); + } + }); + + std::this_thread::sleep_for(500ms); + track->release(); + EXPECT_TRUE(track->isReleased()); + + std::this_thread::sleep_for(200ms); + pushing.store(false); + pusher.join(); + + std::cout << " Iteration " << (iter + 1) << "/" << iterations + << " OK (pushed " << push_count.load() << " frames)" + << std::endl; + } // bridge destroyed here + + std::this_thread::sleep_for(1s); + } +} + +// --------------------------------------------------------------------------- +// Rapid connect/disconnect with active audio callback. Verifies that the +// bridge's reader thread cleanup handles abrupt disconnection. +// --------------------------------------------------------------------------- +TEST_F(BridgeAudioStressTest, RapidConnectDisconnectWithCallback) { + skipIfNotConfigured(); + + const int cycles = config_.test_iterations; + std::cout << "\n=== Bridge Rapid Connect/Disconnect With Audio Callback ===" + << std::endl; + std::cout << "Cycles: " << cycles << std::endl; + + const std::string caller_identity = "rpc-caller"; + + for (int i = 0; i < cycles; ++i) { + { + LiveKitBridge caller; + LiveKitBridge receiver; + + ASSERT_TRUE(connectPair(caller, receiver)) + << "Cycle " << i << ": connect failed"; + + auto track = caller.createAudioTrack( + "rapid-mic", kStressAudioSampleRate, kStressAudioChannels, + livekit::TrackSource::SOURCE_MICROPHONE); + + std::atomic rx_count{0}; + receiver.setOnAudioFrameCallback( + caller_identity, livekit::TrackSource::SOURCE_MICROPHONE, + [&](const livekit::AudioFrame &) { rx_count++; }); + + int phase = 0; + for (int f = 0; f < 50; ++f) { + auto data = makeSineFrame(kStressSamplesPerFrame, 440.0, phase); + track->pushFrame(data, kStressSamplesPerFrame); + std::this_thread::sleep_for( + std::chrono::milliseconds(kStressFrameDurationMs)); + } + + // Clear callback to join reader thread while rx_count is still alive + receiver.clearOnAudioFrameCallback( + caller_identity, livekit::TrackSource::SOURCE_MICROPHONE); + + std::cout << " Cycle " << (i + 1) << "/" << cycles + << " OK (rx=" << rx_count.load() << ")" << std::endl; + } // both bridges destroyed here + + std::this_thread::sleep_for(1s); + } +} + +} // namespace test +} // namespace livekit_bridge diff --git a/bridge/tests/stress/test_bridge_callback_stress.cpp b/bridge/tests/stress/test_bridge_callback_stress.cpp new file mode 100644 index 00000000..7574a6d1 --- /dev/null +++ b/bridge/tests/stress/test_bridge_callback_stress.cpp @@ -0,0 +1,277 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "../common/bridge_test_common.h" +#include +#include + +namespace livekit_bridge { +namespace test { + +constexpr int kCbSampleRate = 48000; +constexpr int kCbChannels = 1; +constexpr int kCbFrameDurationMs = 10; +constexpr int kCbSamplesPerFrame = + kCbSampleRate * kCbFrameDurationMs / 1000; + +static std::vector cbSilentFrame() { + return std::vector(kCbSamplesPerFrame * kCbChannels, 0); +} + +static std::vector cbPayload(size_t size) { + static thread_local std::mt19937 gen(std::random_device{}()); + std::uniform_int_distribution dist(0, 255); + std::vector buf(size); + for (auto &b : buf) + b = static_cast(dist(gen)); + return buf; +} + +class BridgeCallbackStressTest : public BridgeTestBase {}; + +// --------------------------------------------------------------------------- +// Audio callback churn. +// +// Caller pushes audio at real-time pace. A separate thread rapidly +// registers / clears the receiver's audio callback. Each clear() must +// join the reader thread (which closes the AudioStream), and the +// subsequent register must start a new reader. This hammers the +// extract-thread-outside-lock pattern in clearOnAudioFrameCallback(). +// --------------------------------------------------------------------------- +TEST_F(BridgeCallbackStressTest, AudioCallbackChurn) { + skipIfNotConfigured(); + + std::cout << "\n=== Bridge Audio Callback Churn ===" << std::endl; + + LiveKitBridge caller; + LiveKitBridge receiver; + + ASSERT_TRUE(connectPair(caller, receiver)); + + auto audio = caller.createAudioTrack( + "churn-mic", kCbSampleRate, kCbChannels, + livekit::TrackSource::SOURCE_MICROPHONE); + ASSERT_NE(audio, nullptr); + + const std::string caller_identity = "rpc-caller"; + + std::atomic running{true}; + std::atomic total_rx{0}; + std::atomic churn_cycles{0}; + + std::thread pusher([&]() { + auto next = std::chrono::steady_clock::now(); + while (running.load()) { + std::this_thread::sleep_until(next); + next += std::chrono::milliseconds(kCbFrameDurationMs); + auto frame = cbSilentFrame(); + audio->pushFrame(frame, kCbSamplesPerFrame); + } + }); + + std::thread churner([&]() { + while (running.load()) { + receiver.setOnAudioFrameCallback( + caller_identity, livekit::TrackSource::SOURCE_MICROPHONE, + [&](const livekit::AudioFrame &) { total_rx++; }); + + std::this_thread::sleep_for(300ms); + + receiver.clearOnAudioFrameCallback( + caller_identity, livekit::TrackSource::SOURCE_MICROPHONE); + + std::this_thread::sleep_for(100ms); + churn_cycles++; + } + }); + + const int duration_s = std::min(config_.stress_duration_seconds, 30); + std::this_thread::sleep_for(std::chrono::seconds(duration_s)); + + running.store(false); + pusher.join(); + churner.join(); + + std::cout << "Churn cycles: " << churn_cycles.load() << std::endl; + std::cout << "Audio frames received: " << total_rx.load() << std::endl; + + EXPECT_GT(churn_cycles.load(), 0); +} + +// --------------------------------------------------------------------------- +// Mixed audio + data callback churn. +// +// Caller publishes both an audio and data track. Two independent churn +// threads each toggle their respective callback. A third thread pushes +// frames on both tracks. This exercises the bridge's two independent +// callback maps and reader sets under concurrent mutation. +// --------------------------------------------------------------------------- +TEST_F(BridgeCallbackStressTest, MixedCallbackChurn) { + skipIfNotConfigured(); + + std::cout << "\n=== Bridge Mixed Callback Churn ===" << std::endl; + + LiveKitBridge caller; + LiveKitBridge receiver; + + ASSERT_TRUE(connectPair(caller, receiver)); + + auto audio = caller.createAudioTrack( + "mixed-mic", kCbSampleRate, kCbChannels, + livekit::TrackSource::SOURCE_MICROPHONE); + auto data = caller.createDataTrack("mixed-data"); + ASSERT_NE(audio, nullptr); + ASSERT_NE(data, nullptr); + + const std::string caller_identity = "rpc-caller"; + + std::atomic running{true}; + std::atomic audio_rx{0}; + std::atomic data_rx{0}; + std::atomic audio_churns{0}; + std::atomic data_churns{0}; + + std::thread audio_pusher([&]() { + auto next = std::chrono::steady_clock::now(); + while (running.load()) { + std::this_thread::sleep_until(next); + next += std::chrono::milliseconds(kCbFrameDurationMs); + auto frame = cbSilentFrame(); + audio->pushFrame(frame, kCbSamplesPerFrame); + } + }); + + std::thread data_pusher([&]() { + while (running.load()) { + auto payload = cbPayload(256); + data->pushFrame(payload); + std::this_thread::sleep_for(20ms); + } + }); + + std::thread audio_churner([&]() { + while (running.load()) { + receiver.setOnAudioFrameCallback( + caller_identity, livekit::TrackSource::SOURCE_MICROPHONE, + [&](const livekit::AudioFrame &) { audio_rx++; }); + std::this_thread::sleep_for(250ms); + receiver.clearOnAudioFrameCallback( + caller_identity, livekit::TrackSource::SOURCE_MICROPHONE); + std::this_thread::sleep_for(100ms); + audio_churns++; + } + }); + + std::thread data_churner([&]() { + while (running.load()) { + receiver.setOnDataFrameCallback( + caller_identity, "mixed-data", + [&](const std::vector &, + std::optional) { data_rx++; }); + std::this_thread::sleep_for(350ms); + receiver.clearOnDataFrameCallback(caller_identity, "mixed-data"); + std::this_thread::sleep_for(150ms); + data_churns++; + } + }); + + const int duration_s = std::min(config_.stress_duration_seconds, 30); + std::this_thread::sleep_for(std::chrono::seconds(duration_s)); + + running.store(false); + audio_pusher.join(); + data_pusher.join(); + audio_churner.join(); + data_churner.join(); + + std::cout << "Audio churn cycles: " << audio_churns.load() << std::endl; + std::cout << "Data churn cycles: " << data_churns.load() << std::endl; + std::cout << "Audio frames rx: " << audio_rx.load() << std::endl; + std::cout << "Data frames rx: " << data_rx.load() << std::endl; + + EXPECT_GT(audio_churns.load(), 0); + EXPECT_GT(data_churns.load(), 0); +} + +// --------------------------------------------------------------------------- +// Callback replacement storm. +// +// Instead of clear + set, rapidly replace the callback with a new lambda +// (calling setOnAudioFrameCallback twice without a clear in between). +// The bridge should silently overwrite the old callback and the new one +// should start receiving. The old reader thread should eventually be +// replaced the next time onTrackSubscribed fires. +// --------------------------------------------------------------------------- +TEST_F(BridgeCallbackStressTest, CallbackReplacement) { + skipIfNotConfigured(); + + std::cout << "\n=== Bridge Callback Replacement Storm ===" << std::endl; + + LiveKitBridge caller; + LiveKitBridge receiver; + + ASSERT_TRUE(connectPair(caller, receiver)); + + auto audio = caller.createAudioTrack( + "replace-mic", kCbSampleRate, kCbChannels, + livekit::TrackSource::SOURCE_MICROPHONE); + ASSERT_NE(audio, nullptr); + + const std::string caller_identity = "rpc-caller"; + + std::atomic running{true}; + std::atomic total_rx{0}; + std::atomic replacements{0}; + + std::thread pusher([&]() { + auto next = std::chrono::steady_clock::now(); + while (running.load()) { + std::this_thread::sleep_until(next); + next += std::chrono::milliseconds(kCbFrameDurationMs); + auto frame = cbSilentFrame(); + audio->pushFrame(frame, kCbSamplesPerFrame); + } + }); + + std::thread replacer([&]() { + while (running.load()) { + receiver.setOnAudioFrameCallback( + caller_identity, livekit::TrackSource::SOURCE_MICROPHONE, + [&](const livekit::AudioFrame &) { total_rx++; }); + replacements++; + std::this_thread::sleep_for(100ms); + } + }); + + const int duration_s = std::min(config_.stress_duration_seconds, 20); + std::this_thread::sleep_for(std::chrono::seconds(duration_s)); + + running.store(false); + pusher.join(); + replacer.join(); + + // Final clear to join any lingering reader + receiver.clearOnAudioFrameCallback( + caller_identity, livekit::TrackSource::SOURCE_MICROPHONE); + + std::cout << "Replacements: " << replacements.load() << std::endl; + std::cout << "Total frames rx: " << total_rx.load() << std::endl; + + EXPECT_GT(replacements.load(), 0); +} + +} // namespace test +} // namespace livekit_bridge diff --git a/bridge/tests/stress/test_bridge_data_stress.cpp b/bridge/tests/stress/test_bridge_data_stress.cpp new file mode 100644 index 00000000..23937df9 --- /dev/null +++ b/bridge/tests/stress/test_bridge_data_stress.cpp @@ -0,0 +1,337 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "../common/bridge_test_common.h" +#include + +namespace livekit_bridge { +namespace test { + +static std::vector randomPayload(size_t size) { + static thread_local std::mt19937 gen(std::random_device{}()); + std::uniform_int_distribution dist(0, 255); + std::vector data(size); + for (auto &b : data) + b = static_cast(dist(gen)); + return data; +} + +class BridgeDataStressTest : public BridgeTestBase {}; + +// --------------------------------------------------------------------------- +// High-throughput data track stress test. +// +// Pushes data frames as fast as possible for STRESS_DURATION_SECONDS and +// tracks throughput, delivery rate, and back-pressure failures. +// --------------------------------------------------------------------------- +TEST_F(BridgeDataStressTest, HighThroughput) { + skipIfNotConfigured(); + + constexpr size_t kPayloadSize = 1024; + + std::cout << "\n=== Bridge Data High-Throughput Stress Test ===" << std::endl; + std::cout << "Duration: " << config_.stress_duration_seconds << "s" + << std::endl; + std::cout << "Payload size: " << kPayloadSize << " bytes" << std::endl; + + LiveKitBridge caller; + LiveKitBridge receiver; + + ASSERT_TRUE(connectPair(caller, receiver)); + + const std::string track_name = "throughput-data"; + const std::string caller_identity = "rpc-caller"; + + auto data_track = caller.createDataTrack(track_name); + ASSERT_NE(data_track, nullptr); + + StressTestStats stats; + std::atomic frames_received{0}; + std::atomic running{true}; + + receiver.setOnDataFrameCallback( + caller_identity, track_name, + [&](const std::vector &payload, + std::optional) { + frames_received++; + (void)payload; + }); + + std::this_thread::sleep_for(3s); + + auto start_time = std::chrono::steady_clock::now(); + auto duration = std::chrono::seconds(config_.stress_duration_seconds); + + std::thread sender([&]() { + while (running.load()) { + auto payload = randomPayload(kPayloadSize); + + auto t0 = std::chrono::high_resolution_clock::now(); + bool ok = data_track->pushFrame(payload); + auto t1 = std::chrono::high_resolution_clock::now(); + + double latency_ms = + std::chrono::duration(t1 - t0).count(); + + if (ok) { + stats.recordCall(true, latency_ms, kPayloadSize); + } else { + stats.recordCall(false, latency_ms, kPayloadSize); + stats.recordError("push_failed"); + } + + std::this_thread::sleep_for(5ms); + } + }); + + std::thread progress([&]() { + int last_total = 0; + while (running.load()) { + std::this_thread::sleep_for(30s); + if (!running.load()) + break; + + auto elapsed = std::chrono::steady_clock::now() - start_time; + auto elapsed_s = + std::chrono::duration_cast(elapsed).count(); + int cur_total = stats.totalCalls(); + int rate = (cur_total - last_total); + last_total = cur_total; + + std::cout << "[" << elapsed_s << "s]" + << " sent=" << cur_total + << " recv=" << frames_received.load() + << " success=" << stats.successfulCalls() + << " failed=" << stats.failedCalls() + << " rate=" << std::fixed << std::setprecision(1) + << (rate / 30.0) << " pushes/s" << std::endl; + } + }); + + while (std::chrono::steady_clock::now() - start_time < duration) { + std::this_thread::sleep_for(1s); + } + + running.store(false); + sender.join(); + progress.join(); + + std::this_thread::sleep_for(2s); + + stats.printStats("Bridge Data High-Throughput Stress"); + + std::cout << "Frames received: " << frames_received.load() << std::endl; + + EXPECT_GT(stats.successfulCalls(), 0) << "No successful pushes"; + double success_rate = + stats.totalCalls() > 0 + ? (100.0 * stats.successfulCalls() / stats.totalCalls()) + : 0.0; + EXPECT_GT(success_rate, 95.0) << "Push success rate below 95%"; + + double delivery_rate = + stats.successfulCalls() > 0 + ? (100.0 * frames_received.load() / stats.successfulCalls()) + : 0.0; + std::cout << "Delivery rate: " << std::fixed << std::setprecision(2) + << delivery_rate << "%" << std::endl; + + receiver.clearOnDataFrameCallback(caller_identity, track_name); +} + +// --------------------------------------------------------------------------- +// Large payload stress: pushes 64KB payloads for the configured duration. +// Exercises serialization / deserialization with larger frames. +// --------------------------------------------------------------------------- +TEST_F(BridgeDataStressTest, LargePayloadStress) { + skipIfNotConfigured(); + + constexpr size_t kLargePayloadSize = 64 * 1024; + + std::cout << "\n=== Bridge Data Large-Payload Stress Test ===" << std::endl; + std::cout << "Duration: " << config_.stress_duration_seconds << "s" + << std::endl; + std::cout << "Payload size: " << kLargePayloadSize << " bytes (64KB)" + << std::endl; + + LiveKitBridge caller; + LiveKitBridge receiver; + + ASSERT_TRUE(connectPair(caller, receiver)); + + const std::string track_name = "large-data"; + const std::string caller_identity = "rpc-caller"; + + auto data_track = caller.createDataTrack(track_name); + ASSERT_NE(data_track, nullptr); + + StressTestStats stats; + std::atomic frames_received{0}; + std::atomic bytes_received{0}; + std::atomic running{true}; + + receiver.setOnDataFrameCallback( + caller_identity, track_name, + [&](const std::vector &payload, + std::optional) { + frames_received++; + bytes_received += payload.size(); + }); + + std::this_thread::sleep_for(3s); + + auto start_time = std::chrono::steady_clock::now(); + auto duration = std::chrono::seconds(config_.stress_duration_seconds); + + std::thread sender([&]() { + while (running.load()) { + auto payload = randomPayload(kLargePayloadSize); + + auto t0 = std::chrono::high_resolution_clock::now(); + bool ok = data_track->pushFrame(payload); + auto t1 = std::chrono::high_resolution_clock::now(); + + double latency_ms = + std::chrono::duration(t1 - t0).count(); + + if (ok) { + stats.recordCall(true, latency_ms, kLargePayloadSize); + } else { + stats.recordCall(false, latency_ms, kLargePayloadSize); + stats.recordError("push_failed"); + } + + std::this_thread::sleep_for(50ms); + } + }); + + std::thread progress([&]() { + int last_total = 0; + while (running.load()) { + std::this_thread::sleep_for(30s); + if (!running.load()) + break; + + auto elapsed = std::chrono::steady_clock::now() - start_time; + auto elapsed_s = + std::chrono::duration_cast(elapsed).count(); + int cur_total = stats.totalCalls(); + + std::cout << "[" << elapsed_s << "s]" + << " sent=" << cur_total + << " recv=" << frames_received.load() + << " bytes_rx=" << (bytes_received.load() / (1024.0 * 1024.0)) + << " MB" + << " rate=" << std::fixed << std::setprecision(1) + << ((cur_total - last_total) / 30.0) << " pushes/s" + << std::endl; + last_total = cur_total; + } + }); + + while (std::chrono::steady_clock::now() - start_time < duration) { + std::this_thread::sleep_for(1s); + } + + running.store(false); + sender.join(); + progress.join(); + + std::this_thread::sleep_for(2s); + + stats.printStats("Bridge Data Large-Payload Stress"); + + std::cout << "Frames received: " << frames_received.load() << std::endl; + std::cout << "Bytes received: " + << (bytes_received.load() / (1024.0 * 1024.0)) << " MB" + << std::endl; + + EXPECT_GT(stats.successfulCalls(), 0) << "No successful pushes"; + double success_rate = + stats.totalCalls() > 0 + ? (100.0 * stats.successfulCalls() / stats.totalCalls()) + : 0.0; + EXPECT_GT(success_rate, 90.0) << "Push success rate below 90%"; + + receiver.clearOnDataFrameCallback(caller_identity, track_name); +} + +// --------------------------------------------------------------------------- +// Callback churn: rapidly register/unregister the data frame callback while +// the sender is actively pushing. Exercises the bridge's thread-joining +// logic under contention. +// --------------------------------------------------------------------------- +TEST_F(BridgeDataStressTest, CallbackChurn) { + skipIfNotConfigured(); + + std::cout << "\n=== Bridge Data Callback Churn Stress Test ===" << std::endl; + + LiveKitBridge caller; + LiveKitBridge receiver; + + ASSERT_TRUE(connectPair(caller, receiver)); + + const std::string track_name = "churn-data"; + const std::string caller_identity = "rpc-caller"; + + auto data_track = caller.createDataTrack(track_name); + ASSERT_NE(data_track, nullptr); + + std::atomic running{true}; + std::atomic total_received{0}; + std::atomic churn_cycles{0}; + + std::thread sender([&]() { + while (running.load()) { + auto payload = randomPayload(256); + data_track->pushFrame(payload); + std::this_thread::sleep_for(10ms); + } + }); + + std::thread churner([&]() { + while (running.load()) { + receiver.setOnDataFrameCallback( + caller_identity, track_name, + [&](const std::vector &, + std::optional) { total_received++; }); + + std::this_thread::sleep_for(500ms); + + receiver.clearOnDataFrameCallback(caller_identity, track_name); + + std::this_thread::sleep_for(200ms); + churn_cycles++; + } + }); + + const int churn_duration_s = std::min(config_.stress_duration_seconds, 30); + std::this_thread::sleep_for(std::chrono::seconds(churn_duration_s)); + + running.store(false); + sender.join(); + churner.join(); + + std::cout << "Churn cycles completed: " << churn_cycles.load() << std::endl; + std::cout << "Total frames received: " << total_received.load() + << std::endl; + + EXPECT_GT(churn_cycles.load(), 0) + << "Should have completed at least one churn cycle"; +} + +} // namespace test +} // namespace livekit_bridge diff --git a/bridge/tests/stress/test_bridge_lifecycle_stress.cpp b/bridge/tests/stress/test_bridge_lifecycle_stress.cpp new file mode 100644 index 00000000..4d37161f --- /dev/null +++ b/bridge/tests/stress/test_bridge_lifecycle_stress.cpp @@ -0,0 +1,298 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "../common/bridge_test_common.h" +#include +#include + +namespace livekit_bridge { +namespace test { + +constexpr int kLifecycleSampleRate = 48000; +constexpr int kLifecycleChannels = 1; +constexpr int kLifecycleFrameDurationMs = 10; +constexpr int kLifecycleSamplesPerFrame = + kLifecycleSampleRate * kLifecycleFrameDurationMs / 1000; + +static std::vector makeSilent(int samples) { + return std::vector(samples * kLifecycleChannels, 0); +} + +static std::vector makePayload(size_t size) { + static thread_local std::mt19937 gen(std::random_device{}()); + std::uniform_int_distribution dist(0, 255); + std::vector buf(size); + for (auto &b : buf) + b = static_cast(dist(gen)); + return buf; +} + +class BridgeLifecycleStressTest : public BridgeTestBase {}; + +// --------------------------------------------------------------------------- +// Disconnect while frames are actively being pushed and received. +// +// Two threads push audio / data frames at full rate. A third thread +// triggers disconnect() mid-flight after a random delay. The bridge must +// tear down cleanly with no crashes, hangs, or thread leaks. Repeated +// for TEST_ITERATIONS cycles. +// --------------------------------------------------------------------------- +TEST_F(BridgeLifecycleStressTest, DisconnectUnderLoad) { + skipIfNotConfigured(); + + const int cycles = config_.test_iterations; + std::cout << "\n=== Bridge Disconnect Under Load ===" << std::endl; + std::cout << "Cycles: " << cycles << std::endl; + + const std::string caller_identity = "rpc-caller"; + + for (int i = 0; i < cycles; ++i) { + { + LiveKitBridge caller; + LiveKitBridge receiver; + + ASSERT_TRUE(connectPair(caller, receiver)) + << "Cycle " << i << ": connect failed"; + + auto audio = caller.createAudioTrack( + "load-mic", kLifecycleSampleRate, kLifecycleChannels, + livekit::TrackSource::SOURCE_MICROPHONE); + auto data = caller.createDataTrack("load-data"); + + std::atomic audio_rx{0}; + std::atomic data_rx{0}; + + receiver.setOnAudioFrameCallback( + caller_identity, livekit::TrackSource::SOURCE_MICROPHONE, + [&](const livekit::AudioFrame &) { audio_rx++; }); + + receiver.setOnDataFrameCallback( + caller_identity, "load-data", + [&](const std::vector &, + std::optional) { data_rx++; }); + + std::this_thread::sleep_for(2s); + + std::atomic running{true}; + + std::thread audio_pusher([&]() { + while (running.load()) { + auto frame = makeSilent(kLifecycleSamplesPerFrame); + audio->pushFrame(frame, kLifecycleSamplesPerFrame); + std::this_thread::sleep_for( + std::chrono::milliseconds(kLifecycleFrameDurationMs)); + } + }); + + std::thread data_pusher([&]() { + while (running.load()) { + auto payload = makePayload(512); + data->pushFrame(payload); + std::this_thread::sleep_for(20ms); + } + }); + + // Let traffic flow for 1-2 seconds, then pull the plug. + std::this_thread::sleep_for(1500ms); + running.store(false); + audio_pusher.join(); + data_pusher.join(); + + // Clear callbacks while captured atomics are still alive + receiver.clearOnAudioFrameCallback( + caller_identity, livekit::TrackSource::SOURCE_MICROPHONE); + receiver.clearOnDataFrameCallback(caller_identity, "load-data"); + + std::cout << " Cycle " << (i + 1) << "/" << cycles + << " OK (audio_rx=" << audio_rx.load() + << " data_rx=" << data_rx.load() << ")" << std::endl; + } // bridges destroyed → disconnect + shutdown + + std::this_thread::sleep_for(1s); + } +} + +// --------------------------------------------------------------------------- +// Track release while receiver is consuming. +// +// Caller publishes audio + data, receiver is actively consuming via +// callbacks, then caller releases each track individually while the +// receiver's reader threads are still running. Verifies no dangling +// pointers, no use-after-free, and clean thread join. +// --------------------------------------------------------------------------- +TEST_F(BridgeLifecycleStressTest, TrackReleaseWhileReceiving) { + skipIfNotConfigured(); + + const int iterations = config_.test_iterations; + std::cout << "\n=== Bridge Track Release While Receiving ===" << std::endl; + std::cout << "Iterations: " << iterations << std::endl; + + const std::string caller_identity = "rpc-caller"; + + for (int iter = 0; iter < iterations; ++iter) { + { + LiveKitBridge caller; + LiveKitBridge receiver; + + ASSERT_TRUE(connectPair(caller, receiver)) + << "Iteration " << iter << ": connect failed"; + + auto audio = caller.createAudioTrack( + "release-rx-mic", kLifecycleSampleRate, kLifecycleChannels, + livekit::TrackSource::SOURCE_MICROPHONE); + auto data = caller.createDataTrack("release-rx-data"); + + std::atomic audio_rx{0}; + std::atomic data_rx{0}; + + receiver.setOnAudioFrameCallback( + caller_identity, livekit::TrackSource::SOURCE_MICROPHONE, + [&](const livekit::AudioFrame &) { audio_rx++; }); + + receiver.setOnDataFrameCallback( + caller_identity, "release-rx-data", + [&](const std::vector &, + std::optional) { data_rx++; }); + + std::this_thread::sleep_for(2s); + + std::atomic running{true}; + + std::thread audio_pusher([&]() { + while (running.load()) { + if (audio->isReleased()) + break; + auto frame = makeSilent(kLifecycleSamplesPerFrame); + audio->pushFrame(frame, kLifecycleSamplesPerFrame); + std::this_thread::sleep_for( + std::chrono::milliseconds(kLifecycleFrameDurationMs)); + } + }); + + std::thread data_pusher([&]() { + while (running.load()) { + if (data->isReleased()) + break; + auto payload = makePayload(256); + data->pushFrame(payload); + std::this_thread::sleep_for(20ms); + } + }); + + // Let frames flow, then release tracks mid-stream + std::this_thread::sleep_for(800ms); + + audio->release(); + EXPECT_TRUE(audio->isReleased()); + + std::this_thread::sleep_for(200ms); + + data->release(); + EXPECT_TRUE(data->isReleased()); + + running.store(false); + audio_pusher.join(); + data_pusher.join(); + + // pushFrame must return false on released tracks + auto silence = makeSilent(kLifecycleSamplesPerFrame); + EXPECT_FALSE(audio->pushFrame(silence, kLifecycleSamplesPerFrame)); + + auto payload = makePayload(64); + EXPECT_FALSE(data->pushFrame(payload)); + + receiver.clearOnAudioFrameCallback( + caller_identity, livekit::TrackSource::SOURCE_MICROPHONE); + receiver.clearOnDataFrameCallback(caller_identity, "release-rx-data"); + + std::cout << " Iteration " << (iter + 1) << "/" << iterations + << " OK (audio_rx=" << audio_rx.load() + << " data_rx=" << data_rx.load() << ")" << std::endl; + } + + std::this_thread::sleep_for(1s); + } +} + +// --------------------------------------------------------------------------- +// Repeated full lifecycle: connect → create all track types → push → +// release → disconnect. Exercises the complete resource creation and +// teardown path looking for accumulating leaks. +// --------------------------------------------------------------------------- +TEST_F(BridgeLifecycleStressTest, FullLifecycleSoak) { + skipIfNotConfigured(); + + const int cycles = config_.test_iterations; + std::cout << "\n=== Bridge Full Lifecycle Soak ===" << std::endl; + std::cout << "Cycles: " << cycles << std::endl; + + for (int i = 0; i < cycles; ++i) { + { + LiveKitBridge bridge; + livekit::RoomOptions options; + options.auto_subscribe = true; + + bool connected = + bridge.connect(config_.url, config_.caller_token, options); + ASSERT_TRUE(connected) << "Cycle " << i << ": connect failed"; + + auto audio = bridge.createAudioTrack( + "soak-mic", kLifecycleSampleRate, kLifecycleChannels, + livekit::TrackSource::SOURCE_MICROPHONE); + + constexpr int kVideoWidth = 320; + constexpr int kVideoHeight = 240; + auto video = bridge.createVideoTrack( + "soak-cam", kVideoWidth, kVideoHeight, + livekit::TrackSource::SOURCE_CAMERA); + + auto data = bridge.createDataTrack("soak-data"); + + // Push a handful of frames on each track type + for (int f = 0; f < 10; ++f) { + auto pcm = makeSilent(kLifecycleSamplesPerFrame); + audio->pushFrame(pcm, kLifecycleSamplesPerFrame); + + std::vector rgba(kVideoWidth * kVideoHeight * 4, 0x80); + video->pushFrame(rgba); + + auto payload = makePayload(256); + data->pushFrame(payload); + + std::this_thread::sleep_for( + std::chrono::milliseconds(kLifecycleFrameDurationMs)); + } + + // Explicit release in various orders to exercise different teardown paths + if (i % 3 == 0) { + audio->release(); + video->release(); + data->release(); + } else if (i % 3 == 1) { + data->release(); + audio->release(); + video->release(); + } + // else: let disconnect() release all tracks + } // bridge destroyed + + std::cout << " Cycle " << (i + 1) << "/" << cycles << " OK" << std::endl; + std::this_thread::sleep_for(1s); + } +} + +} // namespace test +} // namespace livekit_bridge diff --git a/bridge/tests/stress/test_bridge_multi_track_stress.cpp b/bridge/tests/stress/test_bridge_multi_track_stress.cpp new file mode 100644 index 00000000..c54d2ca7 --- /dev/null +++ b/bridge/tests/stress/test_bridge_multi_track_stress.cpp @@ -0,0 +1,440 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "../common/bridge_test_common.h" +#include +#include + +namespace livekit_bridge { +namespace test { + +constexpr int kMtSampleRate = 48000; +constexpr int kMtChannels = 1; +constexpr int kMtFrameDurationMs = 10; +constexpr int kMtSamplesPerFrame = kMtSampleRate * kMtFrameDurationMs / 1000; +constexpr int kMtVideoWidth = 160; +constexpr int kMtVideoHeight = 120; +constexpr size_t kMtVideoFrameBytes = kMtVideoWidth * kMtVideoHeight * 4; + +static std::vector mtSilentFrame() { + return std::vector(kMtSamplesPerFrame * kMtChannels, 0); +} + +static std::vector mtVideoFrame() { + return std::vector(kMtVideoFrameBytes, 0x42); +} + +static std::vector mtPayload(size_t size) { + static thread_local std::mt19937 gen(std::random_device{}()); + std::uniform_int_distribution dist(0, 255); + std::vector buf(size); + for (auto &b : buf) + b = static_cast(dist(gen)); + return buf; +} + +class BridgeMultiTrackStressTest : public BridgeTestBase {}; + +// --------------------------------------------------------------------------- +// Concurrent pushes on all track types. +// +// Publishes an audio track, a video track, and two data tracks. A +// separate thread pushes frames on each track simultaneously for the +// configured stress duration. All four threads contend on the bridge's +// internal mutex and on the underlying FFI. Reports per-track push +// success rates. +// --------------------------------------------------------------------------- +TEST_F(BridgeMultiTrackStressTest, ConcurrentMultiTrackPush) { + skipIfNotConfigured(); + + std::cout << "\n=== Bridge Concurrent Multi-Track Push ===" << std::endl; + std::cout << "Duration: " << config_.stress_duration_seconds << "s" + << std::endl; + + LiveKitBridge bridge; + livekit::RoomOptions options; + options.auto_subscribe = true; + + bool connected = bridge.connect(config_.url, config_.caller_token, options); + ASSERT_TRUE(connected); + + auto audio = bridge.createAudioTrack( + "mt-mic", kMtSampleRate, kMtChannels, + livekit::TrackSource::SOURCE_MICROPHONE); + + auto video = bridge.createVideoTrack( + "mt-cam", kMtVideoWidth, kMtVideoHeight, + livekit::TrackSource::SOURCE_CAMERA); + + auto data1 = bridge.createDataTrack("mt-data-1"); + auto data2 = bridge.createDataTrack("mt-data-2"); + + ASSERT_NE(audio, nullptr); + ASSERT_NE(video, nullptr); + ASSERT_NE(data1, nullptr); + ASSERT_NE(data2, nullptr); + + struct TrackStats { + std::atomic pushes{0}; + std::atomic successes{0}; + std::atomic failures{0}; + }; + + TrackStats audio_stats, video_stats, data1_stats, data2_stats; + std::atomic running{true}; + + auto start_time = std::chrono::steady_clock::now(); + auto duration = std::chrono::seconds(config_.stress_duration_seconds); + + std::thread audio_thread([&]() { + auto next = std::chrono::steady_clock::now(); + while (running.load()) { + std::this_thread::sleep_until(next); + next += std::chrono::milliseconds(kMtFrameDurationMs); + auto frame = mtSilentFrame(); + bool ok = audio->pushFrame(frame, kMtSamplesPerFrame); + audio_stats.pushes++; + if (ok) + audio_stats.successes++; + else + audio_stats.failures++; + } + }); + + std::thread video_thread([&]() { + while (running.load()) { + auto frame = mtVideoFrame(); + bool ok = video->pushFrame(frame); + video_stats.pushes++; + if (ok) + video_stats.successes++; + else + video_stats.failures++; + // ~30 fps + std::this_thread::sleep_for(33ms); + } + }); + + std::thread data1_thread([&]() { + while (running.load()) { + auto payload = mtPayload(512); + bool ok = data1->pushFrame(payload); + data1_stats.pushes++; + if (ok) + data1_stats.successes++; + else + data1_stats.failures++; + std::this_thread::sleep_for(10ms); + } + }); + + std::thread data2_thread([&]() { + while (running.load()) { + auto payload = mtPayload(2048); + bool ok = data2->pushFrame(payload); + data2_stats.pushes++; + if (ok) + data2_stats.successes++; + else + data2_stats.failures++; + std::this_thread::sleep_for(15ms); + } + }); + + // Progress reporting + std::thread progress([&]() { + while (running.load()) { + std::this_thread::sleep_for(30s); + if (!running.load()) + break; + auto elapsed = std::chrono::steady_clock::now() - start_time; + auto elapsed_s = + std::chrono::duration_cast(elapsed).count(); + std::cout << "[" << elapsed_s << "s]" + << " audio=" << audio_stats.pushes.load() + << " video=" << video_stats.pushes.load() + << " data1=" << data1_stats.pushes.load() + << " data2=" << data2_stats.pushes.load() << std::endl; + } + }); + + while (std::chrono::steady_clock::now() - start_time < duration) { + std::this_thread::sleep_for(1s); + } + + running.store(false); + audio_thread.join(); + video_thread.join(); + data1_thread.join(); + data2_thread.join(); + progress.join(); + + auto printTrack = [](const char *name, const TrackStats &s) { + double rate = s.pushes.load() > 0 + ? (100.0 * s.successes.load() / s.pushes.load()) + : 0.0; + std::cout << " " << name << ": pushes=" << s.pushes.load() + << " ok=" << s.successes.load() + << " fail=" << s.failures.load() << " (" << std::fixed + << std::setprecision(1) << rate << "%)" << std::endl; + }; + + std::cout << "\n========================================" << std::endl; + std::cout << " Multi-Track Push Results" << std::endl; + std::cout << "========================================" << std::endl; + printTrack("audio ", audio_stats); + printTrack("video ", video_stats); + printTrack("data-1", data1_stats); + printTrack("data-2", data2_stats); + std::cout << "========================================\n" << std::endl; + + EXPECT_GT(audio_stats.successes.load(), 0); + EXPECT_GT(video_stats.successes.load(), 0); + EXPECT_GT(data1_stats.successes.load(), 0); + EXPECT_GT(data2_stats.successes.load(), 0); +} + +// --------------------------------------------------------------------------- +// Concurrent track creation and release. +// +// Multiple threads simultaneously create tracks, push a short burst, +// then release them. Exercises the bridge's published_*_tracks_ vectors +// and mutex under heavy concurrent modification. +// --------------------------------------------------------------------------- +TEST_F(BridgeMultiTrackStressTest, ConcurrentCreateRelease) { + skipIfNotConfigured(); + + std::cout << "\n=== Bridge Concurrent Track Create/Release ===" << std::endl; + + LiveKitBridge bridge; + livekit::RoomOptions options; + options.auto_subscribe = true; + + bool connected = bridge.connect(config_.url, config_.caller_token, options); + ASSERT_TRUE(connected); + + std::atomic running{true}; + std::atomic audio_cycles{0}; + std::atomic data_cycles{0}; + std::atomic errors{0}; + + // Each source can only have one active track, so we serialize by source + // but run the two sources concurrently. + + std::thread audio_thread([&]() { + while (running.load()) { + try { + auto track = bridge.createAudioTrack( + "create-release-mic", kMtSampleRate, kMtChannels, + livekit::TrackSource::SOURCE_MICROPHONE); + + for (int i = 0; i < 5; ++i) { + auto frame = mtSilentFrame(); + track->pushFrame(frame, kMtSamplesPerFrame); + std::this_thread::sleep_for( + std::chrono::milliseconds(kMtFrameDurationMs)); + } + + track->release(); + audio_cycles++; + } catch (const std::exception &e) { + errors++; + std::cerr << "Audio create/release error: " << e.what() << std::endl; + } + std::this_thread::sleep_for(200ms); + } + }); + + std::thread data_thread([&]() { + int track_counter = 0; + while (running.load()) { + try { + auto track = bridge.createDataTrack( + "create-release-data-" + std::to_string(track_counter++)); + + for (int i = 0; i < 5; ++i) { + auto payload = mtPayload(128); + track->pushFrame(payload); + std::this_thread::sleep_for(20ms); + } + + track->release(); + data_cycles++; + } catch (const std::exception &e) { + errors++; + std::cerr << "Data create/release error: " << e.what() << std::endl; + } + std::this_thread::sleep_for(200ms); + } + }); + + const int duration_s = std::min(config_.stress_duration_seconds, 30); + std::this_thread::sleep_for(std::chrono::seconds(duration_s)); + + running.store(false); + audio_thread.join(); + data_thread.join(); + + std::cout << "Audio create/release cycles: " << audio_cycles.load() + << std::endl; + std::cout << "Data create/release cycles: " << data_cycles.load() + << std::endl; + std::cout << "Errors: " << errors.load() << std::endl; + + EXPECT_GT(audio_cycles.load(), 0); + EXPECT_GT(data_cycles.load(), 0); + EXPECT_EQ(errors.load(), 0); +} + +// --------------------------------------------------------------------------- +// Full-duplex multi-track. +// +// Both caller and receiver publish audio + data tracks. Both register +// callbacks for the other's tracks. All four push-threads and all four +// reader threads run simultaneously, exercising the bridge's internal +// maps from both the publish side and the subscribe side. +// --------------------------------------------------------------------------- +TEST_F(BridgeMultiTrackStressTest, FullDuplexMultiTrack) { + skipIfNotConfigured(); + + std::cout << "\n=== Bridge Full-Duplex Multi-Track ===" << std::endl; + std::cout << "Duration: " << config_.stress_duration_seconds << "s" + << std::endl; + + LiveKitBridge caller; + LiveKitBridge receiver; + + ASSERT_TRUE(connectPair(caller, receiver)); + + const std::string caller_identity = "rpc-caller"; + const std::string receiver_identity = "rpc-receiver"; + + // Caller publishes + auto caller_audio = caller.createAudioTrack( + "duplex-mic", kMtSampleRate, kMtChannels, + livekit::TrackSource::SOURCE_MICROPHONE); + auto caller_data = caller.createDataTrack("duplex-data-caller"); + + // Receiver publishes + auto receiver_audio = receiver.createAudioTrack( + "duplex-mic", kMtSampleRate, kMtChannels, + livekit::TrackSource::SOURCE_MICROPHONE); + auto receiver_data = receiver.createDataTrack("duplex-data-receiver"); + + // Cross-register callbacks + std::atomic caller_audio_rx{0}; + std::atomic caller_data_rx{0}; + std::atomic receiver_audio_rx{0}; + std::atomic receiver_data_rx{0}; + + caller.setOnAudioFrameCallback( + receiver_identity, livekit::TrackSource::SOURCE_MICROPHONE, + [&](const livekit::AudioFrame &) { caller_audio_rx++; }); + caller.setOnDataFrameCallback( + receiver_identity, "duplex-data-receiver", + [&](const std::vector &, + std::optional) { caller_data_rx++; }); + + receiver.setOnAudioFrameCallback( + caller_identity, livekit::TrackSource::SOURCE_MICROPHONE, + [&](const livekit::AudioFrame &) { receiver_audio_rx++; }); + receiver.setOnDataFrameCallback( + caller_identity, "duplex-data-caller", + [&](const std::vector &, + std::optional) { receiver_data_rx++; }); + + std::this_thread::sleep_for(3s); + + std::atomic running{true}; + auto start_time = std::chrono::steady_clock::now(); + auto duration = std::chrono::seconds(config_.stress_duration_seconds); + + auto audio_push_fn = + [&](std::shared_ptr track) { + auto next = std::chrono::steady_clock::now(); + while (running.load()) { + std::this_thread::sleep_until(next); + next += std::chrono::milliseconds(kMtFrameDurationMs); + auto frame = mtSilentFrame(); + track->pushFrame(frame, kMtSamplesPerFrame); + } + }; + + auto data_push_fn = + [&](std::shared_ptr track) { + while (running.load()) { + auto payload = mtPayload(256); + track->pushFrame(payload); + std::this_thread::sleep_for(20ms); + } + }; + + std::thread t1(audio_push_fn, caller_audio); + std::thread t2(data_push_fn, caller_data); + std::thread t3(audio_push_fn, receiver_audio); + std::thread t4(data_push_fn, receiver_data); + + std::thread progress([&]() { + while (running.load()) { + std::this_thread::sleep_for(30s); + if (!running.load()) + break; + auto elapsed = std::chrono::steady_clock::now() - start_time; + auto elapsed_s = + std::chrono::duration_cast(elapsed).count(); + std::cout << "[" << elapsed_s << "s]" + << " caller_audio_rx=" << caller_audio_rx.load() + << " caller_data_rx=" << caller_data_rx.load() + << " receiver_audio_rx=" << receiver_audio_rx.load() + << " receiver_data_rx=" << receiver_data_rx.load() + << std::endl; + } + }); + + while (std::chrono::steady_clock::now() - start_time < duration) { + std::this_thread::sleep_for(1s); + } + + running.store(false); + t1.join(); + t2.join(); + t3.join(); + t4.join(); + progress.join(); + + std::cout << "\n========================================" << std::endl; + std::cout << " Full-Duplex Multi-Track Results" << std::endl; + std::cout << "========================================" << std::endl; + std::cout << "Caller audio rx: " << caller_audio_rx.load() << std::endl; + std::cout << "Caller data rx: " << caller_data_rx.load() << std::endl; + std::cout << "Receiver audio rx: " << receiver_audio_rx.load() << std::endl; + std::cout << "Receiver data rx: " << receiver_data_rx.load() << std::endl; + std::cout << "========================================\n" << std::endl; + + EXPECT_GT(receiver_audio_rx.load(), 0); + EXPECT_GT(receiver_data_rx.load(), 0); + + // Clear callbacks while atomics are alive + caller.clearOnAudioFrameCallback( + receiver_identity, livekit::TrackSource::SOURCE_MICROPHONE); + caller.clearOnDataFrameCallback(receiver_identity, "duplex-data-receiver"); + receiver.clearOnAudioFrameCallback( + caller_identity, livekit::TrackSource::SOURCE_MICROPHONE); + receiver.clearOnDataFrameCallback(caller_identity, "duplex-data-caller"); +} + +} // namespace test +} // namespace livekit_bridge diff --git a/bridge/tests/unit/test_bridge_audio_track.cpp b/bridge/tests/unit/test_bridge_audio_track.cpp new file mode 100644 index 00000000..ced5ae00 --- /dev/null +++ b/bridge/tests/unit/test_bridge_audio_track.cpp @@ -0,0 +1,118 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// @file test_bridge_audio_track.cpp +/// @brief Unit tests for BridgeAudioTrack. + +#include +#include + +#include +#include +#include + +namespace livekit_bridge { +namespace test { + +class BridgeAudioTrackTest : public ::testing::Test { +protected: + /// Create a BridgeAudioTrack with null SDK objects for pure-logic testing. + /// The track is usable for accessor and state management tests but will + /// crash if pushFrame / mute / unmute try to dereference SDK pointers + /// on a non-released track. + static BridgeAudioTrack createNullTrack(const std::string &name = "mic", + int sample_rate = 48000, + int num_channels = 2) { + return BridgeAudioTrack(name, sample_rate, num_channels, + nullptr, // source + nullptr, // track + nullptr, // publication + nullptr // participant + ); + } +}; + +TEST_F(BridgeAudioTrackTest, AccessorsReturnConstructionValues) { + auto track = createNullTrack("test-mic", 16000, 1); + + EXPECT_EQ(track.name(), "test-mic") << "Name should match construction value"; + EXPECT_EQ(track.sampleRate(), 16000) << "Sample rate should match"; + EXPECT_EQ(track.numChannels(), 1) << "Channel count should match"; +} + +TEST_F(BridgeAudioTrackTest, InitiallyNotReleased) { + auto track = createNullTrack(); + + EXPECT_FALSE(track.isReleased()) + << "Track should not be released immediately after construction"; +} + +TEST_F(BridgeAudioTrackTest, ReleaseMarksTrackAsReleased) { + auto track = createNullTrack(); + + track.release(); + + EXPECT_TRUE(track.isReleased()) + << "Track should be released after calling release()"; +} + +TEST_F(BridgeAudioTrackTest, DoubleReleaseIsIdempotent) { + auto track = createNullTrack(); + + track.release(); + EXPECT_NO_THROW(track.release()) + << "Calling release() a second time should be a no-op"; + EXPECT_TRUE(track.isReleased()); +} + +TEST_F(BridgeAudioTrackTest, PushFrameAfterReleaseReturnsFalse) { + auto track = createNullTrack(); + track.release(); + + std::vector data(960, 0); + + EXPECT_FALSE(track.pushFrame(data, 480)) + << "pushFrame (vector) on a released track should return false"; +} + +TEST_F(BridgeAudioTrackTest, PushFrameRawPointerAfterReleaseReturnsFalse) { + auto track = createNullTrack(); + track.release(); + + std::vector data(960, 0); + + EXPECT_FALSE(track.pushFrame(data.data(), 480)) + << "pushFrame (raw pointer) on a released track should return false"; +} + +TEST_F(BridgeAudioTrackTest, MuteOnReleasedTrackDoesNotCrash) { + auto track = createNullTrack(); + track.release(); + + EXPECT_NO_THROW(track.mute()) + << "mute() on a released track should be a no-op"; +} + +TEST_F(BridgeAudioTrackTest, UnmuteOnReleasedTrackDoesNotCrash) { + auto track = createNullTrack(); + track.release(); + + EXPECT_NO_THROW(track.unmute()) + << "unmute() on a released track should be a no-op"; +} + +} // namespace test +} // namespace livekit_bridge diff --git a/bridge/tests/unit/test_bridge_data_track.cpp b/bridge/tests/unit/test_bridge_data_track.cpp new file mode 100644 index 00000000..f490cc44 --- /dev/null +++ b/bridge/tests/unit/test_bridge_data_track.cpp @@ -0,0 +1,132 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include + +#include +#include +#include + +namespace livekit_bridge { +namespace test { + +class BridgeDataTrackTest : public ::testing::Test { +protected: + /// Create a BridgeDataTrack with a null SDK track for pure-logic testing. + /// The track is usable for accessor and state management tests but + /// pushFrame / isPublished will return false without a real LocalDataTrack. + static BridgeDataTrack createNullTrack(const std::string &name = "data") { + return BridgeDataTrack(name, std::shared_ptr{}, + nullptr); + } +}; + +TEST_F(BridgeDataTrackTest, AccessorsReturnConstructionValues) { + auto track = createNullTrack("sensor-data"); + + EXPECT_EQ(track.name(), "sensor-data") + << "Name should match construction value"; +} + +TEST_F(BridgeDataTrackTest, InitiallyNotReleased) { + auto track = createNullTrack(); + + EXPECT_FALSE(track.isReleased()) + << "Track should not be released immediately after construction"; +} + +TEST_F(BridgeDataTrackTest, ReleaseMarksTrackAsReleased) { + auto track = createNullTrack(); + + track.release(); + + EXPECT_TRUE(track.isReleased()) + << "Track should be released after calling release()"; +} + +TEST_F(BridgeDataTrackTest, DoubleReleaseIsIdempotent) { + auto track = createNullTrack(); + + track.release(); + EXPECT_NO_THROW(track.release()) + << "Calling release() a second time should be a no-op"; + EXPECT_TRUE(track.isReleased()); +} + +TEST_F(BridgeDataTrackTest, PushFrameVectorAfterReleaseReturnsFalse) { + auto track = createNullTrack(); + track.release(); + + std::vector payload = {0x01, 0x02, 0x03}; + + EXPECT_FALSE(track.pushFrame(payload)) + << "pushFrame (vector) on a released track should return false"; +} + +TEST_F(BridgeDataTrackTest, PushFrameVectorWithTimestampAfterReleaseReturnsFalse) { + auto track = createNullTrack(); + track.release(); + + std::vector payload = {0x01, 0x02}; + EXPECT_FALSE(track.pushFrame(payload, 12345u)) + << "pushFrame (vector, timestamp) on a released track should return false"; +} + +TEST_F(BridgeDataTrackTest, PushFrameRawPointerAfterReleaseReturnsFalse) { + auto track = createNullTrack(); + track.release(); + + std::vector payload = {0x01, 0x02, 0x03}; + + EXPECT_FALSE(track.pushFrame(payload.data(), payload.size())) + << "pushFrame (raw pointer) on a released track should return false"; +} + +TEST_F(BridgeDataTrackTest, PushFrameRawPointerWithTimestampAfterReleaseReturnsFalse) { + auto track = createNullTrack(); + track.release(); + + std::uint8_t data[] = {0xab, 0xcd}; + EXPECT_FALSE(track.pushFrame(data, 2, 99999u)) + << "pushFrame (raw pointer, timestamp) on a released track should return false"; +} + +TEST_F(BridgeDataTrackTest, PushFrameWithNullTrackReturnsFalse) { + auto track = createNullTrack(); + + std::vector payload = {0x01}; + EXPECT_FALSE(track.pushFrame(payload)) + << "pushFrame with null underlying track should return false"; +} + +TEST_F(BridgeDataTrackTest, IsPublishedWithNullTrackReturnsFalse) { + auto track = createNullTrack(); + + EXPECT_FALSE(track.isPublished()) + << "isPublished() with null track should return false"; +} + +TEST_F(BridgeDataTrackTest, IsPublishedAfterReleaseReturnsFalse) { + auto track = createNullTrack(); + track.release(); + + EXPECT_FALSE(track.isPublished()) + << "isPublished() after release() should return false"; +} + +} // namespace test +} // namespace livekit_bridge diff --git a/bridge/tests/unit/test_bridge_video_track.cpp b/bridge/tests/unit/test_bridge_video_track.cpp new file mode 100644 index 00000000..5e64b8da --- /dev/null +++ b/bridge/tests/unit/test_bridge_video_track.cpp @@ -0,0 +1,114 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// @file test_bridge_video_track.cpp +/// @brief Unit tests for BridgeVideoTrack. + +#include +#include + +#include +#include +#include + +namespace livekit_bridge { +namespace test { + +class BridgeVideoTrackTest : public ::testing::Test { +protected: + /// Create a BridgeVideoTrack with null SDK objects for pure-logic testing. + static BridgeVideoTrack createNullTrack(const std::string &name = "cam", + int width = 1280, int height = 720) { + return BridgeVideoTrack(name, width, height, + nullptr, // source + nullptr, // track + nullptr, // publication + nullptr // participant + ); + } +}; + +TEST_F(BridgeVideoTrackTest, AccessorsReturnConstructionValues) { + auto track = createNullTrack("test-cam", 640, 480); + + EXPECT_EQ(track.name(), "test-cam") << "Name should match construction value"; + EXPECT_EQ(track.width(), 640) << "Width should match"; + EXPECT_EQ(track.height(), 480) << "Height should match"; +} + +TEST_F(BridgeVideoTrackTest, InitiallyNotReleased) { + auto track = createNullTrack(); + + EXPECT_FALSE(track.isReleased()) + << "Track should not be released immediately after construction"; +} + +TEST_F(BridgeVideoTrackTest, ReleaseMarksTrackAsReleased) { + auto track = createNullTrack(); + + track.release(); + + EXPECT_TRUE(track.isReleased()) + << "Track should be released after calling release()"; +} + +TEST_F(BridgeVideoTrackTest, DoubleReleaseIsIdempotent) { + auto track = createNullTrack(); + + track.release(); + EXPECT_NO_THROW(track.release()) + << "Calling release() a second time should be a no-op"; + EXPECT_TRUE(track.isReleased()); +} + +TEST_F(BridgeVideoTrackTest, PushFrameAfterReleaseReturnsFalse) { + auto track = createNullTrack(); + track.release(); + + std::vector data(1280 * 720 * 4, 0); + + EXPECT_FALSE(track.pushFrame(data)) + << "pushFrame (vector) on a released track should return false"; +} + +TEST_F(BridgeVideoTrackTest, PushFrameRawPointerAfterReleaseReturnsFalse) { + auto track = createNullTrack(); + track.release(); + + std::vector data(1280 * 720 * 4, 0); + + EXPECT_FALSE(track.pushFrame(data.data(), data.size())) + << "pushFrame (raw pointer) on a released track should return false"; +} + +TEST_F(BridgeVideoTrackTest, MuteOnReleasedTrackDoesNotCrash) { + auto track = createNullTrack(); + track.release(); + + EXPECT_NO_THROW(track.mute()) + << "mute() on a released track should be a no-op"; +} + +TEST_F(BridgeVideoTrackTest, UnmuteOnReleasedTrackDoesNotCrash) { + auto track = createNullTrack(); + track.release(); + + EXPECT_NO_THROW(track.unmute()) + << "unmute() on a released track should be a no-op"; +} + +} // namespace test +} // namespace livekit_bridge diff --git a/bridge/tests/unit/test_callback_key.cpp b/bridge/tests/unit/test_callback_key.cpp new file mode 100644 index 00000000..d667f7d4 --- /dev/null +++ b/bridge/tests/unit/test_callback_key.cpp @@ -0,0 +1,124 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// @file test_callback_key.cpp +/// @brief Unit tests for LiveKitBridge::CallbackKey hash and equality. + +#include +#include + +#include + +#include + +namespace livekit_bridge { +namespace test { + +class CallbackKeyTest : public ::testing::Test { +protected: + // Type aliases for convenience -- these are private types in LiveKitBridge, + // accessible via the friend declaration. + using CallbackKey = LiveKitBridge::CallbackKey; + using CallbackKeyHash = LiveKitBridge::CallbackKeyHash; +}; + +TEST_F(CallbackKeyTest, EqualKeysCompareEqual) { + CallbackKey a{"alice", livekit::TrackSource::SOURCE_MICROPHONE}; + CallbackKey b{"alice", livekit::TrackSource::SOURCE_MICROPHONE}; + + EXPECT_TRUE(a == b) << "Identical keys should compare equal"; +} + +TEST_F(CallbackKeyTest, DifferentIdentityComparesUnequal) { + CallbackKey a{"alice", livekit::TrackSource::SOURCE_MICROPHONE}; + CallbackKey b{"bob", livekit::TrackSource::SOURCE_MICROPHONE}; + + EXPECT_FALSE(a == b) << "Keys with different identities should not be equal"; +} + +TEST_F(CallbackKeyTest, DifferentSourceComparesUnequal) { + CallbackKey a{"alice", livekit::TrackSource::SOURCE_MICROPHONE}; + CallbackKey b{"alice", livekit::TrackSource::SOURCE_CAMERA}; + + EXPECT_FALSE(a == b) << "Keys with different sources should not be equal"; +} + +TEST_F(CallbackKeyTest, EqualKeysProduceSameHash) { + CallbackKey a{"alice", livekit::TrackSource::SOURCE_MICROPHONE}; + CallbackKey b{"alice", livekit::TrackSource::SOURCE_MICROPHONE}; + CallbackKeyHash hasher; + + EXPECT_EQ(hasher(a), hasher(b)) + << "Equal keys must produce the same hash value"; +} + +TEST_F(CallbackKeyTest, DifferentKeysProduceDifferentHashes) { + CallbackKeyHash hasher; + + CallbackKey mic{"alice", livekit::TrackSource::SOURCE_MICROPHONE}; + CallbackKey cam{"alice", livekit::TrackSource::SOURCE_CAMERA}; + CallbackKey bob{"bob", livekit::TrackSource::SOURCE_MICROPHONE}; + + // While hash collisions are technically allowed, these simple cases + // should not collide with a reasonable hash function. + EXPECT_NE(hasher(mic), hasher(cam)) + << "Different sources should (likely) produce different hashes"; + EXPECT_NE(hasher(mic), hasher(bob)) + << "Different identities should (likely) produce different hashes"; +} + +TEST_F(CallbackKeyTest, WorksAsUnorderedMapKey) { + std::unordered_map map; + + CallbackKey key1{"alice", livekit::TrackSource::SOURCE_MICROPHONE}; + CallbackKey key2{"bob", livekit::TrackSource::SOURCE_CAMERA}; + CallbackKey key3{"alice", livekit::TrackSource::SOURCE_CAMERA}; + + // Insert + map[key1] = 1; + map[key2] = 2; + map[key3] = 3; + + EXPECT_EQ(map.size(), 3u) + << "Three distinct keys should produce three entries"; + + // Find + EXPECT_EQ(map[key1], 1); + EXPECT_EQ(map[key2], 2); + EXPECT_EQ(map[key3], 3); + + // Overwrite + map[key1] = 42; + EXPECT_EQ(map[key1], 42) << "Inserting with same key should overwrite"; + EXPECT_EQ(map.size(), 3u) << "Size should not change after overwrite"; + + // Erase + map.erase(key2); + EXPECT_EQ(map.size(), 2u); + EXPECT_EQ(map.count(key2), 0u) << "Erased key should not be found"; +} + +TEST_F(CallbackKeyTest, EmptyIdentityWorks) { + CallbackKey empty{"", livekit::TrackSource::SOURCE_UNKNOWN}; + CallbackKey also_empty{"", livekit::TrackSource::SOURCE_UNKNOWN}; + CallbackKeyHash hasher; + + EXPECT_TRUE(empty == also_empty); + EXPECT_EQ(hasher(empty), hasher(also_empty)); +} + +} // namespace test +} // namespace livekit_bridge diff --git a/bridge/tests/unit/test_livekit_bridge.cpp b/bridge/tests/unit/test_livekit_bridge.cpp new file mode 100644 index 00000000..9ba576c9 --- /dev/null +++ b/bridge/tests/unit/test_livekit_bridge.cpp @@ -0,0 +1,138 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// @file test_livekit_bridge.cpp +/// @brief Unit tests for LiveKitBridge. + +#include +#include + +#include + +#include + +namespace livekit_bridge { +namespace test { + +class LiveKitBridgeTest : public ::testing::Test { +protected: + // No SetUp/TearDown needed -- we test the bridge without initializing + // the LiveKit SDK, since we only exercise pre-connection behaviour. +}; + +// ============================================================================ +// Initial state +// ============================================================================ + +TEST_F(LiveKitBridgeTest, InitiallyNotConnected) { + LiveKitBridge bridge; + + EXPECT_FALSE(bridge.isConnected()) + << "Bridge should not be connected immediately after construction"; +} + +TEST_F(LiveKitBridgeTest, DisconnectBeforeConnectIsNoOp) { + LiveKitBridge bridge; + + EXPECT_NO_THROW(bridge.disconnect()) + << "disconnect() on an unconnected bridge should be a safe no-op"; + + EXPECT_FALSE(bridge.isConnected()); +} + +TEST_F(LiveKitBridgeTest, MultipleDisconnectsAreIdempotent) { + LiveKitBridge bridge; + + EXPECT_NO_THROW({ + bridge.disconnect(); + bridge.disconnect(); + bridge.disconnect(); + }) << "Multiple disconnect() calls should be safe"; +} + +TEST_F(LiveKitBridgeTest, DestructorOnUnconnectedBridgeIsSafe) { + // Just verify no crash when the bridge is destroyed without connecting. + EXPECT_NO_THROW({ + LiveKitBridge bridge; + // bridge goes out of scope here + }); +} + +// ============================================================================ +// Track creation before connection +// ============================================================================ + +TEST_F(LiveKitBridgeTest, CreateAudioTrackBeforeConnectThrows) { + LiveKitBridge bridge; + + EXPECT_THROW(bridge.createAudioTrack("mic", 48000, 2, + livekit::TrackSource::SOURCE_MICROPHONE), + std::runtime_error) + << "createAudioTrack should throw when not connected"; +} + +TEST_F(LiveKitBridgeTest, CreateVideoTrackBeforeConnectThrows) { + LiveKitBridge bridge; + + EXPECT_THROW(bridge.createVideoTrack("cam", 1280, 720, + livekit::TrackSource::SOURCE_CAMERA), + std::runtime_error) + << "createVideoTrack should throw when not connected"; +} + +// ============================================================================ +// Callback registration (pre-connection — warns but does not crash) +// ============================================================================ + +TEST_F(LiveKitBridgeTest, SetAndClearAudioCallbackBeforeConnectDoesNotCrash) { + LiveKitBridge bridge; + + EXPECT_NO_THROW({ + bridge.setOnAudioFrameCallback("remote-participant", + livekit::TrackSource::SOURCE_MICROPHONE, + [](const livekit::AudioFrame &) {}); + + bridge.clearOnAudioFrameCallback("remote-participant", + livekit::TrackSource::SOURCE_MICROPHONE); + }) << "set/clear audio callback before connect should be safe (warns)"; +} + +TEST_F(LiveKitBridgeTest, SetAndClearVideoCallbackBeforeConnectDoesNotCrash) { + LiveKitBridge bridge; + + EXPECT_NO_THROW({ + bridge.setOnVideoFrameCallback( + "remote-participant", livekit::TrackSource::SOURCE_CAMERA, + [](const livekit::VideoFrame &, std::int64_t) {}); + + bridge.clearOnVideoFrameCallback("remote-participant", + livekit::TrackSource::SOURCE_CAMERA); + }) << "set/clear video callback before connect should be safe (warns)"; +} + +TEST_F(LiveKitBridgeTest, ClearNonExistentCallbackIsNoOp) { + LiveKitBridge bridge; + + EXPECT_NO_THROW({ + bridge.clearOnAudioFrameCallback("nonexistent", + livekit::TrackSource::SOURCE_MICROPHONE); + bridge.clearOnVideoFrameCallback("nonexistent", + livekit::TrackSource::SOURCE_CAMERA); + }) << "Clearing a callback that was never registered should be a no-op"; +} + +} // namespace test +} // namespace livekit_bridge diff --git a/examples/bridge_human_robot/human.cpp b/examples/bridge_human_robot/human.cpp index 81989eb5..d5e66695 100644 --- a/examples/bridge_human_robot/human.cpp +++ b/examples/bridge_human_robot/human.cpp @@ -16,20 +16,22 @@ /* * Human example -- receives audio and video frames from a robot in a - * LiveKit room and renders them using SDL3. + * LiveKit room and renders them using SDL3. Also receives data track + * messages ("robot-status") and prints them to stdout. * * This example demonstrates the base SDK's convenience frame callback API * (Room::setOnAudioFrameCallback / Room::setOnVideoFrameCallback) which * eliminates the need for a RoomDelegate subclass, manual AudioStream/ * VideoStream creation, and reader threads. * - * The robot publishes two video tracks and two audio tracks: + * The robot publishes two video tracks, two audio tracks, and one data track: * - "robot-cam" (SOURCE_CAMERA) -- webcam or placeholder * - "robot-sim-frame" (SOURCE_SCREENSHARE) -- simulated diagnostic * frame * - "robot-mic" (SOURCE_MICROPHONE) -- real microphone or * silence * - "robot-sim-audio" (SOURCE_SCREENSHARE_AUDIO) -- simulated siren tone + * - "robot-status" (data track) -- periodic status string * * Press 'w' to play the webcam feed + real mic, or 's' for sim frame + siren. * The selection controls both video and audio simultaneously. @@ -59,10 +61,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -272,7 +276,17 @@ int main(int argc, char *argv[]) { } }); - // ----- Stdin input thread ----- + // ----- set data callback ----- + bridge.setOnDataFrameCallback( + "robot", "robot-status", + [](const std::vector &payload, + std::optional /*user_timestamp*/) { + std::string msg(payload.begin(), payload.end()); + std::cout << "[human] Data from robot: " << msg << "\n"; + }); + + // ----- Stdin input thread (for switching when the SDL window is not focused) + // ----- std::thread input_thread([&]() { std::string line; while (g_running.load() && std::getline(std::cin, line)) { diff --git a/examples/bridge_human_robot/robot.cpp b/examples/bridge_human_robot/robot.cpp index 041580ef..5dd4f23c 100644 --- a/examples/bridge_human_robot/robot.cpp +++ b/examples/bridge_human_robot/robot.cpp @@ -16,7 +16,8 @@ /* * Robot example -- streams real webcam video and microphone audio to a - * LiveKit room using SDL3 for hardware capture. + * LiveKit room using SDL3 for hardware capture, and publishes a data + * track ("robot-status") that sends a status string once per second. * * Usage: * robot [--no-mic] @@ -30,7 +31,8 @@ * --join --room my-room --identity robot \ * --valid-for 24h * - * Run alongside the "human" example (which displays the robot's feed). + * Run alongside the "human" example (which displays the robot's feed + * and prints received data messages). */ #include "livekit/audio_frame.h" @@ -389,10 +391,13 @@ int main(int argc, char *argv[]) { auto sim_cam = bridge.createVideoTrack("robot-sim-frame", kSimWidth, kSimHeight, livekit::TrackSource::SOURCE_SCREENSHARE); + + auto data_track = bridge.createDataTrack("robot-status"); + LK_LOG_INFO("[robot] Publishing {} sim audio ({} Hz, {} ch), cam + sim frame " - "({}x{} / {}x{}).", + "({}x{} / {}x{}), data track.", use_mic ? "mic + " : "(no mic) ", kSampleRate, kChannels, kWidth, - kHeight, kSimWidth, kSimHeight); + kHeight, kSimWidth, kSimHeight, data_track->name()); // ----- SDL Mic capture (only when use_mic) ----- // SDLMicSource pulls 10ms frames from the default recording device and @@ -618,6 +623,27 @@ int main(int argc, char *argv[]) { }); LK_LOG_INFO("[robot] Sim audio (siren) track started."); + // ----- Data track: send a status string once per second ----- + std::atomic data_running{true}; + std::thread data_thread([&]() { + std::uint64_t seq = 0; + auto start = std::chrono::steady_clock::now(); + while (data_running.load()) { + auto elapsed_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(); + std::string msg = "robot status #" + std::to_string(seq) + + " uptime=" + std::to_string(elapsed_ms) + "ms"; + std::vector payload(msg.begin(), msg.end()); + if (!data_track->pushFrame(payload)) { + break; + } + ++seq; + std::this_thread::sleep_for(std::chrono::seconds(1)); + } + }); + std::cout << "[robot] Data track (robot-status) started.\n"; + // ----- Main loop: keep alive + pump SDL events ----- LK_LOG_INFO("[robot] Streaming... press Ctrl-C to stop."); @@ -638,6 +664,7 @@ int main(int argc, char *argv[]) { cam_running.store(false); sim_running.store(false); sim_audio_running.store(false); + data_running.store(false); if (mic_thread.joinable()) mic_thread.join(); if (cam_thread.joinable()) @@ -646,6 +673,8 @@ int main(int argc, char *argv[]) { sim_thread.join(); if (sim_audio_thread.joinable()) sim_audio_thread.join(); + if (data_thread.joinable()) + data_thread.join(); sdl_mic.reset(); sdl_cam.reset(); @@ -653,6 +682,7 @@ int main(int argc, char *argv[]) { sim_audio.reset(); cam.reset(); sim_cam.reset(); + data_track.reset(); bridge.disconnect(); SDL_Quit(); diff --git a/examples/realsense-livekit/realsense-to-mcap/.gitignore b/examples/realsense-livekit/realsense-to-mcap/.gitignore new file mode 100644 index 00000000..68d0afad --- /dev/null +++ b/examples/realsense-livekit/realsense-to-mcap/.gitignore @@ -0,0 +1,3 @@ +build/ +external/ +generated/ diff --git a/examples/realsense-livekit/realsense-to-mcap/BuildFileDescriptorSet.cpp b/examples/realsense-livekit/realsense-to-mcap/BuildFileDescriptorSet.cpp new file mode 100644 index 00000000..33dcfdea --- /dev/null +++ b/examples/realsense-livekit/realsense-to-mcap/BuildFileDescriptorSet.cpp @@ -0,0 +1,40 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "BuildFileDescriptorSet.h" + +#include +#include + +google::protobuf::FileDescriptorSet BuildFileDescriptorSet( + const google::protobuf::Descriptor* toplevelDescriptor) { + google::protobuf::FileDescriptorSet fdSet; + std::queue toAdd; + toAdd.push(toplevelDescriptor->file()); + std::unordered_set seenDependencies; + while (!toAdd.empty()) { + const google::protobuf::FileDescriptor* next = toAdd.front(); + toAdd.pop(); + next->CopyTo(fdSet.add_file()); + for (int i = 0; i < next->dependency_count(); ++i) { + const auto* dep = next->dependency(i); + const std::string depName(dep->name()); + if (seenDependencies.find(depName) == seenDependencies.end()) { + seenDependencies.insert(depName); + toAdd.push(dep); + } + } + } + return fdSet; +} diff --git a/examples/realsense-livekit/realsense-to-mcap/BuildFileDescriptorSet.h b/examples/realsense-livekit/realsense-to-mcap/BuildFileDescriptorSet.h new file mode 100644 index 00000000..5d7b7d67 --- /dev/null +++ b/examples/realsense-livekit/realsense-to-mcap/BuildFileDescriptorSet.h @@ -0,0 +1,23 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include + +#include + +google::protobuf::FileDescriptorSet BuildFileDescriptorSet( + const google::protobuf::Descriptor* toplevelDescriptor); diff --git a/examples/realsense-livekit/realsense-to-mcap/CMakeLists.txt b/examples/realsense-livekit/realsense-to-mcap/CMakeLists.txt new file mode 100644 index 00000000..3d20c9c5 --- /dev/null +++ b/examples/realsense-livekit/realsense-to-mcap/CMakeLists.txt @@ -0,0 +1,143 @@ +# Copyright 2026 LiveKit, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +cmake_minimum_required(VERSION 3.16) +project(realsense_mcap) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) + +find_package(Protobuf REQUIRED CONFIG) +find_package(realsense2 REQUIRED) +find_package(PkgConfig REQUIRED) +pkg_check_modules(ZSTD REQUIRED IMPORTED_TARGET libzstd) +pkg_check_modules(LZ4 REQUIRED IMPORTED_TARGET liblz4) +find_package(ZLIB REQUIRED) + +set(REALSENSE_MCAP_DIR ${CMAKE_CURRENT_SOURCE_DIR}) +add_executable(realsense_to_mcap + ${REALSENSE_MCAP_DIR}/src/realsense_to_mcap.cpp + ${REALSENSE_MCAP_DIR}/BuildFileDescriptorSet.cpp + ${REALSENSE_MCAP_DIR}/generated/foxglove/RawImage.pb.cc +) + +target_include_directories(realsense_to_mcap PRIVATE + ${realsense2_INCLUDE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/external/mcap/cpp/mcap/include + ${CMAKE_CURRENT_SOURCE_DIR}/generated + ${CMAKE_CURRENT_SOURCE_DIR} +) + +target_link_libraries(realsense_to_mcap + protobuf::libprotobuf + ${realsense2_LIBRARY} + PkgConfig::ZSTD + PkgConfig::LZ4 +) + +# LiveKit participants: auto-detect SDK build when this tree lives under client-sdk-cpp (e.g. examples/realsense-livekit/realsense-to-mcap) +set(LiveKitBuild_DIR "" CACHE PATH "Path to LiveKit SDK build directory (optional; auto-detected when under client-sdk-cpp)") +if(NOT LiveKitBuild_DIR) + get_filename_component(SDK_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../.." REALPATH) + if(EXISTS "${SDK_ROOT}/build-release/lib") + set(LiveKitBuild_DIR "${SDK_ROOT}/build-release") + elseif(EXISTS "${SDK_ROOT}/build/lib") + set(LiveKitBuild_DIR "${SDK_ROOT}/build") + endif() +endif() + +get_filename_component(LiveKitSource_DIR "${LiveKitBuild_DIR}" DIRECTORY) +set(LIVEKIT_LIB_DIR "${LiveKitBuild_DIR}/lib") +if(NOT LiveKitBuild_DIR OR NOT EXISTS "${LIVEKIT_LIB_DIR}") + message(FATAL_ERROR "LiveKit SDK build not found. Build the client-sdk-cpp repo from its root (e.g. ./build.sh release), or set -DLiveKitBuild_DIR=.") +endif() +find_library(LIVEKIT_BRIDGE_LIB livekit_bridge PATHS "${LIVEKIT_LIB_DIR}" NO_DEFAULT_PATH) +find_library(LIVEKIT_LIB livekit PATHS "${LIVEKIT_LIB_DIR}" NO_DEFAULT_PATH) +if(NOT LIVEKIT_BRIDGE_LIB) + message(FATAL_ERROR "Could not find liblivekit_bridge in ${LIVEKIT_LIB_DIR}. Build the SDK bridge first.") +endif() +if(NOT LIVEKIT_LIB) + message(FATAL_ERROR "Could not find liblivekit in ${LIVEKIT_LIB_DIR}.") +endif() +set(LIVEKIT_BRIDGE_INCLUDE "${LiveKitSource_DIR}/bridge/include") +set(LIVEKIT_INCLUDE "${LiveKitSource_DIR}/include") +if(NOT EXISTS "${LIVEKIT_BRIDGE_INCLUDE}/livekit_bridge/livekit_bridge.h") + message(FATAL_ERROR "LiveKit bridge headers not found at ${LIVEKIT_BRIDGE_INCLUDE}") +endif() + +# realsense_rgbd: captures RealSense, publishes RGB as video + depth/pose as DataTracks +add_executable(realsense_rgbd + ${REALSENSE_MCAP_DIR}/src/realsense_rgbd.cpp + ${REALSENSE_MCAP_DIR}/BuildFileDescriptorSet.cpp + ${REALSENSE_MCAP_DIR}/generated/foxglove/RawImage.pb.cc + ${REALSENSE_MCAP_DIR}/generated/foxglove/PoseInFrame.pb.cc + ${REALSENSE_MCAP_DIR}/generated/foxglove/Pose.pb.cc + ${REALSENSE_MCAP_DIR}/generated/foxglove/Quaternion.pb.cc + ${REALSENSE_MCAP_DIR}/generated/foxglove/Vector3.pb.cc + ) + target_include_directories(realsense_rgbd PRIVATE + ${realsense2_INCLUDE_DIR} + ${LIVEKIT_BRIDGE_INCLUDE} + ${LIVEKIT_INCLUDE} + ${CMAKE_CURRENT_SOURCE_DIR}/generated + ${CMAKE_CURRENT_SOURCE_DIR} + ) + target_link_directories(realsense_rgbd PRIVATE "${LIVEKIT_LIB_DIR}") + target_link_libraries(realsense_rgbd PRIVATE + ${LIVEKIT_BRIDGE_LIB} + ${LIVEKIT_LIB} + ${realsense2_LIBRARY} + protobuf::libprotobuf + ZLIB::ZLIB + ) + if(UNIX AND NOT APPLE) + # Embed SDK lib path so the loader finds liblivekit_bridge.so and liblivekit.so at runtime + set_target_properties(realsense_rgbd PROPERTIES + BUILD_RPATH "${LIVEKIT_LIB_DIR}" + INSTALL_RPATH "${LIVEKIT_LIB_DIR}" + BUILD_WITH_INSTALL_RPATH TRUE + ) + endif() + + # rgbd_viewer: subscribes to realsense_rgbd video + data track, writes MCAP + add_executable(rgbd_viewer + ${REALSENSE_MCAP_DIR}/src/rgbd_viewer.cpp + ${REALSENSE_MCAP_DIR}/BuildFileDescriptorSet.cpp + ${REALSENSE_MCAP_DIR}/generated/foxglove/RawImage.pb.cc + ) + target_include_directories(rgbd_viewer PRIVATE + ${LIVEKIT_BRIDGE_INCLUDE} + ${LIVEKIT_INCLUDE} + ${CMAKE_CURRENT_SOURCE_DIR}/external/mcap/cpp/mcap/include + ${CMAKE_CURRENT_SOURCE_DIR}/generated + ${CMAKE_CURRENT_SOURCE_DIR} + ) + target_link_directories(rgbd_viewer PRIVATE "${LIVEKIT_LIB_DIR}") + target_link_libraries(rgbd_viewer PRIVATE + ${LIVEKIT_BRIDGE_LIB} + ${LIVEKIT_LIB} + protobuf::libprotobuf + PkgConfig::ZSTD + PkgConfig::LZ4 + ZLIB::ZLIB + ) + if(UNIX AND NOT APPLE) + set_target_properties(rgbd_viewer PROPERTIES + BUILD_RPATH "${LIVEKIT_LIB_DIR}" + INSTALL_RPATH "${LIVEKIT_LIB_DIR}" + BUILD_WITH_INSTALL_RPATH TRUE + ) + endif() +message(STATUS "LiveKit participants: realsense_rgbd, rgbd_viewer (LiveKitBuild_DIR=${LiveKitBuild_DIR})") diff --git a/examples/realsense-livekit/realsense-to-mcap/README.md b/examples/realsense-livekit/realsense-to-mcap/README.md new file mode 100644 index 00000000..a54594ab --- /dev/null +++ b/examples/realsense-livekit/realsense-to-mcap/README.md @@ -0,0 +1,130 @@ +# RealSense RGB-D → MCAP (Foxglove Protobuf, No ROS) + +This project records RGB and depth frames from an **Intel RealSense D415** and writes them directly to an MCAP file using Foxglove Protobuf schemas — **without using ROS**. + +The resulting `.mcap` file opens directly in Foxglove Studio with working Image panels. + +--- + +## Overview + +This example: + +- Captures RGB (`rgb8`) and depth (`16UC1`) frames +- Serializes frames using `foxglove.Image` (Protobuf) +- Writes messages to MCAP with ZSTD compression +- Produces a file immediately viewable in Foxglove + +Architecture: +RealSense D415 +↓ +librealsense2 +↓ +foxglove.Image (protobuf) +↓ +MCAP writer +↓ +realsense_rgbd.mcap + + +__NOTE: This has only been tested on Linux and MacOS.__ + +__NOTE: This has only been tested with a D415 camera.__ + +--- + +## Dependencies + +This project uses: + +- Intel RealSense D415 +- librealsense2 +- Protobuf +- MCAP C++ library +- Foxglove protobuf schemas +- Foxglove Studio + +--- + +## Install Dependencies + +### System packages + +**Ubuntu / Debian** + +```bash +sudo apt install librealsense2-dev protobuf-compiler libprotobuf-dev \ + libzstd-dev liblz4-dev zlib1g-dev +``` + +**macOS (Homebrew)** + +```bash +brew install librealsense protobuf zstd lz4 pkg-config +``` + +> zlib ships with macOS and does not need to be installed separately. + +### Clone externals and generate protobuf files + +A setup script handles cloning the MCAP C++ library and the Foxglove SDK +(for proto schemas), then runs `protoc` to generate C++ sources: + +```bash +# From anywhere — the script locates paths relative to itself +../setup_realsense.sh +``` + +This populates `external/` (mcap, foxglove-sdk) and `generated/` (protobuf +C++ sources). Both directories are gitignored. The script is idempotent and +safe to re-run. + +## Build + +All executables are built into `build/bin/`. + +1. Build the LiveKit C++ SDK (including the bridge) from the **client-sdk-cpp** repo root first (e.g. `./build.sh release`). This project links against that build. +2. From this directory: + +```bash +mkdir build +cd build +cmake .. +make -j +``` + +When this project lives under `client-sdk-cpp/examples/realsense-livekit/realsense-to-mcap/`, the SDK build directory is auto-detected (`build-release` or `build` at repo root). Otherwise set it explicitly: + +```bash +cmake -DLiveKitBuild_DIR=/path/to/client-sdk-cpp/build-release .. +``` + +### Executables (in `build/bin/`) + +| Binary | Description | +|--------|-------------| +| `realsense_to_mcap` | Standalone recorder: captures RealSense and writes RGB + depth to an MCAP file. | +| `realsense_rgbd` | LiveKit publisher: captures RealSense, publishes RGB as video and depth as DataTrack (identity `realsense_rgbd`). | +| `rgbd_viewer` | LiveKit subscriber: subscribes to that video + data track and writes to MCAP (identity `rgbd_viewer`). | + +Run the standalone recorder: + +```bash +./bin/realsense_to_mcap +``` + +This produces `realsense_rgbd.mcap` in the current directory. + +--- + +## LiveKit participants + +To stream RGB+D over a LiveKit room and record on the viewer side: + +1. Start **realsense_rgbd** (publisher), then **rgbd_viewer** (subscriber), using the same room URL and tokens with identities `realsense_rgbd` and `rgbd_viewer`. +2. Run from the build directory: + +```bash +./bin/realsense_rgbd +./bin/rgbd_viewer [output.mcap] +``` diff --git a/examples/realsense-livekit/realsense-to-mcap/src/realsense_rgbd.cpp b/examples/realsense-livekit/realsense-to-mcap/src/realsense_rgbd.cpp new file mode 100644 index 00000000..f2d45029 --- /dev/null +++ b/examples/realsense-livekit/realsense-to-mcap/src/realsense_rgbd.cpp @@ -0,0 +1,495 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/* + * realsense_rgbd — LiveKit participant that captures RealSense RGB+D frames, + * publishes RGB as a video track, depth as a DataTrack (foxglove.RawImage), + * and IMU-derived orientation as a DataTrack (foxglove.PoseInFrame). + * + * If the RealSense device has an IMU (e.g. D435i), gyroscope and + * accelerometer data are fused via a complementary filter to produce a + * camera/pose topic. Devices without an IMU skip pose publishing. + * + * Usage: + * realsense_rgbd + * LIVEKIT_URL=... LIVEKIT_TOKEN=... realsense_rgbd + * + * Token must grant identity "realsense_rgbd". Run rgbd_viewer in the same room + * to receive and record to MCAP. + */ + +#include "livekit_bridge/bridge_data_track.h" +#include "livekit_bridge/livekit_bridge.h" +#include "livekit/track.h" + +#include "BuildFileDescriptorSet.h" +#include "foxglove/PoseInFrame.pb.h" +#include "foxglove/RawImage.pb.h" + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static volatile std::sig_atomic_t g_running = 1; +static void signalHandler(int) { g_running = 0; } + +static uint64_t nowNs() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); +} + +static std::string nowStr() { + auto now = std::chrono::system_clock::now(); + auto ms = std::chrono::duration_cast( + now.time_since_epoch()) % 1000; + std::time_t t = std::chrono::system_clock::to_time_t(now); + std::tm tm{}; + localtime_r(&t, &tm); + std::ostringstream os; + os << std::put_time(&tm, "%H:%M:%S") << '.' << std::setfill('0') + << std::setw(3) << ms.count(); + return os.str(); +} + +struct OrientationQuat { + double x, y, z, w; +}; + +/// Convert intrinsic XYZ Euler angles (radians) to a unit quaternion. +static OrientationQuat eulerToQuaternion(double x, double y, double z) { + double cx = std::cos(x / 2), sx = std::sin(x / 2); + double cy = std::cos(y / 2), sy = std::sin(y / 2); + double cz = std::cos(z / 2), sz = std::sin(z / 2); + return { + sx * cy * cz + cx * sy * sz, + cx * sy * cz - sx * cy * sz, + cx * cy * sz + sx * sy * cz, + cx * cy * cz - sx * sy * sz, + }; +} + +/// Fuse gyroscope + accelerometer into an orientation estimate using a +/// complementary filter. Adapted from the librealsense motion example. +class RotationEstimator { +public: + void process_gyro(rs2_vector gyro_data, double ts) { + if (first_gyro_) { + first_gyro_ = false; + last_ts_gyro_ = ts; + return; + } + float dt = static_cast((ts - last_ts_gyro_) / 1000.0); + last_ts_gyro_ = ts; + + std::lock_guard lock(mtx_); + theta_x_ += -gyro_data.z * dt; + theta_y_ += -gyro_data.y * dt; + theta_z_ += gyro_data.x * dt; + } + + void process_accel(rs2_vector accel_data) { + float accel_angle_x = std::atan2(accel_data.x, + std::sqrt(accel_data.y * accel_data.y + + accel_data.z * accel_data.z)); + float accel_angle_z = std::atan2(accel_data.y, accel_data.z); + + std::lock_guard lock(mtx_); + if (first_accel_) { + first_accel_ = false; + theta_x_ = accel_angle_x; + theta_y_ = static_cast(M_PI); + theta_z_ = accel_angle_z; + return; + } + theta_x_ = theta_x_ * kAlpha + accel_angle_x * (1.0f - kAlpha); + theta_z_ = theta_z_ * kAlpha + accel_angle_z * (1.0f - kAlpha); + } + + OrientationQuat get_orientation() const { + std::lock_guard lock(mtx_); + return eulerToQuaternion( + static_cast(theta_x_), + static_cast(theta_y_), + static_cast(theta_z_)); + } + +private: + static constexpr float kAlpha = 0.98f; + mutable std::mutex mtx_; + float theta_x_ = 0, theta_y_ = 0, theta_z_ = 0; + bool first_gyro_ = true; + bool first_accel_ = true; + double last_ts_gyro_ = 0; +}; + +/// Convert RGB8 to RGBA (alpha = 0xFF). Assumes dst has size width*height*4. +static void rgb8ToRgba(const std::uint8_t* rgb, std::uint8_t* rgba, + int width, int height) { + const int rgbStep = width * 3; + const int rgbaStep = width * 4; + for (int y = 0; y < height; ++y) { + const std::uint8_t* src = rgb + y * rgbStep; + std::uint8_t* dst = rgba + y * rgbaStep; + for (int x = 0; x < width; ++x) { + *dst++ = *src++; + *dst++ = *src++; + *dst++ = *src++; + *dst++ = 0xFF; + } + } +} + +int main(int argc, char* argv[]) { + GOOGLE_PROTOBUF_VERIFY_VERSION; + + std::signal(SIGINT, signalHandler); + std::signal(SIGTERM, signalHandler); + + std::string url; + std::string token; + const char* env_url = std::getenv("LIVEKIT_URL"); + const char* env_token = std::getenv("LIVEKIT_TOKEN"); + if (argc >= 3) { + url = argv[1]; + token = argv[2]; + } else if (env_url && env_token) { + url = env_url; + token = env_token; + } else { + std::cerr << "Usage: realsense_rgbd \n" + " or: LIVEKIT_URL=... LIVEKIT_TOKEN=... realsense_rgbd\n"; + return 1; + } + + const int kWidth = 640; + const int kHeight = 480; + const int kDepthFps = 10; // data track depth rate (Hz); limited by SCTP throughput + const int kPoseFps = 30; + + RotationEstimator rotation_est; + + // Check for IMU support (following rs-motion.cpp pattern). + bool imu_supported = false; + { + rs2::context ctx; + auto devices = ctx.query_devices(); + for (auto dev : devices) { + bool found_gyro = false, found_accel = false; + for (auto sensor : dev.query_sensors()) { + for (auto profile : sensor.get_stream_profiles()) { + if (profile.stream_type() == RS2_STREAM_GYRO) + found_gyro = true; + if (profile.stream_type() == RS2_STREAM_ACCEL) + found_accel = true; + } + } + if (found_gyro && found_accel) { + imu_supported = true; + break; + } + } + } + + // Color+depth pipeline. + rs2::pipeline pipe; + rs2::config cfg; + cfg.enable_stream(RS2_STREAM_COLOR, kWidth, kHeight, RS2_FORMAT_RGB8, 30); + cfg.enable_stream(RS2_STREAM_DEPTH, kWidth, kHeight, RS2_FORMAT_Z16, 30); + try { + pipe.start(cfg); + } catch (const rs2::error& e) { + std::cerr << "RealSense error: " << e.what() << "\n"; + return 1; + } + + // Separate IMU-only pipeline with callback, mirroring rs-motion.cpp. + // A dedicated pipeline for gyro+accel avoids interfering with the + // color+depth pipeline's frame syncer. + rs2::pipeline imu_pipe; + bool has_imu = false; + if (imu_supported) { + rs2::config imu_cfg; + imu_cfg.enable_stream(RS2_STREAM_ACCEL, RS2_FORMAT_MOTION_XYZ32F); + imu_cfg.enable_stream(RS2_STREAM_GYRO, RS2_FORMAT_MOTION_XYZ32F); + try { + imu_pipe.start(imu_cfg, [&rotation_est](rs2::frame frame) { + auto motion = frame.as(); + if (motion && + motion.get_profile().stream_type() == RS2_STREAM_GYRO && + motion.get_profile().format() == RS2_FORMAT_MOTION_XYZ32F) { + rotation_est.process_gyro(motion.get_motion_data(), + motion.get_timestamp()); + } + if (motion && + motion.get_profile().stream_type() == RS2_STREAM_ACCEL && + motion.get_profile().format() == RS2_FORMAT_MOTION_XYZ32F) { + rotation_est.process_accel(motion.get_motion_data()); + } + }); + has_imu = true; + std::cout << "[realsense_rgbd] IMU pipeline started.\n"; + } catch (const rs2::error& e) { + std::cerr << "[realsense_rgbd] Could not start IMU pipeline: " + << e.what() << " — continuing without pose.\n"; + } + } else { + std::cout << "[realsense_rgbd] IMU not available, continuing without " + "pose.\n"; + } + + livekit_bridge::LiveKitBridge bridge; + livekit::RoomOptions options; + options.auto_subscribe = false; + + std::cout << "[realsense_rgbd] Connecting to " << url << " ...\n"; + if (!bridge.connect(url, token, options)) { + std::cerr << "[realsense_rgbd] Failed to connect.\n"; + pipe.stop(); + return 1; + } + + std::shared_ptr video_track; + std::shared_ptr depth_track; + std::shared_ptr pose_track; + std::shared_ptr hello_track; + + try { + video_track = bridge.createVideoTrack("camera/color", kWidth, kHeight, + livekit::TrackSource::SOURCE_CAMERA); + depth_track = bridge.createDataTrack("camera/depth"); + if (has_imu) { + pose_track = bridge.createDataTrack("camera/pose"); + } + hello_track = bridge.createDataTrack("hello"); + } catch (const std::exception& e) { + std::cerr << "[realsense_rgbd] Failed to create tracks: " << e.what() + << "\n"; + bridge.disconnect(); + pipe.stop(); + return 1; + } + + std::cout << "[realsense_rgbd] Publishing camera/color (video), " + "camera/depth (DataTrack)" + << (has_imu ? ", camera/pose (DataTrack)" : "") + << ", and hello (DataTrack). Press Ctrl+C to stop.\n"; + + std::vector rgbaBuf(static_cast(kWidth * kHeight * 4)); + + uint32_t hello_seq = 0; + uint32_t depth_pushed = 0; + uint32_t pose_pushed = 0; + uint32_t last_depth_report_count = 0; + auto last_hello = std::chrono::steady_clock::now(); + auto last_depth = std::chrono::steady_clock::now(); + auto last_pose = std::chrono::steady_clock::now(); + auto last_depth_report = std::chrono::steady_clock::now(); + const auto depth_interval = + std::chrono::microseconds(1000000 / kDepthFps); + const auto pose_interval = + std::chrono::microseconds(1000000 / kPoseFps); + + constexpr auto kMinLoopDuration = std::chrono::milliseconds(15); + + while (g_running) { + auto loop_start = std::chrono::steady_clock::now(); + + // Periodic "hello viewer" test message every 10 seconds + if (loop_start - last_hello >= std::chrono::seconds(1)) { + last_hello = loop_start; + ++hello_seq; + std::string text = "hello viewer #" + std::to_string(hello_seq); + uint64_t ts_us = static_cast(nowNs() / 1000); + bool ok = hello_track->pushFrame( + reinterpret_cast(text.data()), text.size(), + ts_us); + std::cout << "[" << nowStr() << "] [realsense_rgbd] Sent hello #" + << hello_seq << " (" << text.size() << " bytes) -> " + << (ok ? "ok" : "FAILED") << "\n"; + } + + rs2::frameset frames; + if (!pipe.poll_for_frames(&frames)) { + auto elapsed = std::chrono::steady_clock::now() - loop_start; + if (elapsed < kMinLoopDuration) + std::this_thread::sleep_for(kMinLoopDuration - elapsed); + continue; + } + + auto color = frames.get_color_frame(); + auto depth = frames.get_depth_frame(); + if (!color || !depth) { + auto elapsed = std::chrono::steady_clock::now() - loop_start; + if (elapsed < kMinLoopDuration) + std::this_thread::sleep_for(kMinLoopDuration - elapsed); + continue; + } + + const uint64_t timestamp_ns = nowNs(); + const std::int64_t timestamp_us = + static_cast(timestamp_ns / 1000); + const int64_t secs = static_cast(timestamp_ns / 1000000000ULL); + const int32_t nsecs = static_cast(timestamp_ns % 1000000000ULL); + + // RGB → RGBA and push to video track + rgb8ToRgba(static_cast(color.get_data()), + rgbaBuf.data(), kWidth, kHeight); + if (!video_track->pushFrame(rgbaBuf.data(), rgbaBuf.size(), timestamp_us)) { + break; + } + + // Depth as RawImage proto on DataTrack (throttled to kDepthFps) + if (loop_start - last_depth >= depth_interval) { + last_depth = loop_start; + + foxglove::RawImage msg; + auto* ts = msg.mutable_timestamp(); + ts->set_seconds(secs); + ts->set_nanos(nsecs); + msg.set_frame_id("camera_depth"); + msg.set_width(depth.get_width()); + msg.set_height(depth.get_height()); + msg.set_encoding("16UC1"); + msg.set_step(depth.get_width() * 2); + msg.set_data(depth.get_data(), depth.get_data_size()); + + std::string serialized = msg.SerializeAsString(); + + uLongf comp_bound = compressBound(static_cast(serialized.size())); + std::vector compressed(comp_bound); + uLongf comp_size = comp_bound; + int zrc = compress2( + compressed.data(), &comp_size, + reinterpret_cast(serialized.data()), + static_cast(serialized.size()), Z_BEST_SPEED); + + auto push_start = std::chrono::steady_clock::now(); + bool ok = false; + if (zrc == Z_OK) { + ok = depth_track->pushFrame( + compressed.data(), static_cast(comp_size), + static_cast(timestamp_us)); + } else { + std::cerr << "[realsense_rgbd] zlib compress failed (" << zrc + << "), sending uncompressed\n"; + ok = depth_track->pushFrame( + reinterpret_cast(serialized.data()), + serialized.size(), static_cast(timestamp_us)); + } + auto push_dur = std::chrono::steady_clock::now() - push_start; + double push_ms = + std::chrono::duration_cast(push_dur) + .count() / 1000.0; + + ++depth_pushed; + if (!ok) { + std::cout << "[" << nowStr() + << "] [realsense_rgbd] Failed to push depth frame #" + << depth_pushed << " (push took " << std::fixed + << std::setprecision(1) << push_ms << "ms)\n"; + break; + } + if (depth_pushed == 1 || depth_pushed % 10 == 0) { + double elapsed_sec = + std::chrono::duration_cast( + loop_start - last_depth_report) + .count() / 1000.0; + double actual_fps = + (elapsed_sec > 0) + ? static_cast(depth_pushed - last_depth_report_count) / + elapsed_sec + : 0; + std::cout << "[" << nowStr() + << "] [realsense_rgbd] Depth #" << depth_pushed + << " push=" << std::fixed << std::setprecision(1) << push_ms + << "ms " << serialized.size() << "B->" + << (zrc == Z_OK ? comp_size : serialized.size()) << "B" + << " actual=" << std::setprecision(1) << actual_fps + << "fps\n"; + last_depth_report = loop_start; + last_depth_report_count = depth_pushed; + } + } + + // Pose as PoseInFrame proto on DataTrack (throttled to kPoseFps) + if (has_imu && pose_track && (loop_start - last_pose >= pose_interval)) { + last_pose = loop_start; + + auto orientation = rotation_est.get_orientation(); + + foxglove::PoseInFrame pose_msg; + auto* pose_ts = pose_msg.mutable_timestamp(); + pose_ts->set_seconds(secs); + pose_ts->set_nanos(nsecs); + pose_msg.set_frame_id("camera_imu"); + + auto* pose = pose_msg.mutable_pose(); + auto* pos = pose->mutable_position(); + pos->set_x(0); + pos->set_y(0); + pos->set_z(0); + auto* orient = pose->mutable_orientation(); + orient->set_x(orientation.x); + orient->set_y(orientation.y); + orient->set_z(orientation.z); + orient->set_w(orientation.w); + + std::string pose_serialized = pose_msg.SerializeAsString(); + bool pose_ok = pose_track->pushFrame( + reinterpret_cast(pose_serialized.data()), + pose_serialized.size(), static_cast(timestamp_us)); + ++pose_pushed; + if (!pose_ok) { + std::cout << "[" << nowStr() + << "] [realsense_rgbd] Failed to push pose frame #" + << pose_pushed << "\n"; + } + if (pose_pushed == 1 || pose_pushed % 100 == 0) { + std::cout << "[" << nowStr() + << "] [realsense_rgbd] Pose #" << pose_pushed + << " " << pose_serialized.size() << "B" + << " q=(" << std::fixed << std::setprecision(3) + << orientation.x << ", " << orientation.y << ", " + << orientation.z << ", " << orientation.w << ")\n"; + } + } + + auto elapsed = std::chrono::steady_clock::now() - loop_start; + if (elapsed < kMinLoopDuration) { + std::this_thread::sleep_for(kMinLoopDuration - elapsed); + } + } + + std::cout << "[realsense_rgbd] Stopping...\n"; + bridge.disconnect(); + if (has_imu) imu_pipe.stop(); + pipe.stop(); + google::protobuf::ShutdownProtobufLibrary(); + return 0; +} diff --git a/examples/realsense-livekit/realsense-to-mcap/src/realsense_to_mcap.cpp b/examples/realsense-livekit/realsense-to-mcap/src/realsense_to_mcap.cpp new file mode 100644 index 00000000..1d7b2b2c --- /dev/null +++ b/examples/realsense-livekit/realsense-to-mcap/src/realsense_to_mcap.cpp @@ -0,0 +1,159 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#define MCAP_IMPLEMENTATION +#include + +#include + +#include "BuildFileDescriptorSet.h" +#include "foxglove/RawImage.pb.h" + +#include +#include +#include + +static volatile std::sig_atomic_t g_running = 1; + +static void signalHandler(int signum) { + (void)signum; + g_running = 0; +} + +static uint64_t nowNs() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); +} + +int main() { + GOOGLE_PROTOBUF_VERIFY_VERSION; + + std::signal(SIGINT, signalHandler); + std::signal(SIGTERM, signalHandler); + + rs2::pipeline pipe; + rs2::config cfg; + cfg.enable_stream(RS2_STREAM_COLOR, 640, 480, RS2_FORMAT_RGB8, 30); + cfg.enable_stream(RS2_STREAM_DEPTH, 640, 480, RS2_FORMAT_Z16, 30); + pipe.start(cfg); + + mcap::McapWriter writer; + auto options = mcap::McapWriterOptions(""); + options.compression = mcap::Compression::Zstd; + + { + const auto res = writer.open("realsense_rgbd.mcap", options); + if (!res.ok()) { + std::cerr << "Failed to open MCAP file: " << res.message << std::endl; + return 1; + } + } + + // Register schema as a serialized FileDescriptorSet (required by MCAP protobuf profile) + mcap::Schema schema( + "foxglove.RawImage", "protobuf", + BuildFileDescriptorSet(foxglove::RawImage::descriptor()).SerializeAsString()); + writer.addSchema(schema); + + mcap::Channel colorChannel("camera/color", "protobuf", schema.id); + writer.addChannel(colorChannel); + + mcap::Channel depthChannel("camera/depth", "protobuf", schema.id); + writer.addChannel(depthChannel); + + std::cout << "Recording to realsense_rgbd.mcap ... Press Ctrl+C to stop.\n"; + + uint32_t seq = 0; + while (g_running) { + rs2::frameset frames; + if (!pipe.poll_for_frames(&frames)) { + continue; + } + + auto color = frames.get_color_frame(); + auto depth = frames.get_depth_frame(); + if (!color || !depth) { + continue; + } + + uint64_t timestamp = nowNs(); + int64_t secs = static_cast(timestamp / 1000000000ULL); + int32_t nsecs = static_cast(timestamp % 1000000000ULL); + + // Color image + { + foxglove::RawImage msg; + auto* ts = msg.mutable_timestamp(); + ts->set_seconds(secs); + ts->set_nanos(nsecs); + msg.set_frame_id("camera_color"); + msg.set_width(color.get_width()); + msg.set_height(color.get_height()); + msg.set_encoding("rgb8"); + msg.set_step(color.get_width() * 3); + msg.set_data(color.get_data(), color.get_data_size()); + + std::string serialized = msg.SerializeAsString(); + + mcap::Message mcapMsg; + mcapMsg.channelId = colorChannel.id; + mcapMsg.sequence = seq; + mcapMsg.logTime = timestamp; + mcapMsg.publishTime = timestamp; + mcapMsg.data = reinterpret_cast(serialized.data()); + mcapMsg.dataSize = serialized.size(); + const auto res = writer.write(mcapMsg); + if (!res.ok()) { + std::cerr << "Failed to write color message: " << res.message << std::endl; + } + } + + // Depth image + { + foxglove::RawImage msg; + auto* ts = msg.mutable_timestamp(); + ts->set_seconds(secs); + ts->set_nanos(nsecs); + msg.set_frame_id("camera_depth"); + msg.set_width(depth.get_width()); + msg.set_height(depth.get_height()); + msg.set_encoding("16UC1"); + msg.set_step(depth.get_width() * 2); + msg.set_data(depth.get_data(), depth.get_data_size()); + + std::string serialized = msg.SerializeAsString(); + + mcap::Message mcapMsg; + mcapMsg.channelId = depthChannel.id; + mcapMsg.sequence = seq; + mcapMsg.logTime = timestamp; + mcapMsg.publishTime = timestamp; + mcapMsg.data = reinterpret_cast(serialized.data()); + mcapMsg.dataSize = serialized.size(); + const auto res = writer.write(mcapMsg); + if (!res.ok()) { + std::cerr << "Failed to write depth message: " << res.message << std::endl; + } + } + + ++seq; + } + + std::cout << "\nStopping... wrote " << seq << " frame pairs.\n"; + writer.close(); + pipe.stop(); + google::protobuf::ShutdownProtobufLibrary(); + return 0; +} diff --git a/examples/realsense-livekit/realsense-to-mcap/src/rgbd_viewer.cpp b/examples/realsense-livekit/realsense-to-mcap/src/rgbd_viewer.cpp new file mode 100644 index 00000000..25df34ed --- /dev/null +++ b/examples/realsense-livekit/realsense-to-mcap/src/rgbd_viewer.cpp @@ -0,0 +1,477 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/* + * rgbd_viewer — LiveKit participant that subscribes to realsense_rgbd's + * video track (RGB) and data track (depth), and writes both to an MCAP file. + * + * Usage: + * rgbd_viewer [output.mcap] + * LIVEKIT_URL=... LIVEKIT_TOKEN=... rgbd_viewer [output.mcap] + * + * Token must grant identity "rgbd_viewer". Start realsense_rgbd in the same + * room first to publish camera/color and camera/depth. + */ + +#define MCAP_IMPLEMENTATION +#include + +#include "livekit/track.h" +#include "livekit/video_frame.h" +#include "livekit_bridge/livekit_bridge.h" + +#include "BuildFileDescriptorSet.h" +#include "foxglove/RawImage.pb.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static volatile std::sig_atomic_t g_running = 1; +static void signalHandler(int) { g_running = 0; } + +static std::string nowStr() { + auto now = std::chrono::system_clock::now(); + auto ms = std::chrono::duration_cast( + now.time_since_epoch()) % + 1000; + std::time_t t = std::chrono::system_clock::to_time_t(now); + std::tm tm{}; + localtime_r(&t, &tm); + std::ostringstream os; + os << std::put_time(&tm, "%H:%M:%S") << '.' << std::setfill('0') + << std::setw(3) << ms.count(); + return os.str(); +} + +static const char kSenderIdentity[] = "realsense_rgbd"; +static const char kColorTrackName[] = "camera/color"; // video track +static const char kDepthTrackName[] = "camera/depth"; // data track +static const char kHelloTrackName[] = "hello"; // test data track + +/// MCAP writer with a background write thread. +/// +/// All public write methods enqueue entries and return immediately so reader +/// callbacks are never blocked by Zstd compression or disk I/O. A dedicated +/// writer thread drains the queue and performs the actual MCAP writes. +struct McapRecorder { + mcap::McapWriter writer; + mcap::ChannelId colorChannelId = 0; + mcap::ChannelId depthChannelId = 0; + mcap::ChannelId helloChannelId = 0; + uint32_t colorSeq = 0; + uint32_t depthSeq = 0; + uint32_t helloSeq = 0; + bool open = false; + + struct WriteEntry { + enum Type { kColor, kDepth, kHello } type; + std::vector data; + std::string text; + int width = 0; + int height = 0; + std::optional user_timestamp_us; + uint64_t wall_time_ns = 0; + }; + + std::deque queue_; + std::mutex queue_mtx_; + std::condition_variable queue_cv_; + std::thread writer_thread_; + std::atomic stop_{false}; + std::atomic color_enqueue_seq_{0}; + + std::atomic depth_received{0}; + std::atomic hello_received{0}; + std::atomic last_depth_latency_us{-1}; + std::atomic last_hello_latency_us{-1}; + + static constexpr size_t kMaxQueueSize = 60; + + static uint64_t wallNs() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + } + + bool openFile(const std::string &path) { + mcap::McapWriterOptions opts(""); + opts.compression = mcap::Compression::Zstd; + auto res = writer.open(path, opts); + if (!res.ok()) { + std::cerr << "[rgbd_viewer] Failed to open MCAP: " << res.message << "\n"; + return false; + } + + mcap::Schema rawImageSchema( + "foxglove.RawImage", "protobuf", + BuildFileDescriptorSet(foxglove::RawImage::descriptor()) + .SerializeAsString()); + writer.addSchema(rawImageSchema); + + mcap::Schema textSchema("hello", "jsonschema", + R"({"type":"object","properties":{"text":{"type":"string"}}})"); + writer.addSchema(textSchema); + + mcap::Channel colorChannel("camera/color", "protobuf", rawImageSchema.id); + writer.addChannel(colorChannel); + colorChannelId = colorChannel.id; + + mcap::Channel depthChannel("camera/depth", "protobuf", rawImageSchema.id); + writer.addChannel(depthChannel); + depthChannelId = depthChannel.id; + + mcap::Channel helloChannel("hello", "json", textSchema.id); + writer.addChannel(helloChannel); + helloChannelId = helloChannel.id; + + open = true; + writer_thread_ = std::thread([this] { writerLoop(); }); + return true; + } + + // Throttle color to ~10fps to reduce memory/write pressure (video arrives + // at 30fps but each RGBA frame is ~1.2 MB before Zstd). + void writeColorFrame(const std::uint8_t *rgba, std::size_t size, int width, + int height, std::int64_t /*timestamp_us*/) { + uint32_t seq = color_enqueue_seq_.fetch_add(1, std::memory_order_relaxed); + if (seq % 3 != 0) return; + + WriteEntry e; + e.type = WriteEntry::kColor; + e.data.assign(rgba, rgba + size); + e.width = width; + e.height = height; + e.wall_time_ns = wallNs(); + enqueue(std::move(e)); + } + + void writeDepthPayload(const std::uint8_t *data, std::size_t size, + std::optional user_timestamp_us) { + WriteEntry e; + e.type = WriteEntry::kDepth; + e.data.assign(data, data + size); + e.user_timestamp_us = user_timestamp_us; + e.wall_time_ns = wallNs(); + enqueue(std::move(e)); + } + + void writeHello(const std::string &text, + std::optional user_timestamp_us) { + WriteEntry e; + e.type = WriteEntry::kHello; + e.text = text; + e.user_timestamp_us = user_timestamp_us; + e.wall_time_ns = wallNs(); + enqueue(std::move(e)); + } + + void close() { + stop_.store(true, std::memory_order_release); + queue_cv_.notify_one(); + if (writer_thread_.joinable()) + writer_thread_.join(); + if (open) { + writer.close(); + open = false; + } + } + +private: + void enqueue(WriteEntry &&e) { + std::lock_guard lock(queue_mtx_); + if (queue_.size() >= kMaxQueueSize) { + for (auto it = queue_.begin(); it != queue_.end(); ++it) { + if (it->type == WriteEntry::kColor) { + queue_.erase(it); + break; + } + } + } + queue_.push_back(std::move(e)); + } + + void writerLoop() { + while (true) { + { + std::unique_lock lock(queue_mtx_); + queue_cv_.wait_for(lock, std::chrono::seconds(1), [this] { + return stop_.load(std::memory_order_acquire); + }); + } + + std::deque batch; + { + std::lock_guard lock(queue_mtx_); + batch.swap(queue_); + } + + uint32_t nc = 0, nd = 0, nh = 0; + for (auto &e : batch) { + switch (e.type) { + case WriteEntry::kColor: + flushColor(e); + ++nc; + break; + case WriteEntry::kDepth: + flushDepth(e); + ++nd; + break; + case WriteEntry::kHello: + flushHello(e); + ++nh; + break; + } + } + + if (nc + nd + nh > 0) { + auto cr = color_enqueue_seq_.load(std::memory_order_relaxed); + auto dr = depth_received.load(std::memory_order_relaxed); + auto hr = hello_received.load(std::memory_order_relaxed); + auto dl = last_depth_latency_us.load(std::memory_order_relaxed); + auto hl = last_hello_latency_us.load(std::memory_order_relaxed); + std::cout << "[" << nowStr() << "] [rgbd_viewer] wrote " + << nc << "c " << nd << "d " << nh << "h" + << " | totals " << cr << "c " << dr << "d " << hr << "h"; + if (dl >= 0) + std::cout << " | depth_lat=" << std::fixed << std::setprecision(1) + << dl / 1000.0 << "ms"; + if (hl >= 0) + std::cout << " | hello_lat=" << std::fixed << std::setprecision(1) + << hl / 1000.0 << "ms"; + std::cout << "\n"; + } + + if (stop_.load(std::memory_order_acquire) && batch.empty()) + break; + } + } + + void flushColor(const WriteEntry &e) { + foxglove::RawImage msg; + auto *ts = msg.mutable_timestamp(); + ts->set_seconds(static_cast(e.wall_time_ns / 1000000000ULL)); + ts->set_nanos(static_cast(e.wall_time_ns % 1000000000ULL)); + msg.set_frame_id("camera_color"); + msg.set_width(e.width); + msg.set_height(e.height); + msg.set_encoding("rgba8"); + msg.set_step(e.width * 4); + msg.set_data(e.data.data(), e.data.size()); + + std::string serialized = msg.SerializeAsString(); + mcap::Message m; + m.channelId = colorChannelId; + m.sequence = colorSeq++; + m.logTime = e.wall_time_ns; + m.publishTime = e.wall_time_ns; + m.data = reinterpret_cast(serialized.data()); + m.dataSize = serialized.size(); + auto res = writer.write(m); + if (!res.ok()) { + std::cerr << "[rgbd_viewer] Write color error: " << res.message << "\n"; + } + } + + void flushDepth(const WriteEntry &e) { + const uint64_t publishTime = + (e.user_timestamp_us && *e.user_timestamp_us > 0) + ? *e.user_timestamp_us * 1000ULL + : e.wall_time_ns; + + // Depth payloads arrive zlib-compressed from the sender. Decompress + // so the MCAP file contains standard foxglove.RawImage protobuf bytes. + const std::uint8_t *write_ptr = e.data.data(); + std::size_t write_size = e.data.size(); + + // 640*480*2 (depth) + proto overhead ≈ 620 KB; 1 MB is a safe upper bound. + static constexpr uLongf kMaxDecompressed = 1024 * 1024; + std::vector decompressed(kMaxDecompressed); + uLongf decompressed_size = kMaxDecompressed; + int zrc = uncompress(decompressed.data(), &decompressed_size, + e.data.data(), + static_cast(e.data.size())); + if (zrc == Z_OK) { + write_ptr = decompressed.data(); + write_size = static_cast(decompressed_size); + } + + mcap::Message m; + m.channelId = depthChannelId; + m.sequence = depthSeq++; + m.logTime = e.wall_time_ns; + m.publishTime = publishTime; + m.data = reinterpret_cast(write_ptr); + m.dataSize = write_size; + auto res = writer.write(m); + if (!res.ok()) { + std::cerr << "[rgbd_viewer] Write depth error: " << res.message << "\n"; + } + } + + void flushHello(const WriteEntry &e) { + const uint64_t publishTime = + (e.user_timestamp_us && *e.user_timestamp_us > 0) + ? *e.user_timestamp_us * 1000ULL + : e.wall_time_ns; + + std::string json = "{\"text\":\"" + e.text + "\"}"; + mcap::Message m; + m.channelId = helloChannelId; + m.sequence = helloSeq++; + m.logTime = e.wall_time_ns; + m.publishTime = publishTime; + m.data = reinterpret_cast(json.data()); + m.dataSize = json.size(); + auto res = writer.write(m); + if (!res.ok()) { + std::cerr << "[rgbd_viewer] Write hello error: " << res.message << "\n"; + } + } +}; + +int main(int argc, char *argv[]) { + GOOGLE_PROTOBUF_VERIFY_VERSION; + + std::signal(SIGINT, signalHandler); + std::signal(SIGTERM, signalHandler); + + std::string outputPath = "rgbd_viewer_output.mcap"; + std::string url; + std::string token; + + const char *env_url = std::getenv("LIVEKIT_URL"); + const char *env_token = std::getenv("LIVEKIT_TOKEN"); + + if (argc >= 4) { + outputPath = argv[1]; + url = argv[2]; + token = argv[3]; + } else if (argc >= 3) { + url = argv[1]; + token = argv[2]; + } else if (env_url && env_token) { + url = env_url; + token = env_token; + if (argc >= 2) { + outputPath = argv[1]; + } + } else { + std::cerr << "Usage: rgbd_viewer [output.mcap] \n" + " or: LIVEKIT_URL=... LIVEKIT_TOKEN=... rgbd_viewer " + "[output.mcap]\n"; + return 1; + } + + std::shared_ptr recorder = std::make_shared(); + if (!recorder->openFile(outputPath)) { + return 1; + } + std::cout << "[rgbd_viewer] Recording to " << outputPath << "\n"; + + livekit_bridge::LiveKitBridge bridge; + + // Video callback: realsense_rgbd's camera (RGB stream as video track) + bridge.setOnVideoFrameCallback( + kSenderIdentity, livekit::TrackSource::SOURCE_CAMERA, + [recorder](const livekit::VideoFrame &frame, std::int64_t timestamp_us) { + const std::uint8_t *data = frame.data(); + const std::size_t size = frame.dataSize(); + if (data && size > 0) { + recorder->writeColorFrame(data, size, frame.width(), frame.height(), + timestamp_us); + } + }); + + // Data callback: realsense_rgbd's camera/depth (RawImage proto bytes) + bridge.setOnDataFrameCallback( + kSenderIdentity, kDepthTrackName, + [recorder](const std::vector &payload, + std::optional user_timestamp) { + if (payload.empty()) + return; + recorder->depth_received.fetch_add(1, std::memory_order_relaxed); + if (user_timestamp) { + uint64_t recv_us = + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + recorder->last_depth_latency_us.store( + static_cast(recv_us - *user_timestamp), + std::memory_order_relaxed); + } + recorder->writeDepthPayload(payload.data(), payload.size(), + user_timestamp); + }); + + // Test callback: realsense_rgbd's "hello" track (plain-text ping) + bridge.setOnDataFrameCallback( + kSenderIdentity, kHelloTrackName, + [recorder](const std::vector &payload, + std::optional user_timestamp) { + recorder->hello_received.fetch_add(1, std::memory_order_relaxed); + if (user_timestamp) { + uint64_t recv_us = + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + recorder->last_hello_latency_us.store( + static_cast(recv_us - *user_timestamp), + std::memory_order_relaxed); + } + std::string text(payload.begin(), payload.end()); + recorder->writeHello(text, user_timestamp); + }); + + std::cout << "[rgbd_viewer] Connecting to " << url << " ...\n"; + livekit::RoomOptions options; + options.auto_subscribe = true; + if (!bridge.connect(url, token, options)) { + std::cerr << "[rgbd_viewer] Failed to connect.\n"; + recorder->close(); + return 1; + } + + std::cout << "[rgbd_viewer] Connected. Waiting for " << kSenderIdentity + << " (camera/color + camera/depth). Press Ctrl+C to stop.\n"; + + while (g_running && bridge.isConnected()) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + + std::cout << "[rgbd_viewer] Stopping... (depth: " + << recorder->depth_received.load(std::memory_order_relaxed) + << ", hello: " + << recorder->hello_received.load(std::memory_order_relaxed) + << ")\n"; + bridge.disconnect(); + recorder->close(); + google::protobuf::ShutdownProtobufLibrary(); + return 0; +} diff --git a/examples/realsense-livekit/setup_realsense.sh b/examples/realsense-livekit/setup_realsense.sh new file mode 100755 index 00000000..76bfc3fe --- /dev/null +++ b/examples/realsense-livekit/setup_realsense.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# Copyright 2026 LiveKit, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Clones external dependencies and generates protobuf C++ files for the +# realsense-to-mcap example. Safe to re-run (idempotent). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="${SCRIPT_DIR}/realsense-to-mcap" +EXTERNAL_DIR="${PROJECT_DIR}/external" +GENERATED_DIR="${PROJECT_DIR}/generated" + +MCAP_REPO="https://github.com/foxglove/mcap.git" +FOXGLOVE_SDK_REPO="https://github.com/foxglove/foxglove-sdk.git" + +# --------------------------------------------------------------------------- +# Detect platform +# --------------------------------------------------------------------------- +OS="$(uname -s)" +case "${OS}" in + Linux*) PLATFORM=linux ;; + Darwin*) PLATFORM=macos ;; + *) PLATFORM=unknown ;; +esac + +# --------------------------------------------------------------------------- +# Check system dependencies +# --------------------------------------------------------------------------- +check_cmd() { + if ! command -v "$1" &>/dev/null; then + echo "WARNING: '$1' not found. $2" + return 1 + fi + return 0 +} + +hint_install() { + local pkg_apt="$1" + local pkg_brew="$2" + if [ "${PLATFORM}" = "macos" ]; then + echo "Install via: brew install ${pkg_brew}" + else + echo "Install via: sudo apt install ${pkg_apt}" + fi +} + +missing=0 +check_cmd protoc "$(hint_install protobuf-compiler protobuf)" || missing=1 +check_cmd pkg-config "$(hint_install pkg-config pkg-config)" || missing=1 + +if pkg-config --exists realsense2 2>/dev/null; then + echo " realsense2 ... found" +else + echo "WARNING: librealsense2 not found via pkg-config." + hint_install librealsense2-dev librealsense + missing=1 +fi + +if [ "$missing" -ne 0 ]; then + echo "" + echo "Some dependencies are missing (see warnings above). You can still" + echo "continue, but the build may fail." + echo "" +fi + +# --------------------------------------------------------------------------- +# Clone / update external repos +# --------------------------------------------------------------------------- +mkdir -p "${EXTERNAL_DIR}" + +clone_or_pull() { + local repo_url="$1" + local dest="$2" + + if [ -d "${dest}/.git" ]; then + echo "Updating $(basename "${dest}") ..." + git -C "${dest}" pull --ff-only + else + echo "Cloning $(basename "${dest}") ..." + git clone --depth 1 "${repo_url}" "${dest}" + fi +} + +clone_or_pull "${MCAP_REPO}" "${EXTERNAL_DIR}/mcap" +clone_or_pull "${FOXGLOVE_SDK_REPO}" "${EXTERNAL_DIR}/foxglove-sdk" + +# --------------------------------------------------------------------------- +# Generate C++ protobuf files from Foxglove schemas +# --------------------------------------------------------------------------- +PROTO_DIR="${EXTERNAL_DIR}/foxglove-sdk/schemas/proto" + +if [ ! -d "${PROTO_DIR}/foxglove" ]; then + echo "ERROR: Proto schemas not found at ${PROTO_DIR}/foxglove" + exit 1 +fi + +mkdir -p "${GENERATED_DIR}" + +echo "Generating protobuf C++ sources ..." +protoc \ + --cpp_out="${GENERATED_DIR}" \ + -I "${PROTO_DIR}" \ + "${PROTO_DIR}"/foxglove/*.proto + +echo "" +echo "Setup complete. Generated files are in:" +echo " ${GENERATED_DIR}" +echo "" +echo "To build:" +echo " cd ${PROJECT_DIR}" +echo " cmake -B build && cmake --build build" diff --git a/include/livekit/data_frame.h b/include/livekit/data_frame.h new file mode 100644 index 00000000..6bfa5e0a --- /dev/null +++ b/include/livekit/data_frame.h @@ -0,0 +1,45 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +namespace livekit { + +/** + * A single frame of data published or received on a data track. + * + * Carries an arbitrary binary payload and an optional user-specified + * timestamp. The unit is application-defined; the SDK examples use + * microseconds since the Unix epoch (system_clock). + */ +struct DataFrame { + /** Arbitrary binary payload (the frame contents). */ + std::vector payload; + + /** + * Optional application-defined timestamp. + * + * The proto field is a bare uint64 with no prescribed unit. + * By convention the SDK examples use microseconds since the Unix epoch. + */ + std::optional user_timestamp; +}; + +} // namespace livekit diff --git a/include/livekit/data_track_frame.h b/include/livekit/data_track_frame.h new file mode 100644 index 00000000..6bfa5e0a --- /dev/null +++ b/include/livekit/data_track_frame.h @@ -0,0 +1,45 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +namespace livekit { + +/** + * A single frame of data published or received on a data track. + * + * Carries an arbitrary binary payload and an optional user-specified + * timestamp. The unit is application-defined; the SDK examples use + * microseconds since the Unix epoch (system_clock). + */ +struct DataFrame { + /** Arbitrary binary payload (the frame contents). */ + std::vector payload; + + /** + * Optional application-defined timestamp. + * + * The proto field is a bare uint64 with no prescribed unit. + * By convention the SDK examples use microseconds since the Unix epoch. + */ + std::optional user_timestamp; +}; + +} // namespace livekit diff --git a/include/livekit/data_track_info.h b/include/livekit/data_track_info.h new file mode 100644 index 00000000..45c4fc5f --- /dev/null +++ b/include/livekit/data_track_info.h @@ -0,0 +1,40 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +namespace livekit { + +/** + * Metadata about a published data track. + * + * Unlike audio/video tracks, data tracks are not part of the Track class + * hierarchy. They carry their own lightweight info struct. + */ +struct DataTrackInfo { + /** Publisher-assigned track name (unique per publisher). */ + std::string name; + + /** SFU-assigned track identifier. */ + std::string sid; + + /** Whether frames on this track use end-to-end encryption. */ + bool uses_e2ee = false; +}; + +} // namespace livekit diff --git a/include/livekit/data_track_subscription.h b/include/livekit/data_track_subscription.h new file mode 100644 index 00000000..ca76cb72 --- /dev/null +++ b/include/livekit/data_track_subscription.h @@ -0,0 +1,124 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "livekit/data_frame.h" +#include "livekit/ffi_handle.h" + +#include +#include +#include +#include +#include + +namespace livekit { + +namespace proto { +class FfiEvent; +} + +/** + * An active subscription to a remote data track. + * + * Provides a blocking read() interface similar to AudioStream / VideoStream. + * Frames are delivered via FfiEvent callbacks and queued internally. + * + * Dropping (destroying) the subscription automatically unsubscribes from the + * remote track by releasing the underlying FFI handle. + * + * Typical usage: + * + * auto sub = remoteDataTrack->subscribe(); + * DataFrame frame; + * while (sub->read(frame)) { + * // process frame.payload + * } + */ +class DataTrackSubscription { +public: + struct Options { + /// Maximum buffered frames (count). 0 = unbounded. + /// When non-zero, behaves as a ring buffer (oldest dropped on overflow). + std::size_t capacity{0}; + }; + + virtual ~DataTrackSubscription(); + + DataTrackSubscription(const DataTrackSubscription &) = delete; + DataTrackSubscription &operator=(const DataTrackSubscription &) = delete; + DataTrackSubscription(DataTrackSubscription &&) noexcept; + DataTrackSubscription &operator=(DataTrackSubscription &&) noexcept; + + /** + * Blocking read: waits until a DataFrame is available, or the + * subscription reaches EOS / is closed. + * + * @param out On success, filled with the next data frame. + * @return true if a frame was delivered; false if the subscription ended. + */ + bool read(DataFrame &out); + + /** + * End the subscription early. + * + * Releases the FFI handle (which unsubscribes from the remote track), + * unregisters the event listener, and wakes any blocking read(). + */ + void close(); + +private: + friend class RemoteDataTrack; + + DataTrackSubscription() = default; + /// Internal init helper, called by RemoteDataTrack. + void init(FfiHandle subscription_handle, const Options &options); + + /// FFI event handler, called by FfiClient. + void onFfiEvent(const proto::FfiEvent &event); + + /// Push a received DataFrame to the internal queue. + void pushFrame(DataFrame &&frame); + + /// Push an end-of-stream signal (EOS). + void pushEos(); + + /** Protects all mutable state below. */ + mutable std::mutex mutex_; + + /** Signalled when a frame is pushed or the subscription ends. */ + std::condition_variable cv_; + + /** FIFO of received frames awaiting read(). */ + std::deque queue_; + + /** Max buffered frames (0 = unbounded). Oldest dropped on overflow. */ + std::size_t capacity_{0}; + + /** True once the remote side signals end-of-stream. */ + bool eof_{false}; + + /** True after close() has been called by the consumer. */ + bool closed_{false}; + + /** RAII handle for the Rust-owned subscription resource. */ + FfiHandle subscription_handle_; + + /** FfiClient listener id for routing FfiEvent callbacks to this object. */ + std::int64_t listener_id_{0}; +}; + +} // namespace livekit diff --git a/include/livekit/local_data_track.h b/include/livekit/local_data_track.h new file mode 100644 index 00000000..285e9930 --- /dev/null +++ b/include/livekit/local_data_track.h @@ -0,0 +1,85 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "livekit/data_frame.h" +#include "livekit/data_track_info.h" +#include "livekit/ffi_handle.h" + +#include +#include + +namespace livekit { + +namespace proto { +class OwnedLocalDataTrack; +} + +/** + * Represents a locally published data track. + * + * Unlike audio/video tracks, data tracks do not extend the Track base class. + * They use a separate publish/unpublish lifecycle and carry arbitrary binary + * frames instead of media. + * + * Created via LocalParticipant::publishDataTrack(). + * + * Typical usage: + * + * auto lp = room->localParticipant(); + * auto dt = lp->publishDataTrack("sensor-data"); + * DataFrame frame; + * frame.payload = {0x01, 0x02, 0x03}; + * dt->tryPush(frame); + * lp->unpublishDataTrack(dt); + */ +class LocalDataTrack { +public: + ~LocalDataTrack() = default; + + LocalDataTrack(const LocalDataTrack &) = delete; + LocalDataTrack &operator=(const LocalDataTrack &) = delete; + + /// Metadata about this data track. + const DataTrackInfo &info() const noexcept { return info_; } + + /** + * Try to push a frame to all subscribers of this track. + * + * @return true on success, false if the push failed (e.g. back-pressure + * or the track has been unpublished). + */ + bool tryPush(const DataFrame &frame); + + /// Whether the track is still published in the room. + bool isPublished() const; + +private: + friend class LocalParticipant; + + explicit LocalDataTrack(const proto::OwnedLocalDataTrack &owned); + + uintptr_t ffi_handle_id() const noexcept { return handle_.get(); } + + /** RAII wrapper for the Rust-owned FFI resource. */ + FfiHandle handle_; + + /** Metadata snapshot taken at construction time. */ + DataTrackInfo info_; +}; + +} // namespace livekit diff --git a/include/livekit/local_participant.h b/include/livekit/local_participant.h index edd7c945..ad57f77f 100644 --- a/include/livekit/local_participant.h +++ b/include/livekit/local_participant.h @@ -18,6 +18,7 @@ #include "livekit/ffi_handle.h" #include "livekit/local_audio_track.h" +#include "livekit/local_data_track.h" #include "livekit/local_video_track.h" #include "livekit/participant.h" #include "livekit/room_event_types.h" @@ -101,7 +102,13 @@ class LocalParticipant : public Participant { const std::string &topic = {}); /** - * Publish SIP DTMF message. + * Publish a SIP DTMF (phone keypad) tone into the room. + * + * Only meaningful when a SIP trunk is bridging a phone call into the + * room. See SipDtmfData for background on SIP and DTMF. + * + * @param code DTMF code (0-15). + * @param digit Human-readable digit string (e.g. "5", "#"). */ void publishDtmf(int code, const std::string &digit); @@ -164,6 +171,29 @@ class LocalParticipant : public Participant { */ void unpublishTrack(const std::string &track_sid); + /** + * Publish a data track to the room. + * + * Data tracks carry arbitrary binary frames and are independent of the + * audio/video track hierarchy. The returned LocalDataTrack can push + * frames via tryPush() and be unpublished via unpublishDataTrack(). + * + * @param name Unique track name visible to other participants. + * @return Shared pointer to the published data track. + * @throws std::runtime_error on FFI or publish failure. + */ + std::shared_ptr publishDataTrack(const std::string &name); + + /** + * Unpublish a data track from the room. + * + * After this call, tryPush() on the track will fail and the track + * cannot be re-published. + * + * @param track The data track to unpublish. Must not be null. + */ + void unpublishDataTrack(const std::shared_ptr &track); + /** * Initiate an RPC call to a remote participant. * diff --git a/include/livekit/remote_data_track.h b/include/livekit/remote_data_track.h new file mode 100644 index 00000000..a457902e --- /dev/null +++ b/include/livekit/remote_data_track.h @@ -0,0 +1,93 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "livekit/data_track_info.h" +#include "livekit/data_track_subscription.h" +#include "livekit/ffi_handle.h" + +#include +#include + +namespace livekit { + +namespace proto { +class OwnedRemoteDataTrack; +} + +/** + * Represents a data track published by a remote participant. + * + * Discovered via the RemoteDataTrackPublishedEvent room event. Unlike + * audio/video tracks, remote data tracks require an explicit subscribe() + * call to begin receiving frames. + * + * Typical usage: + * + * // In RoomDelegate::onRemoteDataTrackPublished callback: + * auto sub = remoteDataTrack->subscribe(); + * DataFrame frame; + * while (sub->read(frame)) { + * // process frame + * } + */ +class RemoteDataTrack { +public: + ~RemoteDataTrack() = default; + + RemoteDataTrack(const RemoteDataTrack &) = delete; + RemoteDataTrack &operator=(const RemoteDataTrack &) = delete; + + /// Metadata about this data track. + const DataTrackInfo &info() const noexcept { return info_; } + + /// Identity of the remote participant who published this track. + const std::string &publisherIdentity() const noexcept { + return publisher_identity_; + } + + /// Whether the track is still published by the remote participant. + bool isPublished() const; + + /** + * Subscribe to this remote data track. + * + * Returns a DataTrackSubscription that delivers frames via blocking + * read(). Destroy the subscription to unsubscribe. + * + * @throws std::runtime_error if the FFI subscribe call fails. + */ + std::shared_ptr + subscribe(const DataTrackSubscription::Options &options = {}); + +private: + friend class Room; + + explicit RemoteDataTrack(const proto::OwnedRemoteDataTrack &owned); + + uintptr_t ffi_handle_id() const noexcept { return handle_.get(); } + /** RAII wrapper for the Rust-owned FFI resource. */ + FfiHandle handle_; + + /** Metadata snapshot taken at construction time. */ + DataTrackInfo info_; + + /** Identity string of the remote participant who published this track. */ + std::string publisher_identity_; +}; + +} // namespace livekit diff --git a/include/livekit/room.h b/include/livekit/room.h index d808ecd4..4fce8614 100644 --- a/include/livekit/room.h +++ b/include/livekit/room.h @@ -297,6 +297,8 @@ class Room { TrackSource source); private: + friend class RoomCallbackTest; + mutable std::mutex lock_; ConnectionState connection_state_ = ConnectionState::Disconnected; RoomDelegate *delegate_ = nullptr; // Not owned diff --git a/include/livekit/room_delegate.h b/include/livekit/room_delegate.h index 04474a9f..7f7598fc 100644 --- a/include/livekit/room_delegate.h +++ b/include/livekit/room_delegate.h @@ -287,6 +287,19 @@ class RoomDelegate { */ virtual void onTextStreamOpened(Room &, const TextStreamOpenedEvent &) {} + // ------------------------------------------------------------------ + // Data tracks + // ------------------------------------------------------------------ + + /** + * Called when a remote participant publishes a data track. + * + * Data tracks are independent of the audio/video track hierarchy and + * require an explicit subscribe() call to start receiving frames. + */ + virtual void + onRemoteDataTrackPublished(Room &, const RemoteDataTrackPublishedEvent &) {} + // ------------------------------------------------------------------ // Participants snapshot // ------------------------------------------------------------------ diff --git a/include/livekit/room_event_types.h b/include/livekit/room_event_types.h index 63c75140..337a6aea 100644 --- a/include/livekit/room_event_types.h +++ b/include/livekit/room_event_types.h @@ -29,6 +29,7 @@ namespace livekit { class Track; class Participant; class RemoteParticipant; +class RemoteDataTrack; class LocalTrackPublication; class RemoteTrackPublication; class TrackPublication; @@ -100,7 +101,7 @@ enum class DisconnectReason { RoomClosed, UserUnavailable, UserRejected, - SipTrunkFailure, + SipTrunkFailure, ///< SIP (telephony) trunk connection failed ConnectionTimeout, MediaFailure }; @@ -117,10 +118,17 @@ struct UserPacketData { }; /** - * SIP DTMF payload carried via data packets. + * SIP (Session Initiation Protocol) DTMF payload carried via data packets. + * + * SIP is a signalling protocol used in VoIP telephony. LiveKit supports + * SIP trunking, which bridges traditional phone calls into LiveKit rooms. + * DTMF (Dual-Tone Multi-Frequency) tones are the signals generated when + * phone keypad buttons are pressed (0-9, *, #). This struct surfaces + * those tones so that applications handling SIP-bridged calls can react + * to caller input (e.g. IVR menu selection). */ struct SipDtmfData { - /** DTMF code value. */ + /** Numeric DTMF code (0-15, mapping to 0-9, *, #, A-D). */ std::uint32_t code = 0; /** Human-readable digit representation (e.g. "1", "#"). */ @@ -719,4 +727,16 @@ struct E2eeStateChangedEvent { EncryptionState state = EncryptionState::New; }; +/** + * Fired when a remote participant publishes a data track. + * + * Data tracks are independent of the audio/video track hierarchy. + * The application must call RemoteDataTrack::subscribe() to start + * receiving frames. + */ +struct RemoteDataTrackPublishedEvent { + /** The newly published remote data track. */ + std::shared_ptr track; +}; + } // namespace livekit diff --git a/src/data_track_subscription.cpp b/src/data_track_subscription.cpp new file mode 100644 index 00000000..94e34c51 --- /dev/null +++ b/src/data_track_subscription.cpp @@ -0,0 +1,171 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "livekit/data_track_subscription.h" + +#include "data_track.pb.h" +#include "ffi.pb.h" +#include "ffi_client.h" + +#include + +namespace livekit { + +using proto::FfiEvent; + +DataTrackSubscription::~DataTrackSubscription() { close(); } + +DataTrackSubscription::DataTrackSubscription( + DataTrackSubscription &&other) noexcept { + std::lock_guard lock(other.mutex_); + queue_ = std::move(other.queue_); + capacity_ = other.capacity_; + eof_ = other.eof_; + closed_ = other.closed_; + subscription_handle_ = std::move(other.subscription_handle_); + listener_id_ = other.listener_id_; + + other.listener_id_ = 0; + other.closed_ = true; +} + +DataTrackSubscription & +DataTrackSubscription::operator=(DataTrackSubscription &&other) noexcept { + if (this == &other) { + return *this; + } + + close(); + + { + std::lock_guard lock_this(mutex_); + std::lock_guard lock_other(other.mutex_); + + queue_ = std::move(other.queue_); + capacity_ = other.capacity_; + eof_ = other.eof_; + closed_ = other.closed_; + subscription_handle_ = std::move(other.subscription_handle_); + listener_id_ = other.listener_id_; + + other.listener_id_ = 0; + other.closed_ = true; + } + + return *this; +} + +void DataTrackSubscription::init(FfiHandle subscription_handle, + const Options &options) { + subscription_handle_ = std::move(subscription_handle); + capacity_ = options.capacity; + + listener_id_ = FfiClient::instance().AddListener( + [this](const FfiEvent &e) { this->onFfiEvent(e); }); +} + +bool DataTrackSubscription::read(DataFrame &out) { + std::unique_lock lock(mutex_); + + cv_.wait(lock, [this] { return !queue_.empty() || eof_ || closed_; }); + + if (closed_ || (queue_.empty() && eof_)) { + return false; + } + + out = std::move(queue_.front()); + queue_.pop_front(); + return true; +} + +void DataTrackSubscription::close() { + { + std::lock_guard lock(mutex_); + if (closed_) { + return; + } + closed_ = true; + } + + if (subscription_handle_.get() != 0) { + subscription_handle_.reset(); + } + + if (listener_id_ != 0) { + FfiClient::instance().RemoveListener(listener_id_); + listener_id_ = 0; + } + + cv_.notify_all(); +} + +void DataTrackSubscription::onFfiEvent(const FfiEvent &event) { + if (event.message_case() != FfiEvent::kDataTrackSubscriptionEvent) { + return; + } + + const auto &dts = event.data_track_subscription_event(); + if (dts.subscription_handle() != + static_cast(subscription_handle_.get())) { + return; + } + + if (dts.has_frame_received()) { + const auto &fr = dts.frame_received().frame(); + DataFrame frame; + const auto &payload_str = fr.payload(); + frame.payload.assign( + reinterpret_cast(payload_str.data()), + reinterpret_cast(payload_str.data()) + + payload_str.size()); + if (fr.has_user_timestamp()) { + frame.user_timestamp = fr.user_timestamp(); + } + pushFrame(std::move(frame)); + } else if (dts.has_eos()) { + pushEos(); + } +} + +void DataTrackSubscription::pushFrame(DataFrame &&frame) { + { + std::lock_guard lock(mutex_); + + if (closed_ || eof_) { + return; + } + + if (capacity_ > 0 && queue_.size() >= capacity_) { + queue_.pop_front(); + } + + queue_.push_back(std::move(frame)); + } + cv_.notify_one(); +} + +void DataTrackSubscription::pushEos() { + { + std::lock_guard lock(mutex_); + if (eof_) { + return; + } + eof_ = true; + } + cv_.notify_all(); +} + +} // namespace livekit diff --git a/src/ffi_client.cpp b/src/ffi_client.cpp index eba73821..63e7a94a 100644 --- a/src/ffi_client.cpp +++ b/src/ffi_client.cpp @@ -16,6 +16,7 @@ #include +#include "data_track.pb.h" #include "e2ee.pb.h" #include "ffi.pb.h" #include "ffi_client.h" @@ -114,6 +115,12 @@ std::optional ExtractAsyncId(const proto::FfiEvent &event) { case E::kSendBytes: return event.send_bytes().async_id(); + // data track async completions + case E::kPublishDataTrack: + return event.publish_data_track().async_id(); + case E::kSubscribeDataTrack: + return event.subscribe_data_track().async_id(); + // NOT async completion: case E::kRoomEvent: case E::kTrackEvent: @@ -121,6 +128,7 @@ std::optional ExtractAsyncId(const proto::FfiEvent &event) { case E::kAudioStreamEvent: case E::kByteStreamReaderEvent: case E::kTextStreamReaderEvent: + case E::kDataTrackSubscriptionEvent: case E::kRpcMethodInvocation: case E::kLogs: case E::kPanic: @@ -318,6 +326,11 @@ FfiClient::connectAsync(const std::string &url, const std::string &token, opts->set_dynacast(options.dynacast); opts->set_single_peer_connection(options.single_peer_connection); + LK_LOG_DEBUG("[FfiClient] connectAsync: auto_subscribe={}, dynacast={}, " + "single_peer_connection={}", + options.auto_subscribe, options.dynacast, + options.single_peer_connection); + // --- E2EE / encryption (optional) --- if (options.encryption.has_value()) { const E2EEOptions &e2ee = *options.encryption; @@ -608,6 +621,99 @@ std::future FfiClient::publishDataAsync( return fut; } +std::future +FfiClient::publishDataTrackAsync(std::uint64_t local_participant_handle, + const std::string &track_name) { + const AsyncId async_id = generateAsyncId(); + + auto fut = registerAsync( + async_id, + [async_id](const proto::FfiEvent &event) { + return event.has_publish_data_track() && + event.publish_data_track().async_id() == async_id; + }, + [](const proto::FfiEvent &event, + std::promise &pr) { + const auto &cb = event.publish_data_track(); + if (cb.has_error() && !cb.error().empty()) { + pr.set_exception( + std::make_exception_ptr(std::runtime_error(cb.error()))); + return; + } + if (!cb.has_track()) { + pr.set_exception(std::make_exception_ptr( + std::runtime_error("PublishDataTrackCallback missing track"))); + return; + } + proto::OwnedLocalDataTrack track = cb.track(); + pr.set_value(std::move(track)); + }); + + proto::FfiRequest req; + auto *msg = req.mutable_publish_data_track(); + msg->set_local_participant_handle(local_participant_handle); + msg->mutable_options()->set_name(track_name); + msg->set_request_async_id(async_id); + + try { + proto::FfiResponse resp = sendRequest(req); + if (!resp.has_publish_data_track()) { + logAndThrow("FfiResponse missing publish_data_track"); + } + } catch (...) { + cancelPendingByAsyncId(async_id); + throw; + } + + return fut; +} + +std::future +FfiClient::subscribeDataTrackAsync(std::uint64_t track_handle) { + const AsyncId async_id = generateAsyncId(); + + auto fut = registerAsync( + async_id, + [async_id](const proto::FfiEvent &event) { + return event.has_subscribe_data_track() && + event.subscribe_data_track().async_id() == async_id; + }, + [](const proto::FfiEvent &event, + std::promise &pr) { + const auto &cb = event.subscribe_data_track(); + if (cb.has_error() && !cb.error().empty()) { + pr.set_exception( + std::make_exception_ptr(std::runtime_error(cb.error()))); + return; + } + if (!cb.has_subscription()) { + pr.set_exception(std::make_exception_ptr(std::runtime_error( + "SubscribeDataTrackCallback missing subscription"))); + return; + } + proto::OwnedDataTrackSubscription sub = cb.subscription(); + pr.set_value(std::move(sub)); + }); + + proto::FfiRequest req; + auto *msg = req.mutable_subscribe_data_track(); + msg->set_track_handle(track_handle); + msg->mutable_options(); + msg->set_request_async_id(async_id); + + try { + proto::FfiResponse resp = sendRequest(req); + if (!resp.has_subscribe_data_track()) { + logAndThrow("FfiResponse missing subscribe_data_track"); + } + } catch (...) { + cancelPendingByAsyncId(async_id); + throw; + } + + return fut; +} + std::future FfiClient::publishSipDtmfAsync( std::uint64_t local_participant_handle, std::uint32_t code, const std::string &digit, diff --git a/src/ffi_client.h b/src/ffi_client.h index 667100ea..327dbd9a 100644 --- a/src/ffi_client.h +++ b/src/ffi_client.h @@ -38,6 +38,8 @@ class FfiEvent; class FfiResponse; class FfiRequest; class OwnedTrackPublication; +class OwnedLocalDataTrack; +class OwnedDataTrackSubscription; class DataStream; } // namespace proto @@ -123,6 +125,13 @@ class FfiClient { const std::string &payload, std::optional response_timeout_ms = std::nullopt); + // Data Track APIs + std::future + publishDataTrackAsync(std::uint64_t local_participant_handle, + const std::string &track_name); + std::future + subscribeDataTrackAsync(std::uint64_t track_handle); + // Data stream functionalities std::future sendStreamHeaderAsync(std::uint64_t local_participant_handle, diff --git a/src/local_data_track.cpp b/src/local_data_track.cpp new file mode 100644 index 00000000..1b290ae6 --- /dev/null +++ b/src/local_data_track.cpp @@ -0,0 +1,65 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "livekit/local_data_track.h" + +#include "data_track.pb.h" +#include "ffi.pb.h" +#include "ffi_client.h" + +namespace livekit { + +LocalDataTrack::LocalDataTrack(const proto::OwnedLocalDataTrack &owned) + : handle_(static_cast(owned.handle().id())) { + const auto &pi = owned.info(); + info_.name = pi.name(); + info_.sid = pi.sid(); + info_.uses_e2ee = pi.uses_e2ee(); +} + +bool LocalDataTrack::tryPush(const DataFrame &frame) { + if (!handle_.valid()) { + return false; + } + + proto::FfiRequest req; + auto *msg = req.mutable_local_data_track_try_push(); + msg->set_track_handle(static_cast(handle_.get())); + auto *pf = msg->mutable_frame(); + pf->set_payload(frame.payload.data(), frame.payload.size()); + if (frame.user_timestamp.has_value()) { + pf->set_user_timestamp(frame.user_timestamp.value()); + } + + proto::FfiResponse resp = FfiClient::instance().sendRequest(req); + const auto &r = resp.local_data_track_try_push(); + return !r.has_error(); +} + +bool LocalDataTrack::isPublished() const { + if (!handle_.valid()) { + return false; + } + + proto::FfiRequest req; + auto *msg = req.mutable_local_data_track_is_published(); + msg->set_track_handle(static_cast(handle_.get())); + + proto::FfiResponse resp = FfiClient::instance().sendRequest(req); + return resp.local_data_track_is_published().is_published(); +} + +} // namespace livekit diff --git a/src/local_participant.cpp b/src/local_participant.cpp index 8aea35ff..219d0482 100644 --- a/src/local_participant.cpp +++ b/src/local_participant.cpp @@ -18,11 +18,13 @@ #include "livekit/ffi_handle.h" #include "livekit/local_audio_track.h" +#include "livekit/local_data_track.h" #include "livekit/local_track_publication.h" #include "livekit/local_video_track.h" #include "livekit/room_delegate.h" #include "livekit/track.h" +#include "data_track.pb.h" #include "ffi.pb.h" #include "ffi_client.h" #include "participant.pb.h" @@ -286,6 +288,39 @@ LocalParticipant::PublicationMap LocalParticipant::trackPublications() const { return out; } +std::shared_ptr +LocalParticipant::publishDataTrack(const std::string &name) { + auto handle_id = ffiHandleId(); + if (handle_id == 0) { + throw std::runtime_error( + "LocalParticipant::publishDataTrack: invalid FFI handle"); + } + + auto fut = FfiClient::instance().publishDataTrackAsync( + static_cast(handle_id), name); + + proto::OwnedLocalDataTrack owned = fut.get(); + return std::shared_ptr(new LocalDataTrack(owned)); +} + +void LocalParticipant::unpublishDataTrack( + const std::shared_ptr &track) { + if (!track) { + return; + } + + auto handle_id = track->ffi_handle_id(); + if (handle_id == 0) { + return; + } + + proto::FfiRequest req; + auto *msg = req.mutable_local_data_track_unpublish(); + msg->set_track_handle(static_cast(handle_id)); + + (void)FfiClient::instance().sendRequest(req); +} + std::string LocalParticipant::performRpc( const std::string &destination_identity, const std::string &method, const std::string &payload, const std::optional &response_timeout) { diff --git a/src/remote_data_track.cpp b/src/remote_data_track.cpp new file mode 100644 index 00000000..6104b2b9 --- /dev/null +++ b/src/remote_data_track.cpp @@ -0,0 +1,68 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "livekit/remote_data_track.h" + +#include "data_track.pb.h" +#include "ffi.pb.h" +#include "ffi_client.h" + +#include + +namespace livekit { + +RemoteDataTrack::RemoteDataTrack(const proto::OwnedRemoteDataTrack &owned) + : handle_(static_cast(owned.handle().id())), + publisher_identity_(owned.publisher_identity()) { + const auto &pi = owned.info(); + info_.name = pi.name(); + info_.sid = pi.sid(); + info_.uses_e2ee = pi.uses_e2ee(); +} + +bool RemoteDataTrack::isPublished() const { + if (!handle_.valid()) { + return false; + } + + proto::FfiRequest req; + auto *msg = req.mutable_remote_data_track_is_published(); + msg->set_track_handle(static_cast(handle_.get())); + + proto::FfiResponse resp = FfiClient::instance().sendRequest(req); + return resp.remote_data_track_is_published().is_published(); +} + +std::shared_ptr +RemoteDataTrack::subscribe(const DataTrackSubscription::Options &options) { + if (!handle_.valid()) { + throw std::runtime_error("RemoteDataTrack::subscribe: invalid FFI handle"); + } + + auto fut = FfiClient::instance().subscribeDataTrackAsync( + static_cast(handle_.get())); + + proto::OwnedDataTrackSubscription owned_sub = fut.get(); + + FfiHandle sub_handle(static_cast(owned_sub.handle().id())); + + auto subscription = + std::shared_ptr(new DataTrackSubscription()); + subscription->init(std::move(sub_handle), options); + return subscription; +} + +} // namespace livekit diff --git a/src/room.cpp b/src/room.cpp index ab7ab286..cad9048c 100644 --- a/src/room.cpp +++ b/src/room.cpp @@ -21,12 +21,14 @@ #include "livekit/local_participant.h" #include "livekit/local_track_publication.h" #include "livekit/remote_audio_track.h" +#include "livekit/remote_data_track.h" #include "livekit/remote_participant.h" #include "livekit/remote_track_publication.h" #include "livekit/remote_video_track.h" #include "livekit/room_delegate.h" #include "livekit/room_event_types.h" +#include "data_track.pb.h" #include "ffi.pb.h" #include "ffi_client.h" #include "livekit/lk_log.h" @@ -656,6 +658,24 @@ void Room::OnEvent(const FfiEvent &event) { } break; } + case proto::RoomEvent::kRemoteDataTrackPublished: { + const auto &rdtp = re.remote_data_track_published(); + auto remote_track = + std::shared_ptr(new RemoteDataTrack(rdtp.track())); + LK_LOG_INFO("[Room] RoomEvent::kRemoteDataTrackPublished: \"{}\" from " + "\"{}\" (sid={})", + remote_track->info().name, remote_track->publisherIdentity(), + remote_track->info().sid); + RemoteDataTrackPublishedEvent ev; + ev.track = remote_track; + if (delegate_snapshot) { + delegate_snapshot->onRemoteDataTrackPublished(*this, ev); + } else { + LK_LOG_ERROR("[Room] No delegate set; RemoteDataTrackPublished " + "event dropped."); + } + break; + } case proto::RoomEvent::kTrackMuted: { TrackMutedEvent ev; bool success = false; diff --git a/src/tests/integration/test_room_callbacks.cpp b/src/tests/integration/test_room_callbacks.cpp new file mode 100644 index 00000000..5791166e --- /dev/null +++ b/src/tests/integration/test_room_callbacks.cpp @@ -0,0 +1,360 @@ +/* + * Copyright 2025 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// @file test_room_callbacks.cpp +/// @brief Unit tests for Room frame callback registration and internals. + +#include +#include + +#include +#include +#include +#include + +namespace livekit { + +class RoomCallbackTest : public ::testing::Test { +protected: + void SetUp() override { + livekit::initialize(livekit::LogLevel::Info, livekit::LogSink::kConsole); + } + + void TearDown() override { livekit::shutdown(); } + + using CallbackKey = Room::CallbackKey; + using CallbackKeyHash = Room::CallbackKeyHash; + + static auto &audioCallbacks(Room &room) { return room.audio_callbacks_; } + static auto &videoCallbacks(Room &room) { return room.video_callbacks_; } + static auto &activeReaders(Room &room) { return room.active_readers_; } + static int maxActiveReaders() { return Room::kMaxActiveReaders; } +}; + +// ============================================================================ +// CallbackKey equality +// ============================================================================ + +TEST_F(RoomCallbackTest, CallbackKeyEqualKeysCompareEqual) { + CallbackKey a{"alice", TrackSource::SOURCE_MICROPHONE}; + CallbackKey b{"alice", TrackSource::SOURCE_MICROPHONE}; + EXPECT_TRUE(a == b); +} + +TEST_F(RoomCallbackTest, CallbackKeyDifferentIdentityNotEqual) { + CallbackKey a{"alice", TrackSource::SOURCE_MICROPHONE}; + CallbackKey b{"bob", TrackSource::SOURCE_MICROPHONE}; + EXPECT_FALSE(a == b); +} + +TEST_F(RoomCallbackTest, CallbackKeyDifferentSourceNotEqual) { + CallbackKey a{"alice", TrackSource::SOURCE_MICROPHONE}; + CallbackKey b{"alice", TrackSource::SOURCE_CAMERA}; + EXPECT_FALSE(a == b); +} + +// ============================================================================ +// CallbackKeyHash +// ============================================================================ + +TEST_F(RoomCallbackTest, CallbackKeyHashEqualKeysProduceSameHash) { + CallbackKey a{"alice", TrackSource::SOURCE_MICROPHONE}; + CallbackKey b{"alice", TrackSource::SOURCE_MICROPHONE}; + CallbackKeyHash hasher; + EXPECT_EQ(hasher(a), hasher(b)); +} + +TEST_F(RoomCallbackTest, CallbackKeyHashDifferentKeysLikelyDifferentHash) { + CallbackKeyHash hasher; + CallbackKey mic{"alice", TrackSource::SOURCE_MICROPHONE}; + CallbackKey cam{"alice", TrackSource::SOURCE_CAMERA}; + CallbackKey bob{"bob", TrackSource::SOURCE_MICROPHONE}; + + EXPECT_NE(hasher(mic), hasher(cam)); + EXPECT_NE(hasher(mic), hasher(bob)); +} + +TEST_F(RoomCallbackTest, CallbackKeyWorksAsUnorderedMapKey) { + std::unordered_map map; + + CallbackKey k1{"alice", TrackSource::SOURCE_MICROPHONE}; + CallbackKey k2{"bob", TrackSource::SOURCE_CAMERA}; + CallbackKey k3{"alice", TrackSource::SOURCE_CAMERA}; + + map[k1] = 1; + map[k2] = 2; + map[k3] = 3; + + EXPECT_EQ(map.size(), 3u); + EXPECT_EQ(map[k1], 1); + EXPECT_EQ(map[k2], 2); + EXPECT_EQ(map[k3], 3); + + map[k1] = 42; + EXPECT_EQ(map[k1], 42); + EXPECT_EQ(map.size(), 3u); + + map.erase(k2); + EXPECT_EQ(map.size(), 2u); + EXPECT_EQ(map.count(k2), 0u); +} + +TEST_F(RoomCallbackTest, CallbackKeyEmptyIdentityWorks) { + CallbackKey a{"", TrackSource::SOURCE_UNKNOWN}; + CallbackKey b{"", TrackSource::SOURCE_UNKNOWN}; + CallbackKeyHash hasher; + EXPECT_TRUE(a == b); + EXPECT_EQ(hasher(a), hasher(b)); +} + +// ============================================================================ +// kMaxActiveReaders +// ============================================================================ + +TEST_F(RoomCallbackTest, MaxActiveReadersIs20) { + EXPECT_EQ(maxActiveReaders(), 20); +} + +// ============================================================================ +// Registration and clearing (pre-connection, no server needed) +// ============================================================================ + +TEST_F(RoomCallbackTest, SetAudioCallbackStoresRegistration) { + Room room; + room.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, + [](const AudioFrame &) {}); + + EXPECT_EQ(audioCallbacks(room).size(), 1u); +} + +TEST_F(RoomCallbackTest, SetVideoCallbackStoresRegistration) { + Room room; + room.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, + [](const VideoFrame &, std::int64_t) {}); + + EXPECT_EQ(videoCallbacks(room).size(), 1u); +} + +TEST_F(RoomCallbackTest, ClearAudioCallbackRemovesRegistration) { + Room room; + room.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, + [](const AudioFrame &) {}); + ASSERT_EQ(audioCallbacks(room).size(), 1u); + + room.clearOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE); + EXPECT_EQ(audioCallbacks(room).size(), 0u); +} + +TEST_F(RoomCallbackTest, ClearVideoCallbackRemovesRegistration) { + Room room; + room.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, + [](const VideoFrame &, std::int64_t) {}); + ASSERT_EQ(videoCallbacks(room).size(), 1u); + + room.clearOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA); + EXPECT_EQ(videoCallbacks(room).size(), 0u); +} + +TEST_F(RoomCallbackTest, ClearNonExistentCallbackIsNoOp) { + Room room; + EXPECT_NO_THROW( + room.clearOnAudioFrameCallback("nobody", TrackSource::SOURCE_MICROPHONE)); + EXPECT_NO_THROW( + room.clearOnVideoFrameCallback("nobody", TrackSource::SOURCE_CAMERA)); +} + +TEST_F(RoomCallbackTest, OverwriteAudioCallbackKeepsSingleEntry) { + Room room; + std::atomic counter1{0}; + std::atomic counter2{0}; + + room.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, + [&counter1](const AudioFrame &) { counter1++; }); + room.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, + [&counter2](const AudioFrame &) { counter2++; }); + + EXPECT_EQ(audioCallbacks(room).size(), 1u) + << "Re-registering with the same key should overwrite, not add"; +} + +TEST_F(RoomCallbackTest, OverwriteVideoCallbackKeepsSingleEntry) { + Room room; + room.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, + [](const VideoFrame &, std::int64_t) {}); + room.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, + [](const VideoFrame &, std::int64_t) {}); + + EXPECT_EQ(videoCallbacks(room).size(), 1u); +} + +TEST_F(RoomCallbackTest, MultipleDistinctCallbacksAreIndependent) { + Room room; + room.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, + [](const AudioFrame &) {}); + room.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, + [](const VideoFrame &, std::int64_t) {}); + room.setOnAudioFrameCallback("bob", TrackSource::SOURCE_MICROPHONE, + [](const AudioFrame &) {}); + room.setOnVideoFrameCallback("bob", TrackSource::SOURCE_CAMERA, + [](const VideoFrame &, std::int64_t) {}); + + EXPECT_EQ(audioCallbacks(room).size(), 2u); + EXPECT_EQ(videoCallbacks(room).size(), 2u); + + room.clearOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE); + EXPECT_EQ(audioCallbacks(room).size(), 1u); + EXPECT_EQ(videoCallbacks(room).size(), 2u); +} + +TEST_F(RoomCallbackTest, ClearingOneSourceDoesNotAffectOther) { + Room room; + room.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, + [](const AudioFrame &) {}); + room.setOnAudioFrameCallback("alice", TrackSource::SOURCE_SCREENSHARE_AUDIO, + [](const AudioFrame &) {}); + ASSERT_EQ(audioCallbacks(room).size(), 2u); + + room.clearOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE); + EXPECT_EQ(audioCallbacks(room).size(), 1u); + + CallbackKey remaining{"alice", TrackSource::SOURCE_SCREENSHARE_AUDIO}; + EXPECT_EQ(audioCallbacks(room).count(remaining), 1u); +} + +// ============================================================================ +// Active readers state (no real streams, just map state) +// ============================================================================ + +TEST_F(RoomCallbackTest, NoActiveReadersInitially) { + Room room; + EXPECT_TRUE(activeReaders(room).empty()); +} + +TEST_F(RoomCallbackTest, ActiveReadersEmptyAfterCallbackRegistration) { + Room room; + room.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, + [](const AudioFrame &) {}); + EXPECT_TRUE(activeReaders(room).empty()) + << "Registering a callback without a subscribed track should not spawn " + "readers"; +} + +// ============================================================================ +// Destruction safety +// ============================================================================ + +TEST_F(RoomCallbackTest, DestroyRoomWithRegisteredCallbacksIsSafe) { + EXPECT_NO_THROW({ + Room room; + room.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, + [](const AudioFrame &) {}); + room.setOnVideoFrameCallback("bob", TrackSource::SOURCE_CAMERA, + [](const VideoFrame &, std::int64_t) {}); + }); +} + +TEST_F(RoomCallbackTest, DestroyRoomAfterClearingCallbacksIsSafe) { + EXPECT_NO_THROW({ + Room room; + room.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, + [](const AudioFrame &) {}); + room.clearOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE); + }); +} + +// ============================================================================ +// Thread-safety of registration/clearing +// ============================================================================ + +TEST_F(RoomCallbackTest, ConcurrentRegistrationDoesNotCrash) { + Room room; + constexpr int kThreads = 8; + constexpr int kIterations = 100; + + std::vector threads; + threads.reserve(kThreads); + + for (int t = 0; t < kThreads; ++t) { + threads.emplace_back([&room, t]() { + for (int i = 0; i < kIterations; ++i) { + std::string id = "participant-" + std::to_string(t); + room.setOnAudioFrameCallback(id, TrackSource::SOURCE_MICROPHONE, + [](const AudioFrame &) {}); + room.clearOnAudioFrameCallback(id, TrackSource::SOURCE_MICROPHONE); + } + }); + } + + for (auto &t : threads) { + t.join(); + } + + EXPECT_TRUE(audioCallbacks(room).empty()) + << "All callbacks should be cleared after concurrent register/clear"; +} + +TEST_F(RoomCallbackTest, ConcurrentMixedAudioVideoRegistration) { + Room room; + constexpr int kThreads = 4; + constexpr int kIterations = 50; + + std::vector threads; + + for (int t = 0; t < kThreads; ++t) { + threads.emplace_back([&room, t]() { + std::string id = "p-" + std::to_string(t); + for (int i = 0; i < kIterations; ++i) { + room.setOnAudioFrameCallback(id, TrackSource::SOURCE_MICROPHONE, + [](const AudioFrame &) {}); + room.setOnVideoFrameCallback(id, TrackSource::SOURCE_CAMERA, + [](const VideoFrame &, std::int64_t) {}); + } + }); + } + + for (auto &t : threads) { + t.join(); + } + + EXPECT_EQ(audioCallbacks(room).size(), static_cast(kThreads)); + EXPECT_EQ(videoCallbacks(room).size(), static_cast(kThreads)); +} + +// ============================================================================ +// Bulk registration +// ============================================================================ + +TEST_F(RoomCallbackTest, ManyDistinctCallbacksCanBeRegistered) { + Room room; + constexpr int kCount = 50; + + for (int i = 0; i < kCount; ++i) { + room.setOnAudioFrameCallback("participant-" + std::to_string(i), + TrackSource::SOURCE_MICROPHONE, + [](const AudioFrame &) {}); + } + + EXPECT_EQ(audioCallbacks(room).size(), static_cast(kCount)); + + for (int i = 0; i < kCount; ++i) { + room.clearOnAudioFrameCallback("participant-" + std::to_string(i), + TrackSource::SOURCE_MICROPHONE); + } + + EXPECT_EQ(audioCallbacks(room).size(), 0u); +} + +} // namespace livekit diff --git a/src/tests/stress/test_latency_measurement.cpp b/src/tests/stress/test_latency_measurement.cpp index ed988b79..c60e9680 100644 --- a/src/tests/stress/test_latency_measurement.cpp +++ b/src/tests/stress/test_latency_measurement.cpp @@ -343,6 +343,9 @@ TEST_F(LatencyMeasurementTest, AudioLatency) { } // Clean up + ASSERT_NE(audio_track, nullptr) << "Audio track is null"; + ASSERT_NE(audio_track->publication(), nullptr) + << "Audio track publication is null"; sender_room->localParticipant()->unpublishTrack( audio_track->publication()->sid()); From 257268d7e8d7ac8862623dd19359d62190adc9a0 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Thu, 19 Mar 2026 16:25:57 -0600 Subject: [PATCH 02/34] compiling with new data tracks work --- bridge/src/bridge_room_delegate.cpp | 11 +++++------ bridge/src/bridge_room_delegate.h | 6 +++--- include/livekit/remote_data_track.h | 4 ++-- include/livekit/room_delegate.h | 3 +-- include/livekit/room_event_types.h | 4 ++-- src/room.cpp | 12 ++++++------ 6 files changed, 19 insertions(+), 21 deletions(-) diff --git a/bridge/src/bridge_room_delegate.cpp b/bridge/src/bridge_room_delegate.cpp index 7dcb4280..b216a454 100644 --- a/bridge/src/bridge_room_delegate.cpp +++ b/bridge/src/bridge_room_delegate.cpp @@ -53,19 +53,18 @@ void BridgeRoomDelegate::onTrackUnsubscribed( bridge_.onTrackUnsubscribed(identity, source); } -void BridgeRoomDelegate::onRemoteDataTrackPublished( - livekit::Room & /*room*/, - const livekit::RemoteDataTrackPublishedEvent &ev) { +void BridgeRoomDelegate::onDataTrackPublished( + livekit::Room & /*room*/, const livekit::DataTrackPublishedEvent &ev) { if (!ev.track) { - LK_LOG_ERROR("[BridgeRoomDelegate] onRemoteDataTrackPublished called " + LK_LOG_ERROR("[BridgeRoomDelegate] onDataTrackPublished called " "with null track."); return; } - LK_LOG_INFO("[BridgeRoomDelegate] onRemoteDataTrackPublished: \"{}\" from " + LK_LOG_INFO("[BridgeRoomDelegate] onDataTrackPublished: \"{}\" from " "\"{}\"", ev.track->info().name, ev.track->publisherIdentity()); - bridge_.onRemoteDataTrackPublished(ev.track); + bridge_.onDataTrackPublished(ev.track); } } // namespace livekit_bridge diff --git a/bridge/src/bridge_room_delegate.h b/bridge/src/bridge_room_delegate.h index 9c5d0da6..58ee681e 100644 --- a/bridge/src/bridge_room_delegate.h +++ b/bridge/src/bridge_room_delegate.h @@ -44,9 +44,9 @@ class BridgeRoomDelegate : public livekit::RoomDelegate { void onTrackUnsubscribed(livekit::Room &room, const livekit::TrackUnsubscribedEvent &ev) override; - void onRemoteDataTrackPublished( - livekit::Room &room, - const livekit::RemoteDataTrackPublishedEvent &ev) override; + void + onDataTrackPublished(livekit::Room &room, + const livekit::DataTrackPublishedEvent &ev) override; private: LiveKitBridge &bridge_; diff --git a/include/livekit/remote_data_track.h b/include/livekit/remote_data_track.h index a457902e..ce755a06 100644 --- a/include/livekit/remote_data_track.h +++ b/include/livekit/remote_data_track.h @@ -32,13 +32,13 @@ class OwnedRemoteDataTrack; /** * Represents a data track published by a remote participant. * - * Discovered via the RemoteDataTrackPublishedEvent room event. Unlike + * Discovered via the DataTrackPublishedEvent room event. Unlike * audio/video tracks, remote data tracks require an explicit subscribe() * call to begin receiving frames. * * Typical usage: * - * // In RoomDelegate::onRemoteDataTrackPublished callback: + * // In RoomDelegate::onDataTrackPublished callback: * auto sub = remoteDataTrack->subscribe(); * DataFrame frame; * while (sub->read(frame)) { diff --git a/include/livekit/room_delegate.h b/include/livekit/room_delegate.h index 7f7598fc..6f7ece9d 100644 --- a/include/livekit/room_delegate.h +++ b/include/livekit/room_delegate.h @@ -297,8 +297,7 @@ class RoomDelegate { * Data tracks are independent of the audio/video track hierarchy and * require an explicit subscribe() call to start receiving frames. */ - virtual void - onRemoteDataTrackPublished(Room &, const RemoteDataTrackPublishedEvent &) {} + virtual void onDataTrackPublished(Room &, const DataTrackPublishedEvent &) {} // ------------------------------------------------------------------ // Participants snapshot diff --git a/include/livekit/room_event_types.h b/include/livekit/room_event_types.h index 337a6aea..4968a7a6 100644 --- a/include/livekit/room_event_types.h +++ b/include/livekit/room_event_types.h @@ -728,13 +728,13 @@ struct E2eeStateChangedEvent { }; /** - * Fired when a remote participant publishes a data track. + * Fired when a participant publishes a data track. * * Data tracks are independent of the audio/video track hierarchy. * The application must call RemoteDataTrack::subscribe() to start * receiving frames. */ -struct RemoteDataTrackPublishedEvent { +struct DataTrackPublishedEvent { /** The newly published remote data track. */ std::shared_ptr track; }; diff --git a/src/room.cpp b/src/room.cpp index cad9048c..16cf7717 100644 --- a/src/room.cpp +++ b/src/room.cpp @@ -658,20 +658,20 @@ void Room::OnEvent(const FfiEvent &event) { } break; } - case proto::RoomEvent::kRemoteDataTrackPublished: { - const auto &rdtp = re.remote_data_track_published(); + case proto::RoomEvent::kDataTrackPublished: { + const auto &rdtp = re.data_track_published(); auto remote_track = std::shared_ptr(new RemoteDataTrack(rdtp.track())); - LK_LOG_INFO("[Room] RoomEvent::kRemoteDataTrackPublished: \"{}\" from " + LK_LOG_INFO("[Room] RoomEvent::kDataTrackPublished: \"{}\" from " "\"{}\" (sid={})", remote_track->info().name, remote_track->publisherIdentity(), remote_track->info().sid); - RemoteDataTrackPublishedEvent ev; + DataTrackPublishedEvent ev; ev.track = remote_track; if (delegate_snapshot) { - delegate_snapshot->onRemoteDataTrackPublished(*this, ev); + delegate_snapshot->onDataTrackPublished(*this, ev); } else { - LK_LOG_ERROR("[Room] No delegate set; RemoteDataTrackPublished " + LK_LOG_ERROR("[Room] No delegate set; DataTrackPublished " "event dropped."); } break; From 3e66ac7feb29de96d5e78a8c3a947e8ab04ac133 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Thu, 19 Mar 2026 16:52:17 -0600 Subject: [PATCH 03/34] data tracks buffering is now handled on the rust side --- bridge/src/bridge_room_delegate.cpp | 5 ++++ bridge/src/bridge_room_delegate.h | 4 +++ include/livekit/data_track_subscription.h | 10 ++----- include/livekit/room_delegate.h | 6 ++++ include/livekit/room_event_types.h | 8 ++++++ src/data_track_subscription.cpp | 35 ++++++++++++++--------- src/ffi_client.cpp | 8 ++++-- src/ffi_client.h | 7 +++-- src/remote_data_track.cpp | 10 +++++-- src/room.cpp | 10 +++++++ 10 files changed, 77 insertions(+), 26 deletions(-) diff --git a/bridge/src/bridge_room_delegate.cpp b/bridge/src/bridge_room_delegate.cpp index b216a454..b3ae7d3f 100644 --- a/bridge/src/bridge_room_delegate.cpp +++ b/bridge/src/bridge_room_delegate.cpp @@ -67,4 +67,9 @@ void BridgeRoomDelegate::onDataTrackPublished( bridge_.onDataTrackPublished(ev.track); } +void BridgeRoomDelegate::onDataTrackUnpublished( + livekit::Room & /*room*/, const livekit::DataTrackUnpublishedEvent &ev) { + bridge_.onDataTrackUnpublished(ev.sid); +} + } // namespace livekit_bridge diff --git a/bridge/src/bridge_room_delegate.h b/bridge/src/bridge_room_delegate.h index 58ee681e..3a6f9d84 100644 --- a/bridge/src/bridge_room_delegate.h +++ b/bridge/src/bridge_room_delegate.h @@ -48,6 +48,10 @@ class BridgeRoomDelegate : public livekit::RoomDelegate { onDataTrackPublished(livekit::Room &room, const livekit::DataTrackPublishedEvent &ev) override; + void + onDataTrackUnpublished(livekit::Room &room, + const livekit::DataTrackUnpublishedEvent &ev) override; + private: LiveKitBridge &bridge_; }; diff --git a/include/livekit/data_track_subscription.h b/include/livekit/data_track_subscription.h index ca76cb72..bbb6ff52 100644 --- a/include/livekit/data_track_subscription.h +++ b/include/livekit/data_track_subscription.h @@ -51,9 +51,8 @@ class FfiEvent; class DataTrackSubscription { public: struct Options { - /// Maximum buffered frames (count). 0 = unbounded. - /// When non-zero, behaves as a ring buffer (oldest dropped on overflow). - std::size_t capacity{0}; + /// Maximum frames buffered on the Rust side. 0 = unbounded. + std::size_t buffer_size{0}; }; virtual ~DataTrackSubscription(); @@ -85,7 +84,7 @@ class DataTrackSubscription { DataTrackSubscription() = default; /// Internal init helper, called by RemoteDataTrack. - void init(FfiHandle subscription_handle, const Options &options); + void init(FfiHandle subscription_handle); /// FFI event handler, called by FfiClient. void onFfiEvent(const proto::FfiEvent &event); @@ -105,9 +104,6 @@ class DataTrackSubscription { /** FIFO of received frames awaiting read(). */ std::deque queue_; - /** Max buffered frames (0 = unbounded). Oldest dropped on overflow. */ - std::size_t capacity_{0}; - /** True once the remote side signals end-of-stream. */ bool eof_{false}; diff --git a/include/livekit/room_delegate.h b/include/livekit/room_delegate.h index 6f7ece9d..2621c92c 100644 --- a/include/livekit/room_delegate.h +++ b/include/livekit/room_delegate.h @@ -299,6 +299,12 @@ class RoomDelegate { */ virtual void onDataTrackPublished(Room &, const DataTrackPublishedEvent &) {} + /** + * Called when a remote participant unpublishes a data track. + */ + virtual void onDataTrackUnpublished(Room &, + const DataTrackUnpublishedEvent &) {} + // ------------------------------------------------------------------ // Participants snapshot // ------------------------------------------------------------------ diff --git a/include/livekit/room_event_types.h b/include/livekit/room_event_types.h index 4968a7a6..553f79c8 100644 --- a/include/livekit/room_event_types.h +++ b/include/livekit/room_event_types.h @@ -739,4 +739,12 @@ struct DataTrackPublishedEvent { std::shared_ptr track; }; +/** + * Fired when a remote participant unpublishes a data track. + */ +struct DataTrackUnpublishedEvent { + /** SID of the track that was unpublished. */ + std::string sid; +}; + } // namespace livekit diff --git a/src/data_track_subscription.cpp b/src/data_track_subscription.cpp index 94e34c51..3d687e26 100644 --- a/src/data_track_subscription.cpp +++ b/src/data_track_subscription.cpp @@ -19,6 +19,7 @@ #include "data_track.pb.h" #include "ffi.pb.h" #include "ffi_client.h" +#include "livekit/lk_log.h" #include @@ -32,7 +33,6 @@ DataTrackSubscription::DataTrackSubscription( DataTrackSubscription &&other) noexcept { std::lock_guard lock(other.mutex_); queue_ = std::move(other.queue_); - capacity_ = other.capacity_; eof_ = other.eof_; closed_ = other.closed_; subscription_handle_ = std::move(other.subscription_handle_); @@ -55,7 +55,6 @@ DataTrackSubscription::operator=(DataTrackSubscription &&other) noexcept { std::lock_guard lock_other(other.mutex_); queue_ = std::move(other.queue_); - capacity_ = other.capacity_; eof_ = other.eof_; closed_ = other.closed_; subscription_handle_ = std::move(other.subscription_handle_); @@ -68,10 +67,8 @@ DataTrackSubscription::operator=(DataTrackSubscription &&other) noexcept { return *this; } -void DataTrackSubscription::init(FfiHandle subscription_handle, - const Options &options) { +void DataTrackSubscription::init(FfiHandle subscription_handle) { subscription_handle_ = std::move(subscription_handle); - capacity_ = options.capacity; listener_id_ = FfiClient::instance().AddListener( [this](const FfiEvent &e) { this->onFfiEvent(e); }); @@ -141,19 +138,31 @@ void DataTrackSubscription::onFfiEvent(const FfiEvent &event) { } void DataTrackSubscription::pushFrame(DataFrame &&frame) { - { - std::lock_guard lock(mutex_); + std::lock_guard lock(mutex_); - if (closed_ || eof_) { - return; - } + if (closed_ || eof_) { + return; + } - if (capacity_ > 0 && queue_.size() >= capacity_) { - queue_.pop_front(); - } + // rust side handles buffering, so we should only really ever have one item + if (queue_.size() >= 2) { + LK_LOG_ERROR("[DataTrackSubscription] Queue size is greater than 1, this " + "should not happen"); + + // notify to try to catch up + cv_.notify_one(); + } else if (queue_.size() >= 1) { + LK_LOG_WARN("[DataTrackSubscription] Queue size is 1, are we able to " + "keep up with rust pushing data?"); + // we expect this to happen, but rarely. + queue_.push_back(std::move(frame)); + } else { + // things are nominal queue_.push_back(std::move(frame)); } + + // notify no matter what since we got a new frame cv_.notify_one(); } diff --git a/src/ffi_client.cpp b/src/ffi_client.cpp index 63e7a94a..78ed9a5b 100644 --- a/src/ffi_client.cpp +++ b/src/ffi_client.cpp @@ -669,7 +669,8 @@ FfiClient::publishDataTrackAsync(std::uint64_t local_participant_handle, } std::future -FfiClient::subscribeDataTrackAsync(std::uint64_t track_handle) { +FfiClient::subscribeDataTrackAsync(std::uint64_t track_handle, + std::optional buffer_size) { const AsyncId async_id = generateAsyncId(); auto fut = registerAsync( @@ -698,7 +699,10 @@ FfiClient::subscribeDataTrackAsync(std::uint64_t track_handle) { proto::FfiRequest req; auto *msg = req.mutable_subscribe_data_track(); msg->set_track_handle(track_handle); - msg->mutable_options(); + auto *opts = msg->mutable_options(); + if (buffer_size.has_value()) { + opts->set_buffer_size(buffer_size.value()); + } msg->set_request_async_id(async_id); try { diff --git a/src/ffi_client.h b/src/ffi_client.h index 327dbd9a..ff23d090 100644 --- a/src/ffi_client.h +++ b/src/ffi_client.h @@ -18,11 +18,13 @@ #define LIVEKIT_FFI_CLIENT_H #include +#include #include #include #include #include #include +#include #include #include @@ -129,8 +131,9 @@ class FfiClient { std::future publishDataTrackAsync(std::uint64_t local_participant_handle, const std::string &track_name); - std::future - subscribeDataTrackAsync(std::uint64_t track_handle); + std::future subscribeDataTrackAsync( + std::uint64_t track_handle, + std::optional buffer_size = std::nullopt); // Data stream functionalities std::future diff --git a/src/remote_data_track.cpp b/src/remote_data_track.cpp index 6104b2b9..7eaf9d21 100644 --- a/src/remote_data_track.cpp +++ b/src/remote_data_track.cpp @@ -20,6 +20,7 @@ #include "ffi.pb.h" #include "ffi_client.h" +#include #include namespace livekit { @@ -52,8 +53,13 @@ RemoteDataTrack::subscribe(const DataTrackSubscription::Options &options) { throw std::runtime_error("RemoteDataTrack::subscribe: invalid FFI handle"); } + std::optional buffer_size; + if (options.buffer_size > 0) { + buffer_size = static_cast(options.buffer_size); + } + auto fut = FfiClient::instance().subscribeDataTrackAsync( - static_cast(handle_.get())); + static_cast(handle_.get()), buffer_size); proto::OwnedDataTrackSubscription owned_sub = fut.get(); @@ -61,7 +67,7 @@ RemoteDataTrack::subscribe(const DataTrackSubscription::Options &options) { auto subscription = std::shared_ptr(new DataTrackSubscription()); - subscription->init(std::move(sub_handle), options); + subscription->init(std::move(sub_handle)); return subscription; } diff --git a/src/room.cpp b/src/room.cpp index 16cf7717..e537dfb0 100644 --- a/src/room.cpp +++ b/src/room.cpp @@ -676,6 +676,16 @@ void Room::OnEvent(const FfiEvent &event) { } break; } + case proto::RoomEvent::kDataTrackUnpublished: { + const auto &dtu = re.data_track_unpublished(); + LK_LOG_INFO("[Room] RoomEvent::kDataTrackUnpublished: sid={}", dtu.sid()); + DataTrackUnpublishedEvent ev; + ev.sid = dtu.sid(); + if (delegate_snapshot) { + delegate_snapshot->onDataTrackUnpublished(*this, ev); + } + break; + } case proto::RoomEvent::kTrackMuted: { TrackMutedEvent ev; bool success = false; From 92dbca2be00abb18859b04f1fa7c1f5084bcd091 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Thu, 19 Mar 2026 17:10:01 -0600 Subject: [PATCH 04/34] data tracks buffering is now handled on the rust side --- include/livekit/data_track_subscription.h | 5 +++-- src/remote_data_track.cpp | 8 +------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/include/livekit/data_track_subscription.h b/include/livekit/data_track_subscription.h index bbb6ff52..22e14858 100644 --- a/include/livekit/data_track_subscription.h +++ b/include/livekit/data_track_subscription.h @@ -24,6 +24,7 @@ #include #include #include +#include namespace livekit { @@ -51,8 +52,8 @@ class FfiEvent; class DataTrackSubscription { public: struct Options { - /// Maximum frames buffered on the Rust side. 0 = unbounded. - std::size_t buffer_size{0}; + /// Maximum frames buffered on the Rust side. Rust defaults to 16. + std::optional buffer_size{std::nullopt}; }; virtual ~DataTrackSubscription(); diff --git a/src/remote_data_track.cpp b/src/remote_data_track.cpp index 7eaf9d21..1b58beed 100644 --- a/src/remote_data_track.cpp +++ b/src/remote_data_track.cpp @@ -20,7 +20,6 @@ #include "ffi.pb.h" #include "ffi_client.h" -#include #include namespace livekit { @@ -53,13 +52,8 @@ RemoteDataTrack::subscribe(const DataTrackSubscription::Options &options) { throw std::runtime_error("RemoteDataTrack::subscribe: invalid FFI handle"); } - std::optional buffer_size; - if (options.buffer_size > 0) { - buffer_size = static_cast(options.buffer_size); - } - auto fut = FfiClient::instance().subscribeDataTrackAsync( - static_cast(handle_.get()), buffer_size); + static_cast(handle_.get()), options.buffer_size); proto::OwnedDataTrackSubscription owned_sub = fut.get(); From e1e6f6706fe4986095c7685385fbd479de7a1585 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Thu, 19 Mar 2026 19:39:48 -0600 Subject: [PATCH 05/34] replace cout/cerr with LK_LOG --- bridge/src/livekit_bridge.cpp | 50 +++++++++++++++++------------------ bridge/src/rpc_controller.cpp | 5 ++-- 2 files changed, 27 insertions(+), 28 deletions(-) diff --git a/bridge/src/livekit_bridge.cpp b/bridge/src/livekit_bridge.cpp index 9f782904..79c85e7e 100644 --- a/bridge/src/livekit_bridge.cpp +++ b/bridge/src/livekit_bridge.cpp @@ -294,14 +294,14 @@ LiveKitBridge::performRpc(const std::string &destination_identity, try { return rpc_controller_->performRpc(destination_identity, method, payload, response_timeout); - } catch (const std::exception &e) { - std::cerr << "[LiveKitBridge] Exception: " << e.what() << "\n"; + } catch (const livekit::RpcError &e) { + LK_LOG_WARN("[LiveKitBridge] RPC error: {}", e.what()); return std::nullopt; } catch (const std::runtime_error &e) { - std::cerr << "[LiveKitBridge] Runtime error: " << e.what() << "\n"; + LK_LOG_ERROR("[LiveKitBridge] Runtime error: {}", e.what()); return std::nullopt; - } catch (const livekit::RpcError &e) { - std::cerr << "[LiveKitBridge] RPC error: " << e.what() << "\n"; + } catch (const std::exception &e) { + LK_LOG_ERROR("[LiveKitBridge] Exception: {}", e.what()); return std::nullopt; } } @@ -316,14 +316,14 @@ bool LiveKitBridge::registerRpcMethod( try { rpc_controller_->registerRpcMethod(method_name, std::move(handler)); return true; - } catch (const std::exception &e) { - std::cerr << "[LiveKitBridge] Exception: " << e.what() << "\n"; + } catch (const livekit::RpcError &e) { + LK_LOG_WARN("[LiveKitBridge] RPC error: {}", e.what()); return false; } catch (const std::runtime_error &e) { - std::cerr << "[LiveKitBridge] Runtime error: " << e.what() << "\n"; + LK_LOG_ERROR("[LiveKitBridge] Runtime error: {}", e.what()); return false; - } catch (const livekit::RpcError &e) { - std::cerr << "[LiveKitBridge] RPC error: " << e.what() << "\n"; + } catch (const std::exception &e) { + LK_LOG_ERROR("[LiveKitBridge] Exception: {}", e.what()); return false; } } @@ -335,14 +335,14 @@ bool LiveKitBridge::unregisterRpcMethod(const std::string &method_name) { try { rpc_controller_->unregisterRpcMethod(method_name); return true; - } catch (const std::exception &e) { - std::cerr << "[LiveKitBridge] Exception: " << e.what() << "\n"; + } catch (const livekit::RpcError &e) { + LK_LOG_WARN("[LiveKitBridge] RPC error: {}", e.what()); return false; } catch (const std::runtime_error &e) { - std::cerr << "[LiveKitBridge] Runtime error: " << e.what() << "\n"; + LK_LOG_ERROR("[LiveKitBridge] Runtime error: {}", e.what()); return false; - } catch (const livekit::RpcError &e) { - std::cerr << "[LiveKitBridge] RPC error: " << e.what() << "\n"; + } catch (const std::exception &e) { + LK_LOG_ERROR("[LiveKitBridge] Exception: {}", e.what()); return false; } } @@ -355,14 +355,14 @@ bool LiveKitBridge::requestRemoteTrackMute( try { rpc_controller_->requestRemoteTrackMute(destination_identity, track_name); return true; - } catch (const std::exception &e) { - std::cerr << "[LiveKitBridge] Exception: " << e.what() << "\n"; + } catch (const livekit::RpcError &e) { + LK_LOG_WARN("[LiveKitBridge] RPC error: {}", e.what()); return false; } catch (const std::runtime_error &e) { - std::cerr << "[LiveKitBridge] Runtime error: " << e.what() << "\n"; + LK_LOG_ERROR("[LiveKitBridge] Runtime error: {}", e.what()); return false; - } catch (const livekit::RpcError &e) { - std::cerr << "[LiveKitBridge] RPC error: " << e.what() << "\n"; + } catch (const std::exception &e) { + LK_LOG_ERROR("[LiveKitBridge] Exception: {}", e.what()); return false; } } @@ -375,14 +375,14 @@ bool LiveKitBridge::requestRemoteTrackUnmute( try { rpc_controller_->requestRemoteTrackUnmute(destination_identity, track_name); return true; - } catch (const std::exception &e) { - std::cerr << "[LiveKitBridge] Exception: " << e.what() << "\n"; + } catch (const livekit::RpcError &e) { + LK_LOG_WARN("[LiveKitBridge] RPC error: {}", e.what()); return false; } catch (const std::runtime_error &e) { - std::cerr << "[LiveKitBridge] Runtime error: " << e.what() << "\n"; + LK_LOG_ERROR("[LiveKitBridge] Runtime error: {}", e.what()); return false; - } catch (const livekit::RpcError &e) { - std::cerr << "[LiveKitBridge] RPC error: " << e.what() << "\n"; + } catch (const std::exception &e) { + LK_LOG_ERROR("[LiveKitBridge] Exception: {}", e.what()); return false; } } diff --git a/bridge/src/rpc_controller.cpp b/bridge/src/rpc_controller.cpp index 31514666..c6004218 100644 --- a/bridge/src/rpc_controller.cpp +++ b/bridge/src/rpc_controller.cpp @@ -20,11 +20,11 @@ #include "rpc_controller.h" #include "livekit_bridge/rpc_constants.h" +#include "livekit/lk_log.h" #include "livekit/local_participant.h" #include "livekit/rpc_error.h" #include -#include namespace livekit_bridge { @@ -117,8 +117,7 @@ std::optional RpcController::handleTrackControlRpc(const livekit::RpcInvocationData &data) { namespace tc = rpc::track_control; - std::cout << "[RpcController] Handling track control RPC: " << data.payload - << "\n"; + LK_LOG_DEBUG("[RpcController] Handling track control RPC: {}", data.payload); auto delim = data.payload.find(tc::kDelimiter); if (delim == std::string::npos || delim == 0) { throw livekit::RpcError( From 1d7579f62177019825496e9fdbb4dbd1254ef873 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Fri, 20 Mar 2026 13:30:37 -0600 Subject: [PATCH 06/34] no longer need BridgeDataTrack() --- .../livekit_bridge/bridge_data_track.h | 127 ----------------- bridge/src/bridge_data_track.cpp | 106 -------------- bridge/src/livekit_bridge.cpp | 50 +++---- bridge/src/rpc_controller.cpp | 5 +- .../test_bridge_data_roundtrip.cpp | 12 +- .../stress/test_bridge_callback_stress.cpp | 2 +- .../tests/stress/test_bridge_data_stress.cpp | 6 +- .../stress/test_bridge_lifecycle_stress.cpp | 23 +-- .../stress/test_bridge_multi_track_stress.cpp | 12 +- bridge/tests/unit/test_bridge_data_track.cpp | 132 ------------------ examples/bridge_human_robot/robot.cpp | 4 +- .../realsense-to-mcap/src/realsense_rgbd.cpp | 16 +-- include/livekit/local_data_track.h | 29 +++- include/livekit/local_participant.h | 9 +- src/local_data_track.cpp | 42 ++++++ src/local_participant.cpp | 11 +- 16 files changed, 142 insertions(+), 444 deletions(-) delete mode 100644 bridge/include/livekit_bridge/bridge_data_track.h delete mode 100644 bridge/src/bridge_data_track.cpp delete mode 100644 bridge/tests/unit/test_bridge_data_track.cpp diff --git a/bridge/include/livekit_bridge/bridge_data_track.h b/bridge/include/livekit_bridge/bridge_data_track.h deleted file mode 100644 index e009f955..00000000 --- a/bridge/include/livekit_bridge/bridge_data_track.h +++ /dev/null @@ -1,127 +0,0 @@ -/* - * Copyright 2026 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#pragma once - -#include -#include -#include -#include -#include -#include - -namespace livekit { -class LocalDataTrack; -class LocalParticipant; -} // namespace livekit - -namespace livekit_bridge { - -namespace test { -class BridgeDataTrackTest; -} // namespace test - -/** - * Handle to a published local data track. - * - * Created via LiveKitBridge::createDataTrack(). The bridge retains a - * reference to every track it creates and will automatically release all - * tracks when disconnect() is called. To unpublish a track mid-session, - * call release() explicitly. - * - * Unlike BridgeAudioTrack / BridgeVideoTrack, data tracks have no - * Source, Publication, mute(), or unmute(). They carry arbitrary binary - * frames via pushFrame(). - * - * All public methods are thread-safe. - * - * Usage: - * auto dt = bridge.createDataTrack("sensor-data"); - * dt->pushFrame({0x01, 0x02, 0x03}); - * dt->release(); // unpublishes mid-session - */ -class BridgeDataTrack { -public: - ~BridgeDataTrack(); - - BridgeDataTrack(const BridgeDataTrack &) = delete; - BridgeDataTrack &operator=(const BridgeDataTrack &) = delete; - - /** - * Push a binary frame to all subscribers of this data track. - * - * @param payload Raw bytes to send. - * @param user_timestamp Optional application-defined timestamp. - * @return true if the frame was pushed, false if the track has been - * released or the push failed (e.g. back-pressure). - */ - bool pushFrame(const std::vector &payload, - std::optional user_timestamp = std::nullopt); - - /** - * Push a binary frame from a raw pointer. - * - * @param data Pointer to raw bytes. - * @param size Number of bytes. - * @param user_timestamp Optional application-defined timestamp. - * @return true on success, false if released or push failed. - */ - bool pushFrame(const std::uint8_t *data, std::size_t size, - std::optional user_timestamp = std::nullopt); - - /// Track name as provided at creation. - const std::string &name() const noexcept { return name_; } - - /// Whether the track is still published in the room. - bool isPublished() const; - - /// Whether this track has been released / unpublished. - bool isReleased() const noexcept; - - /** - * Explicitly unpublish the track and release underlying SDK resources. - * - * After this call, pushFrame() returns false. Called automatically by the - * destructor and by LiveKitBridge::disconnect(). Safe to call multiple - * times (idempotent). - */ - void release(); - -private: - friend class LiveKitBridge; - friend class test::BridgeDataTrackTest; - - BridgeDataTrack(std::string name, - std::shared_ptr track, - livekit::LocalParticipant *participant); - - /** Protects released_ and track_ for thread-safe access. */ - mutable std::mutex mutex_; - - /** Publisher-assigned track name (immutable after construction). */ - std::string name_; - - /** True after release() or disconnect(); prevents further pushFrame(). */ - bool released_ = false; - - /** Underlying SDK data track handle. Nulled on release(). */ - std::shared_ptr track_; - - /** Participant that published this track; used for unpublish. Not owned. */ - livekit::LocalParticipant *participant_ = nullptr; -}; - -} // namespace livekit_bridge diff --git a/bridge/src/bridge_data_track.cpp b/bridge/src/bridge_data_track.cpp deleted file mode 100644 index d69512b5..00000000 --- a/bridge/src/bridge_data_track.cpp +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright 2026 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#include "livekit_bridge/bridge_data_track.h" - -#include "livekit/lk_log.h" - -#include "livekit/data_frame.h" -#include "livekit/local_data_track.h" -#include "livekit/local_participant.h" - -namespace livekit_bridge { - -BridgeDataTrack::BridgeDataTrack(std::string name, - std::shared_ptr track, - livekit::LocalParticipant *participant) - : name_(std::move(name)), track_(std::move(track)), - participant_(participant) {} - -BridgeDataTrack::~BridgeDataTrack() { release(); } - -bool BridgeDataTrack::pushFrame(const std::vector &payload, - std::optional user_timestamp) { - std::lock_guard lock(mutex_); - if (released_ || !track_) { - return false; - } - - livekit::DataFrame frame; - frame.payload = payload; - frame.user_timestamp = user_timestamp; - - try { - return track_->tryPush(frame); - } catch (const std::exception &e) { - LK_LOG_ERROR("[BridgeDataTrack] tryPush error: {}", e.what()); - return false; - } -} - -bool BridgeDataTrack::pushFrame(const std::uint8_t *data, std::size_t size, - std::optional user_timestamp) { - std::lock_guard lock(mutex_); - if (released_ || !track_) { - return false; - } - - livekit::DataFrame frame; - frame.payload.assign(data, data + size); - frame.user_timestamp = user_timestamp; - - try { - return track_->tryPush(frame); - } catch (const std::exception &e) { - LK_LOG_ERROR("[BridgeDataTrack] tryPush error: {}", e.what()); - return false; - } -} - -bool BridgeDataTrack::isPublished() const { - std::lock_guard lock(mutex_); - if (released_ || !track_) { - return false; - } - return track_->isPublished(); -} - -bool BridgeDataTrack::isReleased() const noexcept { - std::lock_guard lock(mutex_); - return released_; -} - -void BridgeDataTrack::release() { - std::lock_guard lock(mutex_); - if (released_) { - return; - } - released_ = true; - - if (participant_ && track_) { - try { - participant_->unpublishDataTrack(track_); - } catch (...) { - LK_LOG_ERROR("[BridgeDataTrack] unpublishDataTrack error, continuing " - "with cleanup"); - } - } - - track_.reset(); - participant_ = nullptr; -} - -} // namespace livekit_bridge diff --git a/bridge/src/livekit_bridge.cpp b/bridge/src/livekit_bridge.cpp index 79c85e7e..9f782904 100644 --- a/bridge/src/livekit_bridge.cpp +++ b/bridge/src/livekit_bridge.cpp @@ -294,14 +294,14 @@ LiveKitBridge::performRpc(const std::string &destination_identity, try { return rpc_controller_->performRpc(destination_identity, method, payload, response_timeout); - } catch (const livekit::RpcError &e) { - LK_LOG_WARN("[LiveKitBridge] RPC error: {}", e.what()); + } catch (const std::exception &e) { + std::cerr << "[LiveKitBridge] Exception: " << e.what() << "\n"; return std::nullopt; } catch (const std::runtime_error &e) { - LK_LOG_ERROR("[LiveKitBridge] Runtime error: {}", e.what()); + std::cerr << "[LiveKitBridge] Runtime error: " << e.what() << "\n"; return std::nullopt; - } catch (const std::exception &e) { - LK_LOG_ERROR("[LiveKitBridge] Exception: {}", e.what()); + } catch (const livekit::RpcError &e) { + std::cerr << "[LiveKitBridge] RPC error: " << e.what() << "\n"; return std::nullopt; } } @@ -316,14 +316,14 @@ bool LiveKitBridge::registerRpcMethod( try { rpc_controller_->registerRpcMethod(method_name, std::move(handler)); return true; - } catch (const livekit::RpcError &e) { - LK_LOG_WARN("[LiveKitBridge] RPC error: {}", e.what()); + } catch (const std::exception &e) { + std::cerr << "[LiveKitBridge] Exception: " << e.what() << "\n"; return false; } catch (const std::runtime_error &e) { - LK_LOG_ERROR("[LiveKitBridge] Runtime error: {}", e.what()); + std::cerr << "[LiveKitBridge] Runtime error: " << e.what() << "\n"; return false; - } catch (const std::exception &e) { - LK_LOG_ERROR("[LiveKitBridge] Exception: {}", e.what()); + } catch (const livekit::RpcError &e) { + std::cerr << "[LiveKitBridge] RPC error: " << e.what() << "\n"; return false; } } @@ -335,14 +335,14 @@ bool LiveKitBridge::unregisterRpcMethod(const std::string &method_name) { try { rpc_controller_->unregisterRpcMethod(method_name); return true; - } catch (const livekit::RpcError &e) { - LK_LOG_WARN("[LiveKitBridge] RPC error: {}", e.what()); + } catch (const std::exception &e) { + std::cerr << "[LiveKitBridge] Exception: " << e.what() << "\n"; return false; } catch (const std::runtime_error &e) { - LK_LOG_ERROR("[LiveKitBridge] Runtime error: {}", e.what()); + std::cerr << "[LiveKitBridge] Runtime error: " << e.what() << "\n"; return false; - } catch (const std::exception &e) { - LK_LOG_ERROR("[LiveKitBridge] Exception: {}", e.what()); + } catch (const livekit::RpcError &e) { + std::cerr << "[LiveKitBridge] RPC error: " << e.what() << "\n"; return false; } } @@ -355,14 +355,14 @@ bool LiveKitBridge::requestRemoteTrackMute( try { rpc_controller_->requestRemoteTrackMute(destination_identity, track_name); return true; - } catch (const livekit::RpcError &e) { - LK_LOG_WARN("[LiveKitBridge] RPC error: {}", e.what()); + } catch (const std::exception &e) { + std::cerr << "[LiveKitBridge] Exception: " << e.what() << "\n"; return false; } catch (const std::runtime_error &e) { - LK_LOG_ERROR("[LiveKitBridge] Runtime error: {}", e.what()); + std::cerr << "[LiveKitBridge] Runtime error: " << e.what() << "\n"; return false; - } catch (const std::exception &e) { - LK_LOG_ERROR("[LiveKitBridge] Exception: {}", e.what()); + } catch (const livekit::RpcError &e) { + std::cerr << "[LiveKitBridge] RPC error: " << e.what() << "\n"; return false; } } @@ -375,14 +375,14 @@ bool LiveKitBridge::requestRemoteTrackUnmute( try { rpc_controller_->requestRemoteTrackUnmute(destination_identity, track_name); return true; - } catch (const livekit::RpcError &e) { - LK_LOG_WARN("[LiveKitBridge] RPC error: {}", e.what()); + } catch (const std::exception &e) { + std::cerr << "[LiveKitBridge] Exception: " << e.what() << "\n"; return false; } catch (const std::runtime_error &e) { - LK_LOG_ERROR("[LiveKitBridge] Runtime error: {}", e.what()); + std::cerr << "[LiveKitBridge] Runtime error: " << e.what() << "\n"; return false; - } catch (const std::exception &e) { - LK_LOG_ERROR("[LiveKitBridge] Exception: {}", e.what()); + } catch (const livekit::RpcError &e) { + std::cerr << "[LiveKitBridge] RPC error: " << e.what() << "\n"; return false; } } diff --git a/bridge/src/rpc_controller.cpp b/bridge/src/rpc_controller.cpp index c6004218..31514666 100644 --- a/bridge/src/rpc_controller.cpp +++ b/bridge/src/rpc_controller.cpp @@ -20,11 +20,11 @@ #include "rpc_controller.h" #include "livekit_bridge/rpc_constants.h" -#include "livekit/lk_log.h" #include "livekit/local_participant.h" #include "livekit/rpc_error.h" #include +#include namespace livekit_bridge { @@ -117,7 +117,8 @@ std::optional RpcController::handleTrackControlRpc(const livekit::RpcInvocationData &data) { namespace tc = rpc::track_control; - LK_LOG_DEBUG("[RpcController] Handling track control RPC: {}", data.payload); + std::cout << "[RpcController] Handling track control RPC: " << data.payload + << "\n"; auto delim = data.payload.find(tc::kDelimiter); if (delim == std::string::npos || delim == 0) { throw livekit::RpcError( diff --git a/bridge/tests/integration/test_bridge_data_roundtrip.cpp b/bridge/tests/integration/test_bridge_data_roundtrip.cpp index 70c1899b..f906c91f 100644 --- a/bridge/tests/integration/test_bridge_data_roundtrip.cpp +++ b/bridge/tests/integration/test_bridge_data_roundtrip.cpp @@ -88,8 +88,8 @@ TEST_F(BridgeDataRoundtripTest, DataFrameRoundTrip) { sent_payloads.push_back(payload); sent_timestamps.push_back(ts); - bool pushed = data_track->pushFrame(payload, ts); - EXPECT_TRUE(pushed) << "pushFrame failed for frame " << i; + bool pushed = data_track->tryPush(payload, ts); + EXPECT_TRUE(pushed) << "tryPush failed for frame " << i; std::this_thread::sleep_for(100ms); } @@ -168,7 +168,7 @@ TEST_F(BridgeDataRoundtripTest, LateCallbackRegistration) { const int num_frames = 5; for (int i = 0; i < num_frames; ++i) { auto payload = generatePayload(128); - data_track->pushFrame(payload); + data_track->tryPush(payload); std::this_thread::sleep_for(100ms); } @@ -231,8 +231,8 @@ TEST_F(BridgeDataRoundtripTest, VaryingPayloadSizes) { for (size_t sz : test_sizes) { auto payload = generatePayload(sz); - bool pushed = data_track->pushFrame(payload); - EXPECT_TRUE(pushed) << "pushFrame failed for size " << sz; + bool pushed = data_track->tryPush(payload); + EXPECT_TRUE(pushed) << "tryPush failed for size " << sz; std::this_thread::sleep_for(200ms); } @@ -282,7 +282,7 @@ TEST_F(BridgeDataRoundtripTest, ConnectPublishDisconnectCycle) { for (int f = 0; f < 5; ++f) { auto payload = generatePayload(256); - track->pushFrame(payload); + track->tryPush(payload); } } // bridge destroyed here → disconnect + shutdown diff --git a/bridge/tests/stress/test_bridge_callback_stress.cpp b/bridge/tests/stress/test_bridge_callback_stress.cpp index 7574a6d1..f2b3eb22 100644 --- a/bridge/tests/stress/test_bridge_callback_stress.cpp +++ b/bridge/tests/stress/test_bridge_callback_stress.cpp @@ -157,7 +157,7 @@ TEST_F(BridgeCallbackStressTest, MixedCallbackChurn) { std::thread data_pusher([&]() { while (running.load()) { auto payload = cbPayload(256); - data->pushFrame(payload); + data->tryPush(payload); std::this_thread::sleep_for(20ms); } }); diff --git a/bridge/tests/stress/test_bridge_data_stress.cpp b/bridge/tests/stress/test_bridge_data_stress.cpp index 23937df9..ec23d711 100644 --- a/bridge/tests/stress/test_bridge_data_stress.cpp +++ b/bridge/tests/stress/test_bridge_data_stress.cpp @@ -80,7 +80,7 @@ TEST_F(BridgeDataStressTest, HighThroughput) { auto payload = randomPayload(kPayloadSize); auto t0 = std::chrono::high_resolution_clock::now(); - bool ok = data_track->pushFrame(payload); + bool ok = data_track->tryPush(payload); auto t1 = std::chrono::high_resolution_clock::now(); double latency_ms = @@ -201,7 +201,7 @@ TEST_F(BridgeDataStressTest, LargePayloadStress) { auto payload = randomPayload(kLargePayloadSize); auto t0 = std::chrono::high_resolution_clock::now(); - bool ok = data_track->pushFrame(payload); + bool ok = data_track->tryPush(payload); auto t1 = std::chrono::high_resolution_clock::now(); double latency_ms = @@ -297,7 +297,7 @@ TEST_F(BridgeDataStressTest, CallbackChurn) { std::thread sender([&]() { while (running.load()) { auto payload = randomPayload(256); - data_track->pushFrame(payload); + data_track->tryPush(payload); std::this_thread::sleep_for(10ms); } }); diff --git a/bridge/tests/stress/test_bridge_lifecycle_stress.cpp b/bridge/tests/stress/test_bridge_lifecycle_stress.cpp index 4d37161f..a610a0a2 100644 --- a/bridge/tests/stress/test_bridge_lifecycle_stress.cpp +++ b/bridge/tests/stress/test_bridge_lifecycle_stress.cpp @@ -100,7 +100,7 @@ TEST_F(BridgeLifecycleStressTest, DisconnectUnderLoad) { std::thread data_pusher([&]() { while (running.load()) { auto payload = makePayload(512); - data->pushFrame(payload); + data->tryPush(payload); std::this_thread::sleep_for(20ms); } }); @@ -184,10 +184,10 @@ TEST_F(BridgeLifecycleStressTest, TrackReleaseWhileReceiving) { std::thread data_pusher([&]() { while (running.load()) { - if (data->isReleased()) + if (!data->isPublished()) break; auto payload = makePayload(256); - data->pushFrame(payload); + data->tryPush(payload); std::this_thread::sleep_for(20ms); } }); @@ -200,19 +200,19 @@ TEST_F(BridgeLifecycleStressTest, TrackReleaseWhileReceiving) { std::this_thread::sleep_for(200ms); - data->release(); - EXPECT_TRUE(data->isReleased()); + data->unpublishDataTrack(); + EXPECT_FALSE(data->isPublished()); running.store(false); audio_pusher.join(); data_pusher.join(); - // pushFrame must return false on released tracks + // pushFrame / tryPush must return false on released / unpublished tracks auto silence = makeSilent(kLifecycleSamplesPerFrame); EXPECT_FALSE(audio->pushFrame(silence, kLifecycleSamplesPerFrame)); auto payload = makePayload(64); - EXPECT_FALSE(data->pushFrame(payload)); + EXPECT_FALSE(data->tryPush(payload)); receiver.clearOnAudioFrameCallback( caller_identity, livekit::TrackSource::SOURCE_MICROPHONE); @@ -270,19 +270,20 @@ TEST_F(BridgeLifecycleStressTest, FullLifecycleSoak) { video->pushFrame(rgba); auto payload = makePayload(256); - data->pushFrame(payload); + data->tryPush(payload); std::this_thread::sleep_for( std::chrono::milliseconds(kLifecycleFrameDurationMs)); } - // Explicit release in various orders to exercise different teardown paths + // Explicit release / unpublish in various orders to exercise different + // teardown paths if (i % 3 == 0) { audio->release(); video->release(); - data->release(); + data->unpublishDataTrack(); } else if (i % 3 == 1) { - data->release(); + data->unpublishDataTrack(); audio->release(); video->release(); } diff --git a/bridge/tests/stress/test_bridge_multi_track_stress.cpp b/bridge/tests/stress/test_bridge_multi_track_stress.cpp index c54d2ca7..8ade04be 100644 --- a/bridge/tests/stress/test_bridge_multi_track_stress.cpp +++ b/bridge/tests/stress/test_bridge_multi_track_stress.cpp @@ -131,7 +131,7 @@ TEST_F(BridgeMultiTrackStressTest, ConcurrentMultiTrackPush) { std::thread data1_thread([&]() { while (running.load()) { auto payload = mtPayload(512); - bool ok = data1->pushFrame(payload); + bool ok = data1->tryPush(payload); data1_stats.pushes++; if (ok) data1_stats.successes++; @@ -144,7 +144,7 @@ TEST_F(BridgeMultiTrackStressTest, ConcurrentMultiTrackPush) { std::thread data2_thread([&]() { while (running.load()) { auto payload = mtPayload(2048); - bool ok = data2->pushFrame(payload); + bool ok = data2->tryPush(payload); data2_stats.pushes++; if (ok) data2_stats.successes++; @@ -267,11 +267,11 @@ TEST_F(BridgeMultiTrackStressTest, ConcurrentCreateRelease) { for (int i = 0; i < 5; ++i) { auto payload = mtPayload(128); - track->pushFrame(payload); + track->tryPush(payload); std::this_thread::sleep_for(20ms); } - track->release(); + track->unpublishDataTrack(); data_cycles++; } catch (const std::exception &e) { errors++; @@ -374,10 +374,10 @@ TEST_F(BridgeMultiTrackStressTest, FullDuplexMultiTrack) { }; auto data_push_fn = - [&](std::shared_ptr track) { + [&](std::shared_ptr track) { while (running.load()) { auto payload = mtPayload(256); - track->pushFrame(payload); + track->tryPush(payload); std::this_thread::sleep_for(20ms); } }; diff --git a/bridge/tests/unit/test_bridge_data_track.cpp b/bridge/tests/unit/test_bridge_data_track.cpp deleted file mode 100644 index f490cc44..00000000 --- a/bridge/tests/unit/test_bridge_data_track.cpp +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright 2026 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#include -#include - -#include -#include -#include - -namespace livekit_bridge { -namespace test { - -class BridgeDataTrackTest : public ::testing::Test { -protected: - /// Create a BridgeDataTrack with a null SDK track for pure-logic testing. - /// The track is usable for accessor and state management tests but - /// pushFrame / isPublished will return false without a real LocalDataTrack. - static BridgeDataTrack createNullTrack(const std::string &name = "data") { - return BridgeDataTrack(name, std::shared_ptr{}, - nullptr); - } -}; - -TEST_F(BridgeDataTrackTest, AccessorsReturnConstructionValues) { - auto track = createNullTrack("sensor-data"); - - EXPECT_EQ(track.name(), "sensor-data") - << "Name should match construction value"; -} - -TEST_F(BridgeDataTrackTest, InitiallyNotReleased) { - auto track = createNullTrack(); - - EXPECT_FALSE(track.isReleased()) - << "Track should not be released immediately after construction"; -} - -TEST_F(BridgeDataTrackTest, ReleaseMarksTrackAsReleased) { - auto track = createNullTrack(); - - track.release(); - - EXPECT_TRUE(track.isReleased()) - << "Track should be released after calling release()"; -} - -TEST_F(BridgeDataTrackTest, DoubleReleaseIsIdempotent) { - auto track = createNullTrack(); - - track.release(); - EXPECT_NO_THROW(track.release()) - << "Calling release() a second time should be a no-op"; - EXPECT_TRUE(track.isReleased()); -} - -TEST_F(BridgeDataTrackTest, PushFrameVectorAfterReleaseReturnsFalse) { - auto track = createNullTrack(); - track.release(); - - std::vector payload = {0x01, 0x02, 0x03}; - - EXPECT_FALSE(track.pushFrame(payload)) - << "pushFrame (vector) on a released track should return false"; -} - -TEST_F(BridgeDataTrackTest, PushFrameVectorWithTimestampAfterReleaseReturnsFalse) { - auto track = createNullTrack(); - track.release(); - - std::vector payload = {0x01, 0x02}; - EXPECT_FALSE(track.pushFrame(payload, 12345u)) - << "pushFrame (vector, timestamp) on a released track should return false"; -} - -TEST_F(BridgeDataTrackTest, PushFrameRawPointerAfterReleaseReturnsFalse) { - auto track = createNullTrack(); - track.release(); - - std::vector payload = {0x01, 0x02, 0x03}; - - EXPECT_FALSE(track.pushFrame(payload.data(), payload.size())) - << "pushFrame (raw pointer) on a released track should return false"; -} - -TEST_F(BridgeDataTrackTest, PushFrameRawPointerWithTimestampAfterReleaseReturnsFalse) { - auto track = createNullTrack(); - track.release(); - - std::uint8_t data[] = {0xab, 0xcd}; - EXPECT_FALSE(track.pushFrame(data, 2, 99999u)) - << "pushFrame (raw pointer, timestamp) on a released track should return false"; -} - -TEST_F(BridgeDataTrackTest, PushFrameWithNullTrackReturnsFalse) { - auto track = createNullTrack(); - - std::vector payload = {0x01}; - EXPECT_FALSE(track.pushFrame(payload)) - << "pushFrame with null underlying track should return false"; -} - -TEST_F(BridgeDataTrackTest, IsPublishedWithNullTrackReturnsFalse) { - auto track = createNullTrack(); - - EXPECT_FALSE(track.isPublished()) - << "isPublished() with null track should return false"; -} - -TEST_F(BridgeDataTrackTest, IsPublishedAfterReleaseReturnsFalse) { - auto track = createNullTrack(); - track.release(); - - EXPECT_FALSE(track.isPublished()) - << "isPublished() after release() should return false"; -} - -} // namespace test -} // namespace livekit_bridge diff --git a/examples/bridge_human_robot/robot.cpp b/examples/bridge_human_robot/robot.cpp index 5dd4f23c..b8fd4513 100644 --- a/examples/bridge_human_robot/robot.cpp +++ b/examples/bridge_human_robot/robot.cpp @@ -397,7 +397,7 @@ int main(int argc, char *argv[]) { LK_LOG_INFO("[robot] Publishing {} sim audio ({} Hz, {} ch), cam + sim frame " "({}x{} / {}x{}), data track.", use_mic ? "mic + " : "(no mic) ", kSampleRate, kChannels, kWidth, - kHeight, kSimWidth, kSimHeight, data_track->name()); + kHeight, kSimWidth, kSimHeight, data_track->info().name); // ----- SDL Mic capture (only when use_mic) ----- // SDLMicSource pulls 10ms frames from the default recording device and @@ -635,7 +635,7 @@ int main(int argc, char *argv[]) { std::string msg = "robot status #" + std::to_string(seq) + " uptime=" + std::to_string(elapsed_ms) + "ms"; std::vector payload(msg.begin(), msg.end()); - if (!data_track->pushFrame(payload)) { + if (!data_track->tryPush(payload)) { break; } ++seq; diff --git a/examples/realsense-livekit/realsense-to-mcap/src/realsense_rgbd.cpp b/examples/realsense-livekit/realsense-to-mcap/src/realsense_rgbd.cpp index f2d45029..49719d9e 100644 --- a/examples/realsense-livekit/realsense-to-mcap/src/realsense_rgbd.cpp +++ b/examples/realsense-livekit/realsense-to-mcap/src/realsense_rgbd.cpp @@ -29,8 +29,8 @@ * to receive and record to MCAP. */ -#include "livekit_bridge/bridge_data_track.h" #include "livekit_bridge/livekit_bridge.h" +#include "livekit/local_data_track.h" #include "livekit/track.h" #include "BuildFileDescriptorSet.h" @@ -275,9 +275,9 @@ int main(int argc, char* argv[]) { } std::shared_ptr video_track; - std::shared_ptr depth_track; - std::shared_ptr pose_track; - std::shared_ptr hello_track; + std::shared_ptr depth_track; + std::shared_ptr pose_track; + std::shared_ptr hello_track; try { video_track = bridge.createVideoTrack("camera/color", kWidth, kHeight, @@ -326,7 +326,7 @@ int main(int argc, char* argv[]) { ++hello_seq; std::string text = "hello viewer #" + std::to_string(hello_seq); uint64_t ts_us = static_cast(nowNs() / 1000); - bool ok = hello_track->pushFrame( + bool ok = hello_track->tryPush( reinterpret_cast(text.data()), text.size(), ts_us); std::cout << "[" << nowStr() << "] [realsense_rgbd] Sent hello #" @@ -392,13 +392,13 @@ int main(int argc, char* argv[]) { auto push_start = std::chrono::steady_clock::now(); bool ok = false; if (zrc == Z_OK) { - ok = depth_track->pushFrame( + ok = depth_track->tryPush( compressed.data(), static_cast(comp_size), static_cast(timestamp_us)); } else { std::cerr << "[realsense_rgbd] zlib compress failed (" << zrc << "), sending uncompressed\n"; - ok = depth_track->pushFrame( + ok = depth_track->tryPush( reinterpret_cast(serialized.data()), serialized.size(), static_cast(timestamp_us)); } @@ -461,7 +461,7 @@ int main(int argc, char* argv[]) { orient->set_w(orientation.w); std::string pose_serialized = pose_msg.SerializeAsString(); - bool pose_ok = pose_track->pushFrame( + bool pose_ok = pose_track->tryPush( reinterpret_cast(pose_serialized.data()), pose_serialized.size(), static_cast(timestamp_us)); ++pose_pushed; diff --git a/include/livekit/local_data_track.h b/include/livekit/local_data_track.h index 285e9930..2375d728 100644 --- a/include/livekit/local_data_track.h +++ b/include/livekit/local_data_track.h @@ -20,8 +20,11 @@ #include "livekit/data_track_info.h" #include "livekit/ffi_handle.h" +#include #include +#include #include +#include namespace livekit { @@ -45,7 +48,7 @@ class OwnedLocalDataTrack; * DataFrame frame; * frame.payload = {0x01, 0x02, 0x03}; * dt->tryPush(frame); - * lp->unpublishDataTrack(dt); + * dt->unpublishDataTrack(); */ class LocalDataTrack { public: @@ -65,9 +68,33 @@ class LocalDataTrack { */ bool tryPush(const DataFrame &frame); + /** + * Try to push a frame to all subscribers of this track. + * + * @return true on success, false if the push failed (e.g. back-pressure + * or the track has been unpublished). + */ + bool tryPush(const std::vector &payload, + std::optional user_timestamp = std::nullopt); + /** + * Try to push a frame to all subscribers of this track. + * + * @return true on success, false if the push failed (e.g. back-pressure + * or the track has been unpublished). + */ + bool tryPush(const std::uint8_t *data, std::size_t size, + std::optional user_timestamp = std::nullopt); + /// Whether the track is still published in the room. bool isPublished() const; + /** + * Unpublish this data track from the room. + * + * After this call, tryPush() fails and the track cannot be re-published. + */ + void unpublishDataTrack(); + private: friend class LocalParticipant; diff --git a/include/livekit/local_participant.h b/include/livekit/local_participant.h index ad57f77f..f37bdfdb 100644 --- a/include/livekit/local_participant.h +++ b/include/livekit/local_participant.h @@ -176,7 +176,8 @@ class LocalParticipant : public Participant { * * Data tracks carry arbitrary binary frames and are independent of the * audio/video track hierarchy. The returned LocalDataTrack can push - * frames via tryPush() and be unpublished via unpublishDataTrack(). + * frames via tryPush() and be unpublished via LocalDataTrack::unpublishDataTrack() + * or LocalParticipant::unpublishDataTrack(). * * @param name Unique track name visible to other participants. * @return Shared pointer to the published data track. @@ -187,10 +188,10 @@ class LocalParticipant : public Participant { /** * Unpublish a data track from the room. * - * After this call, tryPush() on the track will fail and the track - * cannot be re-published. + * Delegates to LocalDataTrack::unpublishDataTrack(). After this call, + * tryPush() on the track will fail and the track cannot be re-published. * - * @param track The data track to unpublish. Must not be null. + * @param track The data track to unpublish. Null is ignored. */ void unpublishDataTrack(const std::shared_ptr &track); diff --git a/src/local_data_track.cpp b/src/local_data_track.cpp index 1b290ae6..380deb57 100644 --- a/src/local_data_track.cpp +++ b/src/local_data_track.cpp @@ -16,6 +16,8 @@ #include "livekit/local_data_track.h" +#include "livekit/lk_log.h" + #include "data_track.pb.h" #include "ffi.pb.h" #include "ffi_client.h" @@ -49,6 +51,34 @@ bool LocalDataTrack::tryPush(const DataFrame &frame) { return !r.has_error(); } +bool LocalDataTrack::tryPush(const std::vector &payload, + std::optional user_timestamp) { + DataFrame frame; + frame.payload = payload; + frame.user_timestamp = user_timestamp; + + try { + return tryPush(frame); + } catch (const std::exception &e) { + LK_LOG_ERROR("[LocalDataTrack] tryPush error: {}", e.what()); + return false; + } +} + +bool LocalDataTrack::tryPush(const std::uint8_t *data, std::size_t size, + std::optional user_timestamp) { + DataFrame frame; + frame.payload.assign(data, data + size); + frame.user_timestamp = user_timestamp; + + try { + return tryPush(frame); + } catch (const std::exception &e) { + LK_LOG_ERROR("[LocalDataTrack] tryPush error: {}", e.what()); + return false; + } +} + bool LocalDataTrack::isPublished() const { if (!handle_.valid()) { return false; @@ -62,4 +92,16 @@ bool LocalDataTrack::isPublished() const { return resp.local_data_track_is_published().is_published(); } +void LocalDataTrack::unpublishDataTrack() { + if (!handle_.valid()) { + return; + } + + proto::FfiRequest req; + auto *msg = req.mutable_local_data_track_unpublish(); + msg->set_track_handle(static_cast(handle_.get())); + + (void)FfiClient::instance().sendRequest(req); +} + } // namespace livekit diff --git a/src/local_participant.cpp b/src/local_participant.cpp index 219d0482..9b5061e8 100644 --- a/src/local_participant.cpp +++ b/src/local_participant.cpp @@ -309,16 +309,7 @@ void LocalParticipant::unpublishDataTrack( return; } - auto handle_id = track->ffi_handle_id(); - if (handle_id == 0) { - return; - } - - proto::FfiRequest req; - auto *msg = req.mutable_local_data_track_unpublish(); - msg->set_track_handle(static_cast(handle_id)); - - (void)FfiClient::instance().sendRequest(req); + track->unpublishDataTrack(); } std::string LocalParticipant::performRpc( From b393c32e2f00241ee98d424488c8e33f9b19e524 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Fri, 20 Mar 2026 14:11:16 -0600 Subject: [PATCH 07/34] create*Track -> publish*Track() --- .../test_bridge_audio_roundtrip.cpp | 35 +++-- .../test_bridge_data_roundtrip.cpp | 44 +++---- .../tests/stress/test_bridge_audio_stress.cpp | 16 +-- .../stress/test_bridge_callback_stress.cpp | 32 ++--- .../tests/stress/test_bridge_data_stress.cpp | 52 ++++---- .../stress/test_bridge_lifecycle_stress.cpp | 28 ++-- .../stress/test_bridge_multi_track_stress.cpp | 98 +++++++------- bridge/tests/unit/test_livekit_bridge.cpp | 16 +-- examples/bridge_human_robot/robot.cpp | 18 +-- examples/bridge_mute_unmute/receiver.cpp | 8 +- .../realsense-to-mcap/src/realsense_rgbd.cpp | 123 +++++++++--------- include/livekit/data_track_subscription.h | 10 +- include/livekit/local_participant.h | 5 +- src/data_track_subscription.cpp | 33 ++--- 14 files changed, 250 insertions(+), 268 deletions(-) diff --git a/bridge/tests/integration/test_bridge_audio_roundtrip.cpp b/bridge/tests/integration/test_bridge_audio_roundtrip.cpp index bad61bb7..90cc4c33 100644 --- a/bridge/tests/integration/test_bridge_audio_roundtrip.cpp +++ b/bridge/tests/integration/test_bridge_audio_roundtrip.cpp @@ -81,7 +81,7 @@ TEST_F(BridgeAudioRoundtripTest, AudioFrameRoundTrip) { std::cout << "Both bridges connected." << std::endl; - auto audio_track = caller.createAudioTrack( + auto audio_track = caller.publishAudioTrack( "roundtrip-mic", kAudioSampleRate, kAudioChannels, livekit::TrackSource::SOURCE_MICROPHONE); ASSERT_NE(audio_track, nullptr); @@ -149,8 +149,8 @@ TEST_F(BridgeAudioRoundtripTest, AudioFrameRoundTrip) { // Clear callback before bridges go out of scope so the reader thread // is joined while the atomic counters above are still alive. - receiver.clearOnAudioFrameCallback( - caller_identity, livekit::TrackSource::SOURCE_MICROPHONE); + receiver.clearOnAudioFrameCallback(caller_identity, + livekit::TrackSource::SOURCE_MICROPHONE); } // --------------------------------------------------------------------------- @@ -170,9 +170,9 @@ TEST_F(BridgeAudioRoundtripTest, AudioLatencyMeasurement) { ASSERT_TRUE(connectPair(caller, receiver)); - auto audio_track = caller.createAudioTrack( - "latency-mic", kAudioSampleRate, kAudioChannels, - livekit::TrackSource::SOURCE_MICROPHONE); + auto audio_track = + caller.publishAudioTrack("latency-mic", kAudioSampleRate, kAudioChannels, + livekit::TrackSource::SOURCE_MICROPHONE); ASSERT_NE(audio_track, nullptr); LatencyStats stats; @@ -221,10 +221,9 @@ TEST_F(BridgeAudioRoundtripTest, AudioLatencyMeasurement) { next_frame_time += frame_duration; if (waiting_for_echo.load() && pulse_send_time > 0) { - uint64_t now_us = - std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count(); + uint64_t now_us = std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); if (now_us - pulse_send_time > kEchoTimeoutUs) { std::cout << " Echo timeout for pulse " << pulses_sent << std::endl; waiting_for_echo.store(false); @@ -239,16 +238,14 @@ TEST_F(BridgeAudioRoundtripTest, AudioLatencyMeasurement) { if (high_energy_remaining > 0) { frame_data = generateHighEnergyFrame(kSamplesPerFrame); high_energy_remaining--; - } else if (frame_count > 0 && - frame_count % frames_between_pulses == 0 && + } else if (frame_count > 0 && frame_count % frames_between_pulses == 0 && !waiting_for_echo.load()) { frame_data = generateHighEnergyFrame(kSamplesPerFrame); high_energy_remaining = kHighEnergyFramesPerPulse - 1; - pulse_send_time = - std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count(); + pulse_send_time = std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); last_send_time_us.store(pulse_send_time); waiting_for_echo.store(true); pulses_sent++; @@ -273,8 +270,8 @@ TEST_F(BridgeAudioRoundtripTest, AudioLatencyMeasurement) { EXPECT_GT(stats.count(), 0u) << "At least one audio latency measurement should be recorded"; - receiver.clearOnAudioFrameCallback( - caller_identity, livekit::TrackSource::SOURCE_MICROPHONE); + receiver.clearOnAudioFrameCallback(caller_identity, + livekit::TrackSource::SOURCE_MICROPHONE); } // --------------------------------------------------------------------------- @@ -301,7 +298,7 @@ TEST_F(BridgeAudioRoundtripTest, ConnectPublishDisconnectCycle) { bridge.connect(config_.url, config_.caller_token, options); ASSERT_TRUE(connected) << "Cycle " << i << ": connect failed"; - auto track = bridge.createAudioTrack( + auto track = bridge.publishAudioTrack( "cycle-mic", kAudioSampleRate, kAudioChannels, livekit::TrackSource::SOURCE_MICROPHONE); ASSERT_NE(track, nullptr); diff --git a/bridge/tests/integration/test_bridge_data_roundtrip.cpp b/bridge/tests/integration/test_bridge_data_roundtrip.cpp index f906c91f..425ca3a8 100644 --- a/bridge/tests/integration/test_bridge_data_roundtrip.cpp +++ b/bridge/tests/integration/test_bridge_data_roundtrip.cpp @@ -52,7 +52,7 @@ TEST_F(BridgeDataRoundtripTest, DataFrameRoundTrip) { const std::string track_name = "roundtrip-data"; const std::string caller_identity = "rpc-caller"; - auto data_track = caller.createDataTrack(track_name); + auto data_track = caller.publishDataTrack(track_name); ASSERT_NE(data_track, nullptr); ASSERT_TRUE(data_track->isPublished()); @@ -141,7 +141,7 @@ TEST_F(BridgeDataRoundtripTest, LateCallbackRegistration) { const std::string track_name = "late-callback-data"; const std::string caller_identity = "rpc-caller"; - auto data_track = caller.createDataTrack(track_name); + auto data_track = caller.publishDataTrack(track_name); ASSERT_NE(data_track, nullptr); std::cout << "Data track published, waiting before registering callback..." @@ -155,8 +155,7 @@ TEST_F(BridgeDataRoundtripTest, LateCallbackRegistration) { receiver.setOnDataFrameCallback( caller_identity, track_name, - [&](const std::vector &, - std::optional) { + [&](const std::vector &, std::optional) { frames_received++; rx_cv.notify_all(); }); @@ -174,9 +173,8 @@ TEST_F(BridgeDataRoundtripTest, LateCallbackRegistration) { { std::unique_lock lock(rx_mutex); - rx_cv.wait_for(lock, 10s, [&] { - return frames_received.load() >= num_frames; - }); + rx_cv.wait_for(lock, 10s, + [&] { return frames_received.load() >= num_frames; }); } std::cout << "Frames received: " << frames_received.load() << std::endl; @@ -206,21 +204,20 @@ TEST_F(BridgeDataRoundtripTest, VaryingPayloadSizes) { const std::string track_name = "size-test-data"; const std::string caller_identity = "rpc-caller"; - auto data_track = caller.createDataTrack(track_name); + auto data_track = caller.publishDataTrack(track_name); ASSERT_NE(data_track, nullptr); std::mutex rx_mutex; std::condition_variable rx_cv; std::vector received_sizes; - receiver.setOnDataFrameCallback( - caller_identity, track_name, - [&](const std::vector &payload, - std::optional) { - std::lock_guard lock(rx_mutex); - received_sizes.push_back(payload.size()); - rx_cv.notify_all(); - }); + receiver.setOnDataFrameCallback(caller_identity, track_name, + [&](const std::vector &payload, + std::optional) { + std::lock_guard lock(rx_mutex); + received_sizes.push_back(payload.size()); + rx_cv.notify_all(); + }); std::this_thread::sleep_for(3s); @@ -238,18 +235,17 @@ TEST_F(BridgeDataRoundtripTest, VaryingPayloadSizes) { { std::unique_lock lock(rx_mutex); - rx_cv.wait_for(lock, 15s, [&] { - return received_sizes.size() >= test_sizes.size(); - }); + rx_cv.wait_for(lock, 15s, + [&] { return received_sizes.size() >= test_sizes.size(); }); } - std::cout << "Received " << received_sizes.size() << "/" - << test_sizes.size() << " frames." << std::endl; + std::cout << "Received " << received_sizes.size() << "/" << test_sizes.size() + << " frames." << std::endl; EXPECT_EQ(received_sizes.size(), test_sizes.size()); - for (size_t i = 0; - i < std::min(received_sizes.size(), test_sizes.size()); ++i) { + for (size_t i = 0; i < std::min(received_sizes.size(), test_sizes.size()); + ++i) { EXPECT_EQ(received_sizes[i], test_sizes[i]) << "Size mismatch at index " << i; } @@ -277,7 +273,7 @@ TEST_F(BridgeDataRoundtripTest, ConnectPublishDisconnectCycle) { bridge.connect(config_.url, config_.caller_token, options); ASSERT_TRUE(connected) << "Cycle " << i << ": connect failed"; - auto track = bridge.createDataTrack("cycle-data"); + auto track = bridge.publishDataTrack("cycle-data"); ASSERT_NE(track, nullptr); for (int f = 0; f < 5; ++f) { diff --git a/bridge/tests/stress/test_bridge_audio_stress.cpp b/bridge/tests/stress/test_bridge_audio_stress.cpp index 88ba97fa..abdf264e 100644 --- a/bridge/tests/stress/test_bridge_audio_stress.cpp +++ b/bridge/tests/stress/test_bridge_audio_stress.cpp @@ -27,13 +27,13 @@ constexpr int kStressSamplesPerFrame = kStressAudioSampleRate * kStressFrameDurationMs / 1000; static std::vector makeSineFrame(int samples, double freq, - int &phase) { + int &phase) { std::vector data(samples * kStressAudioChannels); const double amplitude = 16000.0; for (int i = 0; i < samples; ++i) { double t = static_cast(phase++) / kStressAudioSampleRate; - auto sample = static_cast( - amplitude * std::sin(2.0 * M_PI * freq * t)); + auto sample = + static_cast(amplitude * std::sin(2.0 * M_PI * freq * t)); for (int ch = 0; ch < kStressAudioChannels; ++ch) { data[i * kStressAudioChannels + ch] = sample; } @@ -59,7 +59,7 @@ TEST_F(BridgeAudioStressTest, SustainedAudioPush) { ASSERT_TRUE(connectPair(caller, receiver)); - auto audio_track = caller.createAudioTrack( + auto audio_track = caller.publishAudioTrack( "stress-mic", kStressAudioSampleRate, kStressAudioChannels, livekit::TrackSource::SOURCE_MICROPHONE); ASSERT_NE(audio_track, nullptr); @@ -159,8 +159,8 @@ TEST_F(BridgeAudioStressTest, SustainedAudioPush) { EXPECT_EQ(push_failures.load(), 0) << "No push failures expected"; EXPECT_GT(delivery_rate, 50.0) << "Delivery rate below 50%"; - receiver.clearOnAudioFrameCallback( - caller_identity, livekit::TrackSource::SOURCE_MICROPHONE); + receiver.clearOnAudioFrameCallback(caller_identity, + livekit::TrackSource::SOURCE_MICROPHONE); } // --------------------------------------------------------------------------- @@ -186,7 +186,7 @@ TEST_F(BridgeAudioStressTest, ReleaseUnderActivePush) { bridge.connect(config_.url, config_.caller_token, options); ASSERT_TRUE(connected) << "Iteration " << iter << ": connect failed"; - auto track = bridge.createAudioTrack( + auto track = bridge.publishAudioTrack( "release-stress-mic", kStressAudioSampleRate, kStressAudioChannels, livekit::TrackSource::SOURCE_MICROPHONE); @@ -243,7 +243,7 @@ TEST_F(BridgeAudioStressTest, RapidConnectDisconnectWithCallback) { ASSERT_TRUE(connectPair(caller, receiver)) << "Cycle " << i << ": connect failed"; - auto track = caller.createAudioTrack( + auto track = caller.publishAudioTrack( "rapid-mic", kStressAudioSampleRate, kStressAudioChannels, livekit::TrackSource::SOURCE_MICROPHONE); diff --git a/bridge/tests/stress/test_bridge_callback_stress.cpp b/bridge/tests/stress/test_bridge_callback_stress.cpp index f2b3eb22..1c5a7fb9 100644 --- a/bridge/tests/stress/test_bridge_callback_stress.cpp +++ b/bridge/tests/stress/test_bridge_callback_stress.cpp @@ -24,8 +24,7 @@ namespace test { constexpr int kCbSampleRate = 48000; constexpr int kCbChannels = 1; constexpr int kCbFrameDurationMs = 10; -constexpr int kCbSamplesPerFrame = - kCbSampleRate * kCbFrameDurationMs / 1000; +constexpr int kCbSamplesPerFrame = kCbSampleRate * kCbFrameDurationMs / 1000; static std::vector cbSilentFrame() { return std::vector(kCbSamplesPerFrame * kCbChannels, 0); @@ -61,9 +60,9 @@ TEST_F(BridgeCallbackStressTest, AudioCallbackChurn) { ASSERT_TRUE(connectPair(caller, receiver)); - auto audio = caller.createAudioTrack( - "churn-mic", kCbSampleRate, kCbChannels, - livekit::TrackSource::SOURCE_MICROPHONE); + auto audio = + caller.publishAudioTrack("churn-mic", kCbSampleRate, kCbChannels, + livekit::TrackSource::SOURCE_MICROPHONE); ASSERT_NE(audio, nullptr); const std::string caller_identity = "rpc-caller"; @@ -129,10 +128,10 @@ TEST_F(BridgeCallbackStressTest, MixedCallbackChurn) { ASSERT_TRUE(connectPair(caller, receiver)); - auto audio = caller.createAudioTrack( - "mixed-mic", kCbSampleRate, kCbChannels, - livekit::TrackSource::SOURCE_MICROPHONE); - auto data = caller.createDataTrack("mixed-data"); + auto audio = + caller.publishAudioTrack("mixed-mic", kCbSampleRate, kCbChannels, + livekit::TrackSource::SOURCE_MICROPHONE); + auto data = caller.publishDataTrack("mixed-data"); ASSERT_NE(audio, nullptr); ASSERT_NE(data, nullptr); @@ -179,8 +178,9 @@ TEST_F(BridgeCallbackStressTest, MixedCallbackChurn) { while (running.load()) { receiver.setOnDataFrameCallback( caller_identity, "mixed-data", - [&](const std::vector &, - std::optional) { data_rx++; }); + [&](const std::vector &, std::optional) { + data_rx++; + }); std::this_thread::sleep_for(350ms); receiver.clearOnDataFrameCallback(caller_identity, "mixed-data"); std::this_thread::sleep_for(150ms); @@ -225,9 +225,9 @@ TEST_F(BridgeCallbackStressTest, CallbackReplacement) { ASSERT_TRUE(connectPair(caller, receiver)); - auto audio = caller.createAudioTrack( - "replace-mic", kCbSampleRate, kCbChannels, - livekit::TrackSource::SOURCE_MICROPHONE); + auto audio = + caller.publishAudioTrack("replace-mic", kCbSampleRate, kCbChannels, + livekit::TrackSource::SOURCE_MICROPHONE); ASSERT_NE(audio, nullptr); const std::string caller_identity = "rpc-caller"; @@ -264,8 +264,8 @@ TEST_F(BridgeCallbackStressTest, CallbackReplacement) { replacer.join(); // Final clear to join any lingering reader - receiver.clearOnAudioFrameCallback( - caller_identity, livekit::TrackSource::SOURCE_MICROPHONE); + receiver.clearOnAudioFrameCallback(caller_identity, + livekit::TrackSource::SOURCE_MICROPHONE); std::cout << "Replacements: " << replacements.load() << std::endl; std::cout << "Total frames rx: " << total_rx.load() << std::endl; diff --git a/bridge/tests/stress/test_bridge_data_stress.cpp b/bridge/tests/stress/test_bridge_data_stress.cpp index ec23d711..8d2b90a0 100644 --- a/bridge/tests/stress/test_bridge_data_stress.cpp +++ b/bridge/tests/stress/test_bridge_data_stress.cpp @@ -55,20 +55,19 @@ TEST_F(BridgeDataStressTest, HighThroughput) { const std::string track_name = "throughput-data"; const std::string caller_identity = "rpc-caller"; - auto data_track = caller.createDataTrack(track_name); + auto data_track = caller.publishDataTrack(track_name); ASSERT_NE(data_track, nullptr); StressTestStats stats; std::atomic frames_received{0}; std::atomic running{true}; - receiver.setOnDataFrameCallback( - caller_identity, track_name, - [&](const std::vector &payload, - std::optional) { - frames_received++; - (void)payload; - }); + receiver.setOnDataFrameCallback(caller_identity, track_name, + [&](const std::vector &payload, + std::optional) { + frames_received++; + (void)payload; + }); std::this_thread::sleep_for(3s); @@ -112,12 +111,11 @@ TEST_F(BridgeDataStressTest, HighThroughput) { last_total = cur_total; std::cout << "[" << elapsed_s << "s]" - << " sent=" << cur_total - << " recv=" << frames_received.load() + << " sent=" << cur_total << " recv=" << frames_received.load() << " success=" << stats.successfulCalls() - << " failed=" << stats.failedCalls() - << " rate=" << std::fixed << std::setprecision(1) - << (rate / 30.0) << " pushes/s" << std::endl; + << " failed=" << stats.failedCalls() << " rate=" << std::fixed + << std::setprecision(1) << (rate / 30.0) << " pushes/s" + << std::endl; } }); @@ -175,7 +173,7 @@ TEST_F(BridgeDataStressTest, LargePayloadStress) { const std::string track_name = "large-data"; const std::string caller_identity = "rpc-caller"; - auto data_track = caller.createDataTrack(track_name); + auto data_track = caller.publishDataTrack(track_name); ASSERT_NE(data_track, nullptr); StressTestStats stats; @@ -183,13 +181,12 @@ TEST_F(BridgeDataStressTest, LargePayloadStress) { std::atomic bytes_received{0}; std::atomic running{true}; - receiver.setOnDataFrameCallback( - caller_identity, track_name, - [&](const std::vector &payload, - std::optional) { - frames_received++; - bytes_received += payload.size(); - }); + receiver.setOnDataFrameCallback(caller_identity, track_name, + [&](const std::vector &payload, + std::optional) { + frames_received++; + bytes_received += payload.size(); + }); std::this_thread::sleep_for(3s); @@ -231,8 +228,7 @@ TEST_F(BridgeDataStressTest, LargePayloadStress) { int cur_total = stats.totalCalls(); std::cout << "[" << elapsed_s << "s]" - << " sent=" << cur_total - << " recv=" << frames_received.load() + << " sent=" << cur_total << " recv=" << frames_received.load() << " bytes_rx=" << (bytes_received.load() / (1024.0 * 1024.0)) << " MB" << " rate=" << std::fixed << std::setprecision(1) @@ -287,7 +283,7 @@ TEST_F(BridgeDataStressTest, CallbackChurn) { const std::string track_name = "churn-data"; const std::string caller_identity = "rpc-caller"; - auto data_track = caller.createDataTrack(track_name); + auto data_track = caller.publishDataTrack(track_name); ASSERT_NE(data_track, nullptr); std::atomic running{true}; @@ -306,8 +302,9 @@ TEST_F(BridgeDataStressTest, CallbackChurn) { while (running.load()) { receiver.setOnDataFrameCallback( caller_identity, track_name, - [&](const std::vector &, - std::optional) { total_received++; }); + [&](const std::vector &, std::optional) { + total_received++; + }); std::this_thread::sleep_for(500ms); @@ -326,8 +323,7 @@ TEST_F(BridgeDataStressTest, CallbackChurn) { churner.join(); std::cout << "Churn cycles completed: " << churn_cycles.load() << std::endl; - std::cout << "Total frames received: " << total_received.load() - << std::endl; + std::cout << "Total frames received: " << total_received.load() << std::endl; EXPECT_GT(churn_cycles.load(), 0) << "Should have completed at least one churn cycle"; diff --git a/bridge/tests/stress/test_bridge_lifecycle_stress.cpp b/bridge/tests/stress/test_bridge_lifecycle_stress.cpp index a610a0a2..67cab7d4 100644 --- a/bridge/tests/stress/test_bridge_lifecycle_stress.cpp +++ b/bridge/tests/stress/test_bridge_lifecycle_stress.cpp @@ -67,10 +67,10 @@ TEST_F(BridgeLifecycleStressTest, DisconnectUnderLoad) { ASSERT_TRUE(connectPair(caller, receiver)) << "Cycle " << i << ": connect failed"; - auto audio = caller.createAudioTrack( + auto audio = caller.publishAudioTrack( "load-mic", kLifecycleSampleRate, kLifecycleChannels, livekit::TrackSource::SOURCE_MICROPHONE); - auto data = caller.createDataTrack("load-data"); + auto data = caller.publishDataTrack("load-data"); std::atomic audio_rx{0}; std::atomic data_rx{0}; @@ -81,8 +81,9 @@ TEST_F(BridgeLifecycleStressTest, DisconnectUnderLoad) { receiver.setOnDataFrameCallback( caller_identity, "load-data", - [&](const std::vector &, - std::optional) { data_rx++; }); + [&](const std::vector &, std::optional) { + data_rx++; + }); std::this_thread::sleep_for(2s); @@ -150,10 +151,10 @@ TEST_F(BridgeLifecycleStressTest, TrackReleaseWhileReceiving) { ASSERT_TRUE(connectPair(caller, receiver)) << "Iteration " << iter << ": connect failed"; - auto audio = caller.createAudioTrack( + auto audio = caller.publishAudioTrack( "release-rx-mic", kLifecycleSampleRate, kLifecycleChannels, livekit::TrackSource::SOURCE_MICROPHONE); - auto data = caller.createDataTrack("release-rx-data"); + auto data = caller.publishDataTrack("release-rx-data"); std::atomic audio_rx{0}; std::atomic data_rx{0}; @@ -164,8 +165,9 @@ TEST_F(BridgeLifecycleStressTest, TrackReleaseWhileReceiving) { receiver.setOnDataFrameCallback( caller_identity, "release-rx-data", - [&](const std::vector &, - std::optional) { data_rx++; }); + [&](const std::vector &, std::optional) { + data_rx++; + }); std::this_thread::sleep_for(2s); @@ -249,17 +251,17 @@ TEST_F(BridgeLifecycleStressTest, FullLifecycleSoak) { bridge.connect(config_.url, config_.caller_token, options); ASSERT_TRUE(connected) << "Cycle " << i << ": connect failed"; - auto audio = bridge.createAudioTrack( + auto audio = bridge.publishAudioTrack( "soak-mic", kLifecycleSampleRate, kLifecycleChannels, livekit::TrackSource::SOURCE_MICROPHONE); constexpr int kVideoWidth = 320; constexpr int kVideoHeight = 240; - auto video = bridge.createVideoTrack( - "soak-cam", kVideoWidth, kVideoHeight, - livekit::TrackSource::SOURCE_CAMERA); + auto video = + bridge.publishVideoTrack("soak-cam", kVideoWidth, kVideoHeight, + livekit::TrackSource::SOURCE_CAMERA); - auto data = bridge.createDataTrack("soak-data"); + auto data = bridge.publishDataTrack("soak-data"); // Push a handful of frames on each track type for (int f = 0; f < 10; ++f) { diff --git a/bridge/tests/stress/test_bridge_multi_track_stress.cpp b/bridge/tests/stress/test_bridge_multi_track_stress.cpp index 8ade04be..40619ce0 100644 --- a/bridge/tests/stress/test_bridge_multi_track_stress.cpp +++ b/bridge/tests/stress/test_bridge_multi_track_stress.cpp @@ -71,16 +71,15 @@ TEST_F(BridgeMultiTrackStressTest, ConcurrentMultiTrackPush) { bool connected = bridge.connect(config_.url, config_.caller_token, options); ASSERT_TRUE(connected); - auto audio = bridge.createAudioTrack( - "mt-mic", kMtSampleRate, kMtChannels, - livekit::TrackSource::SOURCE_MICROPHONE); + auto audio = + bridge.publishAudioTrack("mt-mic", kMtSampleRate, kMtChannels, + livekit::TrackSource::SOURCE_MICROPHONE); - auto video = bridge.createVideoTrack( - "mt-cam", kMtVideoWidth, kMtVideoHeight, - livekit::TrackSource::SOURCE_CAMERA); + auto video = bridge.publishVideoTrack("mt-cam", kMtVideoWidth, kMtVideoHeight, + livekit::TrackSource::SOURCE_CAMERA); - auto data1 = bridge.createDataTrack("mt-data-1"); - auto data2 = bridge.createDataTrack("mt-data-2"); + auto data1 = bridge.publishDataTrack("mt-data-1"); + auto data2 = bridge.publishDataTrack("mt-data-2"); ASSERT_NE(audio, nullptr); ASSERT_NE(video, nullptr); @@ -187,9 +186,9 @@ TEST_F(BridgeMultiTrackStressTest, ConcurrentMultiTrackPush) { ? (100.0 * s.successes.load() / s.pushes.load()) : 0.0; std::cout << " " << name << ": pushes=" << s.pushes.load() - << " ok=" << s.successes.load() - << " fail=" << s.failures.load() << " (" << std::fixed - << std::setprecision(1) << rate << "%)" << std::endl; + << " ok=" << s.successes.load() << " fail=" << s.failures.load() + << " (" << std::fixed << std::setprecision(1) << rate << "%)" + << std::endl; }; std::cout << "\n========================================" << std::endl; @@ -237,7 +236,7 @@ TEST_F(BridgeMultiTrackStressTest, ConcurrentCreateRelease) { std::thread audio_thread([&]() { while (running.load()) { try { - auto track = bridge.createAudioTrack( + auto track = bridge.publishAudioTrack( "create-release-mic", kMtSampleRate, kMtChannels, livekit::TrackSource::SOURCE_MICROPHONE); @@ -262,8 +261,8 @@ TEST_F(BridgeMultiTrackStressTest, ConcurrentCreateRelease) { int track_counter = 0; while (running.load()) { try { - auto track = bridge.createDataTrack( - "create-release-data-" + std::to_string(track_counter++)); + auto track = bridge.publishDataTrack("create-release-data-" + + std::to_string(track_counter++)); for (int i = 0; i < 5; ++i) { auto payload = mtPayload(128); @@ -323,16 +322,16 @@ TEST_F(BridgeMultiTrackStressTest, FullDuplexMultiTrack) { const std::string receiver_identity = "rpc-receiver"; // Caller publishes - auto caller_audio = caller.createAudioTrack( - "duplex-mic", kMtSampleRate, kMtChannels, - livekit::TrackSource::SOURCE_MICROPHONE); - auto caller_data = caller.createDataTrack("duplex-data-caller"); + auto caller_audio = + caller.publishAudioTrack("duplex-mic", kMtSampleRate, kMtChannels, + livekit::TrackSource::SOURCE_MICROPHONE); + auto caller_data = caller.publishDataTrack("duplex-data-caller"); // Receiver publishes - auto receiver_audio = receiver.createAudioTrack( - "duplex-mic", kMtSampleRate, kMtChannels, - livekit::TrackSource::SOURCE_MICROPHONE); - auto receiver_data = receiver.createDataTrack("duplex-data-receiver"); + auto receiver_audio = + receiver.publishAudioTrack("duplex-mic", kMtSampleRate, kMtChannels, + livekit::TrackSource::SOURCE_MICROPHONE); + auto receiver_data = receiver.publishDataTrack("duplex-data-receiver"); // Cross-register callbacks std::atomic caller_audio_rx{0}; @@ -345,16 +344,18 @@ TEST_F(BridgeMultiTrackStressTest, FullDuplexMultiTrack) { [&](const livekit::AudioFrame &) { caller_audio_rx++; }); caller.setOnDataFrameCallback( receiver_identity, "duplex-data-receiver", - [&](const std::vector &, - std::optional) { caller_data_rx++; }); + [&](const std::vector &, std::optional) { + caller_data_rx++; + }); receiver.setOnAudioFrameCallback( caller_identity, livekit::TrackSource::SOURCE_MICROPHONE, [&](const livekit::AudioFrame &) { receiver_audio_rx++; }); receiver.setOnDataFrameCallback( caller_identity, "duplex-data-caller", - [&](const std::vector &, - std::optional) { receiver_data_rx++; }); + [&](const std::vector &, std::optional) { + receiver_data_rx++; + }); std::this_thread::sleep_for(3s); @@ -362,25 +363,23 @@ TEST_F(BridgeMultiTrackStressTest, FullDuplexMultiTrack) { auto start_time = std::chrono::steady_clock::now(); auto duration = std::chrono::seconds(config_.stress_duration_seconds); - auto audio_push_fn = - [&](std::shared_ptr track) { - auto next = std::chrono::steady_clock::now(); - while (running.load()) { - std::this_thread::sleep_until(next); - next += std::chrono::milliseconds(kMtFrameDurationMs); - auto frame = mtSilentFrame(); - track->pushFrame(frame, kMtSamplesPerFrame); - } - }; + auto audio_push_fn = [&](std::shared_ptr track) { + auto next = std::chrono::steady_clock::now(); + while (running.load()) { + std::this_thread::sleep_until(next); + next += std::chrono::milliseconds(kMtFrameDurationMs); + auto frame = mtSilentFrame(); + track->pushFrame(frame, kMtSamplesPerFrame); + } + }; - auto data_push_fn = - [&](std::shared_ptr track) { - while (running.load()) { - auto payload = mtPayload(256); - track->tryPush(payload); - std::this_thread::sleep_for(20ms); - } - }; + auto data_push_fn = [&](std::shared_ptr track) { + while (running.load()) { + auto payload = mtPayload(256); + track->tryPush(payload); + std::this_thread::sleep_for(20ms); + } + }; std::thread t1(audio_push_fn, caller_audio); std::thread t2(data_push_fn, caller_data); @@ -399,8 +398,7 @@ TEST_F(BridgeMultiTrackStressTest, FullDuplexMultiTrack) { << " caller_audio_rx=" << caller_audio_rx.load() << " caller_data_rx=" << caller_data_rx.load() << " receiver_audio_rx=" << receiver_audio_rx.load() - << " receiver_data_rx=" << receiver_data_rx.load() - << std::endl; + << " receiver_data_rx=" << receiver_data_rx.load() << std::endl; } }); @@ -428,11 +426,11 @@ TEST_F(BridgeMultiTrackStressTest, FullDuplexMultiTrack) { EXPECT_GT(receiver_data_rx.load(), 0); // Clear callbacks while atomics are alive - caller.clearOnAudioFrameCallback( - receiver_identity, livekit::TrackSource::SOURCE_MICROPHONE); + caller.clearOnAudioFrameCallback(receiver_identity, + livekit::TrackSource::SOURCE_MICROPHONE); caller.clearOnDataFrameCallback(receiver_identity, "duplex-data-receiver"); - receiver.clearOnAudioFrameCallback( - caller_identity, livekit::TrackSource::SOURCE_MICROPHONE); + receiver.clearOnAudioFrameCallback(caller_identity, + livekit::TrackSource::SOURCE_MICROPHONE); receiver.clearOnDataFrameCallback(caller_identity, "duplex-data-caller"); } diff --git a/bridge/tests/unit/test_livekit_bridge.cpp b/bridge/tests/unit/test_livekit_bridge.cpp index 9ba576c9..26864b71 100644 --- a/bridge/tests/unit/test_livekit_bridge.cpp +++ b/bridge/tests/unit/test_livekit_bridge.cpp @@ -75,22 +75,22 @@ TEST_F(LiveKitBridgeTest, DestructorOnUnconnectedBridgeIsSafe) { // Track creation before connection // ============================================================================ -TEST_F(LiveKitBridgeTest, CreateAudioTrackBeforeConnectThrows) { +TEST_F(LiveKitBridgeTest, publishAudioTrackBeforeConnectThrows) { LiveKitBridge bridge; - EXPECT_THROW(bridge.createAudioTrack("mic", 48000, 2, - livekit::TrackSource::SOURCE_MICROPHONE), + EXPECT_THROW(bridge.publishAudioTrack( + "mic", 48000, 2, livekit::TrackSource::SOURCE_MICROPHONE), std::runtime_error) - << "createAudioTrack should throw when not connected"; + << "publishAudioTrack should throw when not connected"; } -TEST_F(LiveKitBridgeTest, CreateVideoTrackBeforeConnectThrows) { +TEST_F(LiveKitBridgeTest, publishVideoTrackBeforeConnectThrows) { LiveKitBridge bridge; - EXPECT_THROW(bridge.createVideoTrack("cam", 1280, 720, - livekit::TrackSource::SOURCE_CAMERA), + EXPECT_THROW(bridge.publishVideoTrack("cam", 1280, 720, + livekit::TrackSource::SOURCE_CAMERA), std::runtime_error) - << "createVideoTrack should throw when not connected"; + << "publishVideoTrack should throw when not connected"; } // ============================================================================ diff --git a/examples/bridge_human_robot/robot.cpp b/examples/bridge_human_robot/robot.cpp index b8fd4513..575f1334 100644 --- a/examples/bridge_human_robot/robot.cpp +++ b/examples/bridge_human_robot/robot.cpp @@ -380,19 +380,19 @@ int main(int argc, char *argv[]) { std::shared_ptr mic; if (use_mic) { - mic = bridge.createAudioTrack("robot-mic", kSampleRate, kChannels, - livekit::TrackSource::SOURCE_MICROPHONE); + mic = bridge.publishAudioTrack("robot-mic", kSampleRate, kChannels, + livekit::TrackSource::SOURCE_MICROPHONE); } auto sim_audio = - bridge.createAudioTrack("robot-sim-audio", kSampleRate, kChannels, - livekit::TrackSource::SOURCE_SCREENSHARE_AUDIO); - auto cam = bridge.createVideoTrack("robot-cam", kWidth, kHeight, - livekit::TrackSource::SOURCE_CAMERA); + bridge.publishAudioTrack("robot-sim-audio", kSampleRate, kChannels, + livekit::TrackSource::SOURCE_SCREENSHARE_AUDIO); + auto cam = bridge.publishVideoTrack("robot-cam", kWidth, kHeight, + livekit::TrackSource::SOURCE_CAMERA); auto sim_cam = - bridge.createVideoTrack("robot-sim-frame", kSimWidth, kSimHeight, - livekit::TrackSource::SOURCE_SCREENSHARE); + bridge.publishVideoTrack("robot-sim-frame", kSimWidth, kSimHeight, + livekit::TrackSource::SOURCE_SCREENSHARE); - auto data_track = bridge.createDataTrack("robot-status"); + auto data_track = bridge.publishDataTrack("robot-status"); LK_LOG_INFO("[robot] Publishing {} sim audio ({} Hz, {} ch), cam + sim frame " "({}x{} / {}x{}), data track.", diff --git a/examples/bridge_mute_unmute/receiver.cpp b/examples/bridge_mute_unmute/receiver.cpp index 1abafbc9..3ca04372 100644 --- a/examples/bridge_mute_unmute/receiver.cpp +++ b/examples/bridge_mute_unmute/receiver.cpp @@ -101,10 +101,10 @@ int main(int argc, char *argv[]) { constexpr int kWidth = 1280; constexpr int kHeight = 720; - auto mic = bridge.createAudioTrack("mic", kSampleRate, kChannels, - livekit::TrackSource::SOURCE_MICROPHONE); - auto cam = bridge.createVideoTrack("cam", kWidth, kHeight, - livekit::TrackSource::SOURCE_CAMERA); + auto mic = bridge.publishAudioTrack("mic", kSampleRate, kChannels, + livekit::TrackSource::SOURCE_MICROPHONE); + auto cam = bridge.publishVideoTrack("cam", kWidth, kHeight, + livekit::TrackSource::SOURCE_CAMERA); std::cout << "[receiver] Published audio track \"mic\" and video track " "\"cam\".\n"; diff --git a/examples/realsense-livekit/realsense-to-mcap/src/realsense_rgbd.cpp b/examples/realsense-livekit/realsense-to-mcap/src/realsense_rgbd.cpp index 49719d9e..e3722f6e 100644 --- a/examples/realsense-livekit/realsense-to-mcap/src/realsense_rgbd.cpp +++ b/examples/realsense-livekit/realsense-to-mcap/src/realsense_rgbd.cpp @@ -29,9 +29,9 @@ * to receive and record to MCAP. */ -#include "livekit_bridge/livekit_bridge.h" #include "livekit/local_data_track.h" #include "livekit/track.h" +#include "livekit_bridge/livekit_bridge.h" #include "BuildFileDescriptorSet.h" #include "foxglove/PoseInFrame.pb.h" @@ -67,7 +67,8 @@ static uint64_t nowNs() { static std::string nowStr() { auto now = std::chrono::system_clock::now(); auto ms = std::chrono::duration_cast( - now.time_since_epoch()) % 1000; + now.time_since_epoch()) % + 1000; std::time_t t = std::chrono::system_clock::to_time_t(now); std::tm tm{}; localtime_r(&t, &tm); @@ -114,9 +115,9 @@ class RotationEstimator { } void process_accel(rs2_vector accel_data) { - float accel_angle_x = std::atan2(accel_data.x, - std::sqrt(accel_data.y * accel_data.y + - accel_data.z * accel_data.z)); + float accel_angle_x = + std::atan2(accel_data.x, std::sqrt(accel_data.y * accel_data.y + + accel_data.z * accel_data.z)); float accel_angle_z = std::atan2(accel_data.y, accel_data.z); std::lock_guard lock(mtx_); @@ -133,10 +134,9 @@ class RotationEstimator { OrientationQuat get_orientation() const { std::lock_guard lock(mtx_); - return eulerToQuaternion( - static_cast(theta_x_), - static_cast(theta_y_), - static_cast(theta_z_)); + return eulerToQuaternion(static_cast(theta_x_), + static_cast(theta_y_), + static_cast(theta_z_)); } private: @@ -149,13 +149,13 @@ class RotationEstimator { }; /// Convert RGB8 to RGBA (alpha = 0xFF). Assumes dst has size width*height*4. -static void rgb8ToRgba(const std::uint8_t* rgb, std::uint8_t* rgba, - int width, int height) { +static void rgb8ToRgba(const std::uint8_t *rgb, std::uint8_t *rgba, int width, + int height) { const int rgbStep = width * 3; const int rgbaStep = width * 4; for (int y = 0; y < height; ++y) { - const std::uint8_t* src = rgb + y * rgbStep; - std::uint8_t* dst = rgba + y * rgbaStep; + const std::uint8_t *src = rgb + y * rgbStep; + std::uint8_t *dst = rgba + y * rgbaStep; for (int x = 0; x < width; ++x) { *dst++ = *src++; *dst++ = *src++; @@ -165,7 +165,7 @@ static void rgb8ToRgba(const std::uint8_t* rgb, std::uint8_t* rgba, } } -int main(int argc, char* argv[]) { +int main(int argc, char *argv[]) { GOOGLE_PROTOBUF_VERIFY_VERSION; std::signal(SIGINT, signalHandler); @@ -173,8 +173,8 @@ int main(int argc, char* argv[]) { std::string url; std::string token; - const char* env_url = std::getenv("LIVEKIT_URL"); - const char* env_token = std::getenv("LIVEKIT_TOKEN"); + const char *env_url = std::getenv("LIVEKIT_URL"); + const char *env_token = std::getenv("LIVEKIT_TOKEN"); if (argc >= 3) { url = argv[1]; token = argv[2]; @@ -189,7 +189,8 @@ int main(int argc, char* argv[]) { const int kWidth = 640; const int kHeight = 480; - const int kDepthFps = 10; // data track depth rate (Hz); limited by SCTP throughput + const int kDepthFps = + 10; // data track depth rate (Hz); limited by SCTP throughput const int kPoseFps = 30; RotationEstimator rotation_est; @@ -223,7 +224,7 @@ int main(int argc, char* argv[]) { cfg.enable_stream(RS2_STREAM_DEPTH, kWidth, kHeight, RS2_FORMAT_Z16, 30); try { pipe.start(cfg); - } catch (const rs2::error& e) { + } catch (const rs2::error &e) { std::cerr << "RealSense error: " << e.what() << "\n"; return 1; } @@ -240,23 +241,21 @@ int main(int argc, char* argv[]) { try { imu_pipe.start(imu_cfg, [&rotation_est](rs2::frame frame) { auto motion = frame.as(); - if (motion && - motion.get_profile().stream_type() == RS2_STREAM_GYRO && + if (motion && motion.get_profile().stream_type() == RS2_STREAM_GYRO && motion.get_profile().format() == RS2_FORMAT_MOTION_XYZ32F) { rotation_est.process_gyro(motion.get_motion_data(), motion.get_timestamp()); } - if (motion && - motion.get_profile().stream_type() == RS2_STREAM_ACCEL && + if (motion && motion.get_profile().stream_type() == RS2_STREAM_ACCEL && motion.get_profile().format() == RS2_FORMAT_MOTION_XYZ32F) { rotation_est.process_accel(motion.get_motion_data()); } }); has_imu = true; std::cout << "[realsense_rgbd] IMU pipeline started.\n"; - } catch (const rs2::error& e) { - std::cerr << "[realsense_rgbd] Could not start IMU pipeline: " - << e.what() << " — continuing without pose.\n"; + } catch (const rs2::error &e) { + std::cerr << "[realsense_rgbd] Could not start IMU pipeline: " << e.what() + << " — continuing without pose.\n"; } } else { std::cout << "[realsense_rgbd] IMU not available, continuing without " @@ -280,14 +279,14 @@ int main(int argc, char* argv[]) { std::shared_ptr hello_track; try { - video_track = bridge.createVideoTrack("camera/color", kWidth, kHeight, - livekit::TrackSource::SOURCE_CAMERA); - depth_track = bridge.createDataTrack("camera/depth"); + video_track = bridge.publishVideoTrack("camera/color", kWidth, kHeight, + livekit::TrackSource::SOURCE_CAMERA); + depth_track = bridge.publishDataTrack("camera/depth"); if (has_imu) { - pose_track = bridge.createDataTrack("camera/pose"); + pose_track = bridge.publishDataTrack("camera/pose"); } - hello_track = bridge.createDataTrack("hello"); - } catch (const std::exception& e) { + hello_track = bridge.publishDataTrack("hello"); + } catch (const std::exception &e) { std::cerr << "[realsense_rgbd] Failed to create tracks: " << e.what() << "\n"; bridge.disconnect(); @@ -300,7 +299,8 @@ int main(int argc, char* argv[]) { << (has_imu ? ", camera/pose (DataTrack)" : "") << ", and hello (DataTrack). Press Ctrl+C to stop.\n"; - std::vector rgbaBuf(static_cast(kWidth * kHeight * 4)); + std::vector rgbaBuf( + static_cast(kWidth * kHeight * 4)); uint32_t hello_seq = 0; uint32_t depth_pushed = 0; @@ -310,10 +310,8 @@ int main(int argc, char* argv[]) { auto last_depth = std::chrono::steady_clock::now(); auto last_pose = std::chrono::steady_clock::now(); auto last_depth_report = std::chrono::steady_clock::now(); - const auto depth_interval = - std::chrono::microseconds(1000000 / kDepthFps); - const auto pose_interval = - std::chrono::microseconds(1000000 / kPoseFps); + const auto depth_interval = std::chrono::microseconds(1000000 / kDepthFps); + const auto pose_interval = std::chrono::microseconds(1000000 / kPoseFps); constexpr auto kMinLoopDuration = std::chrono::milliseconds(15); @@ -327,7 +325,7 @@ int main(int argc, char* argv[]) { std::string text = "hello viewer #" + std::to_string(hello_seq); uint64_t ts_us = static_cast(nowNs() / 1000); bool ok = hello_track->tryPush( - reinterpret_cast(text.data()), text.size(), + reinterpret_cast(text.data()), text.size(), ts_us); std::cout << "[" << nowStr() << "] [realsense_rgbd] Sent hello #" << hello_seq << " (" << text.size() << " bytes) -> " @@ -358,7 +356,7 @@ int main(int argc, char* argv[]) { const int32_t nsecs = static_cast(timestamp_ns % 1000000000ULL); // RGB → RGBA and push to video track - rgb8ToRgba(static_cast(color.get_data()), + rgb8ToRgba(static_cast(color.get_data()), rgbaBuf.data(), kWidth, kHeight); if (!video_track->pushFrame(rgbaBuf.data(), rgbaBuf.size(), timestamp_us)) { break; @@ -369,7 +367,7 @@ int main(int argc, char* argv[]) { last_depth = loop_start; foxglove::RawImage msg; - auto* ts = msg.mutable_timestamp(); + auto *ts = msg.mutable_timestamp(); ts->set_seconds(secs); ts->set_nanos(nsecs); msg.set_frame_id("camera_depth"); @@ -384,28 +382,28 @@ int main(int argc, char* argv[]) { uLongf comp_bound = compressBound(static_cast(serialized.size())); std::vector compressed(comp_bound); uLongf comp_size = comp_bound; - int zrc = compress2( - compressed.data(), &comp_size, - reinterpret_cast(serialized.data()), - static_cast(serialized.size()), Z_BEST_SPEED); + int zrc = compress2(compressed.data(), &comp_size, + reinterpret_cast(serialized.data()), + static_cast(serialized.size()), Z_BEST_SPEED); auto push_start = std::chrono::steady_clock::now(); bool ok = false; if (zrc == Z_OK) { - ok = depth_track->tryPush( - compressed.data(), static_cast(comp_size), - static_cast(timestamp_us)); + ok = depth_track->tryPush(compressed.data(), + static_cast(comp_size), + static_cast(timestamp_us)); } else { std::cerr << "[realsense_rgbd] zlib compress failed (" << zrc << "), sending uncompressed\n"; ok = depth_track->tryPush( - reinterpret_cast(serialized.data()), + reinterpret_cast(serialized.data()), serialized.size(), static_cast(timestamp_us)); } auto push_dur = std::chrono::steady_clock::now() - push_start; double push_ms = std::chrono::duration_cast(push_dur) - .count() / 1000.0; + .count() / + 1000.0; ++depth_pushed; if (!ok) { @@ -419,16 +417,17 @@ int main(int argc, char* argv[]) { double elapsed_sec = std::chrono::duration_cast( loop_start - last_depth_report) - .count() / 1000.0; + .count() / + 1000.0; double actual_fps = (elapsed_sec > 0) ? static_cast(depth_pushed - last_depth_report_count) / elapsed_sec : 0; - std::cout << "[" << nowStr() - << "] [realsense_rgbd] Depth #" << depth_pushed - << " push=" << std::fixed << std::setprecision(1) << push_ms - << "ms " << serialized.size() << "B->" + std::cout << "[" << nowStr() << "] [realsense_rgbd] Depth #" + << depth_pushed << " push=" << std::fixed + << std::setprecision(1) << push_ms << "ms " + << serialized.size() << "B->" << (zrc == Z_OK ? comp_size : serialized.size()) << "B" << " actual=" << std::setprecision(1) << actual_fps << "fps\n"; @@ -444,17 +443,17 @@ int main(int argc, char* argv[]) { auto orientation = rotation_est.get_orientation(); foxglove::PoseInFrame pose_msg; - auto* pose_ts = pose_msg.mutable_timestamp(); + auto *pose_ts = pose_msg.mutable_timestamp(); pose_ts->set_seconds(secs); pose_ts->set_nanos(nsecs); pose_msg.set_frame_id("camera_imu"); - auto* pose = pose_msg.mutable_pose(); - auto* pos = pose->mutable_position(); + auto *pose = pose_msg.mutable_pose(); + auto *pos = pose->mutable_position(); pos->set_x(0); pos->set_y(0); pos->set_z(0); - auto* orient = pose->mutable_orientation(); + auto *orient = pose->mutable_orientation(); orient->set_x(orientation.x); orient->set_y(orientation.y); orient->set_z(orientation.z); @@ -462,7 +461,7 @@ int main(int argc, char* argv[]) { std::string pose_serialized = pose_msg.SerializeAsString(); bool pose_ok = pose_track->tryPush( - reinterpret_cast(pose_serialized.data()), + reinterpret_cast(pose_serialized.data()), pose_serialized.size(), static_cast(timestamp_us)); ++pose_pushed; if (!pose_ok) { @@ -471,9 +470,8 @@ int main(int argc, char* argv[]) { << pose_pushed << "\n"; } if (pose_pushed == 1 || pose_pushed % 100 == 0) { - std::cout << "[" << nowStr() - << "] [realsense_rgbd] Pose #" << pose_pushed - << " " << pose_serialized.size() << "B" + std::cout << "[" << nowStr() << "] [realsense_rgbd] Pose #" + << pose_pushed << " " << pose_serialized.size() << "B" << " q=(" << std::fixed << std::setprecision(3) << orientation.x << ", " << orientation.y << ", " << orientation.z << ", " << orientation.w << ")\n"; @@ -488,7 +486,8 @@ int main(int argc, char* argv[]) { std::cout << "[realsense_rgbd] Stopping...\n"; bridge.disconnect(); - if (has_imu) imu_pipe.stop(); + if (has_imu) + imu_pipe.stop(); pipe.stop(); google::protobuf::ShutdownProtobufLibrary(); return 0; diff --git a/include/livekit/data_track_subscription.h b/include/livekit/data_track_subscription.h index 22e14858..4f3619f3 100644 --- a/include/livekit/data_track_subscription.h +++ b/include/livekit/data_track_subscription.h @@ -36,7 +36,7 @@ class FfiEvent; * An active subscription to a remote data track. * * Provides a blocking read() interface similar to AudioStream / VideoStream. - * Frames are delivered via FfiEvent callbacks and queued internally. + * Frames are delivered via FfiEvent callbacks and stored internally. * * Dropping (destroying) the subscription automatically unsubscribes from the * remote track by releasing the underlying FFI handle. @@ -90,7 +90,7 @@ class DataTrackSubscription { /// FFI event handler, called by FfiClient. void onFfiEvent(const proto::FfiEvent &event); - /// Push a received DataFrame to the internal queue. + /// Push a received DataFrame to the internal storage. void pushFrame(DataFrame &&frame); /// Push an end-of-stream signal (EOS). @@ -102,8 +102,10 @@ class DataTrackSubscription { /** Signalled when a frame is pushed or the subscription ends. */ std::condition_variable cv_; - /** FIFO of received frames awaiting read(). */ - std::deque queue_; + /** Received frame awaiting read(). + NOTE: the rust side handles buffering, so we should only really ever have one + item*/ + std::optional frame_; /** True once the remote side signals end-of-stream. */ bool eof_{false}; diff --git a/include/livekit/local_participant.h b/include/livekit/local_participant.h index f37bdfdb..a0fb21af 100644 --- a/include/livekit/local_participant.h +++ b/include/livekit/local_participant.h @@ -176,8 +176,9 @@ class LocalParticipant : public Participant { * * Data tracks carry arbitrary binary frames and are independent of the * audio/video track hierarchy. The returned LocalDataTrack can push - * frames via tryPush() and be unpublished via LocalDataTrack::unpublishDataTrack() - * or LocalParticipant::unpublishDataTrack(). + * frames via tryPush() and be unpublished via + * LocalDataTrack::unpublishDataTrack() or + * LocalParticipant::unpublishDataTrack(). * * @param name Unique track name visible to other participants. * @return Shared pointer to the published data track. diff --git a/src/data_track_subscription.cpp b/src/data_track_subscription.cpp index 3d687e26..d602c732 100644 --- a/src/data_track_subscription.cpp +++ b/src/data_track_subscription.cpp @@ -32,7 +32,7 @@ DataTrackSubscription::~DataTrackSubscription() { close(); } DataTrackSubscription::DataTrackSubscription( DataTrackSubscription &&other) noexcept { std::lock_guard lock(other.mutex_); - queue_ = std::move(other.queue_); + frame_ = std::move(other.frame_); eof_ = other.eof_; closed_ = other.closed_; subscription_handle_ = std::move(other.subscription_handle_); @@ -54,7 +54,7 @@ DataTrackSubscription::operator=(DataTrackSubscription &&other) noexcept { std::lock_guard lock_this(mutex_); std::lock_guard lock_other(other.mutex_); - queue_ = std::move(other.queue_); + frame_ = std::move(other.frame_); eof_ = other.eof_; closed_ = other.closed_; subscription_handle_ = std::move(other.subscription_handle_); @@ -77,14 +77,14 @@ void DataTrackSubscription::init(FfiHandle subscription_handle) { bool DataTrackSubscription::read(DataFrame &out) { std::unique_lock lock(mutex_); - cv_.wait(lock, [this] { return !queue_.empty() || eof_ || closed_; }); + cv_.wait(lock, [this] { return frame_.has_value() || eof_ || closed_; }); - if (closed_ || (queue_.empty() && eof_)) { + if (closed_ || (!frame_.has_value() && eof_)) { return false; } - out = std::move(queue_.front()); - queue_.pop_front(); + out = std::move(frame_.value()); + frame_.reset(); return true; } @@ -145,23 +145,14 @@ void DataTrackSubscription::pushFrame(DataFrame &&frame) { } // rust side handles buffering, so we should only really ever have one item - if (queue_.size() >= 2) { - LK_LOG_ERROR("[DataTrackSubscription] Queue size is greater than 1, this " - "should not happen"); - - // notify to try to catch up - cv_.notify_one(); - } else if (queue_.size() >= 1) { - LK_LOG_WARN("[DataTrackSubscription] Queue size is 1, are we able to " - "keep up with rust pushing data?"); - - // we expect this to happen, but rarely. - queue_.push_back(std::move(frame)); - } else { - // things are nominal - queue_.push_back(std::move(frame)); + if (frame_.has_value()) { + LK_LOG_ERROR("[DataTrackSubscription] Frame is already set, the " + "application cannot keep up with the data rate"); + return; } + frame_ = std::move(frame); + // notify no matter what since we got a new frame cv_.notify_one(); } From a6ca22679825458a9a4dec7c90135e1e289427c8 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Tue, 24 Mar 2026 13:44:12 -0600 Subject: [PATCH 08/34] dont add/remove stuff from bridge since we are targetting a branch which deprecates bridge. Add helper set/clearDataTrackCallback(), and publishDataTrack helper functions --- .../include/livekit_bridge/livekit_bridge.h | 42 ++ bridge/src/livekit_bridge.cpp | 31 ++ bridge/tests/common/bridge_test_common.h | 311 ------------- .../test_bridge_audio_roundtrip.cpp | 318 ------------- .../test_bridge_data_roundtrip.cpp | 291 ------------ .../tests/stress/test_bridge_audio_stress.cpp | 276 ----------- .../stress/test_bridge_callback_stress.cpp | 277 ----------- .../tests/stress/test_bridge_data_stress.cpp | 333 ------------- .../stress/test_bridge_lifecycle_stress.cpp | 301 ------------ .../stress/test_bridge_multi_track_stress.cpp | 438 ------------------ bridge/tests/unit/test_bridge_audio_track.cpp | 118 ----- bridge/tests/unit/test_bridge_video_track.cpp | 114 ----- bridge/tests/unit/test_callback_key.cpp | 124 ----- bridge/tests/unit/test_livekit_bridge.cpp | 138 ------ examples/bridge_human_robot/human.cpp | 20 +- examples/bridge_human_robot/robot.cpp | 54 +-- examples/bridge_mute_unmute/receiver.cpp | 8 +- include/livekit/local_participant.h | 1 + include/livekit/room.h | 37 ++ src/room.cpp | 53 ++- 20 files changed, 180 insertions(+), 3105 deletions(-) delete mode 100644 bridge/tests/common/bridge_test_common.h delete mode 100644 bridge/tests/integration/test_bridge_audio_roundtrip.cpp delete mode 100644 bridge/tests/integration/test_bridge_data_roundtrip.cpp delete mode 100644 bridge/tests/stress/test_bridge_audio_stress.cpp delete mode 100644 bridge/tests/stress/test_bridge_callback_stress.cpp delete mode 100644 bridge/tests/stress/test_bridge_data_stress.cpp delete mode 100644 bridge/tests/stress/test_bridge_lifecycle_stress.cpp delete mode 100644 bridge/tests/stress/test_bridge_multi_track_stress.cpp delete mode 100644 bridge/tests/unit/test_bridge_audio_track.cpp delete mode 100644 bridge/tests/unit/test_bridge_video_track.cpp delete mode 100644 bridge/tests/unit/test_callback_key.cpp delete mode 100644 bridge/tests/unit/test_livekit_bridge.cpp diff --git a/bridge/include/livekit_bridge/livekit_bridge.h b/bridge/include/livekit_bridge/livekit_bridge.h index 47f13d46..7cc4a70f 100644 --- a/bridge/include/livekit_bridge/livekit_bridge.h +++ b/bridge/include/livekit_bridge/livekit_bridge.h @@ -59,6 +59,12 @@ using AudioFrameCallback = livekit::AudioFrameCallback; /// @param timestamp_us Presentation timestamp in microseconds. using VideoFrameCallback = livekit::VideoFrameCallback; +/// Callback type for incoming data track frames. +/// Called on a background reader thread owned by Room. +/// @param payload Raw binary data received. +/// @param user_timestamp Optional application-defined timestamp from sender. +using DataFrameCallback = livekit::DataFrameCallback; + /** * High-level bridge to the LiveKit C++ SDK. * @@ -262,6 +268,42 @@ class LiveKitBridge { void clearOnVideoFrameCallback(const std::string &participant_identity, livekit::TrackSource source); + /** + * Set the callback for data frames from a specific remote participant's + * data track. + * + * Delegates to Room::setOnDataFrameCallback. + * + * @param participant_identity Identity of the remote participant. + * @param track_name Name of the remote data track. + * @param callback Function to invoke per data frame. + */ + void setOnDataFrameCallback(const std::string &participant_identity, + const std::string &track_name, + DataFrameCallback callback); + + /** + * Clear the data frame callback for a specific remote participant + track + * name. + * + * Delegates to Room::clearOnDataFrameCallback. + */ + void clearOnDataFrameCallback(const std::string &participant_identity, + const std::string &track_name); + + /** + * Create and publish a local data track. + * + * Delegates to Room::publishDataTrack (which delegates to + * LocalParticipant::publishDataTrack). + * + * @param name Unique track name visible to other participants. + * @return Shared pointer to the published data track (never null). + * @throws std::runtime_error if the bridge is not connected. + */ + std::shared_ptr + publishDataTrack(const std::string &name); + // --------------------------------------------------------------- // RPC (Remote Procedure Call) // --------------------------------------------------------------- diff --git a/bridge/src/livekit_bridge.cpp b/bridge/src/livekit_bridge.cpp index 9f782904..21840cb0 100644 --- a/bridge/src/livekit_bridge.cpp +++ b/bridge/src/livekit_bridge.cpp @@ -278,6 +278,37 @@ void LiveKitBridge::clearOnVideoFrameCallback( room_->clearOnVideoFrameCallback(participant_identity, source); } +void LiveKitBridge::setOnDataFrameCallback( + const std::string &participant_identity, const std::string &track_name, + DataFrameCallback callback) { + std::lock_guard lock(mutex_); + if (!room_) { + LK_LOG_WARN("setOnDataFrameCallback called before connect(); ignored"); + return; + } + room_->setOnDataFrameCallback(participant_identity, track_name, + std::move(callback)); +} + +void LiveKitBridge::clearOnDataFrameCallback( + const std::string &participant_identity, const std::string &track_name) { + std::lock_guard lock(mutex_); + if (!room_) { + return; + } + room_->clearOnDataFrameCallback(participant_identity, track_name); +} + +std::shared_ptr +LiveKitBridge::publishDataTrack(const std::string &name) { + std::lock_guard lock(mutex_); + if (!connected_ || !room_ || !room_->localParticipant()) { + throw std::runtime_error( + "publishDataTrack requires an active connection; call connect() first"); + } + return room_->localParticipant()->publishDataTrack(name); +} + // --------------------------------------------------------------- // RPC (delegates to RpcController) // --------------------------------------------------------------- diff --git a/bridge/tests/common/bridge_test_common.h b/bridge/tests/common/bridge_test_common.h deleted file mode 100644 index 9389a509..00000000 --- a/bridge/tests/common/bridge_test_common.h +++ /dev/null @@ -1,311 +0,0 @@ -/* - * Copyright 2026 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace livekit_bridge { -namespace test { - -using namespace std::chrono_literals; - -constexpr int kDefaultTestIterations = 10; -constexpr int kDefaultStressDurationSeconds = 600; - -/** - * Test configuration loaded from the same environment variables used by the - * base SDK tests (see README "RPC Test Environment Variables"). - * - * LIVEKIT_URL - WebSocket URL of the LiveKit server - * LIVEKIT_CALLER_TOKEN - Token for the caller/sender participant - * LIVEKIT_RECEIVER_TOKEN - Token for the receiver participant - * TEST_ITERATIONS - Number of iterations (default: 10) - * STRESS_DURATION_SECONDS - Duration for stress tests (default: 600) - */ -struct BridgeTestConfig { - std::string url; - std::string caller_token; - std::string receiver_token; - int test_iterations; - int stress_duration_seconds; - bool available = false; - - static BridgeTestConfig fromEnv() { - BridgeTestConfig config; - const char *url = std::getenv("LIVEKIT_URL"); - const char *caller_token = std::getenv("LIVEKIT_CALLER_TOKEN"); - const char *receiver_token = std::getenv("LIVEKIT_RECEIVER_TOKEN"); - const char *iterations_env = std::getenv("TEST_ITERATIONS"); - const char *duration_env = std::getenv("STRESS_DURATION_SECONDS"); - - if (url && caller_token && receiver_token) { - config.url = url; - config.caller_token = caller_token; - config.receiver_token = receiver_token; - config.available = true; - } - - config.test_iterations = - iterations_env ? std::atoi(iterations_env) : kDefaultTestIterations; - config.stress_duration_seconds = - duration_env ? std::atoi(duration_env) : kDefaultStressDurationSeconds; - - return config; - } -}; - -/** - * Thread-safe latency statistics collector. - * Identical to livekit::test::LatencyStats but lives in the bridge namespace - * to avoid linking against the base SDK test helpers. - */ -class LatencyStats { -public: - void addMeasurement(double latency_ms) { - std::lock_guard lock(mutex_); - measurements_.push_back(latency_ms); - } - - void printStats(const std::string &title) const { - std::lock_guard lock(mutex_); - - if (measurements_.empty()) { - std::cout << "\n" << title << ": No measurements collected" << std::endl; - return; - } - - std::vector sorted = measurements_; - std::sort(sorted.begin(), sorted.end()); - - double sum = std::accumulate(sorted.begin(), sorted.end(), 0.0); - double avg = sum / sorted.size(); - - std::cout << "\n========================================" << std::endl; - std::cout << " " << title << std::endl; - std::cout << "========================================" << std::endl; - std::cout << "Samples: " << sorted.size() << std::endl; - std::cout << std::fixed << std::setprecision(2); - std::cout << "Min: " << sorted.front() << " ms" << std::endl; - std::cout << "Avg: " << avg << " ms" << std::endl; - std::cout << "P50: " << getPercentile(sorted, 50) << " ms" - << std::endl; - std::cout << "P95: " << getPercentile(sorted, 95) << " ms" - << std::endl; - std::cout << "P99: " << getPercentile(sorted, 99) << " ms" - << std::endl; - std::cout << "Max: " << sorted.back() << " ms" << std::endl; - std::cout << "========================================\n" << std::endl; - } - - size_t count() const { - std::lock_guard lock(mutex_); - return measurements_.size(); - } - - void clear() { - std::lock_guard lock(mutex_); - measurements_.clear(); - } - -private: - static double getPercentile(const std::vector &sorted, - int percentile) { - if (sorted.empty()) - return 0.0; - size_t index = (sorted.size() * percentile) / 100; - if (index >= sorted.size()) - index = sorted.size() - 1; - return sorted[index]; - } - - mutable std::mutex mutex_; - std::vector measurements_; -}; - -/** - * Extended statistics collector for stress tests. - */ -class StressTestStats { -public: - void recordCall(bool success, double latency_ms, size_t payload_size = 0) { - std::lock_guard lock(mutex_); - total_calls_++; - if (success) { - successful_calls_++; - latencies_.push_back(latency_ms); - total_bytes_ += payload_size; - } else { - failed_calls_++; - } - } - - void recordError(const std::string &error_type) { - std::lock_guard lock(mutex_); - error_counts_[error_type]++; - } - - void printStats(const std::string &title = "Stress Test Statistics") const { - std::lock_guard lock(mutex_); - - std::cout << "\n========================================" << std::endl; - std::cout << " " << title << std::endl; - std::cout << "========================================" << std::endl; - std::cout << "Total calls: " << total_calls_ << std::endl; - std::cout << "Successful: " << successful_calls_ << std::endl; - std::cout << "Failed: " << failed_calls_ << std::endl; - std::cout << "Success rate: " << std::fixed << std::setprecision(2) - << (total_calls_ > 0 - ? (100.0 * successful_calls_ / total_calls_) - : 0.0) - << "%" << std::endl; - std::cout << "Total bytes: " << total_bytes_ << " (" - << (total_bytes_ / (1024.0 * 1024.0)) << " MB)" << std::endl; - - if (!latencies_.empty()) { - std::vector sorted = latencies_; - std::sort(sorted.begin(), sorted.end()); - - double sum = - std::accumulate(sorted.begin(), sorted.end(), 0.0); - double avg = sum / sorted.size(); - - std::cout << "\nLatency (ms):" << std::endl; - std::cout << " Min: " << sorted.front() << std::endl; - std::cout << " Avg: " << avg << std::endl; - std::cout << " P50: " - << sorted[sorted.size() * 50 / 100] << std::endl; - std::cout << " P95: " - << sorted[sorted.size() * 95 / 100] << std::endl; - std::cout << " P99: " - << sorted[sorted.size() * 99 / 100] << std::endl; - std::cout << " Max: " << sorted.back() << std::endl; - } - - if (!error_counts_.empty()) { - std::cout << "\nError breakdown:" << std::endl; - for (const auto &pair : error_counts_) { - std::cout << " " << pair.first << ": " << pair.second << std::endl; - } - } - - std::cout << "========================================\n" << std::endl; - } - - int totalCalls() const { - std::lock_guard lock(mutex_); - return total_calls_; - } - - int successfulCalls() const { - std::lock_guard lock(mutex_); - return successful_calls_; - } - - int failedCalls() const { - std::lock_guard lock(mutex_); - return failed_calls_; - } - -private: - mutable std::mutex mutex_; - int total_calls_ = 0; - int successful_calls_ = 0; - int failed_calls_ = 0; - size_t total_bytes_ = 0; - std::vector latencies_; - std::map error_counts_; -}; - -/** - * Base test fixture for bridge E2E tests. - * - * IMPORTANT — SDK lifecycle constraints: - * - * • livekit::initialize() / livekit::shutdown() operate on a process-global - * singleton (FfiClient). shutdown() calls livekit_ffi_dispose() which - * tears down the Rust runtime. Re-initializing after a dispose can leave - * internal Rust state corrupt when done many times in rapid succession. - * - * • Each LiveKitBridge instance independently calls initialize()/shutdown() - * in connect()/disconnect(). With two bridges in the same test the first - * one to disconnect() shuts down the SDK while the second is still alive. - * - * Our strategy: - * 1. Tests should let bridge destructors handle disconnect. Do NOT call - * bridge.disconnect() at the end of a test — just let the bridge go - * out of scope and its destructor will disconnect and (eventually) - * call shutdown. - * 2. If you need to explicitly disconnect mid-test (e.g. to test - * lifecycle), accept that this triggers a shutdown. Add a 1 s sleep - * after destruction before creating the next bridge so the Rust - * runtime fully cleans up. - */ -class BridgeTestBase : public ::testing::Test { -protected: - void SetUp() override { config_ = BridgeTestConfig::fromEnv(); } - - void skipIfNotConfigured() { - if (!config_.available) { - GTEST_SKIP() << "LIVEKIT_URL, LIVEKIT_CALLER_TOKEN, and " - "LIVEKIT_RECEIVER_TOKEN not set"; - } - } - - /** - * Connect two bridges (caller and receiver) and verify both are connected. - * Returns false if either connection fails. - */ - bool connectPair(LiveKitBridge &caller, LiveKitBridge &receiver) { - livekit::RoomOptions options; - options.auto_subscribe = true; - - bool receiver_ok = - receiver.connect(config_.url, config_.receiver_token, options); - if (!receiver_ok) - return false; - - bool caller_ok = - caller.connect(config_.url, config_.caller_token, options); - if (!caller_ok) { - receiver.disconnect(); - return false; - } - - // Allow time for peer discovery - std::this_thread::sleep_for(2s); - return true; - } - - BridgeTestConfig config_; -}; - -} // namespace test -} // namespace livekit_bridge diff --git a/bridge/tests/integration/test_bridge_audio_roundtrip.cpp b/bridge/tests/integration/test_bridge_audio_roundtrip.cpp deleted file mode 100644 index 90cc4c33..00000000 --- a/bridge/tests/integration/test_bridge_audio_roundtrip.cpp +++ /dev/null @@ -1,318 +0,0 @@ -/* - * Copyright 2026 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#include "../common/bridge_test_common.h" -#include -#include - -namespace livekit_bridge { -namespace test { - -constexpr int kAudioSampleRate = 48000; -constexpr int kAudioChannels = 1; -constexpr int kAudioFrameDurationMs = 10; -constexpr int kSamplesPerFrame = - kAudioSampleRate * kAudioFrameDurationMs / 1000; -constexpr double kHighEnergyThreshold = 0.3; -constexpr int kHighEnergyFramesPerPulse = 5; - -static std::vector generateHighEnergyFrame(int samples) { - std::vector data(samples * kAudioChannels); - const double frequency = 1000.0; - const double amplitude = 30000.0; - for (int i = 0; i < samples; ++i) { - double t = static_cast(i) / kAudioSampleRate; - auto sample = static_cast( - amplitude * std::sin(2.0 * M_PI * frequency * t)); - for (int ch = 0; ch < kAudioChannels; ++ch) { - data[i * kAudioChannels + ch] = sample; - } - } - return data; -} - -static std::vector generateSilentFrame(int samples) { - return std::vector(samples * kAudioChannels, 0); -} - -static double calculateEnergy(const std::vector &samples) { - if (samples.empty()) - return 0.0; - double sum_squared = 0.0; - for (auto s : samples) { - double normalized = static_cast(s) / 32768.0; - sum_squared += normalized * normalized; - } - return std::sqrt(sum_squared / samples.size()); -} - -class BridgeAudioRoundtripTest : public BridgeTestBase {}; - -// --------------------------------------------------------------------------- -// Test 1: Basic audio frame round-trip through the bridge API. -// -// Caller bridge publishes an audio track, receiver bridge receives frames via -// setOnAudioFrameCallback. We send high-energy pulses interleaved with -// silence and verify that the receiver detects them. -// --------------------------------------------------------------------------- -TEST_F(BridgeAudioRoundtripTest, AudioFrameRoundTrip) { - skipIfNotConfigured(); - - std::cout << "\n=== Bridge Audio Frame Round-Trip Test ===" << std::endl; - - LiveKitBridge caller; - LiveKitBridge receiver; - - ASSERT_TRUE(connectPair(caller, receiver)) - << "Failed to connect caller/receiver pair"; - - std::cout << "Both bridges connected." << std::endl; - - auto audio_track = caller.publishAudioTrack( - "roundtrip-mic", kAudioSampleRate, kAudioChannels, - livekit::TrackSource::SOURCE_MICROPHONE); - ASSERT_NE(audio_track, nullptr); - - std::cout << "Audio track published." << std::endl; - - std::atomic frames_received{0}; - std::atomic high_energy_frames{0}; - - const std::string caller_identity = "rpc-caller"; - - receiver.setOnAudioFrameCallback( - caller_identity, livekit::TrackSource::SOURCE_MICROPHONE, - [&](const livekit::AudioFrame &frame) { - frames_received++; - double energy = calculateEnergy(frame.data()); - if (energy > kHighEnergyThreshold) { - high_energy_frames++; - } - }); - - std::cout << "Callback registered, sending audio..." << std::endl; - - const int total_frames = 500; - const int frames_between_pulses = 100; - auto next_frame_time = std::chrono::steady_clock::now(); - const auto frame_duration = std::chrono::milliseconds(kAudioFrameDurationMs); - int pulses_sent = 0; - int high_energy_remaining = 0; - - for (int i = 0; i < total_frames; ++i) { - std::this_thread::sleep_until(next_frame_time); - next_frame_time += frame_duration; - - std::vector frame_data; - - if (high_energy_remaining > 0) { - frame_data = generateHighEnergyFrame(kSamplesPerFrame); - high_energy_remaining--; - } else if (i > 0 && i % frames_between_pulses == 0) { - frame_data = generateHighEnergyFrame(kSamplesPerFrame); - high_energy_remaining = kHighEnergyFramesPerPulse - 1; - pulses_sent++; - std::cout << " Sent pulse " << pulses_sent << std::endl; - } else { - frame_data = generateSilentFrame(kSamplesPerFrame); - } - - audio_track->pushFrame(frame_data, kSamplesPerFrame); - } - - std::this_thread::sleep_for(2s); - - std::cout << "\nResults:" << std::endl; - std::cout << " Pulses sent: " << pulses_sent << std::endl; - std::cout << " Frames received: " << frames_received.load() - << std::endl; - std::cout << " High-energy frames rx: " << high_energy_frames.load() - << std::endl; - - EXPECT_GT(frames_received.load(), 0) - << "Receiver should have received at least one audio frame"; - EXPECT_GT(high_energy_frames.load(), 0) - << "Receiver should have detected at least one high-energy pulse"; - - // Clear callback before bridges go out of scope so the reader thread - // is joined while the atomic counters above are still alive. - receiver.clearOnAudioFrameCallback(caller_identity, - livekit::TrackSource::SOURCE_MICROPHONE); -} - -// --------------------------------------------------------------------------- -// Test 2: Audio latency measurement through the bridge. -// -// Same energy-detection approach as the base SDK's AudioLatency test but -// exercises the full bridge pipeline: pushFrame() → SFU → reader thread → -// AudioFrameCallback. -// --------------------------------------------------------------------------- -TEST_F(BridgeAudioRoundtripTest, AudioLatencyMeasurement) { - skipIfNotConfigured(); - - std::cout << "\n=== Bridge Audio Latency Measurement ===" << std::endl; - - LiveKitBridge caller; - LiveKitBridge receiver; - - ASSERT_TRUE(connectPair(caller, receiver)); - - auto audio_track = - caller.publishAudioTrack("latency-mic", kAudioSampleRate, kAudioChannels, - livekit::TrackSource::SOURCE_MICROPHONE); - ASSERT_NE(audio_track, nullptr); - - LatencyStats stats; - std::atomic running{true}; - std::atomic last_send_time_us{0}; - std::atomic waiting_for_echo{false}; - std::atomic missed_pulses{0}; - constexpr uint64_t kEchoTimeoutUs = 2'000'000; - - const std::string caller_identity = "rpc-caller"; - - receiver.setOnAudioFrameCallback( - caller_identity, livekit::TrackSource::SOURCE_MICROPHONE, - [&](const livekit::AudioFrame &frame) { - double energy = calculateEnergy(frame.data()); - if (waiting_for_echo.load() && energy > kHighEnergyThreshold) { - uint64_t rx_us = - std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count(); - uint64_t tx_us = last_send_time_us.load(); - if (tx_us > 0) { - double latency_ms = (rx_us - tx_us) / 1000.0; - if (latency_ms > 0 && latency_ms < 5000) { - stats.addMeasurement(latency_ms); - std::cout << " Latency: " << std::fixed << std::setprecision(2) - << latency_ms << " ms" << std::endl; - } - waiting_for_echo.store(false); - } - } - }); - - const int total_pulses = 10; - const int frames_between_pulses = 100; - int pulses_sent = 0; - int high_energy_remaining = 0; - uint64_t pulse_send_time = 0; - - auto next_frame_time = std::chrono::steady_clock::now(); - const auto frame_duration = std::chrono::milliseconds(kAudioFrameDurationMs); - int frame_count = 0; - - while (running.load() && pulses_sent < total_pulses) { - std::this_thread::sleep_until(next_frame_time); - next_frame_time += frame_duration; - - if (waiting_for_echo.load() && pulse_send_time > 0) { - uint64_t now_us = std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count(); - if (now_us - pulse_send_time > kEchoTimeoutUs) { - std::cout << " Echo timeout for pulse " << pulses_sent << std::endl; - waiting_for_echo.store(false); - missed_pulses++; - pulse_send_time = 0; - high_energy_remaining = 0; - } - } - - std::vector frame_data; - - if (high_energy_remaining > 0) { - frame_data = generateHighEnergyFrame(kSamplesPerFrame); - high_energy_remaining--; - } else if (frame_count > 0 && frame_count % frames_between_pulses == 0 && - !waiting_for_echo.load()) { - frame_data = generateHighEnergyFrame(kSamplesPerFrame); - high_energy_remaining = kHighEnergyFramesPerPulse - 1; - - pulse_send_time = std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count(); - last_send_time_us.store(pulse_send_time); - waiting_for_echo.store(true); - pulses_sent++; - std::cout << "Sent pulse " << pulses_sent << "/" << total_pulses - << std::endl; - } else { - frame_data = generateSilentFrame(kSamplesPerFrame); - } - - audio_track->pushFrame(frame_data, kSamplesPerFrame); - frame_count++; - } - - std::this_thread::sleep_for(2s); - - stats.printStats("Bridge Audio Latency Statistics"); - - if (missed_pulses > 0) { - std::cout << "Missed pulses (timeout): " << missed_pulses << std::endl; - } - - EXPECT_GT(stats.count(), 0u) - << "At least one audio latency measurement should be recorded"; - - receiver.clearOnAudioFrameCallback(caller_identity, - livekit::TrackSource::SOURCE_MICROPHONE); -} - -// --------------------------------------------------------------------------- -// Test 3: Connect → publish → disconnect cycle for audio tracks. -// -// Each cycle creates a bridge in a scoped block so the destructor runs -// before the next cycle begins. A sleep between cycles gives the Rust -// runtime time to fully tear down. -// --------------------------------------------------------------------------- -TEST_F(BridgeAudioRoundtripTest, ConnectPublishDisconnectCycle) { - skipIfNotConfigured(); - - const int cycles = config_.test_iterations; - std::cout << "\n=== Bridge Audio Connect/Disconnect Cycles ===" << std::endl; - std::cout << "Cycles: " << cycles << std::endl; - - for (int i = 0; i < cycles; ++i) { - { - LiveKitBridge bridge; - livekit::RoomOptions options; - options.auto_subscribe = true; - - bool connected = - bridge.connect(config_.url, config_.caller_token, options); - ASSERT_TRUE(connected) << "Cycle " << i << ": connect failed"; - - auto track = bridge.publishAudioTrack( - "cycle-mic", kAudioSampleRate, kAudioChannels, - livekit::TrackSource::SOURCE_MICROPHONE); - ASSERT_NE(track, nullptr); - - for (int f = 0; f < 10; ++f) { - auto data = generateSilentFrame(kSamplesPerFrame); - track->pushFrame(data, kSamplesPerFrame); - } - } // bridge destroyed here → disconnect + shutdown - - std::cout << " Cycle " << (i + 1) << "/" << cycles << " OK" << std::endl; - std::this_thread::sleep_for(1s); - } -} - -} // namespace test -} // namespace livekit_bridge diff --git a/bridge/tests/integration/test_bridge_data_roundtrip.cpp b/bridge/tests/integration/test_bridge_data_roundtrip.cpp deleted file mode 100644 index 425ca3a8..00000000 --- a/bridge/tests/integration/test_bridge_data_roundtrip.cpp +++ /dev/null @@ -1,291 +0,0 @@ -/* - * Copyright 2026 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#include "../common/bridge_test_common.h" -#include -#include - -namespace livekit_bridge { -namespace test { - -static std::vector generatePayload(size_t size) { - static thread_local std::mt19937 gen(std::random_device{}()); - std::uniform_int_distribution dist(0, 255); - std::vector data(size); - for (auto &b : data) { - b = static_cast(dist(gen)); - } - return data; -} - -class BridgeDataRoundtripTest : public BridgeTestBase {}; - -// --------------------------------------------------------------------------- -// Test 1: Basic data track round-trip. -// -// Caller publishes a data track, receiver registers a callback, caller sends -// frames, receiver verifies payload integrity. -// --------------------------------------------------------------------------- -TEST_F(BridgeDataRoundtripTest, DataFrameRoundTrip) { - skipIfNotConfigured(); - - std::cout << "\n=== Bridge Data Frame Round-Trip Test ===" << std::endl; - - LiveKitBridge caller; - LiveKitBridge receiver; - - ASSERT_TRUE(connectPair(caller, receiver)); - - const std::string track_name = "roundtrip-data"; - const std::string caller_identity = "rpc-caller"; - - auto data_track = caller.publishDataTrack(track_name); - ASSERT_NE(data_track, nullptr); - ASSERT_TRUE(data_track->isPublished()); - - std::cout << "Data track published." << std::endl; - - std::mutex rx_mutex; - std::condition_variable rx_cv; - std::vector> received_payloads; - std::vector> received_timestamps; - - receiver.setOnDataFrameCallback( - caller_identity, track_name, - [&](const std::vector &payload, - std::optional user_timestamp) { - std::lock_guard lock(rx_mutex); - received_payloads.push_back(payload); - received_timestamps.push_back(user_timestamp); - rx_cv.notify_all(); - }); - - // Give the subscription time to be established - std::this_thread::sleep_for(3s); - - std::cout << "Sending data frames..." << std::endl; - - const int num_frames = 10; - std::vector> sent_payloads; - std::vector sent_timestamps; - - for (int i = 0; i < num_frames; ++i) { - auto payload = generatePayload(256); - auto ts = static_cast(i * 1000); - sent_payloads.push_back(payload); - sent_timestamps.push_back(ts); - - bool pushed = data_track->tryPush(payload, ts); - EXPECT_TRUE(pushed) << "tryPush failed for frame " << i; - - std::this_thread::sleep_for(100ms); - } - - { - std::unique_lock lock(rx_mutex); - rx_cv.wait_for(lock, 10s, [&] { - return received_payloads.size() >= static_cast(num_frames); - }); - } - - std::cout << "\nResults:" << std::endl; - std::cout << " Frames sent: " << num_frames << std::endl; - std::cout << " Frames received: " << received_payloads.size() << std::endl; - - EXPECT_EQ(received_payloads.size(), static_cast(num_frames)) - << "Should receive all sent frames"; - - for (size_t i = 0; - i < std::min(received_payloads.size(), sent_payloads.size()); ++i) { - EXPECT_EQ(received_payloads[i], sent_payloads[i]) - << "Payload mismatch at frame " << i; - ASSERT_TRUE(received_timestamps[i].has_value()) - << "Missing timestamp at frame " << i; - EXPECT_EQ(received_timestamps[i].value(), sent_timestamps[i]) - << "Timestamp mismatch at frame " << i; - } - - receiver.clearOnDataFrameCallback(caller_identity, track_name); -} - -// --------------------------------------------------------------------------- -// Test 2: Data track with callback registered AFTER track is published. -// -// Exercises the bridge's pending_remote_data_tracks_ mechanism: the remote -// data track is published before the receiver registers its callback. -// --------------------------------------------------------------------------- -TEST_F(BridgeDataRoundtripTest, LateCallbackRegistration) { - skipIfNotConfigured(); - - std::cout << "\n=== Bridge Data Late Callback Registration Test ===" - << std::endl; - - LiveKitBridge caller; - LiveKitBridge receiver; - - ASSERT_TRUE(connectPair(caller, receiver)); - - const std::string track_name = "late-callback-data"; - const std::string caller_identity = "rpc-caller"; - - auto data_track = caller.publishDataTrack(track_name); - ASSERT_NE(data_track, nullptr); - - std::cout << "Data track published, waiting before registering callback..." - << std::endl; - - std::this_thread::sleep_for(3s); - - std::atomic frames_received{0}; - std::condition_variable rx_cv; - std::mutex rx_mutex; - - receiver.setOnDataFrameCallback( - caller_identity, track_name, - [&](const std::vector &, std::optional) { - frames_received++; - rx_cv.notify_all(); - }); - - std::cout << "Callback registered (late), sending frames..." << std::endl; - - std::this_thread::sleep_for(2s); - - const int num_frames = 5; - for (int i = 0; i < num_frames; ++i) { - auto payload = generatePayload(128); - data_track->tryPush(payload); - std::this_thread::sleep_for(100ms); - } - - { - std::unique_lock lock(rx_mutex); - rx_cv.wait_for(lock, 10s, - [&] { return frames_received.load() >= num_frames; }); - } - - std::cout << "Frames received: " << frames_received.load() << std::endl; - - EXPECT_EQ(frames_received.load(), num_frames) - << "Late callback should still receive all frames"; - - receiver.clearOnDataFrameCallback(caller_identity, track_name); -} - -// --------------------------------------------------------------------------- -// Test 3: Varying payload sizes. -// -// Tests data track with payloads from tiny (1 byte) to large (64KB) to -// verify the bridge handles different frame sizes correctly. -// --------------------------------------------------------------------------- -TEST_F(BridgeDataRoundtripTest, VaryingPayloadSizes) { - skipIfNotConfigured(); - - std::cout << "\n=== Bridge Data Varying Payload Sizes Test ===" << std::endl; - - LiveKitBridge caller; - LiveKitBridge receiver; - - ASSERT_TRUE(connectPair(caller, receiver)); - - const std::string track_name = "size-test-data"; - const std::string caller_identity = "rpc-caller"; - - auto data_track = caller.publishDataTrack(track_name); - ASSERT_NE(data_track, nullptr); - - std::mutex rx_mutex; - std::condition_variable rx_cv; - std::vector received_sizes; - - receiver.setOnDataFrameCallback(caller_identity, track_name, - [&](const std::vector &payload, - std::optional) { - std::lock_guard lock(rx_mutex); - received_sizes.push_back(payload.size()); - rx_cv.notify_all(); - }); - - std::this_thread::sleep_for(3s); - - std::vector test_sizes = {1, 10, 100, 1024, 4096, 16384, 65536}; - - std::cout << "Sending " << test_sizes.size() - << " frames with varying sizes..." << std::endl; - - for (size_t sz : test_sizes) { - auto payload = generatePayload(sz); - bool pushed = data_track->tryPush(payload); - EXPECT_TRUE(pushed) << "tryPush failed for size " << sz; - std::this_thread::sleep_for(200ms); - } - - { - std::unique_lock lock(rx_mutex); - rx_cv.wait_for(lock, 15s, - [&] { return received_sizes.size() >= test_sizes.size(); }); - } - - std::cout << "Received " << received_sizes.size() << "/" << test_sizes.size() - << " frames." << std::endl; - - EXPECT_EQ(received_sizes.size(), test_sizes.size()); - - for (size_t i = 0; i < std::min(received_sizes.size(), test_sizes.size()); - ++i) { - EXPECT_EQ(received_sizes[i], test_sizes[i]) - << "Size mismatch at index " << i; - } - - receiver.clearOnDataFrameCallback(caller_identity, track_name); -} - -// --------------------------------------------------------------------------- -// Test 4: Connect → publish data track → disconnect cycle. -// --------------------------------------------------------------------------- -TEST_F(BridgeDataRoundtripTest, ConnectPublishDisconnectCycle) { - skipIfNotConfigured(); - - const int cycles = config_.test_iterations; - std::cout << "\n=== Bridge Data Connect/Disconnect Cycles ===" << std::endl; - std::cout << "Cycles: " << cycles << std::endl; - - for (int i = 0; i < cycles; ++i) { - { - LiveKitBridge bridge; - livekit::RoomOptions options; - options.auto_subscribe = true; - - bool connected = - bridge.connect(config_.url, config_.caller_token, options); - ASSERT_TRUE(connected) << "Cycle " << i << ": connect failed"; - - auto track = bridge.publishDataTrack("cycle-data"); - ASSERT_NE(track, nullptr); - - for (int f = 0; f < 5; ++f) { - auto payload = generatePayload(256); - track->tryPush(payload); - } - } // bridge destroyed here → disconnect + shutdown - - std::cout << " Cycle " << (i + 1) << "/" << cycles << " OK" << std::endl; - std::this_thread::sleep_for(1s); - } -} - -} // namespace test -} // namespace livekit_bridge diff --git a/bridge/tests/stress/test_bridge_audio_stress.cpp b/bridge/tests/stress/test_bridge_audio_stress.cpp deleted file mode 100644 index abdf264e..00000000 --- a/bridge/tests/stress/test_bridge_audio_stress.cpp +++ /dev/null @@ -1,276 +0,0 @@ -/* - * Copyright 2026 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#include "../common/bridge_test_common.h" -#include - -namespace livekit_bridge { -namespace test { - -constexpr int kStressAudioSampleRate = 48000; -constexpr int kStressAudioChannels = 1; -constexpr int kStressFrameDurationMs = 10; -constexpr int kStressSamplesPerFrame = - kStressAudioSampleRate * kStressFrameDurationMs / 1000; - -static std::vector makeSineFrame(int samples, double freq, - int &phase) { - std::vector data(samples * kStressAudioChannels); - const double amplitude = 16000.0; - for (int i = 0; i < samples; ++i) { - double t = static_cast(phase++) / kStressAudioSampleRate; - auto sample = - static_cast(amplitude * std::sin(2.0 * M_PI * freq * t)); - for (int ch = 0; ch < kStressAudioChannels; ++ch) { - data[i * kStressAudioChannels + ch] = sample; - } - } - return data; -} - -class BridgeAudioStressTest : public BridgeTestBase {}; - -// --------------------------------------------------------------------------- -// Sustained audio pushing: sends audio at real-time pace for the configured -// stress duration and tracks frames received, delivery rate, and errors. -// --------------------------------------------------------------------------- -TEST_F(BridgeAudioStressTest, SustainedAudioPush) { - skipIfNotConfigured(); - - std::cout << "\n=== Bridge Sustained Audio Stress Test ===" << std::endl; - std::cout << "Duration: " << config_.stress_duration_seconds << " seconds" - << std::endl; - - LiveKitBridge caller; - LiveKitBridge receiver; - - ASSERT_TRUE(connectPair(caller, receiver)); - - auto audio_track = caller.publishAudioTrack( - "stress-mic", kStressAudioSampleRate, kStressAudioChannels, - livekit::TrackSource::SOURCE_MICROPHONE); - ASSERT_NE(audio_track, nullptr); - - const std::string caller_identity = "rpc-caller"; - - std::atomic frames_sent{0}; - std::atomic frames_received{0}; - std::atomic push_failures{0}; - std::atomic running{true}; - - receiver.setOnAudioFrameCallback( - caller_identity, livekit::TrackSource::SOURCE_MICROPHONE, - [&](const livekit::AudioFrame &) { frames_received++; }); - - std::this_thread::sleep_for(3s); - - auto start_time = std::chrono::steady_clock::now(); - auto duration = std::chrono::seconds(config_.stress_duration_seconds); - - std::thread sender([&]() { - int phase = 0; - auto next_frame_time = std::chrono::steady_clock::now(); - const auto frame_duration = - std::chrono::milliseconds(kStressFrameDurationMs); - - while (running.load()) { - std::this_thread::sleep_until(next_frame_time); - next_frame_time += frame_duration; - - auto data = makeSineFrame(kStressSamplesPerFrame, 440.0, phase); - bool ok = audio_track->pushFrame(data, kStressSamplesPerFrame); - if (ok) { - frames_sent++; - } else { - push_failures++; - } - } - }); - - std::thread progress([&]() { - int64_t last_sent = 0; - int64_t last_received = 0; - while (running.load()) { - std::this_thread::sleep_for(30s); - if (!running.load()) - break; - - auto elapsed = std::chrono::steady_clock::now() - start_time; - auto elapsed_s = - std::chrono::duration_cast(elapsed).count(); - int64_t cur_sent = frames_sent.load(); - int64_t cur_received = frames_received.load(); - double send_rate = (cur_sent - last_sent) / 30.0; - double recv_rate = (cur_received - last_received) / 30.0; - last_sent = cur_sent; - last_received = cur_received; - - std::cout << "[" << elapsed_s << "s]" - << " sent=" << cur_sent << " recv=" << cur_received - << " failures=" << push_failures.load() - << " send_rate=" << std::fixed << std::setprecision(1) - << send_rate << "/s" - << " recv_rate=" << recv_rate << "/s" << std::endl; - } - }); - - while (std::chrono::steady_clock::now() - start_time < duration) { - std::this_thread::sleep_for(1s); - } - - running.store(false); - sender.join(); - progress.join(); - - std::this_thread::sleep_for(2s); - - std::cout << "\n========================================" << std::endl; - std::cout << " Sustained Audio Stress Results" << std::endl; - std::cout << "========================================" << std::endl; - std::cout << "Duration: " << config_.stress_duration_seconds << "s" - << std::endl; - std::cout << "Frames sent: " << frames_sent.load() << std::endl; - std::cout << "Frames received: " << frames_received.load() << std::endl; - std::cout << "Push failures: " << push_failures.load() << std::endl; - - double delivery_rate = - frames_sent.load() > 0 - ? (100.0 * frames_received.load() / frames_sent.load()) - : 0.0; - std::cout << "Delivery rate: " << std::fixed << std::setprecision(2) - << delivery_rate << "%" << std::endl; - std::cout << "========================================\n" << std::endl; - - EXPECT_GT(frames_sent.load(), 0) << "Should have sent frames"; - EXPECT_GT(frames_received.load(), 0) << "Should have received frames"; - EXPECT_EQ(push_failures.load(), 0) << "No push failures expected"; - EXPECT_GT(delivery_rate, 50.0) << "Delivery rate below 50%"; - - receiver.clearOnAudioFrameCallback(caller_identity, - livekit::TrackSource::SOURCE_MICROPHONE); -} - -// --------------------------------------------------------------------------- -// Track release under active push: one thread pushes audio continuously -// while another releases the track after a delay. Verifies clean shutdown -// with no crashes or hangs. -// --------------------------------------------------------------------------- -TEST_F(BridgeAudioStressTest, ReleaseUnderActivePush) { - skipIfNotConfigured(); - - std::cout << "\n=== Bridge Audio Release Under Active Push ===" << std::endl; - - const int iterations = config_.test_iterations; - std::cout << "Iterations: " << iterations << std::endl; - - for (int iter = 0; iter < iterations; ++iter) { - { - LiveKitBridge bridge; - livekit::RoomOptions options; - options.auto_subscribe = true; - - bool connected = - bridge.connect(config_.url, config_.caller_token, options); - ASSERT_TRUE(connected) << "Iteration " << iter << ": connect failed"; - - auto track = bridge.publishAudioTrack( - "release-stress-mic", kStressAudioSampleRate, kStressAudioChannels, - livekit::TrackSource::SOURCE_MICROPHONE); - - std::atomic pushing{true}; - std::atomic push_count{0}; - - std::thread pusher([&]() { - int phase = 0; - while (pushing.load()) { - auto data = makeSineFrame(kStressSamplesPerFrame, 440.0, phase); - track->pushFrame(data, kStressSamplesPerFrame); - push_count++; - std::this_thread::sleep_for( - std::chrono::milliseconds(kStressFrameDurationMs)); - } - }); - - std::this_thread::sleep_for(500ms); - track->release(); - EXPECT_TRUE(track->isReleased()); - - std::this_thread::sleep_for(200ms); - pushing.store(false); - pusher.join(); - - std::cout << " Iteration " << (iter + 1) << "/" << iterations - << " OK (pushed " << push_count.load() << " frames)" - << std::endl; - } // bridge destroyed here - - std::this_thread::sleep_for(1s); - } -} - -// --------------------------------------------------------------------------- -// Rapid connect/disconnect with active audio callback. Verifies that the -// bridge's reader thread cleanup handles abrupt disconnection. -// --------------------------------------------------------------------------- -TEST_F(BridgeAudioStressTest, RapidConnectDisconnectWithCallback) { - skipIfNotConfigured(); - - const int cycles = config_.test_iterations; - std::cout << "\n=== Bridge Rapid Connect/Disconnect With Audio Callback ===" - << std::endl; - std::cout << "Cycles: " << cycles << std::endl; - - const std::string caller_identity = "rpc-caller"; - - for (int i = 0; i < cycles; ++i) { - { - LiveKitBridge caller; - LiveKitBridge receiver; - - ASSERT_TRUE(connectPair(caller, receiver)) - << "Cycle " << i << ": connect failed"; - - auto track = caller.publishAudioTrack( - "rapid-mic", kStressAudioSampleRate, kStressAudioChannels, - livekit::TrackSource::SOURCE_MICROPHONE); - - std::atomic rx_count{0}; - receiver.setOnAudioFrameCallback( - caller_identity, livekit::TrackSource::SOURCE_MICROPHONE, - [&](const livekit::AudioFrame &) { rx_count++; }); - - int phase = 0; - for (int f = 0; f < 50; ++f) { - auto data = makeSineFrame(kStressSamplesPerFrame, 440.0, phase); - track->pushFrame(data, kStressSamplesPerFrame); - std::this_thread::sleep_for( - std::chrono::milliseconds(kStressFrameDurationMs)); - } - - // Clear callback to join reader thread while rx_count is still alive - receiver.clearOnAudioFrameCallback( - caller_identity, livekit::TrackSource::SOURCE_MICROPHONE); - - std::cout << " Cycle " << (i + 1) << "/" << cycles - << " OK (rx=" << rx_count.load() << ")" << std::endl; - } // both bridges destroyed here - - std::this_thread::sleep_for(1s); - } -} - -} // namespace test -} // namespace livekit_bridge diff --git a/bridge/tests/stress/test_bridge_callback_stress.cpp b/bridge/tests/stress/test_bridge_callback_stress.cpp deleted file mode 100644 index 1c5a7fb9..00000000 --- a/bridge/tests/stress/test_bridge_callback_stress.cpp +++ /dev/null @@ -1,277 +0,0 @@ -/* - * Copyright 2026 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#include "../common/bridge_test_common.h" -#include -#include - -namespace livekit_bridge { -namespace test { - -constexpr int kCbSampleRate = 48000; -constexpr int kCbChannels = 1; -constexpr int kCbFrameDurationMs = 10; -constexpr int kCbSamplesPerFrame = kCbSampleRate * kCbFrameDurationMs / 1000; - -static std::vector cbSilentFrame() { - return std::vector(kCbSamplesPerFrame * kCbChannels, 0); -} - -static std::vector cbPayload(size_t size) { - static thread_local std::mt19937 gen(std::random_device{}()); - std::uniform_int_distribution dist(0, 255); - std::vector buf(size); - for (auto &b : buf) - b = static_cast(dist(gen)); - return buf; -} - -class BridgeCallbackStressTest : public BridgeTestBase {}; - -// --------------------------------------------------------------------------- -// Audio callback churn. -// -// Caller pushes audio at real-time pace. A separate thread rapidly -// registers / clears the receiver's audio callback. Each clear() must -// join the reader thread (which closes the AudioStream), and the -// subsequent register must start a new reader. This hammers the -// extract-thread-outside-lock pattern in clearOnAudioFrameCallback(). -// --------------------------------------------------------------------------- -TEST_F(BridgeCallbackStressTest, AudioCallbackChurn) { - skipIfNotConfigured(); - - std::cout << "\n=== Bridge Audio Callback Churn ===" << std::endl; - - LiveKitBridge caller; - LiveKitBridge receiver; - - ASSERT_TRUE(connectPair(caller, receiver)); - - auto audio = - caller.publishAudioTrack("churn-mic", kCbSampleRate, kCbChannels, - livekit::TrackSource::SOURCE_MICROPHONE); - ASSERT_NE(audio, nullptr); - - const std::string caller_identity = "rpc-caller"; - - std::atomic running{true}; - std::atomic total_rx{0}; - std::atomic churn_cycles{0}; - - std::thread pusher([&]() { - auto next = std::chrono::steady_clock::now(); - while (running.load()) { - std::this_thread::sleep_until(next); - next += std::chrono::milliseconds(kCbFrameDurationMs); - auto frame = cbSilentFrame(); - audio->pushFrame(frame, kCbSamplesPerFrame); - } - }); - - std::thread churner([&]() { - while (running.load()) { - receiver.setOnAudioFrameCallback( - caller_identity, livekit::TrackSource::SOURCE_MICROPHONE, - [&](const livekit::AudioFrame &) { total_rx++; }); - - std::this_thread::sleep_for(300ms); - - receiver.clearOnAudioFrameCallback( - caller_identity, livekit::TrackSource::SOURCE_MICROPHONE); - - std::this_thread::sleep_for(100ms); - churn_cycles++; - } - }); - - const int duration_s = std::min(config_.stress_duration_seconds, 30); - std::this_thread::sleep_for(std::chrono::seconds(duration_s)); - - running.store(false); - pusher.join(); - churner.join(); - - std::cout << "Churn cycles: " << churn_cycles.load() << std::endl; - std::cout << "Audio frames received: " << total_rx.load() << std::endl; - - EXPECT_GT(churn_cycles.load(), 0); -} - -// --------------------------------------------------------------------------- -// Mixed audio + data callback churn. -// -// Caller publishes both an audio and data track. Two independent churn -// threads each toggle their respective callback. A third thread pushes -// frames on both tracks. This exercises the bridge's two independent -// callback maps and reader sets under concurrent mutation. -// --------------------------------------------------------------------------- -TEST_F(BridgeCallbackStressTest, MixedCallbackChurn) { - skipIfNotConfigured(); - - std::cout << "\n=== Bridge Mixed Callback Churn ===" << std::endl; - - LiveKitBridge caller; - LiveKitBridge receiver; - - ASSERT_TRUE(connectPair(caller, receiver)); - - auto audio = - caller.publishAudioTrack("mixed-mic", kCbSampleRate, kCbChannels, - livekit::TrackSource::SOURCE_MICROPHONE); - auto data = caller.publishDataTrack("mixed-data"); - ASSERT_NE(audio, nullptr); - ASSERT_NE(data, nullptr); - - const std::string caller_identity = "rpc-caller"; - - std::atomic running{true}; - std::atomic audio_rx{0}; - std::atomic data_rx{0}; - std::atomic audio_churns{0}; - std::atomic data_churns{0}; - - std::thread audio_pusher([&]() { - auto next = std::chrono::steady_clock::now(); - while (running.load()) { - std::this_thread::sleep_until(next); - next += std::chrono::milliseconds(kCbFrameDurationMs); - auto frame = cbSilentFrame(); - audio->pushFrame(frame, kCbSamplesPerFrame); - } - }); - - std::thread data_pusher([&]() { - while (running.load()) { - auto payload = cbPayload(256); - data->tryPush(payload); - std::this_thread::sleep_for(20ms); - } - }); - - std::thread audio_churner([&]() { - while (running.load()) { - receiver.setOnAudioFrameCallback( - caller_identity, livekit::TrackSource::SOURCE_MICROPHONE, - [&](const livekit::AudioFrame &) { audio_rx++; }); - std::this_thread::sleep_for(250ms); - receiver.clearOnAudioFrameCallback( - caller_identity, livekit::TrackSource::SOURCE_MICROPHONE); - std::this_thread::sleep_for(100ms); - audio_churns++; - } - }); - - std::thread data_churner([&]() { - while (running.load()) { - receiver.setOnDataFrameCallback( - caller_identity, "mixed-data", - [&](const std::vector &, std::optional) { - data_rx++; - }); - std::this_thread::sleep_for(350ms); - receiver.clearOnDataFrameCallback(caller_identity, "mixed-data"); - std::this_thread::sleep_for(150ms); - data_churns++; - } - }); - - const int duration_s = std::min(config_.stress_duration_seconds, 30); - std::this_thread::sleep_for(std::chrono::seconds(duration_s)); - - running.store(false); - audio_pusher.join(); - data_pusher.join(); - audio_churner.join(); - data_churner.join(); - - std::cout << "Audio churn cycles: " << audio_churns.load() << std::endl; - std::cout << "Data churn cycles: " << data_churns.load() << std::endl; - std::cout << "Audio frames rx: " << audio_rx.load() << std::endl; - std::cout << "Data frames rx: " << data_rx.load() << std::endl; - - EXPECT_GT(audio_churns.load(), 0); - EXPECT_GT(data_churns.load(), 0); -} - -// --------------------------------------------------------------------------- -// Callback replacement storm. -// -// Instead of clear + set, rapidly replace the callback with a new lambda -// (calling setOnAudioFrameCallback twice without a clear in between). -// The bridge should silently overwrite the old callback and the new one -// should start receiving. The old reader thread should eventually be -// replaced the next time onTrackSubscribed fires. -// --------------------------------------------------------------------------- -TEST_F(BridgeCallbackStressTest, CallbackReplacement) { - skipIfNotConfigured(); - - std::cout << "\n=== Bridge Callback Replacement Storm ===" << std::endl; - - LiveKitBridge caller; - LiveKitBridge receiver; - - ASSERT_TRUE(connectPair(caller, receiver)); - - auto audio = - caller.publishAudioTrack("replace-mic", kCbSampleRate, kCbChannels, - livekit::TrackSource::SOURCE_MICROPHONE); - ASSERT_NE(audio, nullptr); - - const std::string caller_identity = "rpc-caller"; - - std::atomic running{true}; - std::atomic total_rx{0}; - std::atomic replacements{0}; - - std::thread pusher([&]() { - auto next = std::chrono::steady_clock::now(); - while (running.load()) { - std::this_thread::sleep_until(next); - next += std::chrono::milliseconds(kCbFrameDurationMs); - auto frame = cbSilentFrame(); - audio->pushFrame(frame, kCbSamplesPerFrame); - } - }); - - std::thread replacer([&]() { - while (running.load()) { - receiver.setOnAudioFrameCallback( - caller_identity, livekit::TrackSource::SOURCE_MICROPHONE, - [&](const livekit::AudioFrame &) { total_rx++; }); - replacements++; - std::this_thread::sleep_for(100ms); - } - }); - - const int duration_s = std::min(config_.stress_duration_seconds, 20); - std::this_thread::sleep_for(std::chrono::seconds(duration_s)); - - running.store(false); - pusher.join(); - replacer.join(); - - // Final clear to join any lingering reader - receiver.clearOnAudioFrameCallback(caller_identity, - livekit::TrackSource::SOURCE_MICROPHONE); - - std::cout << "Replacements: " << replacements.load() << std::endl; - std::cout << "Total frames rx: " << total_rx.load() << std::endl; - - EXPECT_GT(replacements.load(), 0); -} - -} // namespace test -} // namespace livekit_bridge diff --git a/bridge/tests/stress/test_bridge_data_stress.cpp b/bridge/tests/stress/test_bridge_data_stress.cpp deleted file mode 100644 index 8d2b90a0..00000000 --- a/bridge/tests/stress/test_bridge_data_stress.cpp +++ /dev/null @@ -1,333 +0,0 @@ -/* - * Copyright 2026 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#include "../common/bridge_test_common.h" -#include - -namespace livekit_bridge { -namespace test { - -static std::vector randomPayload(size_t size) { - static thread_local std::mt19937 gen(std::random_device{}()); - std::uniform_int_distribution dist(0, 255); - std::vector data(size); - for (auto &b : data) - b = static_cast(dist(gen)); - return data; -} - -class BridgeDataStressTest : public BridgeTestBase {}; - -// --------------------------------------------------------------------------- -// High-throughput data track stress test. -// -// Pushes data frames as fast as possible for STRESS_DURATION_SECONDS and -// tracks throughput, delivery rate, and back-pressure failures. -// --------------------------------------------------------------------------- -TEST_F(BridgeDataStressTest, HighThroughput) { - skipIfNotConfigured(); - - constexpr size_t kPayloadSize = 1024; - - std::cout << "\n=== Bridge Data High-Throughput Stress Test ===" << std::endl; - std::cout << "Duration: " << config_.stress_duration_seconds << "s" - << std::endl; - std::cout << "Payload size: " << kPayloadSize << " bytes" << std::endl; - - LiveKitBridge caller; - LiveKitBridge receiver; - - ASSERT_TRUE(connectPair(caller, receiver)); - - const std::string track_name = "throughput-data"; - const std::string caller_identity = "rpc-caller"; - - auto data_track = caller.publishDataTrack(track_name); - ASSERT_NE(data_track, nullptr); - - StressTestStats stats; - std::atomic frames_received{0}; - std::atomic running{true}; - - receiver.setOnDataFrameCallback(caller_identity, track_name, - [&](const std::vector &payload, - std::optional) { - frames_received++; - (void)payload; - }); - - std::this_thread::sleep_for(3s); - - auto start_time = std::chrono::steady_clock::now(); - auto duration = std::chrono::seconds(config_.stress_duration_seconds); - - std::thread sender([&]() { - while (running.load()) { - auto payload = randomPayload(kPayloadSize); - - auto t0 = std::chrono::high_resolution_clock::now(); - bool ok = data_track->tryPush(payload); - auto t1 = std::chrono::high_resolution_clock::now(); - - double latency_ms = - std::chrono::duration(t1 - t0).count(); - - if (ok) { - stats.recordCall(true, latency_ms, kPayloadSize); - } else { - stats.recordCall(false, latency_ms, kPayloadSize); - stats.recordError("push_failed"); - } - - std::this_thread::sleep_for(5ms); - } - }); - - std::thread progress([&]() { - int last_total = 0; - while (running.load()) { - std::this_thread::sleep_for(30s); - if (!running.load()) - break; - - auto elapsed = std::chrono::steady_clock::now() - start_time; - auto elapsed_s = - std::chrono::duration_cast(elapsed).count(); - int cur_total = stats.totalCalls(); - int rate = (cur_total - last_total); - last_total = cur_total; - - std::cout << "[" << elapsed_s << "s]" - << " sent=" << cur_total << " recv=" << frames_received.load() - << " success=" << stats.successfulCalls() - << " failed=" << stats.failedCalls() << " rate=" << std::fixed - << std::setprecision(1) << (rate / 30.0) << " pushes/s" - << std::endl; - } - }); - - while (std::chrono::steady_clock::now() - start_time < duration) { - std::this_thread::sleep_for(1s); - } - - running.store(false); - sender.join(); - progress.join(); - - std::this_thread::sleep_for(2s); - - stats.printStats("Bridge Data High-Throughput Stress"); - - std::cout << "Frames received: " << frames_received.load() << std::endl; - - EXPECT_GT(stats.successfulCalls(), 0) << "No successful pushes"; - double success_rate = - stats.totalCalls() > 0 - ? (100.0 * stats.successfulCalls() / stats.totalCalls()) - : 0.0; - EXPECT_GT(success_rate, 95.0) << "Push success rate below 95%"; - - double delivery_rate = - stats.successfulCalls() > 0 - ? (100.0 * frames_received.load() / stats.successfulCalls()) - : 0.0; - std::cout << "Delivery rate: " << std::fixed << std::setprecision(2) - << delivery_rate << "%" << std::endl; - - receiver.clearOnDataFrameCallback(caller_identity, track_name); -} - -// --------------------------------------------------------------------------- -// Large payload stress: pushes 64KB payloads for the configured duration. -// Exercises serialization / deserialization with larger frames. -// --------------------------------------------------------------------------- -TEST_F(BridgeDataStressTest, LargePayloadStress) { - skipIfNotConfigured(); - - constexpr size_t kLargePayloadSize = 64 * 1024; - - std::cout << "\n=== Bridge Data Large-Payload Stress Test ===" << std::endl; - std::cout << "Duration: " << config_.stress_duration_seconds << "s" - << std::endl; - std::cout << "Payload size: " << kLargePayloadSize << " bytes (64KB)" - << std::endl; - - LiveKitBridge caller; - LiveKitBridge receiver; - - ASSERT_TRUE(connectPair(caller, receiver)); - - const std::string track_name = "large-data"; - const std::string caller_identity = "rpc-caller"; - - auto data_track = caller.publishDataTrack(track_name); - ASSERT_NE(data_track, nullptr); - - StressTestStats stats; - std::atomic frames_received{0}; - std::atomic bytes_received{0}; - std::atomic running{true}; - - receiver.setOnDataFrameCallback(caller_identity, track_name, - [&](const std::vector &payload, - std::optional) { - frames_received++; - bytes_received += payload.size(); - }); - - std::this_thread::sleep_for(3s); - - auto start_time = std::chrono::steady_clock::now(); - auto duration = std::chrono::seconds(config_.stress_duration_seconds); - - std::thread sender([&]() { - while (running.load()) { - auto payload = randomPayload(kLargePayloadSize); - - auto t0 = std::chrono::high_resolution_clock::now(); - bool ok = data_track->tryPush(payload); - auto t1 = std::chrono::high_resolution_clock::now(); - - double latency_ms = - std::chrono::duration(t1 - t0).count(); - - if (ok) { - stats.recordCall(true, latency_ms, kLargePayloadSize); - } else { - stats.recordCall(false, latency_ms, kLargePayloadSize); - stats.recordError("push_failed"); - } - - std::this_thread::sleep_for(50ms); - } - }); - - std::thread progress([&]() { - int last_total = 0; - while (running.load()) { - std::this_thread::sleep_for(30s); - if (!running.load()) - break; - - auto elapsed = std::chrono::steady_clock::now() - start_time; - auto elapsed_s = - std::chrono::duration_cast(elapsed).count(); - int cur_total = stats.totalCalls(); - - std::cout << "[" << elapsed_s << "s]" - << " sent=" << cur_total << " recv=" << frames_received.load() - << " bytes_rx=" << (bytes_received.load() / (1024.0 * 1024.0)) - << " MB" - << " rate=" << std::fixed << std::setprecision(1) - << ((cur_total - last_total) / 30.0) << " pushes/s" - << std::endl; - last_total = cur_total; - } - }); - - while (std::chrono::steady_clock::now() - start_time < duration) { - std::this_thread::sleep_for(1s); - } - - running.store(false); - sender.join(); - progress.join(); - - std::this_thread::sleep_for(2s); - - stats.printStats("Bridge Data Large-Payload Stress"); - - std::cout << "Frames received: " << frames_received.load() << std::endl; - std::cout << "Bytes received: " - << (bytes_received.load() / (1024.0 * 1024.0)) << " MB" - << std::endl; - - EXPECT_GT(stats.successfulCalls(), 0) << "No successful pushes"; - double success_rate = - stats.totalCalls() > 0 - ? (100.0 * stats.successfulCalls() / stats.totalCalls()) - : 0.0; - EXPECT_GT(success_rate, 90.0) << "Push success rate below 90%"; - - receiver.clearOnDataFrameCallback(caller_identity, track_name); -} - -// --------------------------------------------------------------------------- -// Callback churn: rapidly register/unregister the data frame callback while -// the sender is actively pushing. Exercises the bridge's thread-joining -// logic under contention. -// --------------------------------------------------------------------------- -TEST_F(BridgeDataStressTest, CallbackChurn) { - skipIfNotConfigured(); - - std::cout << "\n=== Bridge Data Callback Churn Stress Test ===" << std::endl; - - LiveKitBridge caller; - LiveKitBridge receiver; - - ASSERT_TRUE(connectPair(caller, receiver)); - - const std::string track_name = "churn-data"; - const std::string caller_identity = "rpc-caller"; - - auto data_track = caller.publishDataTrack(track_name); - ASSERT_NE(data_track, nullptr); - - std::atomic running{true}; - std::atomic total_received{0}; - std::atomic churn_cycles{0}; - - std::thread sender([&]() { - while (running.load()) { - auto payload = randomPayload(256); - data_track->tryPush(payload); - std::this_thread::sleep_for(10ms); - } - }); - - std::thread churner([&]() { - while (running.load()) { - receiver.setOnDataFrameCallback( - caller_identity, track_name, - [&](const std::vector &, std::optional) { - total_received++; - }); - - std::this_thread::sleep_for(500ms); - - receiver.clearOnDataFrameCallback(caller_identity, track_name); - - std::this_thread::sleep_for(200ms); - churn_cycles++; - } - }); - - const int churn_duration_s = std::min(config_.stress_duration_seconds, 30); - std::this_thread::sleep_for(std::chrono::seconds(churn_duration_s)); - - running.store(false); - sender.join(); - churner.join(); - - std::cout << "Churn cycles completed: " << churn_cycles.load() << std::endl; - std::cout << "Total frames received: " << total_received.load() << std::endl; - - EXPECT_GT(churn_cycles.load(), 0) - << "Should have completed at least one churn cycle"; -} - -} // namespace test -} // namespace livekit_bridge diff --git a/bridge/tests/stress/test_bridge_lifecycle_stress.cpp b/bridge/tests/stress/test_bridge_lifecycle_stress.cpp deleted file mode 100644 index 67cab7d4..00000000 --- a/bridge/tests/stress/test_bridge_lifecycle_stress.cpp +++ /dev/null @@ -1,301 +0,0 @@ -/* - * Copyright 2026 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#include "../common/bridge_test_common.h" -#include -#include - -namespace livekit_bridge { -namespace test { - -constexpr int kLifecycleSampleRate = 48000; -constexpr int kLifecycleChannels = 1; -constexpr int kLifecycleFrameDurationMs = 10; -constexpr int kLifecycleSamplesPerFrame = - kLifecycleSampleRate * kLifecycleFrameDurationMs / 1000; - -static std::vector makeSilent(int samples) { - return std::vector(samples * kLifecycleChannels, 0); -} - -static std::vector makePayload(size_t size) { - static thread_local std::mt19937 gen(std::random_device{}()); - std::uniform_int_distribution dist(0, 255); - std::vector buf(size); - for (auto &b : buf) - b = static_cast(dist(gen)); - return buf; -} - -class BridgeLifecycleStressTest : public BridgeTestBase {}; - -// --------------------------------------------------------------------------- -// Disconnect while frames are actively being pushed and received. -// -// Two threads push audio / data frames at full rate. A third thread -// triggers disconnect() mid-flight after a random delay. The bridge must -// tear down cleanly with no crashes, hangs, or thread leaks. Repeated -// for TEST_ITERATIONS cycles. -// --------------------------------------------------------------------------- -TEST_F(BridgeLifecycleStressTest, DisconnectUnderLoad) { - skipIfNotConfigured(); - - const int cycles = config_.test_iterations; - std::cout << "\n=== Bridge Disconnect Under Load ===" << std::endl; - std::cout << "Cycles: " << cycles << std::endl; - - const std::string caller_identity = "rpc-caller"; - - for (int i = 0; i < cycles; ++i) { - { - LiveKitBridge caller; - LiveKitBridge receiver; - - ASSERT_TRUE(connectPair(caller, receiver)) - << "Cycle " << i << ": connect failed"; - - auto audio = caller.publishAudioTrack( - "load-mic", kLifecycleSampleRate, kLifecycleChannels, - livekit::TrackSource::SOURCE_MICROPHONE); - auto data = caller.publishDataTrack("load-data"); - - std::atomic audio_rx{0}; - std::atomic data_rx{0}; - - receiver.setOnAudioFrameCallback( - caller_identity, livekit::TrackSource::SOURCE_MICROPHONE, - [&](const livekit::AudioFrame &) { audio_rx++; }); - - receiver.setOnDataFrameCallback( - caller_identity, "load-data", - [&](const std::vector &, std::optional) { - data_rx++; - }); - - std::this_thread::sleep_for(2s); - - std::atomic running{true}; - - std::thread audio_pusher([&]() { - while (running.load()) { - auto frame = makeSilent(kLifecycleSamplesPerFrame); - audio->pushFrame(frame, kLifecycleSamplesPerFrame); - std::this_thread::sleep_for( - std::chrono::milliseconds(kLifecycleFrameDurationMs)); - } - }); - - std::thread data_pusher([&]() { - while (running.load()) { - auto payload = makePayload(512); - data->tryPush(payload); - std::this_thread::sleep_for(20ms); - } - }); - - // Let traffic flow for 1-2 seconds, then pull the plug. - std::this_thread::sleep_for(1500ms); - running.store(false); - audio_pusher.join(); - data_pusher.join(); - - // Clear callbacks while captured atomics are still alive - receiver.clearOnAudioFrameCallback( - caller_identity, livekit::TrackSource::SOURCE_MICROPHONE); - receiver.clearOnDataFrameCallback(caller_identity, "load-data"); - - std::cout << " Cycle " << (i + 1) << "/" << cycles - << " OK (audio_rx=" << audio_rx.load() - << " data_rx=" << data_rx.load() << ")" << std::endl; - } // bridges destroyed → disconnect + shutdown - - std::this_thread::sleep_for(1s); - } -} - -// --------------------------------------------------------------------------- -// Track release while receiver is consuming. -// -// Caller publishes audio + data, receiver is actively consuming via -// callbacks, then caller releases each track individually while the -// receiver's reader threads are still running. Verifies no dangling -// pointers, no use-after-free, and clean thread join. -// --------------------------------------------------------------------------- -TEST_F(BridgeLifecycleStressTest, TrackReleaseWhileReceiving) { - skipIfNotConfigured(); - - const int iterations = config_.test_iterations; - std::cout << "\n=== Bridge Track Release While Receiving ===" << std::endl; - std::cout << "Iterations: " << iterations << std::endl; - - const std::string caller_identity = "rpc-caller"; - - for (int iter = 0; iter < iterations; ++iter) { - { - LiveKitBridge caller; - LiveKitBridge receiver; - - ASSERT_TRUE(connectPair(caller, receiver)) - << "Iteration " << iter << ": connect failed"; - - auto audio = caller.publishAudioTrack( - "release-rx-mic", kLifecycleSampleRate, kLifecycleChannels, - livekit::TrackSource::SOURCE_MICROPHONE); - auto data = caller.publishDataTrack("release-rx-data"); - - std::atomic audio_rx{0}; - std::atomic data_rx{0}; - - receiver.setOnAudioFrameCallback( - caller_identity, livekit::TrackSource::SOURCE_MICROPHONE, - [&](const livekit::AudioFrame &) { audio_rx++; }); - - receiver.setOnDataFrameCallback( - caller_identity, "release-rx-data", - [&](const std::vector &, std::optional) { - data_rx++; - }); - - std::this_thread::sleep_for(2s); - - std::atomic running{true}; - - std::thread audio_pusher([&]() { - while (running.load()) { - if (audio->isReleased()) - break; - auto frame = makeSilent(kLifecycleSamplesPerFrame); - audio->pushFrame(frame, kLifecycleSamplesPerFrame); - std::this_thread::sleep_for( - std::chrono::milliseconds(kLifecycleFrameDurationMs)); - } - }); - - std::thread data_pusher([&]() { - while (running.load()) { - if (!data->isPublished()) - break; - auto payload = makePayload(256); - data->tryPush(payload); - std::this_thread::sleep_for(20ms); - } - }); - - // Let frames flow, then release tracks mid-stream - std::this_thread::sleep_for(800ms); - - audio->release(); - EXPECT_TRUE(audio->isReleased()); - - std::this_thread::sleep_for(200ms); - - data->unpublishDataTrack(); - EXPECT_FALSE(data->isPublished()); - - running.store(false); - audio_pusher.join(); - data_pusher.join(); - - // pushFrame / tryPush must return false on released / unpublished tracks - auto silence = makeSilent(kLifecycleSamplesPerFrame); - EXPECT_FALSE(audio->pushFrame(silence, kLifecycleSamplesPerFrame)); - - auto payload = makePayload(64); - EXPECT_FALSE(data->tryPush(payload)); - - receiver.clearOnAudioFrameCallback( - caller_identity, livekit::TrackSource::SOURCE_MICROPHONE); - receiver.clearOnDataFrameCallback(caller_identity, "release-rx-data"); - - std::cout << " Iteration " << (iter + 1) << "/" << iterations - << " OK (audio_rx=" << audio_rx.load() - << " data_rx=" << data_rx.load() << ")" << std::endl; - } - - std::this_thread::sleep_for(1s); - } -} - -// --------------------------------------------------------------------------- -// Repeated full lifecycle: connect → create all track types → push → -// release → disconnect. Exercises the complete resource creation and -// teardown path looking for accumulating leaks. -// --------------------------------------------------------------------------- -TEST_F(BridgeLifecycleStressTest, FullLifecycleSoak) { - skipIfNotConfigured(); - - const int cycles = config_.test_iterations; - std::cout << "\n=== Bridge Full Lifecycle Soak ===" << std::endl; - std::cout << "Cycles: " << cycles << std::endl; - - for (int i = 0; i < cycles; ++i) { - { - LiveKitBridge bridge; - livekit::RoomOptions options; - options.auto_subscribe = true; - - bool connected = - bridge.connect(config_.url, config_.caller_token, options); - ASSERT_TRUE(connected) << "Cycle " << i << ": connect failed"; - - auto audio = bridge.publishAudioTrack( - "soak-mic", kLifecycleSampleRate, kLifecycleChannels, - livekit::TrackSource::SOURCE_MICROPHONE); - - constexpr int kVideoWidth = 320; - constexpr int kVideoHeight = 240; - auto video = - bridge.publishVideoTrack("soak-cam", kVideoWidth, kVideoHeight, - livekit::TrackSource::SOURCE_CAMERA); - - auto data = bridge.publishDataTrack("soak-data"); - - // Push a handful of frames on each track type - for (int f = 0; f < 10; ++f) { - auto pcm = makeSilent(kLifecycleSamplesPerFrame); - audio->pushFrame(pcm, kLifecycleSamplesPerFrame); - - std::vector rgba(kVideoWidth * kVideoHeight * 4, 0x80); - video->pushFrame(rgba); - - auto payload = makePayload(256); - data->tryPush(payload); - - std::this_thread::sleep_for( - std::chrono::milliseconds(kLifecycleFrameDurationMs)); - } - - // Explicit release / unpublish in various orders to exercise different - // teardown paths - if (i % 3 == 0) { - audio->release(); - video->release(); - data->unpublishDataTrack(); - } else if (i % 3 == 1) { - data->unpublishDataTrack(); - audio->release(); - video->release(); - } - // else: let disconnect() release all tracks - } // bridge destroyed - - std::cout << " Cycle " << (i + 1) << "/" << cycles << " OK" << std::endl; - std::this_thread::sleep_for(1s); - } -} - -} // namespace test -} // namespace livekit_bridge diff --git a/bridge/tests/stress/test_bridge_multi_track_stress.cpp b/bridge/tests/stress/test_bridge_multi_track_stress.cpp deleted file mode 100644 index 40619ce0..00000000 --- a/bridge/tests/stress/test_bridge_multi_track_stress.cpp +++ /dev/null @@ -1,438 +0,0 @@ -/* - * Copyright 2026 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#include "../common/bridge_test_common.h" -#include -#include - -namespace livekit_bridge { -namespace test { - -constexpr int kMtSampleRate = 48000; -constexpr int kMtChannels = 1; -constexpr int kMtFrameDurationMs = 10; -constexpr int kMtSamplesPerFrame = kMtSampleRate * kMtFrameDurationMs / 1000; -constexpr int kMtVideoWidth = 160; -constexpr int kMtVideoHeight = 120; -constexpr size_t kMtVideoFrameBytes = kMtVideoWidth * kMtVideoHeight * 4; - -static std::vector mtSilentFrame() { - return std::vector(kMtSamplesPerFrame * kMtChannels, 0); -} - -static std::vector mtVideoFrame() { - return std::vector(kMtVideoFrameBytes, 0x42); -} - -static std::vector mtPayload(size_t size) { - static thread_local std::mt19937 gen(std::random_device{}()); - std::uniform_int_distribution dist(0, 255); - std::vector buf(size); - for (auto &b : buf) - b = static_cast(dist(gen)); - return buf; -} - -class BridgeMultiTrackStressTest : public BridgeTestBase {}; - -// --------------------------------------------------------------------------- -// Concurrent pushes on all track types. -// -// Publishes an audio track, a video track, and two data tracks. A -// separate thread pushes frames on each track simultaneously for the -// configured stress duration. All four threads contend on the bridge's -// internal mutex and on the underlying FFI. Reports per-track push -// success rates. -// --------------------------------------------------------------------------- -TEST_F(BridgeMultiTrackStressTest, ConcurrentMultiTrackPush) { - skipIfNotConfigured(); - - std::cout << "\n=== Bridge Concurrent Multi-Track Push ===" << std::endl; - std::cout << "Duration: " << config_.stress_duration_seconds << "s" - << std::endl; - - LiveKitBridge bridge; - livekit::RoomOptions options; - options.auto_subscribe = true; - - bool connected = bridge.connect(config_.url, config_.caller_token, options); - ASSERT_TRUE(connected); - - auto audio = - bridge.publishAudioTrack("mt-mic", kMtSampleRate, kMtChannels, - livekit::TrackSource::SOURCE_MICROPHONE); - - auto video = bridge.publishVideoTrack("mt-cam", kMtVideoWidth, kMtVideoHeight, - livekit::TrackSource::SOURCE_CAMERA); - - auto data1 = bridge.publishDataTrack("mt-data-1"); - auto data2 = bridge.publishDataTrack("mt-data-2"); - - ASSERT_NE(audio, nullptr); - ASSERT_NE(video, nullptr); - ASSERT_NE(data1, nullptr); - ASSERT_NE(data2, nullptr); - - struct TrackStats { - std::atomic pushes{0}; - std::atomic successes{0}; - std::atomic failures{0}; - }; - - TrackStats audio_stats, video_stats, data1_stats, data2_stats; - std::atomic running{true}; - - auto start_time = std::chrono::steady_clock::now(); - auto duration = std::chrono::seconds(config_.stress_duration_seconds); - - std::thread audio_thread([&]() { - auto next = std::chrono::steady_clock::now(); - while (running.load()) { - std::this_thread::sleep_until(next); - next += std::chrono::milliseconds(kMtFrameDurationMs); - auto frame = mtSilentFrame(); - bool ok = audio->pushFrame(frame, kMtSamplesPerFrame); - audio_stats.pushes++; - if (ok) - audio_stats.successes++; - else - audio_stats.failures++; - } - }); - - std::thread video_thread([&]() { - while (running.load()) { - auto frame = mtVideoFrame(); - bool ok = video->pushFrame(frame); - video_stats.pushes++; - if (ok) - video_stats.successes++; - else - video_stats.failures++; - // ~30 fps - std::this_thread::sleep_for(33ms); - } - }); - - std::thread data1_thread([&]() { - while (running.load()) { - auto payload = mtPayload(512); - bool ok = data1->tryPush(payload); - data1_stats.pushes++; - if (ok) - data1_stats.successes++; - else - data1_stats.failures++; - std::this_thread::sleep_for(10ms); - } - }); - - std::thread data2_thread([&]() { - while (running.load()) { - auto payload = mtPayload(2048); - bool ok = data2->tryPush(payload); - data2_stats.pushes++; - if (ok) - data2_stats.successes++; - else - data2_stats.failures++; - std::this_thread::sleep_for(15ms); - } - }); - - // Progress reporting - std::thread progress([&]() { - while (running.load()) { - std::this_thread::sleep_for(30s); - if (!running.load()) - break; - auto elapsed = std::chrono::steady_clock::now() - start_time; - auto elapsed_s = - std::chrono::duration_cast(elapsed).count(); - std::cout << "[" << elapsed_s << "s]" - << " audio=" << audio_stats.pushes.load() - << " video=" << video_stats.pushes.load() - << " data1=" << data1_stats.pushes.load() - << " data2=" << data2_stats.pushes.load() << std::endl; - } - }); - - while (std::chrono::steady_clock::now() - start_time < duration) { - std::this_thread::sleep_for(1s); - } - - running.store(false); - audio_thread.join(); - video_thread.join(); - data1_thread.join(); - data2_thread.join(); - progress.join(); - - auto printTrack = [](const char *name, const TrackStats &s) { - double rate = s.pushes.load() > 0 - ? (100.0 * s.successes.load() / s.pushes.load()) - : 0.0; - std::cout << " " << name << ": pushes=" << s.pushes.load() - << " ok=" << s.successes.load() << " fail=" << s.failures.load() - << " (" << std::fixed << std::setprecision(1) << rate << "%)" - << std::endl; - }; - - std::cout << "\n========================================" << std::endl; - std::cout << " Multi-Track Push Results" << std::endl; - std::cout << "========================================" << std::endl; - printTrack("audio ", audio_stats); - printTrack("video ", video_stats); - printTrack("data-1", data1_stats); - printTrack("data-2", data2_stats); - std::cout << "========================================\n" << std::endl; - - EXPECT_GT(audio_stats.successes.load(), 0); - EXPECT_GT(video_stats.successes.load(), 0); - EXPECT_GT(data1_stats.successes.load(), 0); - EXPECT_GT(data2_stats.successes.load(), 0); -} - -// --------------------------------------------------------------------------- -// Concurrent track creation and release. -// -// Multiple threads simultaneously create tracks, push a short burst, -// then release them. Exercises the bridge's published_*_tracks_ vectors -// and mutex under heavy concurrent modification. -// --------------------------------------------------------------------------- -TEST_F(BridgeMultiTrackStressTest, ConcurrentCreateRelease) { - skipIfNotConfigured(); - - std::cout << "\n=== Bridge Concurrent Track Create/Release ===" << std::endl; - - LiveKitBridge bridge; - livekit::RoomOptions options; - options.auto_subscribe = true; - - bool connected = bridge.connect(config_.url, config_.caller_token, options); - ASSERT_TRUE(connected); - - std::atomic running{true}; - std::atomic audio_cycles{0}; - std::atomic data_cycles{0}; - std::atomic errors{0}; - - // Each source can only have one active track, so we serialize by source - // but run the two sources concurrently. - - std::thread audio_thread([&]() { - while (running.load()) { - try { - auto track = bridge.publishAudioTrack( - "create-release-mic", kMtSampleRate, kMtChannels, - livekit::TrackSource::SOURCE_MICROPHONE); - - for (int i = 0; i < 5; ++i) { - auto frame = mtSilentFrame(); - track->pushFrame(frame, kMtSamplesPerFrame); - std::this_thread::sleep_for( - std::chrono::milliseconds(kMtFrameDurationMs)); - } - - track->release(); - audio_cycles++; - } catch (const std::exception &e) { - errors++; - std::cerr << "Audio create/release error: " << e.what() << std::endl; - } - std::this_thread::sleep_for(200ms); - } - }); - - std::thread data_thread([&]() { - int track_counter = 0; - while (running.load()) { - try { - auto track = bridge.publishDataTrack("create-release-data-" + - std::to_string(track_counter++)); - - for (int i = 0; i < 5; ++i) { - auto payload = mtPayload(128); - track->tryPush(payload); - std::this_thread::sleep_for(20ms); - } - - track->unpublishDataTrack(); - data_cycles++; - } catch (const std::exception &e) { - errors++; - std::cerr << "Data create/release error: " << e.what() << std::endl; - } - std::this_thread::sleep_for(200ms); - } - }); - - const int duration_s = std::min(config_.stress_duration_seconds, 30); - std::this_thread::sleep_for(std::chrono::seconds(duration_s)); - - running.store(false); - audio_thread.join(); - data_thread.join(); - - std::cout << "Audio create/release cycles: " << audio_cycles.load() - << std::endl; - std::cout << "Data create/release cycles: " << data_cycles.load() - << std::endl; - std::cout << "Errors: " << errors.load() << std::endl; - - EXPECT_GT(audio_cycles.load(), 0); - EXPECT_GT(data_cycles.load(), 0); - EXPECT_EQ(errors.load(), 0); -} - -// --------------------------------------------------------------------------- -// Full-duplex multi-track. -// -// Both caller and receiver publish audio + data tracks. Both register -// callbacks for the other's tracks. All four push-threads and all four -// reader threads run simultaneously, exercising the bridge's internal -// maps from both the publish side and the subscribe side. -// --------------------------------------------------------------------------- -TEST_F(BridgeMultiTrackStressTest, FullDuplexMultiTrack) { - skipIfNotConfigured(); - - std::cout << "\n=== Bridge Full-Duplex Multi-Track ===" << std::endl; - std::cout << "Duration: " << config_.stress_duration_seconds << "s" - << std::endl; - - LiveKitBridge caller; - LiveKitBridge receiver; - - ASSERT_TRUE(connectPair(caller, receiver)); - - const std::string caller_identity = "rpc-caller"; - const std::string receiver_identity = "rpc-receiver"; - - // Caller publishes - auto caller_audio = - caller.publishAudioTrack("duplex-mic", kMtSampleRate, kMtChannels, - livekit::TrackSource::SOURCE_MICROPHONE); - auto caller_data = caller.publishDataTrack("duplex-data-caller"); - - // Receiver publishes - auto receiver_audio = - receiver.publishAudioTrack("duplex-mic", kMtSampleRate, kMtChannels, - livekit::TrackSource::SOURCE_MICROPHONE); - auto receiver_data = receiver.publishDataTrack("duplex-data-receiver"); - - // Cross-register callbacks - std::atomic caller_audio_rx{0}; - std::atomic caller_data_rx{0}; - std::atomic receiver_audio_rx{0}; - std::atomic receiver_data_rx{0}; - - caller.setOnAudioFrameCallback( - receiver_identity, livekit::TrackSource::SOURCE_MICROPHONE, - [&](const livekit::AudioFrame &) { caller_audio_rx++; }); - caller.setOnDataFrameCallback( - receiver_identity, "duplex-data-receiver", - [&](const std::vector &, std::optional) { - caller_data_rx++; - }); - - receiver.setOnAudioFrameCallback( - caller_identity, livekit::TrackSource::SOURCE_MICROPHONE, - [&](const livekit::AudioFrame &) { receiver_audio_rx++; }); - receiver.setOnDataFrameCallback( - caller_identity, "duplex-data-caller", - [&](const std::vector &, std::optional) { - receiver_data_rx++; - }); - - std::this_thread::sleep_for(3s); - - std::atomic running{true}; - auto start_time = std::chrono::steady_clock::now(); - auto duration = std::chrono::seconds(config_.stress_duration_seconds); - - auto audio_push_fn = [&](std::shared_ptr track) { - auto next = std::chrono::steady_clock::now(); - while (running.load()) { - std::this_thread::sleep_until(next); - next += std::chrono::milliseconds(kMtFrameDurationMs); - auto frame = mtSilentFrame(); - track->pushFrame(frame, kMtSamplesPerFrame); - } - }; - - auto data_push_fn = [&](std::shared_ptr track) { - while (running.load()) { - auto payload = mtPayload(256); - track->tryPush(payload); - std::this_thread::sleep_for(20ms); - } - }; - - std::thread t1(audio_push_fn, caller_audio); - std::thread t2(data_push_fn, caller_data); - std::thread t3(audio_push_fn, receiver_audio); - std::thread t4(data_push_fn, receiver_data); - - std::thread progress([&]() { - while (running.load()) { - std::this_thread::sleep_for(30s); - if (!running.load()) - break; - auto elapsed = std::chrono::steady_clock::now() - start_time; - auto elapsed_s = - std::chrono::duration_cast(elapsed).count(); - std::cout << "[" << elapsed_s << "s]" - << " caller_audio_rx=" << caller_audio_rx.load() - << " caller_data_rx=" << caller_data_rx.load() - << " receiver_audio_rx=" << receiver_audio_rx.load() - << " receiver_data_rx=" << receiver_data_rx.load() << std::endl; - } - }); - - while (std::chrono::steady_clock::now() - start_time < duration) { - std::this_thread::sleep_for(1s); - } - - running.store(false); - t1.join(); - t2.join(); - t3.join(); - t4.join(); - progress.join(); - - std::cout << "\n========================================" << std::endl; - std::cout << " Full-Duplex Multi-Track Results" << std::endl; - std::cout << "========================================" << std::endl; - std::cout << "Caller audio rx: " << caller_audio_rx.load() << std::endl; - std::cout << "Caller data rx: " << caller_data_rx.load() << std::endl; - std::cout << "Receiver audio rx: " << receiver_audio_rx.load() << std::endl; - std::cout << "Receiver data rx: " << receiver_data_rx.load() << std::endl; - std::cout << "========================================\n" << std::endl; - - EXPECT_GT(receiver_audio_rx.load(), 0); - EXPECT_GT(receiver_data_rx.load(), 0); - - // Clear callbacks while atomics are alive - caller.clearOnAudioFrameCallback(receiver_identity, - livekit::TrackSource::SOURCE_MICROPHONE); - caller.clearOnDataFrameCallback(receiver_identity, "duplex-data-receiver"); - receiver.clearOnAudioFrameCallback(caller_identity, - livekit::TrackSource::SOURCE_MICROPHONE); - receiver.clearOnDataFrameCallback(caller_identity, "duplex-data-caller"); -} - -} // namespace test -} // namespace livekit_bridge diff --git a/bridge/tests/unit/test_bridge_audio_track.cpp b/bridge/tests/unit/test_bridge_audio_track.cpp deleted file mode 100644 index ced5ae00..00000000 --- a/bridge/tests/unit/test_bridge_audio_track.cpp +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright 2026 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/// @file test_bridge_audio_track.cpp -/// @brief Unit tests for BridgeAudioTrack. - -#include -#include - -#include -#include -#include - -namespace livekit_bridge { -namespace test { - -class BridgeAudioTrackTest : public ::testing::Test { -protected: - /// Create a BridgeAudioTrack with null SDK objects for pure-logic testing. - /// The track is usable for accessor and state management tests but will - /// crash if pushFrame / mute / unmute try to dereference SDK pointers - /// on a non-released track. - static BridgeAudioTrack createNullTrack(const std::string &name = "mic", - int sample_rate = 48000, - int num_channels = 2) { - return BridgeAudioTrack(name, sample_rate, num_channels, - nullptr, // source - nullptr, // track - nullptr, // publication - nullptr // participant - ); - } -}; - -TEST_F(BridgeAudioTrackTest, AccessorsReturnConstructionValues) { - auto track = createNullTrack("test-mic", 16000, 1); - - EXPECT_EQ(track.name(), "test-mic") << "Name should match construction value"; - EXPECT_EQ(track.sampleRate(), 16000) << "Sample rate should match"; - EXPECT_EQ(track.numChannels(), 1) << "Channel count should match"; -} - -TEST_F(BridgeAudioTrackTest, InitiallyNotReleased) { - auto track = createNullTrack(); - - EXPECT_FALSE(track.isReleased()) - << "Track should not be released immediately after construction"; -} - -TEST_F(BridgeAudioTrackTest, ReleaseMarksTrackAsReleased) { - auto track = createNullTrack(); - - track.release(); - - EXPECT_TRUE(track.isReleased()) - << "Track should be released after calling release()"; -} - -TEST_F(BridgeAudioTrackTest, DoubleReleaseIsIdempotent) { - auto track = createNullTrack(); - - track.release(); - EXPECT_NO_THROW(track.release()) - << "Calling release() a second time should be a no-op"; - EXPECT_TRUE(track.isReleased()); -} - -TEST_F(BridgeAudioTrackTest, PushFrameAfterReleaseReturnsFalse) { - auto track = createNullTrack(); - track.release(); - - std::vector data(960, 0); - - EXPECT_FALSE(track.pushFrame(data, 480)) - << "pushFrame (vector) on a released track should return false"; -} - -TEST_F(BridgeAudioTrackTest, PushFrameRawPointerAfterReleaseReturnsFalse) { - auto track = createNullTrack(); - track.release(); - - std::vector data(960, 0); - - EXPECT_FALSE(track.pushFrame(data.data(), 480)) - << "pushFrame (raw pointer) on a released track should return false"; -} - -TEST_F(BridgeAudioTrackTest, MuteOnReleasedTrackDoesNotCrash) { - auto track = createNullTrack(); - track.release(); - - EXPECT_NO_THROW(track.mute()) - << "mute() on a released track should be a no-op"; -} - -TEST_F(BridgeAudioTrackTest, UnmuteOnReleasedTrackDoesNotCrash) { - auto track = createNullTrack(); - track.release(); - - EXPECT_NO_THROW(track.unmute()) - << "unmute() on a released track should be a no-op"; -} - -} // namespace test -} // namespace livekit_bridge diff --git a/bridge/tests/unit/test_bridge_video_track.cpp b/bridge/tests/unit/test_bridge_video_track.cpp deleted file mode 100644 index 5e64b8da..00000000 --- a/bridge/tests/unit/test_bridge_video_track.cpp +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright 2026 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/// @file test_bridge_video_track.cpp -/// @brief Unit tests for BridgeVideoTrack. - -#include -#include - -#include -#include -#include - -namespace livekit_bridge { -namespace test { - -class BridgeVideoTrackTest : public ::testing::Test { -protected: - /// Create a BridgeVideoTrack with null SDK objects for pure-logic testing. - static BridgeVideoTrack createNullTrack(const std::string &name = "cam", - int width = 1280, int height = 720) { - return BridgeVideoTrack(name, width, height, - nullptr, // source - nullptr, // track - nullptr, // publication - nullptr // participant - ); - } -}; - -TEST_F(BridgeVideoTrackTest, AccessorsReturnConstructionValues) { - auto track = createNullTrack("test-cam", 640, 480); - - EXPECT_EQ(track.name(), "test-cam") << "Name should match construction value"; - EXPECT_EQ(track.width(), 640) << "Width should match"; - EXPECT_EQ(track.height(), 480) << "Height should match"; -} - -TEST_F(BridgeVideoTrackTest, InitiallyNotReleased) { - auto track = createNullTrack(); - - EXPECT_FALSE(track.isReleased()) - << "Track should not be released immediately after construction"; -} - -TEST_F(BridgeVideoTrackTest, ReleaseMarksTrackAsReleased) { - auto track = createNullTrack(); - - track.release(); - - EXPECT_TRUE(track.isReleased()) - << "Track should be released after calling release()"; -} - -TEST_F(BridgeVideoTrackTest, DoubleReleaseIsIdempotent) { - auto track = createNullTrack(); - - track.release(); - EXPECT_NO_THROW(track.release()) - << "Calling release() a second time should be a no-op"; - EXPECT_TRUE(track.isReleased()); -} - -TEST_F(BridgeVideoTrackTest, PushFrameAfterReleaseReturnsFalse) { - auto track = createNullTrack(); - track.release(); - - std::vector data(1280 * 720 * 4, 0); - - EXPECT_FALSE(track.pushFrame(data)) - << "pushFrame (vector) on a released track should return false"; -} - -TEST_F(BridgeVideoTrackTest, PushFrameRawPointerAfterReleaseReturnsFalse) { - auto track = createNullTrack(); - track.release(); - - std::vector data(1280 * 720 * 4, 0); - - EXPECT_FALSE(track.pushFrame(data.data(), data.size())) - << "pushFrame (raw pointer) on a released track should return false"; -} - -TEST_F(BridgeVideoTrackTest, MuteOnReleasedTrackDoesNotCrash) { - auto track = createNullTrack(); - track.release(); - - EXPECT_NO_THROW(track.mute()) - << "mute() on a released track should be a no-op"; -} - -TEST_F(BridgeVideoTrackTest, UnmuteOnReleasedTrackDoesNotCrash) { - auto track = createNullTrack(); - track.release(); - - EXPECT_NO_THROW(track.unmute()) - << "unmute() on a released track should be a no-op"; -} - -} // namespace test -} // namespace livekit_bridge diff --git a/bridge/tests/unit/test_callback_key.cpp b/bridge/tests/unit/test_callback_key.cpp deleted file mode 100644 index d667f7d4..00000000 --- a/bridge/tests/unit/test_callback_key.cpp +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright 2026 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/// @file test_callback_key.cpp -/// @brief Unit tests for LiveKitBridge::CallbackKey hash and equality. - -#include -#include - -#include - -#include - -namespace livekit_bridge { -namespace test { - -class CallbackKeyTest : public ::testing::Test { -protected: - // Type aliases for convenience -- these are private types in LiveKitBridge, - // accessible via the friend declaration. - using CallbackKey = LiveKitBridge::CallbackKey; - using CallbackKeyHash = LiveKitBridge::CallbackKeyHash; -}; - -TEST_F(CallbackKeyTest, EqualKeysCompareEqual) { - CallbackKey a{"alice", livekit::TrackSource::SOURCE_MICROPHONE}; - CallbackKey b{"alice", livekit::TrackSource::SOURCE_MICROPHONE}; - - EXPECT_TRUE(a == b) << "Identical keys should compare equal"; -} - -TEST_F(CallbackKeyTest, DifferentIdentityComparesUnequal) { - CallbackKey a{"alice", livekit::TrackSource::SOURCE_MICROPHONE}; - CallbackKey b{"bob", livekit::TrackSource::SOURCE_MICROPHONE}; - - EXPECT_FALSE(a == b) << "Keys with different identities should not be equal"; -} - -TEST_F(CallbackKeyTest, DifferentSourceComparesUnequal) { - CallbackKey a{"alice", livekit::TrackSource::SOURCE_MICROPHONE}; - CallbackKey b{"alice", livekit::TrackSource::SOURCE_CAMERA}; - - EXPECT_FALSE(a == b) << "Keys with different sources should not be equal"; -} - -TEST_F(CallbackKeyTest, EqualKeysProduceSameHash) { - CallbackKey a{"alice", livekit::TrackSource::SOURCE_MICROPHONE}; - CallbackKey b{"alice", livekit::TrackSource::SOURCE_MICROPHONE}; - CallbackKeyHash hasher; - - EXPECT_EQ(hasher(a), hasher(b)) - << "Equal keys must produce the same hash value"; -} - -TEST_F(CallbackKeyTest, DifferentKeysProduceDifferentHashes) { - CallbackKeyHash hasher; - - CallbackKey mic{"alice", livekit::TrackSource::SOURCE_MICROPHONE}; - CallbackKey cam{"alice", livekit::TrackSource::SOURCE_CAMERA}; - CallbackKey bob{"bob", livekit::TrackSource::SOURCE_MICROPHONE}; - - // While hash collisions are technically allowed, these simple cases - // should not collide with a reasonable hash function. - EXPECT_NE(hasher(mic), hasher(cam)) - << "Different sources should (likely) produce different hashes"; - EXPECT_NE(hasher(mic), hasher(bob)) - << "Different identities should (likely) produce different hashes"; -} - -TEST_F(CallbackKeyTest, WorksAsUnorderedMapKey) { - std::unordered_map map; - - CallbackKey key1{"alice", livekit::TrackSource::SOURCE_MICROPHONE}; - CallbackKey key2{"bob", livekit::TrackSource::SOURCE_CAMERA}; - CallbackKey key3{"alice", livekit::TrackSource::SOURCE_CAMERA}; - - // Insert - map[key1] = 1; - map[key2] = 2; - map[key3] = 3; - - EXPECT_EQ(map.size(), 3u) - << "Three distinct keys should produce three entries"; - - // Find - EXPECT_EQ(map[key1], 1); - EXPECT_EQ(map[key2], 2); - EXPECT_EQ(map[key3], 3); - - // Overwrite - map[key1] = 42; - EXPECT_EQ(map[key1], 42) << "Inserting with same key should overwrite"; - EXPECT_EQ(map.size(), 3u) << "Size should not change after overwrite"; - - // Erase - map.erase(key2); - EXPECT_EQ(map.size(), 2u); - EXPECT_EQ(map.count(key2), 0u) << "Erased key should not be found"; -} - -TEST_F(CallbackKeyTest, EmptyIdentityWorks) { - CallbackKey empty{"", livekit::TrackSource::SOURCE_UNKNOWN}; - CallbackKey also_empty{"", livekit::TrackSource::SOURCE_UNKNOWN}; - CallbackKeyHash hasher; - - EXPECT_TRUE(empty == also_empty); - EXPECT_EQ(hasher(empty), hasher(also_empty)); -} - -} // namespace test -} // namespace livekit_bridge diff --git a/bridge/tests/unit/test_livekit_bridge.cpp b/bridge/tests/unit/test_livekit_bridge.cpp deleted file mode 100644 index 26864b71..00000000 --- a/bridge/tests/unit/test_livekit_bridge.cpp +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright 2026 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/// @file test_livekit_bridge.cpp -/// @brief Unit tests for LiveKitBridge. - -#include -#include - -#include - -#include - -namespace livekit_bridge { -namespace test { - -class LiveKitBridgeTest : public ::testing::Test { -protected: - // No SetUp/TearDown needed -- we test the bridge without initializing - // the LiveKit SDK, since we only exercise pre-connection behaviour. -}; - -// ============================================================================ -// Initial state -// ============================================================================ - -TEST_F(LiveKitBridgeTest, InitiallyNotConnected) { - LiveKitBridge bridge; - - EXPECT_FALSE(bridge.isConnected()) - << "Bridge should not be connected immediately after construction"; -} - -TEST_F(LiveKitBridgeTest, DisconnectBeforeConnectIsNoOp) { - LiveKitBridge bridge; - - EXPECT_NO_THROW(bridge.disconnect()) - << "disconnect() on an unconnected bridge should be a safe no-op"; - - EXPECT_FALSE(bridge.isConnected()); -} - -TEST_F(LiveKitBridgeTest, MultipleDisconnectsAreIdempotent) { - LiveKitBridge bridge; - - EXPECT_NO_THROW({ - bridge.disconnect(); - bridge.disconnect(); - bridge.disconnect(); - }) << "Multiple disconnect() calls should be safe"; -} - -TEST_F(LiveKitBridgeTest, DestructorOnUnconnectedBridgeIsSafe) { - // Just verify no crash when the bridge is destroyed without connecting. - EXPECT_NO_THROW({ - LiveKitBridge bridge; - // bridge goes out of scope here - }); -} - -// ============================================================================ -// Track creation before connection -// ============================================================================ - -TEST_F(LiveKitBridgeTest, publishAudioTrackBeforeConnectThrows) { - LiveKitBridge bridge; - - EXPECT_THROW(bridge.publishAudioTrack( - "mic", 48000, 2, livekit::TrackSource::SOURCE_MICROPHONE), - std::runtime_error) - << "publishAudioTrack should throw when not connected"; -} - -TEST_F(LiveKitBridgeTest, publishVideoTrackBeforeConnectThrows) { - LiveKitBridge bridge; - - EXPECT_THROW(bridge.publishVideoTrack("cam", 1280, 720, - livekit::TrackSource::SOURCE_CAMERA), - std::runtime_error) - << "publishVideoTrack should throw when not connected"; -} - -// ============================================================================ -// Callback registration (pre-connection — warns but does not crash) -// ============================================================================ - -TEST_F(LiveKitBridgeTest, SetAndClearAudioCallbackBeforeConnectDoesNotCrash) { - LiveKitBridge bridge; - - EXPECT_NO_THROW({ - bridge.setOnAudioFrameCallback("remote-participant", - livekit::TrackSource::SOURCE_MICROPHONE, - [](const livekit::AudioFrame &) {}); - - bridge.clearOnAudioFrameCallback("remote-participant", - livekit::TrackSource::SOURCE_MICROPHONE); - }) << "set/clear audio callback before connect should be safe (warns)"; -} - -TEST_F(LiveKitBridgeTest, SetAndClearVideoCallbackBeforeConnectDoesNotCrash) { - LiveKitBridge bridge; - - EXPECT_NO_THROW({ - bridge.setOnVideoFrameCallback( - "remote-participant", livekit::TrackSource::SOURCE_CAMERA, - [](const livekit::VideoFrame &, std::int64_t) {}); - - bridge.clearOnVideoFrameCallback("remote-participant", - livekit::TrackSource::SOURCE_CAMERA); - }) << "set/clear video callback before connect should be safe (warns)"; -} - -TEST_F(LiveKitBridgeTest, ClearNonExistentCallbackIsNoOp) { - LiveKitBridge bridge; - - EXPECT_NO_THROW({ - bridge.clearOnAudioFrameCallback("nonexistent", - livekit::TrackSource::SOURCE_MICROPHONE); - bridge.clearOnVideoFrameCallback("nonexistent", - livekit::TrackSource::SOURCE_CAMERA); - }) << "Clearing a callback that was never registered should be a no-op"; -} - -} // namespace test -} // namespace livekit_bridge diff --git a/examples/bridge_human_robot/human.cpp b/examples/bridge_human_robot/human.cpp index d5e66695..81989eb5 100644 --- a/examples/bridge_human_robot/human.cpp +++ b/examples/bridge_human_robot/human.cpp @@ -16,22 +16,20 @@ /* * Human example -- receives audio and video frames from a robot in a - * LiveKit room and renders them using SDL3. Also receives data track - * messages ("robot-status") and prints them to stdout. + * LiveKit room and renders them using SDL3. * * This example demonstrates the base SDK's convenience frame callback API * (Room::setOnAudioFrameCallback / Room::setOnVideoFrameCallback) which * eliminates the need for a RoomDelegate subclass, manual AudioStream/ * VideoStream creation, and reader threads. * - * The robot publishes two video tracks, two audio tracks, and one data track: + * The robot publishes two video tracks and two audio tracks: * - "robot-cam" (SOURCE_CAMERA) -- webcam or placeholder * - "robot-sim-frame" (SOURCE_SCREENSHARE) -- simulated diagnostic * frame * - "robot-mic" (SOURCE_MICROPHONE) -- real microphone or * silence * - "robot-sim-audio" (SOURCE_SCREENSHARE_AUDIO) -- simulated siren tone - * - "robot-status" (data track) -- periodic status string * * Press 'w' to play the webcam feed + real mic, or 's' for sim frame + siren. * The selection controls both video and audio simultaneously. @@ -61,12 +59,10 @@ #include #include #include -#include #include #include #include #include -#include #include #include #include @@ -276,17 +272,7 @@ int main(int argc, char *argv[]) { } }); - // ----- set data callback ----- - bridge.setOnDataFrameCallback( - "robot", "robot-status", - [](const std::vector &payload, - std::optional /*user_timestamp*/) { - std::string msg(payload.begin(), payload.end()); - std::cout << "[human] Data from robot: " << msg << "\n"; - }); - - // ----- Stdin input thread (for switching when the SDL window is not focused) - // ----- + // ----- Stdin input thread ----- std::thread input_thread([&]() { std::string line; while (g_running.load() && std::getline(std::cin, line)) { diff --git a/examples/bridge_human_robot/robot.cpp b/examples/bridge_human_robot/robot.cpp index 575f1334..041580ef 100644 --- a/examples/bridge_human_robot/robot.cpp +++ b/examples/bridge_human_robot/robot.cpp @@ -16,8 +16,7 @@ /* * Robot example -- streams real webcam video and microphone audio to a - * LiveKit room using SDL3 for hardware capture, and publishes a data - * track ("robot-status") that sends a status string once per second. + * LiveKit room using SDL3 for hardware capture. * * Usage: * robot [--no-mic] @@ -31,8 +30,7 @@ * --join --room my-room --identity robot \ * --valid-for 24h * - * Run alongside the "human" example (which displays the robot's feed - * and prints received data messages). + * Run alongside the "human" example (which displays the robot's feed). */ #include "livekit/audio_frame.h" @@ -380,24 +378,21 @@ int main(int argc, char *argv[]) { std::shared_ptr mic; if (use_mic) { - mic = bridge.publishAudioTrack("robot-mic", kSampleRate, kChannels, - livekit::TrackSource::SOURCE_MICROPHONE); + mic = bridge.createAudioTrack("robot-mic", kSampleRate, kChannels, + livekit::TrackSource::SOURCE_MICROPHONE); } auto sim_audio = - bridge.publishAudioTrack("robot-sim-audio", kSampleRate, kChannels, - livekit::TrackSource::SOURCE_SCREENSHARE_AUDIO); - auto cam = bridge.publishVideoTrack("robot-cam", kWidth, kHeight, - livekit::TrackSource::SOURCE_CAMERA); + bridge.createAudioTrack("robot-sim-audio", kSampleRate, kChannels, + livekit::TrackSource::SOURCE_SCREENSHARE_AUDIO); + auto cam = bridge.createVideoTrack("robot-cam", kWidth, kHeight, + livekit::TrackSource::SOURCE_CAMERA); auto sim_cam = - bridge.publishVideoTrack("robot-sim-frame", kSimWidth, kSimHeight, - livekit::TrackSource::SOURCE_SCREENSHARE); - - auto data_track = bridge.publishDataTrack("robot-status"); - + bridge.createVideoTrack("robot-sim-frame", kSimWidth, kSimHeight, + livekit::TrackSource::SOURCE_SCREENSHARE); LK_LOG_INFO("[robot] Publishing {} sim audio ({} Hz, {} ch), cam + sim frame " - "({}x{} / {}x{}), data track.", + "({}x{} / {}x{}).", use_mic ? "mic + " : "(no mic) ", kSampleRate, kChannels, kWidth, - kHeight, kSimWidth, kSimHeight, data_track->info().name); + kHeight, kSimWidth, kSimHeight); // ----- SDL Mic capture (only when use_mic) ----- // SDLMicSource pulls 10ms frames from the default recording device and @@ -623,27 +618,6 @@ int main(int argc, char *argv[]) { }); LK_LOG_INFO("[robot] Sim audio (siren) track started."); - // ----- Data track: send a status string once per second ----- - std::atomic data_running{true}; - std::thread data_thread([&]() { - std::uint64_t seq = 0; - auto start = std::chrono::steady_clock::now(); - while (data_running.load()) { - auto elapsed_ms = std::chrono::duration_cast( - std::chrono::steady_clock::now() - start) - .count(); - std::string msg = "robot status #" + std::to_string(seq) + - " uptime=" + std::to_string(elapsed_ms) + "ms"; - std::vector payload(msg.begin(), msg.end()); - if (!data_track->tryPush(payload)) { - break; - } - ++seq; - std::this_thread::sleep_for(std::chrono::seconds(1)); - } - }); - std::cout << "[robot] Data track (robot-status) started.\n"; - // ----- Main loop: keep alive + pump SDL events ----- LK_LOG_INFO("[robot] Streaming... press Ctrl-C to stop."); @@ -664,7 +638,6 @@ int main(int argc, char *argv[]) { cam_running.store(false); sim_running.store(false); sim_audio_running.store(false); - data_running.store(false); if (mic_thread.joinable()) mic_thread.join(); if (cam_thread.joinable()) @@ -673,8 +646,6 @@ int main(int argc, char *argv[]) { sim_thread.join(); if (sim_audio_thread.joinable()) sim_audio_thread.join(); - if (data_thread.joinable()) - data_thread.join(); sdl_mic.reset(); sdl_cam.reset(); @@ -682,7 +653,6 @@ int main(int argc, char *argv[]) { sim_audio.reset(); cam.reset(); sim_cam.reset(); - data_track.reset(); bridge.disconnect(); SDL_Quit(); diff --git a/examples/bridge_mute_unmute/receiver.cpp b/examples/bridge_mute_unmute/receiver.cpp index 3ca04372..1abafbc9 100644 --- a/examples/bridge_mute_unmute/receiver.cpp +++ b/examples/bridge_mute_unmute/receiver.cpp @@ -101,10 +101,10 @@ int main(int argc, char *argv[]) { constexpr int kWidth = 1280; constexpr int kHeight = 720; - auto mic = bridge.publishAudioTrack("mic", kSampleRate, kChannels, - livekit::TrackSource::SOURCE_MICROPHONE); - auto cam = bridge.publishVideoTrack("cam", kWidth, kHeight, - livekit::TrackSource::SOURCE_CAMERA); + auto mic = bridge.createAudioTrack("mic", kSampleRate, kChannels, + livekit::TrackSource::SOURCE_MICROPHONE); + auto cam = bridge.createVideoTrack("cam", kWidth, kHeight, + livekit::TrackSource::SOURCE_CAMERA); std::cout << "[receiver] Published audio track \"mic\" and video track " "\"cam\".\n"; diff --git a/include/livekit/local_participant.h b/include/livekit/local_participant.h index a0fb21af..fed3a190 100644 --- a/include/livekit/local_participant.h +++ b/include/livekit/local_participant.h @@ -276,6 +276,7 @@ class LocalParticipant : public Participant { /// cached publication). \c mutable so \ref trackPublications() const can /// prune expired \c weak_ptr entries. mutable TrackMap published_tracks_by_sid_; + std::unordered_map rpc_handlers_; // Shared state for RPC invocation tracking. Using shared_ptr so the state diff --git a/include/livekit/room.h b/include/livekit/room.h index 4fce8614..88b0cc96 100644 --- a/include/livekit/room.h +++ b/include/livekit/room.h @@ -18,6 +18,7 @@ #define LIVEKIT_ROOM_H #include "livekit/data_stream.h" +#include "livekit/data_track_subscription.h" #include "livekit/e2ee.h" #include "livekit/ffi_handle.h" #include "livekit/room_event_types.h" @@ -30,6 +31,8 @@ namespace livekit { class RoomDelegate; +class RemoteDataTrack; +class DataTrackSubscription; struct RoomInfoData; namespace proto { class FfiEvent; @@ -296,6 +299,40 @@ class Room { void clearOnVideoFrameCallback(const std::string &participant_identity, TrackSource source); + /** + * Set a callback for data frames from a specific remote participant's + * data track. + * + * The callback fires on a background thread whenever a new data frame is + * received. If the remote data track has not yet been published, the + * callback is stored and auto-wired when the track appears (via + * DataTrackPublished). + * + * Data tracks are keyed by (participant_identity, track_name) rather + * than TrackSource, since data tracks don't have a TrackSource enum. + * + * Only one callback may exist per (participant, track_name) pair. + * Re-calling with the same pair replaces the previous callback. + * + * @param participant_identity Identity of the remote participant. + * @param track_name Name of the remote data track. + * @param callback Function to invoke per data frame. + */ + void setOnDataFrameCallback(const std::string &participant_identity, + const std::string &track_name, + DataFrameCallback callback); + + /** + * Clear the data frame callback for a specific (participant, track_name) + * pair. Stops and joins any active reader thread. + * No-op if no callback is registered for this key. + * + * @param participant_identity Identity of the remote participant. + * @param track_name Name of the remote data track. + */ + void clearOnDataFrameCallback(const std::string &participant_identity, + const std::string &track_name); + private: friend class RoomCallbackTest; diff --git a/src/room.cpp b/src/room.cpp index e537dfb0..1344dc6b 100644 --- a/src/room.cpp +++ b/src/room.cpp @@ -17,7 +17,10 @@ #include "livekit/room.h" #include "livekit/audio_stream.h" +#include "livekit/data_frame.h" +#include "livekit/data_track_subscription.h" #include "livekit/e2ee.h" +#include "livekit/local_data_track.h" #include "livekit/local_participant.h" #include "livekit/local_track_publication.h" #include "livekit/remote_audio_track.h" @@ -666,19 +669,63 @@ void Room::OnEvent(const FfiEvent &event) { "\"{}\" (sid={})", remote_track->info().name, remote_track->publisherIdentity(), remote_track->info().sid); + + // Auto-wire data callback if one is registered + std::thread old_thread; + { + std::lock_guard guard(lock_); + DataCallbackKey key{remote_track->publisherIdentity(), + remote_track->info().name}; + auto it = data_callbacks_.find(key); + if (it != data_callbacks_.end()) { + old_thread = startDataReader(key, remote_track, it->second); + } else { + pending_remote_data_tracks_[key] = remote_track; + } + } + if (old_thread.joinable()) { + old_thread.join(); + } + DataTrackPublishedEvent ev; ev.track = remote_track; if (delegate_snapshot) { delegate_snapshot->onDataTrackPublished(*this, ev); - } else { - LK_LOG_ERROR("[Room] No delegate set; DataTrackPublished " - "event dropped."); } break; } case proto::RoomEvent::kDataTrackUnpublished: { const auto &dtu = re.data_track_unpublished(); LK_LOG_INFO("[Room] RoomEvent::kDataTrackUnpublished: sid={}", dtu.sid()); + + // Tear down active data reader or remove pending track by SID + std::thread old_thread; + { + std::lock_guard guard(lock_); + for (auto it = active_data_readers_.begin(); + it != active_data_readers_.end(); ++it) { + if (it->second.remote_track && + it->second.remote_track->info().sid == dtu.sid()) { + if (it->second.subscription) { + it->second.subscription->close(); + } + old_thread = std::move(it->second.thread); + active_data_readers_.erase(it); + break; + } + } + for (auto it = pending_remote_data_tracks_.begin(); + it != pending_remote_data_tracks_.end(); ++it) { + if (it->second && it->second->info().sid == dtu.sid()) { + pending_remote_data_tracks_.erase(it); + break; + } + } + } + if (old_thread.joinable()) { + old_thread.join(); + } + DataTrackUnpublishedEvent ev; ev.sid = dtu.sid(); if (delegate_snapshot) { From 87e00b770e78c4665c940b548c91e6c3f9da00e0 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Tue, 24 Mar 2026 14:41:52 -0600 Subject: [PATCH 09/34] ActiveDataReader: subscriber mutex --- examples/bridge_human_robot/human.cpp | 26 ++++- examples/bridge_human_robot/robot.cpp | 42 +++++++- src/room.cpp | 148 +++++++++++++++++++++++++- 3 files changed, 202 insertions(+), 14 deletions(-) diff --git a/examples/bridge_human_robot/human.cpp b/examples/bridge_human_robot/human.cpp index 81989eb5..d0dcc3b0 100644 --- a/examples/bridge_human_robot/human.cpp +++ b/examples/bridge_human_robot/human.cpp @@ -15,21 +15,23 @@ */ /* - * Human example -- receives audio and video frames from a robot in a + * Human example -- receives audio, video, and data frames from a robot in a * LiveKit room and renders them using SDL3. * * This example demonstrates the base SDK's convenience frame callback API - * (Room::setOnAudioFrameCallback / Room::setOnVideoFrameCallback) which - * eliminates the need for a RoomDelegate subclass, manual AudioStream/ - * VideoStream creation, and reader threads. + * (Room::setOnAudioFrameCallback / Room::setOnVideoFrameCallback / + * Room::setOnDataFrameCallback) which eliminates the need for a + * RoomDelegate subclass, manual AudioStream/VideoStream creation, and + * reader threads. * - * The robot publishes two video tracks and two audio tracks: + * The robot publishes two video tracks, two audio tracks, and one data track: * - "robot-cam" (SOURCE_CAMERA) -- webcam or placeholder * - "robot-sim-frame" (SOURCE_SCREENSHARE) -- simulated diagnostic * frame * - "robot-mic" (SOURCE_MICROPHONE) -- real microphone or * silence * - "robot-sim-audio" (SOURCE_SCREENSHARE_AUDIO) -- simulated siren tone + * - "robot-status" (DATA_TRACK) -- periodic status string * * Press 'w' to play the webcam feed + real mic, or 's' for sim frame + siren. * The selection controls both video and audio simultaneously. @@ -63,6 +65,7 @@ #include #include #include +#include #include #include #include @@ -272,6 +275,19 @@ int main(int argc, char *argv[]) { } }); + // ----- Set data frame callback using Room::setOnDataFrameCallback ----- + room->setOnDataFrameCallback( + "robot", "robot-status", + [](const std::vector &payload, + std::optional /*user_timestamp*/) { + try { + LK_LOG_INFO("[human] robot-status received."); + // LK_LOG_INFO("[human] robot-status: {}", payload); + } catch (const std::exception &e) { + // LK_LOG_ERROR("[human] Failed to process data track: {}", e.what()); + } + }); + // ----- Stdin input thread ----- std::thread input_thread([&]() { std::string line; diff --git a/examples/bridge_human_robot/robot.cpp b/examples/bridge_human_robot/robot.cpp index 041580ef..993969a8 100644 --- a/examples/bridge_human_robot/robot.cpp +++ b/examples/bridge_human_robot/robot.cpp @@ -16,7 +16,8 @@ /* * Robot example -- streams real webcam video and microphone audio to a - * LiveKit room using SDL3 for hardware capture. + * LiveKit room using SDL3 for hardware capture, and publishes a data + * track ("robot-status") that sends a status string once per second. * * Usage: * robot [--no-mic] @@ -30,10 +31,12 @@ * --join --room my-room --identity robot \ * --valid-for 24h * - * Run alongside the "human" example (which displays the robot's feed). + * Run alongside the "human" example (which displays the robot's feed + * and prints received data messages). */ #include "livekit/audio_frame.h" +#include "livekit/local_data_track.h" #include "livekit/track.h" #include "livekit/video_frame.h" #include "livekit_bridge/livekit_bridge.h" @@ -389,8 +392,11 @@ int main(int argc, char *argv[]) { auto sim_cam = bridge.createVideoTrack("robot-sim-frame", kSimWidth, kSimHeight, livekit::TrackSource::SOURCE_SCREENSHARE); - LK_LOG_INFO("[robot] Publishing {} sim audio ({} Hz, {} ch), cam + sim frame " - "({}x{} / {}x{}).", + + auto data_track = bridge.publishDataTrack("robot-status"); + + LK_LOG_INFO("[robot] Publishing {}sim audio ({} Hz, {} ch), cam + sim frame " + "({}x{} / {}x{}), data track.", use_mic ? "mic + " : "(no mic) ", kSampleRate, kChannels, kWidth, kHeight, kSimWidth, kSimHeight); @@ -618,6 +624,30 @@ int main(int argc, char *argv[]) { }); LK_LOG_INFO("[robot] Sim audio (siren) track started."); + // ----- Data track: send a status string once per second ----- + std::atomic data_running{true}; + std::thread data_thread([&]() { + std::uint64_t count = 0; + auto start = std::chrono::steady_clock::now(); + while (data_running.load()) { + auto elapsed = std::chrono::steady_clock::now() - start; + double secs = std::chrono::duration(elapsed).count(); + char buf[64]; + std::snprintf(buf, sizeof(buf), "%.2f, count: %llu", secs, + static_cast(count)); + std::string msg(buf); + std::vector payload(msg.begin(), msg.end()); + if (!data_track->tryPush(payload)) { + LK_LOG_ERROR("[robot] Failed to push data track."); + break; + } + LK_LOG_INFO("[robot] Data track pushed: {}", msg); + ++count; + std::this_thread::sleep_for(std::chrono::seconds(1)); + } + }); + LK_LOG_INFO("[robot] Data track (robot-status) started."); + // ----- Main loop: keep alive + pump SDL events ----- LK_LOG_INFO("[robot] Streaming... press Ctrl-C to stop."); @@ -638,6 +668,7 @@ int main(int argc, char *argv[]) { cam_running.store(false); sim_running.store(false); sim_audio_running.store(false); + data_running.store(false); if (mic_thread.joinable()) mic_thread.join(); if (cam_thread.joinable()) @@ -646,6 +677,8 @@ int main(int argc, char *argv[]) { sim_thread.join(); if (sim_audio_thread.joinable()) sim_audio_thread.join(); + if (data_thread.joinable()) + data_thread.join(); sdl_mic.reset(); sdl_cam.reset(); @@ -653,6 +686,7 @@ int main(int argc, char *argv[]) { sim_audio.reset(); cam.reset(); sim_cam.reset(); + data_track.reset(); bridge.disconnect(); SDL_Quit(); diff --git a/src/room.cpp b/src/room.cpp index 1344dc6b..a47cfdff 100644 --- a/src/room.cpp +++ b/src/room.cpp @@ -304,6 +304,140 @@ void Room::clearOnVideoFrameCallback(const std::string &participant_identity, } } +void Room::setOnDataFrameCallback(const std::string &participant_identity, + const std::string &track_name, + DataFrameCallback callback) { + std::thread old_thread; + { + std::lock_guard lock(lock_); + DataCallbackKey key{participant_identity, track_name}; + data_callbacks_[key] = std::move(callback); + + auto pending_it = pending_remote_data_tracks_.find(key); + if (pending_it != pending_remote_data_tracks_.end()) { + auto track = std::move(pending_it->second); + pending_remote_data_tracks_.erase(pending_it); + auto cb_it = data_callbacks_.find(key); + if (cb_it != data_callbacks_.end()) { + old_thread = startDataReader(key, track, cb_it->second); + } + } + } + if (old_thread.joinable()) { + old_thread.join(); + } +} + +void Room::clearOnDataFrameCallback(const std::string &participant_identity, + const std::string &track_name) { + std::thread old_thread; + { + std::lock_guard lock(lock_); + DataCallbackKey key{participant_identity, track_name}; + data_callbacks_.erase(key); + old_thread = extractDataReaderThread(key); + } + if (old_thread.joinable()) { + old_thread.join(); + } +} + +std::thread Room::extractReaderThread(const CallbackKey &key) { + auto it = active_readers_.find(key); + if (it == active_readers_.end()) { + return {}; + } + ActiveReader reader = std::move(it->second); + active_readers_.erase(it); + + if (reader.audio_stream) { + reader.audio_stream->close(); + } + if (reader.video_stream) { + reader.video_stream->close(); + } + return std::move(reader.thread); +} + +std::thread Room::extractDataReaderThread(const DataCallbackKey &key) { + auto it = active_data_readers_.find(key); + if (it == active_data_readers_.end()) { + return {}; + } + auto reader = std::move(it->second); + active_data_readers_.erase(it); + { + std::lock_guard guard(reader->sub_mutex); + if (reader->subscription) { + reader->subscription->close(); + } + } + return std::move(reader->thread); +} + +std::thread Room::startDataReader(const DataCallbackKey &key, + const std::shared_ptr &track, + DataFrameCallback cb) { + auto old_thread = extractDataReaderThread(key); + + if (static_cast(active_readers_.size() + active_data_readers_.size()) >= + kMaxActiveReaders) { + LK_LOG_ERROR("Cannot start data reader for {} track={}: active reader " + "limit ({}) reached", + key.participant_identity, key.track_name, kMaxActiveReaders); + return old_thread; + } + + LK_LOG_INFO("[Room] Starting data reader for \"{}\" track=\"{}\"", + key.participant_identity, key.track_name); + + // subscribe() is async over FFI — it sends a request and blocks on a future + // whose response arrives via LivekitFfiCallback. If we are already on the + // FFI callback thread (e.g. inside kDataTrackPublished handling), blocking + // here would deadlock. The reader thread performs subscribe() + read loop + // so the callback thread is never blocked. + auto reader = std::make_shared(); + reader->remote_track = track; + auto identity = key.participant_identity; + auto track_name = key.track_name; + reader->thread = std::thread([reader, track, cb, identity, track_name]() { + LK_LOG_INFO("[Room] Data reader thread: subscribing to \"{}\" " + "track=\"{}\"", + identity, track_name); + std::shared_ptr subscription; + try { + subscription = track->subscribe(); + } catch (const std::exception &e) { + LK_LOG_ERROR("Failed to subscribe to data track \"{}\" from \"{}\": {}", + track_name, identity, e.what()); + return; + } + LK_LOG_INFO("[Room] Data reader thread: subscribed to \"{}\" track=\"{}\"", + identity, track_name); + + { + std::lock_guard guard(reader->sub_mutex); + reader->subscription = subscription; + } + + LK_LOG_INFO("[Room] Data reader thread: entering read loop for \"{}\" " + "track=\"{}\"", + identity, track_name); + DataFrame frame; + while (subscription->read(frame)) { + try { + cb(frame.payload, frame.user_timestamp); + } catch (const std::exception &e) { + LK_LOG_ERROR("Data frame callback exception: {}", e.what()); + } + } + LK_LOG_INFO("[Room] Data reader thread exiting for \"{}\" track=\"{}\"", + identity, track_name); + }); + active_data_readers_[key] = reader; + return old_thread; +} + void Room::OnEvent(const FfiEvent &event) { // Take a snapshot of the delegate under lock, but do NOT call it under the // lock. @@ -704,12 +838,16 @@ void Room::OnEvent(const FfiEvent &event) { std::lock_guard guard(lock_); for (auto it = active_data_readers_.begin(); it != active_data_readers_.end(); ++it) { - if (it->second.remote_track && - it->second.remote_track->info().sid == dtu.sid()) { - if (it->second.subscription) { - it->second.subscription->close(); + auto &reader = it->second; + if (reader->remote_track && + reader->remote_track->info().sid == dtu.sid()) { + { + std::lock_guard sub_guard(reader->sub_mutex); + if (reader->subscription) { + reader->subscription->close(); + } } - old_thread = std::move(it->second.thread); + old_thread = std::move(reader->thread); active_data_readers_.erase(it); break; } From b237ce49bc9cd004ba8a47baf45c6608e746cc9b Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Tue, 24 Mar 2026 14:45:11 -0600 Subject: [PATCH 10/34] DataTrackSubscription: read() sends request for latest message --- src/data_track_subscription.cpp | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/data_track_subscription.cpp b/src/data_track_subscription.cpp index d602c732..db8ffede 100644 --- a/src/data_track_subscription.cpp +++ b/src/data_track_subscription.cpp @@ -75,8 +75,23 @@ void DataTrackSubscription::init(FfiHandle subscription_handle) { } bool DataTrackSubscription::read(DataFrame &out) { - std::unique_lock lock(mutex_); + { + std::lock_guard lock(mutex_); + if (closed_ || eof_) { + return false; + } + } + + // Signal the Rust side that we're ready to receive the next frame. + // The Rust SubscriptionTask uses a demand-driven protocol: it won't pull + // from the underlying stream until notified via this request. + proto::FfiRequest req; + auto *msg = req.mutable_data_track_subscription_read(); + msg->set_subscription_handle( + static_cast(subscription_handle_.get())); + FfiClient::instance().sendRequest(req); + std::unique_lock lock(mutex_); cv_.wait(lock, [this] { return frame_.has_value() || eof_ || closed_; }); if (closed_ || (!frame_.has_value() && eof_)) { @@ -120,6 +135,9 @@ void DataTrackSubscription::onFfiEvent(const FfiEvent &event) { return; } + LK_LOG_INFO("[DataTrackSubscription] Received event for handle {}", + static_cast(subscription_handle_.get())); + if (dts.has_frame_received()) { const auto &fr = dts.frame_received().frame(); DataFrame frame; From 609a624adcb533b50772c67d2ec3ca181ce74206 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Tue, 24 Mar 2026 14:48:04 -0600 Subject: [PATCH 11/34] move realsense-livekit out of this --- .../realsense-to-mcap/.gitignore | 3 - .../BuildFileDescriptorSet.cpp | 40 -- .../BuildFileDescriptorSet.h | 23 - .../realsense-to-mcap/CMakeLists.txt | 143 ----- .../realsense-to-mcap/README.md | 130 ----- .../realsense-to-mcap/src/realsense_rgbd.cpp | 494 ------------------ .../src/realsense_to_mcap.cpp | 159 ------ .../realsense-to-mcap/src/rgbd_viewer.cpp | 477 ----------------- examples/realsense-livekit/setup_realsense.sh | 124 ----- 9 files changed, 1593 deletions(-) delete mode 100644 examples/realsense-livekit/realsense-to-mcap/.gitignore delete mode 100644 examples/realsense-livekit/realsense-to-mcap/BuildFileDescriptorSet.cpp delete mode 100644 examples/realsense-livekit/realsense-to-mcap/BuildFileDescriptorSet.h delete mode 100644 examples/realsense-livekit/realsense-to-mcap/CMakeLists.txt delete mode 100644 examples/realsense-livekit/realsense-to-mcap/README.md delete mode 100644 examples/realsense-livekit/realsense-to-mcap/src/realsense_rgbd.cpp delete mode 100644 examples/realsense-livekit/realsense-to-mcap/src/realsense_to_mcap.cpp delete mode 100644 examples/realsense-livekit/realsense-to-mcap/src/rgbd_viewer.cpp delete mode 100755 examples/realsense-livekit/setup_realsense.sh diff --git a/examples/realsense-livekit/realsense-to-mcap/.gitignore b/examples/realsense-livekit/realsense-to-mcap/.gitignore deleted file mode 100644 index 68d0afad..00000000 --- a/examples/realsense-livekit/realsense-to-mcap/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -build/ -external/ -generated/ diff --git a/examples/realsense-livekit/realsense-to-mcap/BuildFileDescriptorSet.cpp b/examples/realsense-livekit/realsense-to-mcap/BuildFileDescriptorSet.cpp deleted file mode 100644 index 33dcfdea..00000000 --- a/examples/realsense-livekit/realsense-to-mcap/BuildFileDescriptorSet.cpp +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2026 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -#include "BuildFileDescriptorSet.h" - -#include -#include - -google::protobuf::FileDescriptorSet BuildFileDescriptorSet( - const google::protobuf::Descriptor* toplevelDescriptor) { - google::protobuf::FileDescriptorSet fdSet; - std::queue toAdd; - toAdd.push(toplevelDescriptor->file()); - std::unordered_set seenDependencies; - while (!toAdd.empty()) { - const google::protobuf::FileDescriptor* next = toAdd.front(); - toAdd.pop(); - next->CopyTo(fdSet.add_file()); - for (int i = 0; i < next->dependency_count(); ++i) { - const auto* dep = next->dependency(i); - const std::string depName(dep->name()); - if (seenDependencies.find(depName) == seenDependencies.end()) { - seenDependencies.insert(depName); - toAdd.push(dep); - } - } - } - return fdSet; -} diff --git a/examples/realsense-livekit/realsense-to-mcap/BuildFileDescriptorSet.h b/examples/realsense-livekit/realsense-to-mcap/BuildFileDescriptorSet.h deleted file mode 100644 index 5d7b7d67..00000000 --- a/examples/realsense-livekit/realsense-to-mcap/BuildFileDescriptorSet.h +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright 2026 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -#pragma once - -#include -#include - -#include - -google::protobuf::FileDescriptorSet BuildFileDescriptorSet( - const google::protobuf::Descriptor* toplevelDescriptor); diff --git a/examples/realsense-livekit/realsense-to-mcap/CMakeLists.txt b/examples/realsense-livekit/realsense-to-mcap/CMakeLists.txt deleted file mode 100644 index 3d20c9c5..00000000 --- a/examples/realsense-livekit/realsense-to-mcap/CMakeLists.txt +++ /dev/null @@ -1,143 +0,0 @@ -# Copyright 2026 LiveKit, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -cmake_minimum_required(VERSION 3.16) -project(realsense_mcap) - -set(CMAKE_CXX_STANDARD 17) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) - -find_package(Protobuf REQUIRED CONFIG) -find_package(realsense2 REQUIRED) -find_package(PkgConfig REQUIRED) -pkg_check_modules(ZSTD REQUIRED IMPORTED_TARGET libzstd) -pkg_check_modules(LZ4 REQUIRED IMPORTED_TARGET liblz4) -find_package(ZLIB REQUIRED) - -set(REALSENSE_MCAP_DIR ${CMAKE_CURRENT_SOURCE_DIR}) -add_executable(realsense_to_mcap - ${REALSENSE_MCAP_DIR}/src/realsense_to_mcap.cpp - ${REALSENSE_MCAP_DIR}/BuildFileDescriptorSet.cpp - ${REALSENSE_MCAP_DIR}/generated/foxglove/RawImage.pb.cc -) - -target_include_directories(realsense_to_mcap PRIVATE - ${realsense2_INCLUDE_DIR} - ${CMAKE_CURRENT_SOURCE_DIR}/external/mcap/cpp/mcap/include - ${CMAKE_CURRENT_SOURCE_DIR}/generated - ${CMAKE_CURRENT_SOURCE_DIR} -) - -target_link_libraries(realsense_to_mcap - protobuf::libprotobuf - ${realsense2_LIBRARY} - PkgConfig::ZSTD - PkgConfig::LZ4 -) - -# LiveKit participants: auto-detect SDK build when this tree lives under client-sdk-cpp (e.g. examples/realsense-livekit/realsense-to-mcap) -set(LiveKitBuild_DIR "" CACHE PATH "Path to LiveKit SDK build directory (optional; auto-detected when under client-sdk-cpp)") -if(NOT LiveKitBuild_DIR) - get_filename_component(SDK_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../.." REALPATH) - if(EXISTS "${SDK_ROOT}/build-release/lib") - set(LiveKitBuild_DIR "${SDK_ROOT}/build-release") - elseif(EXISTS "${SDK_ROOT}/build/lib") - set(LiveKitBuild_DIR "${SDK_ROOT}/build") - endif() -endif() - -get_filename_component(LiveKitSource_DIR "${LiveKitBuild_DIR}" DIRECTORY) -set(LIVEKIT_LIB_DIR "${LiveKitBuild_DIR}/lib") -if(NOT LiveKitBuild_DIR OR NOT EXISTS "${LIVEKIT_LIB_DIR}") - message(FATAL_ERROR "LiveKit SDK build not found. Build the client-sdk-cpp repo from its root (e.g. ./build.sh release), or set -DLiveKitBuild_DIR=.") -endif() -find_library(LIVEKIT_BRIDGE_LIB livekit_bridge PATHS "${LIVEKIT_LIB_DIR}" NO_DEFAULT_PATH) -find_library(LIVEKIT_LIB livekit PATHS "${LIVEKIT_LIB_DIR}" NO_DEFAULT_PATH) -if(NOT LIVEKIT_BRIDGE_LIB) - message(FATAL_ERROR "Could not find liblivekit_bridge in ${LIVEKIT_LIB_DIR}. Build the SDK bridge first.") -endif() -if(NOT LIVEKIT_LIB) - message(FATAL_ERROR "Could not find liblivekit in ${LIVEKIT_LIB_DIR}.") -endif() -set(LIVEKIT_BRIDGE_INCLUDE "${LiveKitSource_DIR}/bridge/include") -set(LIVEKIT_INCLUDE "${LiveKitSource_DIR}/include") -if(NOT EXISTS "${LIVEKIT_BRIDGE_INCLUDE}/livekit_bridge/livekit_bridge.h") - message(FATAL_ERROR "LiveKit bridge headers not found at ${LIVEKIT_BRIDGE_INCLUDE}") -endif() - -# realsense_rgbd: captures RealSense, publishes RGB as video + depth/pose as DataTracks -add_executable(realsense_rgbd - ${REALSENSE_MCAP_DIR}/src/realsense_rgbd.cpp - ${REALSENSE_MCAP_DIR}/BuildFileDescriptorSet.cpp - ${REALSENSE_MCAP_DIR}/generated/foxglove/RawImage.pb.cc - ${REALSENSE_MCAP_DIR}/generated/foxglove/PoseInFrame.pb.cc - ${REALSENSE_MCAP_DIR}/generated/foxglove/Pose.pb.cc - ${REALSENSE_MCAP_DIR}/generated/foxglove/Quaternion.pb.cc - ${REALSENSE_MCAP_DIR}/generated/foxglove/Vector3.pb.cc - ) - target_include_directories(realsense_rgbd PRIVATE - ${realsense2_INCLUDE_DIR} - ${LIVEKIT_BRIDGE_INCLUDE} - ${LIVEKIT_INCLUDE} - ${CMAKE_CURRENT_SOURCE_DIR}/generated - ${CMAKE_CURRENT_SOURCE_DIR} - ) - target_link_directories(realsense_rgbd PRIVATE "${LIVEKIT_LIB_DIR}") - target_link_libraries(realsense_rgbd PRIVATE - ${LIVEKIT_BRIDGE_LIB} - ${LIVEKIT_LIB} - ${realsense2_LIBRARY} - protobuf::libprotobuf - ZLIB::ZLIB - ) - if(UNIX AND NOT APPLE) - # Embed SDK lib path so the loader finds liblivekit_bridge.so and liblivekit.so at runtime - set_target_properties(realsense_rgbd PROPERTIES - BUILD_RPATH "${LIVEKIT_LIB_DIR}" - INSTALL_RPATH "${LIVEKIT_LIB_DIR}" - BUILD_WITH_INSTALL_RPATH TRUE - ) - endif() - - # rgbd_viewer: subscribes to realsense_rgbd video + data track, writes MCAP - add_executable(rgbd_viewer - ${REALSENSE_MCAP_DIR}/src/rgbd_viewer.cpp - ${REALSENSE_MCAP_DIR}/BuildFileDescriptorSet.cpp - ${REALSENSE_MCAP_DIR}/generated/foxglove/RawImage.pb.cc - ) - target_include_directories(rgbd_viewer PRIVATE - ${LIVEKIT_BRIDGE_INCLUDE} - ${LIVEKIT_INCLUDE} - ${CMAKE_CURRENT_SOURCE_DIR}/external/mcap/cpp/mcap/include - ${CMAKE_CURRENT_SOURCE_DIR}/generated - ${CMAKE_CURRENT_SOURCE_DIR} - ) - target_link_directories(rgbd_viewer PRIVATE "${LIVEKIT_LIB_DIR}") - target_link_libraries(rgbd_viewer PRIVATE - ${LIVEKIT_BRIDGE_LIB} - ${LIVEKIT_LIB} - protobuf::libprotobuf - PkgConfig::ZSTD - PkgConfig::LZ4 - ZLIB::ZLIB - ) - if(UNIX AND NOT APPLE) - set_target_properties(rgbd_viewer PROPERTIES - BUILD_RPATH "${LIVEKIT_LIB_DIR}" - INSTALL_RPATH "${LIVEKIT_LIB_DIR}" - BUILD_WITH_INSTALL_RPATH TRUE - ) - endif() -message(STATUS "LiveKit participants: realsense_rgbd, rgbd_viewer (LiveKitBuild_DIR=${LiveKitBuild_DIR})") diff --git a/examples/realsense-livekit/realsense-to-mcap/README.md b/examples/realsense-livekit/realsense-to-mcap/README.md deleted file mode 100644 index a54594ab..00000000 --- a/examples/realsense-livekit/realsense-to-mcap/README.md +++ /dev/null @@ -1,130 +0,0 @@ -# RealSense RGB-D → MCAP (Foxglove Protobuf, No ROS) - -This project records RGB and depth frames from an **Intel RealSense D415** and writes them directly to an MCAP file using Foxglove Protobuf schemas — **without using ROS**. - -The resulting `.mcap` file opens directly in Foxglove Studio with working Image panels. - ---- - -## Overview - -This example: - -- Captures RGB (`rgb8`) and depth (`16UC1`) frames -- Serializes frames using `foxglove.Image` (Protobuf) -- Writes messages to MCAP with ZSTD compression -- Produces a file immediately viewable in Foxglove - -Architecture: -RealSense D415 -↓ -librealsense2 -↓ -foxglove.Image (protobuf) -↓ -MCAP writer -↓ -realsense_rgbd.mcap - - -__NOTE: This has only been tested on Linux and MacOS.__ - -__NOTE: This has only been tested with a D415 camera.__ - ---- - -## Dependencies - -This project uses: - -- Intel RealSense D415 -- librealsense2 -- Protobuf -- MCAP C++ library -- Foxglove protobuf schemas -- Foxglove Studio - ---- - -## Install Dependencies - -### System packages - -**Ubuntu / Debian** - -```bash -sudo apt install librealsense2-dev protobuf-compiler libprotobuf-dev \ - libzstd-dev liblz4-dev zlib1g-dev -``` - -**macOS (Homebrew)** - -```bash -brew install librealsense protobuf zstd lz4 pkg-config -``` - -> zlib ships with macOS and does not need to be installed separately. - -### Clone externals and generate protobuf files - -A setup script handles cloning the MCAP C++ library and the Foxglove SDK -(for proto schemas), then runs `protoc` to generate C++ sources: - -```bash -# From anywhere — the script locates paths relative to itself -../setup_realsense.sh -``` - -This populates `external/` (mcap, foxglove-sdk) and `generated/` (protobuf -C++ sources). Both directories are gitignored. The script is idempotent and -safe to re-run. - -## Build - -All executables are built into `build/bin/`. - -1. Build the LiveKit C++ SDK (including the bridge) from the **client-sdk-cpp** repo root first (e.g. `./build.sh release`). This project links against that build. -2. From this directory: - -```bash -mkdir build -cd build -cmake .. -make -j -``` - -When this project lives under `client-sdk-cpp/examples/realsense-livekit/realsense-to-mcap/`, the SDK build directory is auto-detected (`build-release` or `build` at repo root). Otherwise set it explicitly: - -```bash -cmake -DLiveKitBuild_DIR=/path/to/client-sdk-cpp/build-release .. -``` - -### Executables (in `build/bin/`) - -| Binary | Description | -|--------|-------------| -| `realsense_to_mcap` | Standalone recorder: captures RealSense and writes RGB + depth to an MCAP file. | -| `realsense_rgbd` | LiveKit publisher: captures RealSense, publishes RGB as video and depth as DataTrack (identity `realsense_rgbd`). | -| `rgbd_viewer` | LiveKit subscriber: subscribes to that video + data track and writes to MCAP (identity `rgbd_viewer`). | - -Run the standalone recorder: - -```bash -./bin/realsense_to_mcap -``` - -This produces `realsense_rgbd.mcap` in the current directory. - ---- - -## LiveKit participants - -To stream RGB+D over a LiveKit room and record on the viewer side: - -1. Start **realsense_rgbd** (publisher), then **rgbd_viewer** (subscriber), using the same room URL and tokens with identities `realsense_rgbd` and `rgbd_viewer`. -2. Run from the build directory: - -```bash -./bin/realsense_rgbd -./bin/rgbd_viewer [output.mcap] -``` diff --git a/examples/realsense-livekit/realsense-to-mcap/src/realsense_rgbd.cpp b/examples/realsense-livekit/realsense-to-mcap/src/realsense_rgbd.cpp deleted file mode 100644 index e3722f6e..00000000 --- a/examples/realsense-livekit/realsense-to-mcap/src/realsense_rgbd.cpp +++ /dev/null @@ -1,494 +0,0 @@ -// Copyright 2026 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -/* - * realsense_rgbd — LiveKit participant that captures RealSense RGB+D frames, - * publishes RGB as a video track, depth as a DataTrack (foxglove.RawImage), - * and IMU-derived orientation as a DataTrack (foxglove.PoseInFrame). - * - * If the RealSense device has an IMU (e.g. D435i), gyroscope and - * accelerometer data are fused via a complementary filter to produce a - * camera/pose topic. Devices without an IMU skip pose publishing. - * - * Usage: - * realsense_rgbd - * LIVEKIT_URL=... LIVEKIT_TOKEN=... realsense_rgbd - * - * Token must grant identity "realsense_rgbd". Run rgbd_viewer in the same room - * to receive and record to MCAP. - */ - -#include "livekit/local_data_track.h" -#include "livekit/track.h" -#include "livekit_bridge/livekit_bridge.h" - -#include "BuildFileDescriptorSet.h" -#include "foxglove/PoseInFrame.pb.h" -#include "foxglove/RawImage.pb.h" - -#include - -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -static volatile std::sig_atomic_t g_running = 1; -static void signalHandler(int) { g_running = 0; } - -static uint64_t nowNs() { - return std::chrono::duration_cast( - std::chrono::system_clock::now().time_since_epoch()) - .count(); -} - -static std::string nowStr() { - auto now = std::chrono::system_clock::now(); - auto ms = std::chrono::duration_cast( - now.time_since_epoch()) % - 1000; - std::time_t t = std::chrono::system_clock::to_time_t(now); - std::tm tm{}; - localtime_r(&t, &tm); - std::ostringstream os; - os << std::put_time(&tm, "%H:%M:%S") << '.' << std::setfill('0') - << std::setw(3) << ms.count(); - return os.str(); -} - -struct OrientationQuat { - double x, y, z, w; -}; - -/// Convert intrinsic XYZ Euler angles (radians) to a unit quaternion. -static OrientationQuat eulerToQuaternion(double x, double y, double z) { - double cx = std::cos(x / 2), sx = std::sin(x / 2); - double cy = std::cos(y / 2), sy = std::sin(y / 2); - double cz = std::cos(z / 2), sz = std::sin(z / 2); - return { - sx * cy * cz + cx * sy * sz, - cx * sy * cz - sx * cy * sz, - cx * cy * sz + sx * sy * cz, - cx * cy * cz - sx * sy * sz, - }; -} - -/// Fuse gyroscope + accelerometer into an orientation estimate using a -/// complementary filter. Adapted from the librealsense motion example. -class RotationEstimator { -public: - void process_gyro(rs2_vector gyro_data, double ts) { - if (first_gyro_) { - first_gyro_ = false; - last_ts_gyro_ = ts; - return; - } - float dt = static_cast((ts - last_ts_gyro_) / 1000.0); - last_ts_gyro_ = ts; - - std::lock_guard lock(mtx_); - theta_x_ += -gyro_data.z * dt; - theta_y_ += -gyro_data.y * dt; - theta_z_ += gyro_data.x * dt; - } - - void process_accel(rs2_vector accel_data) { - float accel_angle_x = - std::atan2(accel_data.x, std::sqrt(accel_data.y * accel_data.y + - accel_data.z * accel_data.z)); - float accel_angle_z = std::atan2(accel_data.y, accel_data.z); - - std::lock_guard lock(mtx_); - if (first_accel_) { - first_accel_ = false; - theta_x_ = accel_angle_x; - theta_y_ = static_cast(M_PI); - theta_z_ = accel_angle_z; - return; - } - theta_x_ = theta_x_ * kAlpha + accel_angle_x * (1.0f - kAlpha); - theta_z_ = theta_z_ * kAlpha + accel_angle_z * (1.0f - kAlpha); - } - - OrientationQuat get_orientation() const { - std::lock_guard lock(mtx_); - return eulerToQuaternion(static_cast(theta_x_), - static_cast(theta_y_), - static_cast(theta_z_)); - } - -private: - static constexpr float kAlpha = 0.98f; - mutable std::mutex mtx_; - float theta_x_ = 0, theta_y_ = 0, theta_z_ = 0; - bool first_gyro_ = true; - bool first_accel_ = true; - double last_ts_gyro_ = 0; -}; - -/// Convert RGB8 to RGBA (alpha = 0xFF). Assumes dst has size width*height*4. -static void rgb8ToRgba(const std::uint8_t *rgb, std::uint8_t *rgba, int width, - int height) { - const int rgbStep = width * 3; - const int rgbaStep = width * 4; - for (int y = 0; y < height; ++y) { - const std::uint8_t *src = rgb + y * rgbStep; - std::uint8_t *dst = rgba + y * rgbaStep; - for (int x = 0; x < width; ++x) { - *dst++ = *src++; - *dst++ = *src++; - *dst++ = *src++; - *dst++ = 0xFF; - } - } -} - -int main(int argc, char *argv[]) { - GOOGLE_PROTOBUF_VERIFY_VERSION; - - std::signal(SIGINT, signalHandler); - std::signal(SIGTERM, signalHandler); - - std::string url; - std::string token; - const char *env_url = std::getenv("LIVEKIT_URL"); - const char *env_token = std::getenv("LIVEKIT_TOKEN"); - if (argc >= 3) { - url = argv[1]; - token = argv[2]; - } else if (env_url && env_token) { - url = env_url; - token = env_token; - } else { - std::cerr << "Usage: realsense_rgbd \n" - " or: LIVEKIT_URL=... LIVEKIT_TOKEN=... realsense_rgbd\n"; - return 1; - } - - const int kWidth = 640; - const int kHeight = 480; - const int kDepthFps = - 10; // data track depth rate (Hz); limited by SCTP throughput - const int kPoseFps = 30; - - RotationEstimator rotation_est; - - // Check for IMU support (following rs-motion.cpp pattern). - bool imu_supported = false; - { - rs2::context ctx; - auto devices = ctx.query_devices(); - for (auto dev : devices) { - bool found_gyro = false, found_accel = false; - for (auto sensor : dev.query_sensors()) { - for (auto profile : sensor.get_stream_profiles()) { - if (profile.stream_type() == RS2_STREAM_GYRO) - found_gyro = true; - if (profile.stream_type() == RS2_STREAM_ACCEL) - found_accel = true; - } - } - if (found_gyro && found_accel) { - imu_supported = true; - break; - } - } - } - - // Color+depth pipeline. - rs2::pipeline pipe; - rs2::config cfg; - cfg.enable_stream(RS2_STREAM_COLOR, kWidth, kHeight, RS2_FORMAT_RGB8, 30); - cfg.enable_stream(RS2_STREAM_DEPTH, kWidth, kHeight, RS2_FORMAT_Z16, 30); - try { - pipe.start(cfg); - } catch (const rs2::error &e) { - std::cerr << "RealSense error: " << e.what() << "\n"; - return 1; - } - - // Separate IMU-only pipeline with callback, mirroring rs-motion.cpp. - // A dedicated pipeline for gyro+accel avoids interfering with the - // color+depth pipeline's frame syncer. - rs2::pipeline imu_pipe; - bool has_imu = false; - if (imu_supported) { - rs2::config imu_cfg; - imu_cfg.enable_stream(RS2_STREAM_ACCEL, RS2_FORMAT_MOTION_XYZ32F); - imu_cfg.enable_stream(RS2_STREAM_GYRO, RS2_FORMAT_MOTION_XYZ32F); - try { - imu_pipe.start(imu_cfg, [&rotation_est](rs2::frame frame) { - auto motion = frame.as(); - if (motion && motion.get_profile().stream_type() == RS2_STREAM_GYRO && - motion.get_profile().format() == RS2_FORMAT_MOTION_XYZ32F) { - rotation_est.process_gyro(motion.get_motion_data(), - motion.get_timestamp()); - } - if (motion && motion.get_profile().stream_type() == RS2_STREAM_ACCEL && - motion.get_profile().format() == RS2_FORMAT_MOTION_XYZ32F) { - rotation_est.process_accel(motion.get_motion_data()); - } - }); - has_imu = true; - std::cout << "[realsense_rgbd] IMU pipeline started.\n"; - } catch (const rs2::error &e) { - std::cerr << "[realsense_rgbd] Could not start IMU pipeline: " << e.what() - << " — continuing without pose.\n"; - } - } else { - std::cout << "[realsense_rgbd] IMU not available, continuing without " - "pose.\n"; - } - - livekit_bridge::LiveKitBridge bridge; - livekit::RoomOptions options; - options.auto_subscribe = false; - - std::cout << "[realsense_rgbd] Connecting to " << url << " ...\n"; - if (!bridge.connect(url, token, options)) { - std::cerr << "[realsense_rgbd] Failed to connect.\n"; - pipe.stop(); - return 1; - } - - std::shared_ptr video_track; - std::shared_ptr depth_track; - std::shared_ptr pose_track; - std::shared_ptr hello_track; - - try { - video_track = bridge.publishVideoTrack("camera/color", kWidth, kHeight, - livekit::TrackSource::SOURCE_CAMERA); - depth_track = bridge.publishDataTrack("camera/depth"); - if (has_imu) { - pose_track = bridge.publishDataTrack("camera/pose"); - } - hello_track = bridge.publishDataTrack("hello"); - } catch (const std::exception &e) { - std::cerr << "[realsense_rgbd] Failed to create tracks: " << e.what() - << "\n"; - bridge.disconnect(); - pipe.stop(); - return 1; - } - - std::cout << "[realsense_rgbd] Publishing camera/color (video), " - "camera/depth (DataTrack)" - << (has_imu ? ", camera/pose (DataTrack)" : "") - << ", and hello (DataTrack). Press Ctrl+C to stop.\n"; - - std::vector rgbaBuf( - static_cast(kWidth * kHeight * 4)); - - uint32_t hello_seq = 0; - uint32_t depth_pushed = 0; - uint32_t pose_pushed = 0; - uint32_t last_depth_report_count = 0; - auto last_hello = std::chrono::steady_clock::now(); - auto last_depth = std::chrono::steady_clock::now(); - auto last_pose = std::chrono::steady_clock::now(); - auto last_depth_report = std::chrono::steady_clock::now(); - const auto depth_interval = std::chrono::microseconds(1000000 / kDepthFps); - const auto pose_interval = std::chrono::microseconds(1000000 / kPoseFps); - - constexpr auto kMinLoopDuration = std::chrono::milliseconds(15); - - while (g_running) { - auto loop_start = std::chrono::steady_clock::now(); - - // Periodic "hello viewer" test message every 10 seconds - if (loop_start - last_hello >= std::chrono::seconds(1)) { - last_hello = loop_start; - ++hello_seq; - std::string text = "hello viewer #" + std::to_string(hello_seq); - uint64_t ts_us = static_cast(nowNs() / 1000); - bool ok = hello_track->tryPush( - reinterpret_cast(text.data()), text.size(), - ts_us); - std::cout << "[" << nowStr() << "] [realsense_rgbd] Sent hello #" - << hello_seq << " (" << text.size() << " bytes) -> " - << (ok ? "ok" : "FAILED") << "\n"; - } - - rs2::frameset frames; - if (!pipe.poll_for_frames(&frames)) { - auto elapsed = std::chrono::steady_clock::now() - loop_start; - if (elapsed < kMinLoopDuration) - std::this_thread::sleep_for(kMinLoopDuration - elapsed); - continue; - } - - auto color = frames.get_color_frame(); - auto depth = frames.get_depth_frame(); - if (!color || !depth) { - auto elapsed = std::chrono::steady_clock::now() - loop_start; - if (elapsed < kMinLoopDuration) - std::this_thread::sleep_for(kMinLoopDuration - elapsed); - continue; - } - - const uint64_t timestamp_ns = nowNs(); - const std::int64_t timestamp_us = - static_cast(timestamp_ns / 1000); - const int64_t secs = static_cast(timestamp_ns / 1000000000ULL); - const int32_t nsecs = static_cast(timestamp_ns % 1000000000ULL); - - // RGB → RGBA and push to video track - rgb8ToRgba(static_cast(color.get_data()), - rgbaBuf.data(), kWidth, kHeight); - if (!video_track->pushFrame(rgbaBuf.data(), rgbaBuf.size(), timestamp_us)) { - break; - } - - // Depth as RawImage proto on DataTrack (throttled to kDepthFps) - if (loop_start - last_depth >= depth_interval) { - last_depth = loop_start; - - foxglove::RawImage msg; - auto *ts = msg.mutable_timestamp(); - ts->set_seconds(secs); - ts->set_nanos(nsecs); - msg.set_frame_id("camera_depth"); - msg.set_width(depth.get_width()); - msg.set_height(depth.get_height()); - msg.set_encoding("16UC1"); - msg.set_step(depth.get_width() * 2); - msg.set_data(depth.get_data(), depth.get_data_size()); - - std::string serialized = msg.SerializeAsString(); - - uLongf comp_bound = compressBound(static_cast(serialized.size())); - std::vector compressed(comp_bound); - uLongf comp_size = comp_bound; - int zrc = compress2(compressed.data(), &comp_size, - reinterpret_cast(serialized.data()), - static_cast(serialized.size()), Z_BEST_SPEED); - - auto push_start = std::chrono::steady_clock::now(); - bool ok = false; - if (zrc == Z_OK) { - ok = depth_track->tryPush(compressed.data(), - static_cast(comp_size), - static_cast(timestamp_us)); - } else { - std::cerr << "[realsense_rgbd] zlib compress failed (" << zrc - << "), sending uncompressed\n"; - ok = depth_track->tryPush( - reinterpret_cast(serialized.data()), - serialized.size(), static_cast(timestamp_us)); - } - auto push_dur = std::chrono::steady_clock::now() - push_start; - double push_ms = - std::chrono::duration_cast(push_dur) - .count() / - 1000.0; - - ++depth_pushed; - if (!ok) { - std::cout << "[" << nowStr() - << "] [realsense_rgbd] Failed to push depth frame #" - << depth_pushed << " (push took " << std::fixed - << std::setprecision(1) << push_ms << "ms)\n"; - break; - } - if (depth_pushed == 1 || depth_pushed % 10 == 0) { - double elapsed_sec = - std::chrono::duration_cast( - loop_start - last_depth_report) - .count() / - 1000.0; - double actual_fps = - (elapsed_sec > 0) - ? static_cast(depth_pushed - last_depth_report_count) / - elapsed_sec - : 0; - std::cout << "[" << nowStr() << "] [realsense_rgbd] Depth #" - << depth_pushed << " push=" << std::fixed - << std::setprecision(1) << push_ms << "ms " - << serialized.size() << "B->" - << (zrc == Z_OK ? comp_size : serialized.size()) << "B" - << " actual=" << std::setprecision(1) << actual_fps - << "fps\n"; - last_depth_report = loop_start; - last_depth_report_count = depth_pushed; - } - } - - // Pose as PoseInFrame proto on DataTrack (throttled to kPoseFps) - if (has_imu && pose_track && (loop_start - last_pose >= pose_interval)) { - last_pose = loop_start; - - auto orientation = rotation_est.get_orientation(); - - foxglove::PoseInFrame pose_msg; - auto *pose_ts = pose_msg.mutable_timestamp(); - pose_ts->set_seconds(secs); - pose_ts->set_nanos(nsecs); - pose_msg.set_frame_id("camera_imu"); - - auto *pose = pose_msg.mutable_pose(); - auto *pos = pose->mutable_position(); - pos->set_x(0); - pos->set_y(0); - pos->set_z(0); - auto *orient = pose->mutable_orientation(); - orient->set_x(orientation.x); - orient->set_y(orientation.y); - orient->set_z(orientation.z); - orient->set_w(orientation.w); - - std::string pose_serialized = pose_msg.SerializeAsString(); - bool pose_ok = pose_track->tryPush( - reinterpret_cast(pose_serialized.data()), - pose_serialized.size(), static_cast(timestamp_us)); - ++pose_pushed; - if (!pose_ok) { - std::cout << "[" << nowStr() - << "] [realsense_rgbd] Failed to push pose frame #" - << pose_pushed << "\n"; - } - if (pose_pushed == 1 || pose_pushed % 100 == 0) { - std::cout << "[" << nowStr() << "] [realsense_rgbd] Pose #" - << pose_pushed << " " << pose_serialized.size() << "B" - << " q=(" << std::fixed << std::setprecision(3) - << orientation.x << ", " << orientation.y << ", " - << orientation.z << ", " << orientation.w << ")\n"; - } - } - - auto elapsed = std::chrono::steady_clock::now() - loop_start; - if (elapsed < kMinLoopDuration) { - std::this_thread::sleep_for(kMinLoopDuration - elapsed); - } - } - - std::cout << "[realsense_rgbd] Stopping...\n"; - bridge.disconnect(); - if (has_imu) - imu_pipe.stop(); - pipe.stop(); - google::protobuf::ShutdownProtobufLibrary(); - return 0; -} diff --git a/examples/realsense-livekit/realsense-to-mcap/src/realsense_to_mcap.cpp b/examples/realsense-livekit/realsense-to-mcap/src/realsense_to_mcap.cpp deleted file mode 100644 index 1d7b2b2c..00000000 --- a/examples/realsense-livekit/realsense-to-mcap/src/realsense_to_mcap.cpp +++ /dev/null @@ -1,159 +0,0 @@ -// Copyright 2026 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -#define MCAP_IMPLEMENTATION -#include - -#include - -#include "BuildFileDescriptorSet.h" -#include "foxglove/RawImage.pb.h" - -#include -#include -#include - -static volatile std::sig_atomic_t g_running = 1; - -static void signalHandler(int signum) { - (void)signum; - g_running = 0; -} - -static uint64_t nowNs() { - return std::chrono::duration_cast( - std::chrono::system_clock::now().time_since_epoch()) - .count(); -} - -int main() { - GOOGLE_PROTOBUF_VERIFY_VERSION; - - std::signal(SIGINT, signalHandler); - std::signal(SIGTERM, signalHandler); - - rs2::pipeline pipe; - rs2::config cfg; - cfg.enable_stream(RS2_STREAM_COLOR, 640, 480, RS2_FORMAT_RGB8, 30); - cfg.enable_stream(RS2_STREAM_DEPTH, 640, 480, RS2_FORMAT_Z16, 30); - pipe.start(cfg); - - mcap::McapWriter writer; - auto options = mcap::McapWriterOptions(""); - options.compression = mcap::Compression::Zstd; - - { - const auto res = writer.open("realsense_rgbd.mcap", options); - if (!res.ok()) { - std::cerr << "Failed to open MCAP file: " << res.message << std::endl; - return 1; - } - } - - // Register schema as a serialized FileDescriptorSet (required by MCAP protobuf profile) - mcap::Schema schema( - "foxglove.RawImage", "protobuf", - BuildFileDescriptorSet(foxglove::RawImage::descriptor()).SerializeAsString()); - writer.addSchema(schema); - - mcap::Channel colorChannel("camera/color", "protobuf", schema.id); - writer.addChannel(colorChannel); - - mcap::Channel depthChannel("camera/depth", "protobuf", schema.id); - writer.addChannel(depthChannel); - - std::cout << "Recording to realsense_rgbd.mcap ... Press Ctrl+C to stop.\n"; - - uint32_t seq = 0; - while (g_running) { - rs2::frameset frames; - if (!pipe.poll_for_frames(&frames)) { - continue; - } - - auto color = frames.get_color_frame(); - auto depth = frames.get_depth_frame(); - if (!color || !depth) { - continue; - } - - uint64_t timestamp = nowNs(); - int64_t secs = static_cast(timestamp / 1000000000ULL); - int32_t nsecs = static_cast(timestamp % 1000000000ULL); - - // Color image - { - foxglove::RawImage msg; - auto* ts = msg.mutable_timestamp(); - ts->set_seconds(secs); - ts->set_nanos(nsecs); - msg.set_frame_id("camera_color"); - msg.set_width(color.get_width()); - msg.set_height(color.get_height()); - msg.set_encoding("rgb8"); - msg.set_step(color.get_width() * 3); - msg.set_data(color.get_data(), color.get_data_size()); - - std::string serialized = msg.SerializeAsString(); - - mcap::Message mcapMsg; - mcapMsg.channelId = colorChannel.id; - mcapMsg.sequence = seq; - mcapMsg.logTime = timestamp; - mcapMsg.publishTime = timestamp; - mcapMsg.data = reinterpret_cast(serialized.data()); - mcapMsg.dataSize = serialized.size(); - const auto res = writer.write(mcapMsg); - if (!res.ok()) { - std::cerr << "Failed to write color message: " << res.message << std::endl; - } - } - - // Depth image - { - foxglove::RawImage msg; - auto* ts = msg.mutable_timestamp(); - ts->set_seconds(secs); - ts->set_nanos(nsecs); - msg.set_frame_id("camera_depth"); - msg.set_width(depth.get_width()); - msg.set_height(depth.get_height()); - msg.set_encoding("16UC1"); - msg.set_step(depth.get_width() * 2); - msg.set_data(depth.get_data(), depth.get_data_size()); - - std::string serialized = msg.SerializeAsString(); - - mcap::Message mcapMsg; - mcapMsg.channelId = depthChannel.id; - mcapMsg.sequence = seq; - mcapMsg.logTime = timestamp; - mcapMsg.publishTime = timestamp; - mcapMsg.data = reinterpret_cast(serialized.data()); - mcapMsg.dataSize = serialized.size(); - const auto res = writer.write(mcapMsg); - if (!res.ok()) { - std::cerr << "Failed to write depth message: " << res.message << std::endl; - } - } - - ++seq; - } - - std::cout << "\nStopping... wrote " << seq << " frame pairs.\n"; - writer.close(); - pipe.stop(); - google::protobuf::ShutdownProtobufLibrary(); - return 0; -} diff --git a/examples/realsense-livekit/realsense-to-mcap/src/rgbd_viewer.cpp b/examples/realsense-livekit/realsense-to-mcap/src/rgbd_viewer.cpp deleted file mode 100644 index 25df34ed..00000000 --- a/examples/realsense-livekit/realsense-to-mcap/src/rgbd_viewer.cpp +++ /dev/null @@ -1,477 +0,0 @@ -// Copyright 2026 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -/* - * rgbd_viewer — LiveKit participant that subscribes to realsense_rgbd's - * video track (RGB) and data track (depth), and writes both to an MCAP file. - * - * Usage: - * rgbd_viewer [output.mcap] - * LIVEKIT_URL=... LIVEKIT_TOKEN=... rgbd_viewer [output.mcap] - * - * Token must grant identity "rgbd_viewer". Start realsense_rgbd in the same - * room first to publish camera/color and camera/depth. - */ - -#define MCAP_IMPLEMENTATION -#include - -#include "livekit/track.h" -#include "livekit/video_frame.h" -#include "livekit_bridge/livekit_bridge.h" - -#include "BuildFileDescriptorSet.h" -#include "foxglove/RawImage.pb.h" - -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -static volatile std::sig_atomic_t g_running = 1; -static void signalHandler(int) { g_running = 0; } - -static std::string nowStr() { - auto now = std::chrono::system_clock::now(); - auto ms = std::chrono::duration_cast( - now.time_since_epoch()) % - 1000; - std::time_t t = std::chrono::system_clock::to_time_t(now); - std::tm tm{}; - localtime_r(&t, &tm); - std::ostringstream os; - os << std::put_time(&tm, "%H:%M:%S") << '.' << std::setfill('0') - << std::setw(3) << ms.count(); - return os.str(); -} - -static const char kSenderIdentity[] = "realsense_rgbd"; -static const char kColorTrackName[] = "camera/color"; // video track -static const char kDepthTrackName[] = "camera/depth"; // data track -static const char kHelloTrackName[] = "hello"; // test data track - -/// MCAP writer with a background write thread. -/// -/// All public write methods enqueue entries and return immediately so reader -/// callbacks are never blocked by Zstd compression or disk I/O. A dedicated -/// writer thread drains the queue and performs the actual MCAP writes. -struct McapRecorder { - mcap::McapWriter writer; - mcap::ChannelId colorChannelId = 0; - mcap::ChannelId depthChannelId = 0; - mcap::ChannelId helloChannelId = 0; - uint32_t colorSeq = 0; - uint32_t depthSeq = 0; - uint32_t helloSeq = 0; - bool open = false; - - struct WriteEntry { - enum Type { kColor, kDepth, kHello } type; - std::vector data; - std::string text; - int width = 0; - int height = 0; - std::optional user_timestamp_us; - uint64_t wall_time_ns = 0; - }; - - std::deque queue_; - std::mutex queue_mtx_; - std::condition_variable queue_cv_; - std::thread writer_thread_; - std::atomic stop_{false}; - std::atomic color_enqueue_seq_{0}; - - std::atomic depth_received{0}; - std::atomic hello_received{0}; - std::atomic last_depth_latency_us{-1}; - std::atomic last_hello_latency_us{-1}; - - static constexpr size_t kMaxQueueSize = 60; - - static uint64_t wallNs() { - return std::chrono::duration_cast( - std::chrono::system_clock::now().time_since_epoch()) - .count(); - } - - bool openFile(const std::string &path) { - mcap::McapWriterOptions opts(""); - opts.compression = mcap::Compression::Zstd; - auto res = writer.open(path, opts); - if (!res.ok()) { - std::cerr << "[rgbd_viewer] Failed to open MCAP: " << res.message << "\n"; - return false; - } - - mcap::Schema rawImageSchema( - "foxglove.RawImage", "protobuf", - BuildFileDescriptorSet(foxglove::RawImage::descriptor()) - .SerializeAsString()); - writer.addSchema(rawImageSchema); - - mcap::Schema textSchema("hello", "jsonschema", - R"({"type":"object","properties":{"text":{"type":"string"}}})"); - writer.addSchema(textSchema); - - mcap::Channel colorChannel("camera/color", "protobuf", rawImageSchema.id); - writer.addChannel(colorChannel); - colorChannelId = colorChannel.id; - - mcap::Channel depthChannel("camera/depth", "protobuf", rawImageSchema.id); - writer.addChannel(depthChannel); - depthChannelId = depthChannel.id; - - mcap::Channel helloChannel("hello", "json", textSchema.id); - writer.addChannel(helloChannel); - helloChannelId = helloChannel.id; - - open = true; - writer_thread_ = std::thread([this] { writerLoop(); }); - return true; - } - - // Throttle color to ~10fps to reduce memory/write pressure (video arrives - // at 30fps but each RGBA frame is ~1.2 MB before Zstd). - void writeColorFrame(const std::uint8_t *rgba, std::size_t size, int width, - int height, std::int64_t /*timestamp_us*/) { - uint32_t seq = color_enqueue_seq_.fetch_add(1, std::memory_order_relaxed); - if (seq % 3 != 0) return; - - WriteEntry e; - e.type = WriteEntry::kColor; - e.data.assign(rgba, rgba + size); - e.width = width; - e.height = height; - e.wall_time_ns = wallNs(); - enqueue(std::move(e)); - } - - void writeDepthPayload(const std::uint8_t *data, std::size_t size, - std::optional user_timestamp_us) { - WriteEntry e; - e.type = WriteEntry::kDepth; - e.data.assign(data, data + size); - e.user_timestamp_us = user_timestamp_us; - e.wall_time_ns = wallNs(); - enqueue(std::move(e)); - } - - void writeHello(const std::string &text, - std::optional user_timestamp_us) { - WriteEntry e; - e.type = WriteEntry::kHello; - e.text = text; - e.user_timestamp_us = user_timestamp_us; - e.wall_time_ns = wallNs(); - enqueue(std::move(e)); - } - - void close() { - stop_.store(true, std::memory_order_release); - queue_cv_.notify_one(); - if (writer_thread_.joinable()) - writer_thread_.join(); - if (open) { - writer.close(); - open = false; - } - } - -private: - void enqueue(WriteEntry &&e) { - std::lock_guard lock(queue_mtx_); - if (queue_.size() >= kMaxQueueSize) { - for (auto it = queue_.begin(); it != queue_.end(); ++it) { - if (it->type == WriteEntry::kColor) { - queue_.erase(it); - break; - } - } - } - queue_.push_back(std::move(e)); - } - - void writerLoop() { - while (true) { - { - std::unique_lock lock(queue_mtx_); - queue_cv_.wait_for(lock, std::chrono::seconds(1), [this] { - return stop_.load(std::memory_order_acquire); - }); - } - - std::deque batch; - { - std::lock_guard lock(queue_mtx_); - batch.swap(queue_); - } - - uint32_t nc = 0, nd = 0, nh = 0; - for (auto &e : batch) { - switch (e.type) { - case WriteEntry::kColor: - flushColor(e); - ++nc; - break; - case WriteEntry::kDepth: - flushDepth(e); - ++nd; - break; - case WriteEntry::kHello: - flushHello(e); - ++nh; - break; - } - } - - if (nc + nd + nh > 0) { - auto cr = color_enqueue_seq_.load(std::memory_order_relaxed); - auto dr = depth_received.load(std::memory_order_relaxed); - auto hr = hello_received.load(std::memory_order_relaxed); - auto dl = last_depth_latency_us.load(std::memory_order_relaxed); - auto hl = last_hello_latency_us.load(std::memory_order_relaxed); - std::cout << "[" << nowStr() << "] [rgbd_viewer] wrote " - << nc << "c " << nd << "d " << nh << "h" - << " | totals " << cr << "c " << dr << "d " << hr << "h"; - if (dl >= 0) - std::cout << " | depth_lat=" << std::fixed << std::setprecision(1) - << dl / 1000.0 << "ms"; - if (hl >= 0) - std::cout << " | hello_lat=" << std::fixed << std::setprecision(1) - << hl / 1000.0 << "ms"; - std::cout << "\n"; - } - - if (stop_.load(std::memory_order_acquire) && batch.empty()) - break; - } - } - - void flushColor(const WriteEntry &e) { - foxglove::RawImage msg; - auto *ts = msg.mutable_timestamp(); - ts->set_seconds(static_cast(e.wall_time_ns / 1000000000ULL)); - ts->set_nanos(static_cast(e.wall_time_ns % 1000000000ULL)); - msg.set_frame_id("camera_color"); - msg.set_width(e.width); - msg.set_height(e.height); - msg.set_encoding("rgba8"); - msg.set_step(e.width * 4); - msg.set_data(e.data.data(), e.data.size()); - - std::string serialized = msg.SerializeAsString(); - mcap::Message m; - m.channelId = colorChannelId; - m.sequence = colorSeq++; - m.logTime = e.wall_time_ns; - m.publishTime = e.wall_time_ns; - m.data = reinterpret_cast(serialized.data()); - m.dataSize = serialized.size(); - auto res = writer.write(m); - if (!res.ok()) { - std::cerr << "[rgbd_viewer] Write color error: " << res.message << "\n"; - } - } - - void flushDepth(const WriteEntry &e) { - const uint64_t publishTime = - (e.user_timestamp_us && *e.user_timestamp_us > 0) - ? *e.user_timestamp_us * 1000ULL - : e.wall_time_ns; - - // Depth payloads arrive zlib-compressed from the sender. Decompress - // so the MCAP file contains standard foxglove.RawImage protobuf bytes. - const std::uint8_t *write_ptr = e.data.data(); - std::size_t write_size = e.data.size(); - - // 640*480*2 (depth) + proto overhead ≈ 620 KB; 1 MB is a safe upper bound. - static constexpr uLongf kMaxDecompressed = 1024 * 1024; - std::vector decompressed(kMaxDecompressed); - uLongf decompressed_size = kMaxDecompressed; - int zrc = uncompress(decompressed.data(), &decompressed_size, - e.data.data(), - static_cast(e.data.size())); - if (zrc == Z_OK) { - write_ptr = decompressed.data(); - write_size = static_cast(decompressed_size); - } - - mcap::Message m; - m.channelId = depthChannelId; - m.sequence = depthSeq++; - m.logTime = e.wall_time_ns; - m.publishTime = publishTime; - m.data = reinterpret_cast(write_ptr); - m.dataSize = write_size; - auto res = writer.write(m); - if (!res.ok()) { - std::cerr << "[rgbd_viewer] Write depth error: " << res.message << "\n"; - } - } - - void flushHello(const WriteEntry &e) { - const uint64_t publishTime = - (e.user_timestamp_us && *e.user_timestamp_us > 0) - ? *e.user_timestamp_us * 1000ULL - : e.wall_time_ns; - - std::string json = "{\"text\":\"" + e.text + "\"}"; - mcap::Message m; - m.channelId = helloChannelId; - m.sequence = helloSeq++; - m.logTime = e.wall_time_ns; - m.publishTime = publishTime; - m.data = reinterpret_cast(json.data()); - m.dataSize = json.size(); - auto res = writer.write(m); - if (!res.ok()) { - std::cerr << "[rgbd_viewer] Write hello error: " << res.message << "\n"; - } - } -}; - -int main(int argc, char *argv[]) { - GOOGLE_PROTOBUF_VERIFY_VERSION; - - std::signal(SIGINT, signalHandler); - std::signal(SIGTERM, signalHandler); - - std::string outputPath = "rgbd_viewer_output.mcap"; - std::string url; - std::string token; - - const char *env_url = std::getenv("LIVEKIT_URL"); - const char *env_token = std::getenv("LIVEKIT_TOKEN"); - - if (argc >= 4) { - outputPath = argv[1]; - url = argv[2]; - token = argv[3]; - } else if (argc >= 3) { - url = argv[1]; - token = argv[2]; - } else if (env_url && env_token) { - url = env_url; - token = env_token; - if (argc >= 2) { - outputPath = argv[1]; - } - } else { - std::cerr << "Usage: rgbd_viewer [output.mcap] \n" - " or: LIVEKIT_URL=... LIVEKIT_TOKEN=... rgbd_viewer " - "[output.mcap]\n"; - return 1; - } - - std::shared_ptr recorder = std::make_shared(); - if (!recorder->openFile(outputPath)) { - return 1; - } - std::cout << "[rgbd_viewer] Recording to " << outputPath << "\n"; - - livekit_bridge::LiveKitBridge bridge; - - // Video callback: realsense_rgbd's camera (RGB stream as video track) - bridge.setOnVideoFrameCallback( - kSenderIdentity, livekit::TrackSource::SOURCE_CAMERA, - [recorder](const livekit::VideoFrame &frame, std::int64_t timestamp_us) { - const std::uint8_t *data = frame.data(); - const std::size_t size = frame.dataSize(); - if (data && size > 0) { - recorder->writeColorFrame(data, size, frame.width(), frame.height(), - timestamp_us); - } - }); - - // Data callback: realsense_rgbd's camera/depth (RawImage proto bytes) - bridge.setOnDataFrameCallback( - kSenderIdentity, kDepthTrackName, - [recorder](const std::vector &payload, - std::optional user_timestamp) { - if (payload.empty()) - return; - recorder->depth_received.fetch_add(1, std::memory_order_relaxed); - if (user_timestamp) { - uint64_t recv_us = - std::chrono::duration_cast( - std::chrono::system_clock::now().time_since_epoch()) - .count(); - recorder->last_depth_latency_us.store( - static_cast(recv_us - *user_timestamp), - std::memory_order_relaxed); - } - recorder->writeDepthPayload(payload.data(), payload.size(), - user_timestamp); - }); - - // Test callback: realsense_rgbd's "hello" track (plain-text ping) - bridge.setOnDataFrameCallback( - kSenderIdentity, kHelloTrackName, - [recorder](const std::vector &payload, - std::optional user_timestamp) { - recorder->hello_received.fetch_add(1, std::memory_order_relaxed); - if (user_timestamp) { - uint64_t recv_us = - std::chrono::duration_cast( - std::chrono::system_clock::now().time_since_epoch()) - .count(); - recorder->last_hello_latency_us.store( - static_cast(recv_us - *user_timestamp), - std::memory_order_relaxed); - } - std::string text(payload.begin(), payload.end()); - recorder->writeHello(text, user_timestamp); - }); - - std::cout << "[rgbd_viewer] Connecting to " << url << " ...\n"; - livekit::RoomOptions options; - options.auto_subscribe = true; - if (!bridge.connect(url, token, options)) { - std::cerr << "[rgbd_viewer] Failed to connect.\n"; - recorder->close(); - return 1; - } - - std::cout << "[rgbd_viewer] Connected. Waiting for " << kSenderIdentity - << " (camera/color + camera/depth). Press Ctrl+C to stop.\n"; - - while (g_running && bridge.isConnected()) { - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - } - - std::cout << "[rgbd_viewer] Stopping... (depth: " - << recorder->depth_received.load(std::memory_order_relaxed) - << ", hello: " - << recorder->hello_received.load(std::memory_order_relaxed) - << ")\n"; - bridge.disconnect(); - recorder->close(); - google::protobuf::ShutdownProtobufLibrary(); - return 0; -} diff --git a/examples/realsense-livekit/setup_realsense.sh b/examples/realsense-livekit/setup_realsense.sh deleted file mode 100755 index 76bfc3fe..00000000 --- a/examples/realsense-livekit/setup_realsense.sh +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env bash -# Copyright 2026 LiveKit, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# Clones external dependencies and generates protobuf C++ files for the -# realsense-to-mcap example. Safe to re-run (idempotent). - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_DIR="${SCRIPT_DIR}/realsense-to-mcap" -EXTERNAL_DIR="${PROJECT_DIR}/external" -GENERATED_DIR="${PROJECT_DIR}/generated" - -MCAP_REPO="https://github.com/foxglove/mcap.git" -FOXGLOVE_SDK_REPO="https://github.com/foxglove/foxglove-sdk.git" - -# --------------------------------------------------------------------------- -# Detect platform -# --------------------------------------------------------------------------- -OS="$(uname -s)" -case "${OS}" in - Linux*) PLATFORM=linux ;; - Darwin*) PLATFORM=macos ;; - *) PLATFORM=unknown ;; -esac - -# --------------------------------------------------------------------------- -# Check system dependencies -# --------------------------------------------------------------------------- -check_cmd() { - if ! command -v "$1" &>/dev/null; then - echo "WARNING: '$1' not found. $2" - return 1 - fi - return 0 -} - -hint_install() { - local pkg_apt="$1" - local pkg_brew="$2" - if [ "${PLATFORM}" = "macos" ]; then - echo "Install via: brew install ${pkg_brew}" - else - echo "Install via: sudo apt install ${pkg_apt}" - fi -} - -missing=0 -check_cmd protoc "$(hint_install protobuf-compiler protobuf)" || missing=1 -check_cmd pkg-config "$(hint_install pkg-config pkg-config)" || missing=1 - -if pkg-config --exists realsense2 2>/dev/null; then - echo " realsense2 ... found" -else - echo "WARNING: librealsense2 not found via pkg-config." - hint_install librealsense2-dev librealsense - missing=1 -fi - -if [ "$missing" -ne 0 ]; then - echo "" - echo "Some dependencies are missing (see warnings above). You can still" - echo "continue, but the build may fail." - echo "" -fi - -# --------------------------------------------------------------------------- -# Clone / update external repos -# --------------------------------------------------------------------------- -mkdir -p "${EXTERNAL_DIR}" - -clone_or_pull() { - local repo_url="$1" - local dest="$2" - - if [ -d "${dest}/.git" ]; then - echo "Updating $(basename "${dest}") ..." - git -C "${dest}" pull --ff-only - else - echo "Cloning $(basename "${dest}") ..." - git clone --depth 1 "${repo_url}" "${dest}" - fi -} - -clone_or_pull "${MCAP_REPO}" "${EXTERNAL_DIR}/mcap" -clone_or_pull "${FOXGLOVE_SDK_REPO}" "${EXTERNAL_DIR}/foxglove-sdk" - -# --------------------------------------------------------------------------- -# Generate C++ protobuf files from Foxglove schemas -# --------------------------------------------------------------------------- -PROTO_DIR="${EXTERNAL_DIR}/foxglove-sdk/schemas/proto" - -if [ ! -d "${PROTO_DIR}/foxglove" ]; then - echo "ERROR: Proto schemas not found at ${PROTO_DIR}/foxglove" - exit 1 -fi - -mkdir -p "${GENERATED_DIR}" - -echo "Generating protobuf C++ sources ..." -protoc \ - --cpp_out="${GENERATED_DIR}" \ - -I "${PROTO_DIR}" \ - "${PROTO_DIR}"/foxglove/*.proto - -echo "" -echo "Setup complete. Generated files are in:" -echo " ${GENERATED_DIR}" -echo "" -echo "To build:" -echo " cd ${PROJECT_DIR}" -echo " cmake -B build && cmake --build build" From de3c30f7b3914f39200a4858f68ab0934cdb9ab8 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Tue, 24 Mar 2026 14:51:45 -0600 Subject: [PATCH 12/34] no changes to bridge --- .../include/livekit_bridge/livekit_bridge.h | 42 ----------- bridge/src/bridge_room_delegate.cpp | 75 ------------------- bridge/src/bridge_room_delegate.h | 59 --------------- bridge/src/livekit_bridge.cpp | 31 -------- examples/bridge_human_robot/human.cpp | 26 ++----- examples/bridge_human_robot/robot.cpp | 42 +---------- 6 files changed, 9 insertions(+), 266 deletions(-) delete mode 100644 bridge/src/bridge_room_delegate.cpp delete mode 100644 bridge/src/bridge_room_delegate.h diff --git a/bridge/include/livekit_bridge/livekit_bridge.h b/bridge/include/livekit_bridge/livekit_bridge.h index 7cc4a70f..47f13d46 100644 --- a/bridge/include/livekit_bridge/livekit_bridge.h +++ b/bridge/include/livekit_bridge/livekit_bridge.h @@ -59,12 +59,6 @@ using AudioFrameCallback = livekit::AudioFrameCallback; /// @param timestamp_us Presentation timestamp in microseconds. using VideoFrameCallback = livekit::VideoFrameCallback; -/// Callback type for incoming data track frames. -/// Called on a background reader thread owned by Room. -/// @param payload Raw binary data received. -/// @param user_timestamp Optional application-defined timestamp from sender. -using DataFrameCallback = livekit::DataFrameCallback; - /** * High-level bridge to the LiveKit C++ SDK. * @@ -268,42 +262,6 @@ class LiveKitBridge { void clearOnVideoFrameCallback(const std::string &participant_identity, livekit::TrackSource source); - /** - * Set the callback for data frames from a specific remote participant's - * data track. - * - * Delegates to Room::setOnDataFrameCallback. - * - * @param participant_identity Identity of the remote participant. - * @param track_name Name of the remote data track. - * @param callback Function to invoke per data frame. - */ - void setOnDataFrameCallback(const std::string &participant_identity, - const std::string &track_name, - DataFrameCallback callback); - - /** - * Clear the data frame callback for a specific remote participant + track - * name. - * - * Delegates to Room::clearOnDataFrameCallback. - */ - void clearOnDataFrameCallback(const std::string &participant_identity, - const std::string &track_name); - - /** - * Create and publish a local data track. - * - * Delegates to Room::publishDataTrack (which delegates to - * LocalParticipant::publishDataTrack). - * - * @param name Unique track name visible to other participants. - * @return Shared pointer to the published data track (never null). - * @throws std::runtime_error if the bridge is not connected. - */ - std::shared_ptr - publishDataTrack(const std::string &name); - // --------------------------------------------------------------- // RPC (Remote Procedure Call) // --------------------------------------------------------------- diff --git a/bridge/src/bridge_room_delegate.cpp b/bridge/src/bridge_room_delegate.cpp deleted file mode 100644 index b3ae7d3f..00000000 --- a/bridge/src/bridge_room_delegate.cpp +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright 2026 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/// @file bridge_room_delegate.cpp -/// @brief Implementation of BridgeRoomDelegate event forwarding. - -#include "bridge_room_delegate.h" - -#include "livekit/lk_log.h" - -#include "livekit/remote_data_track.h" -#include "livekit/remote_participant.h" -#include "livekit/remote_track_publication.h" -#include "livekit/track.h" -#include "livekit_bridge/livekit_bridge.h" - -namespace livekit_bridge { - -void BridgeRoomDelegate::onTrackSubscribed( - livekit::Room & /*room*/, const livekit::TrackSubscribedEvent &ev) { - if (!ev.track || !ev.participant || !ev.publication) { - return; - } - - const std::string identity = ev.participant->identity(); - const livekit::TrackSource source = ev.publication->source(); - - bridge_.onTrackSubscribed(identity, source, ev.track); -} - -void BridgeRoomDelegate::onTrackUnsubscribed( - livekit::Room & /*room*/, const livekit::TrackUnsubscribedEvent &ev) { - if (!ev.participant || !ev.publication) { - return; - } - - const std::string identity = ev.participant->identity(); - const livekit::TrackSource source = ev.publication->source(); - - bridge_.onTrackUnsubscribed(identity, source); -} - -void BridgeRoomDelegate::onDataTrackPublished( - livekit::Room & /*room*/, const livekit::DataTrackPublishedEvent &ev) { - if (!ev.track) { - LK_LOG_ERROR("[BridgeRoomDelegate] onDataTrackPublished called " - "with null track."); - return; - } - - LK_LOG_INFO("[BridgeRoomDelegate] onDataTrackPublished: \"{}\" from " - "\"{}\"", - ev.track->info().name, ev.track->publisherIdentity()); - bridge_.onDataTrackPublished(ev.track); -} - -void BridgeRoomDelegate::onDataTrackUnpublished( - livekit::Room & /*room*/, const livekit::DataTrackUnpublishedEvent &ev) { - bridge_.onDataTrackUnpublished(ev.sid); -} - -} // namespace livekit_bridge diff --git a/bridge/src/bridge_room_delegate.h b/bridge/src/bridge_room_delegate.h deleted file mode 100644 index 3a6f9d84..00000000 --- a/bridge/src/bridge_room_delegate.h +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright 2026 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/// @file bridge_room_delegate.h -/// @brief Internal RoomDelegate forwarding SDK events to LiveKitBridge. - -#pragma once - -#include "livekit/room_delegate.h" - -namespace livekit_bridge { - -class LiveKitBridge; - -/** - * Internal RoomDelegate that forwards SDK room events to the LiveKitBridge. - * - * Handles track subscribe/unsubscribe lifecycle. Not part of the public API, - * so its in src/ instead of include/. - */ -class BridgeRoomDelegate : public livekit::RoomDelegate { -public: - explicit BridgeRoomDelegate(LiveKitBridge &bridge) : bridge_(bridge) {} - - /// Forwards a track-subscribed event to LiveKitBridge::onTrackSubscribed(). - void onTrackSubscribed(livekit::Room &room, - const livekit::TrackSubscribedEvent &ev) override; - - /// Forwards a track-unsubscribed event to - /// LiveKitBridge::onTrackUnsubscribed(). - void onTrackUnsubscribed(livekit::Room &room, - const livekit::TrackUnsubscribedEvent &ev) override; - - void - onDataTrackPublished(livekit::Room &room, - const livekit::DataTrackPublishedEvent &ev) override; - - void - onDataTrackUnpublished(livekit::Room &room, - const livekit::DataTrackUnpublishedEvent &ev) override; - -private: - LiveKitBridge &bridge_; -}; - -} // namespace livekit_bridge diff --git a/bridge/src/livekit_bridge.cpp b/bridge/src/livekit_bridge.cpp index 21840cb0..9f782904 100644 --- a/bridge/src/livekit_bridge.cpp +++ b/bridge/src/livekit_bridge.cpp @@ -278,37 +278,6 @@ void LiveKitBridge::clearOnVideoFrameCallback( room_->clearOnVideoFrameCallback(participant_identity, source); } -void LiveKitBridge::setOnDataFrameCallback( - const std::string &participant_identity, const std::string &track_name, - DataFrameCallback callback) { - std::lock_guard lock(mutex_); - if (!room_) { - LK_LOG_WARN("setOnDataFrameCallback called before connect(); ignored"); - return; - } - room_->setOnDataFrameCallback(participant_identity, track_name, - std::move(callback)); -} - -void LiveKitBridge::clearOnDataFrameCallback( - const std::string &participant_identity, const std::string &track_name) { - std::lock_guard lock(mutex_); - if (!room_) { - return; - } - room_->clearOnDataFrameCallback(participant_identity, track_name); -} - -std::shared_ptr -LiveKitBridge::publishDataTrack(const std::string &name) { - std::lock_guard lock(mutex_); - if (!connected_ || !room_ || !room_->localParticipant()) { - throw std::runtime_error( - "publishDataTrack requires an active connection; call connect() first"); - } - return room_->localParticipant()->publishDataTrack(name); -} - // --------------------------------------------------------------- // RPC (delegates to RpcController) // --------------------------------------------------------------- diff --git a/examples/bridge_human_robot/human.cpp b/examples/bridge_human_robot/human.cpp index d0dcc3b0..81989eb5 100644 --- a/examples/bridge_human_robot/human.cpp +++ b/examples/bridge_human_robot/human.cpp @@ -15,23 +15,21 @@ */ /* - * Human example -- receives audio, video, and data frames from a robot in a + * Human example -- receives audio and video frames from a robot in a * LiveKit room and renders them using SDL3. * * This example demonstrates the base SDK's convenience frame callback API - * (Room::setOnAudioFrameCallback / Room::setOnVideoFrameCallback / - * Room::setOnDataFrameCallback) which eliminates the need for a - * RoomDelegate subclass, manual AudioStream/VideoStream creation, and - * reader threads. + * (Room::setOnAudioFrameCallback / Room::setOnVideoFrameCallback) which + * eliminates the need for a RoomDelegate subclass, manual AudioStream/ + * VideoStream creation, and reader threads. * - * The robot publishes two video tracks, two audio tracks, and one data track: + * The robot publishes two video tracks and two audio tracks: * - "robot-cam" (SOURCE_CAMERA) -- webcam or placeholder * - "robot-sim-frame" (SOURCE_SCREENSHARE) -- simulated diagnostic * frame * - "robot-mic" (SOURCE_MICROPHONE) -- real microphone or * silence * - "robot-sim-audio" (SOURCE_SCREENSHARE_AUDIO) -- simulated siren tone - * - "robot-status" (DATA_TRACK) -- periodic status string * * Press 'w' to play the webcam feed + real mic, or 's' for sim frame + siren. * The selection controls both video and audio simultaneously. @@ -65,7 +63,6 @@ #include #include #include -#include #include #include #include @@ -275,19 +272,6 @@ int main(int argc, char *argv[]) { } }); - // ----- Set data frame callback using Room::setOnDataFrameCallback ----- - room->setOnDataFrameCallback( - "robot", "robot-status", - [](const std::vector &payload, - std::optional /*user_timestamp*/) { - try { - LK_LOG_INFO("[human] robot-status received."); - // LK_LOG_INFO("[human] robot-status: {}", payload); - } catch (const std::exception &e) { - // LK_LOG_ERROR("[human] Failed to process data track: {}", e.what()); - } - }); - // ----- Stdin input thread ----- std::thread input_thread([&]() { std::string line; diff --git a/examples/bridge_human_robot/robot.cpp b/examples/bridge_human_robot/robot.cpp index 993969a8..041580ef 100644 --- a/examples/bridge_human_robot/robot.cpp +++ b/examples/bridge_human_robot/robot.cpp @@ -16,8 +16,7 @@ /* * Robot example -- streams real webcam video and microphone audio to a - * LiveKit room using SDL3 for hardware capture, and publishes a data - * track ("robot-status") that sends a status string once per second. + * LiveKit room using SDL3 for hardware capture. * * Usage: * robot [--no-mic] @@ -31,12 +30,10 @@ * --join --room my-room --identity robot \ * --valid-for 24h * - * Run alongside the "human" example (which displays the robot's feed - * and prints received data messages). + * Run alongside the "human" example (which displays the robot's feed). */ #include "livekit/audio_frame.h" -#include "livekit/local_data_track.h" #include "livekit/track.h" #include "livekit/video_frame.h" #include "livekit_bridge/livekit_bridge.h" @@ -392,11 +389,8 @@ int main(int argc, char *argv[]) { auto sim_cam = bridge.createVideoTrack("robot-sim-frame", kSimWidth, kSimHeight, livekit::TrackSource::SOURCE_SCREENSHARE); - - auto data_track = bridge.publishDataTrack("robot-status"); - - LK_LOG_INFO("[robot] Publishing {}sim audio ({} Hz, {} ch), cam + sim frame " - "({}x{} / {}x{}), data track.", + LK_LOG_INFO("[robot] Publishing {} sim audio ({} Hz, {} ch), cam + sim frame " + "({}x{} / {}x{}).", use_mic ? "mic + " : "(no mic) ", kSampleRate, kChannels, kWidth, kHeight, kSimWidth, kSimHeight); @@ -624,30 +618,6 @@ int main(int argc, char *argv[]) { }); LK_LOG_INFO("[robot] Sim audio (siren) track started."); - // ----- Data track: send a status string once per second ----- - std::atomic data_running{true}; - std::thread data_thread([&]() { - std::uint64_t count = 0; - auto start = std::chrono::steady_clock::now(); - while (data_running.load()) { - auto elapsed = std::chrono::steady_clock::now() - start; - double secs = std::chrono::duration(elapsed).count(); - char buf[64]; - std::snprintf(buf, sizeof(buf), "%.2f, count: %llu", secs, - static_cast(count)); - std::string msg(buf); - std::vector payload(msg.begin(), msg.end()); - if (!data_track->tryPush(payload)) { - LK_LOG_ERROR("[robot] Failed to push data track."); - break; - } - LK_LOG_INFO("[robot] Data track pushed: {}", msg); - ++count; - std::this_thread::sleep_for(std::chrono::seconds(1)); - } - }); - LK_LOG_INFO("[robot] Data track (robot-status) started."); - // ----- Main loop: keep alive + pump SDL events ----- LK_LOG_INFO("[robot] Streaming... press Ctrl-C to stop."); @@ -668,7 +638,6 @@ int main(int argc, char *argv[]) { cam_running.store(false); sim_running.store(false); sim_audio_running.store(false); - data_running.store(false); if (mic_thread.joinable()) mic_thread.join(); if (cam_thread.joinable()) @@ -677,8 +646,6 @@ int main(int argc, char *argv[]) { sim_thread.join(); if (sim_audio_thread.joinable()) sim_audio_thread.join(); - if (data_thread.joinable()) - data_thread.join(); sdl_mic.reset(); sdl_cam.reset(); @@ -686,7 +653,6 @@ int main(int argc, char *argv[]) { sim_audio.reset(); cam.reset(); sim_cam.reset(); - data_track.reset(); bridge.disconnect(); SDL_Quit(); From f4f07ff441429c966d00a6f883c535440ef0c009 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Tue, 24 Mar 2026 14:59:09 -0600 Subject: [PATCH 13/34] simple_status example --- README.md | 2 - examples/CMakeLists.txt | 28 +++++ examples/simple_status/consumer.cpp | 124 +++++++++++++++++++++++ examples/simple_status/producer.cpp | 152 ++++++++++++++++++++++++++++ 4 files changed, 304 insertions(+), 2 deletions(-) create mode 100644 examples/simple_status/consumer.cpp create mode 100644 examples/simple_status/producer.cpp diff --git a/README.md b/README.md index 97fc77fc..b318e0d5 100644 --- a/README.md +++ b/README.md @@ -451,8 +451,6 @@ brew install clang-format #### Memory Checks Run valgrind on various examples or tests to check for memory leaks and other issues. ```bash -valgrind --leak-check=full ./build-debug/bin/BridgeRobot -valgrind --leak-check=full ./build-debug/bin/BridgeHuman valgrind --leak-check=full ./build-debug/bin/livekit_integration_tests valgrind --leak-check=full ./build-debug/bin/livekit_stress_tests ``` diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 19a37781..4691ca74 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -43,6 +43,8 @@ set(EXAMPLES_ALL SimpleJoystickSender SimpleJoystickReceiver SimpleDataStream + SimpleStatusProducer + SimpleStatusConsumer LoggingLevelsBasicUsage LoggingLevelsCustomSinks BridgeRobot @@ -242,6 +244,32 @@ add_custom_command( $/data ) +# --- simple_status (producer + consumer text stream on producer-status) --- + +add_executable(SimpleStatusProducer + simple_status/producer.cpp +) + +target_include_directories(SimpleStatusProducer PRIVATE ${EXAMPLES_PRIVATE_INCLUDE_DIRS}) + +target_link_libraries(SimpleStatusProducer + PRIVATE + livekit + spdlog::spdlog +) + +add_executable(SimpleStatusConsumer + simple_status/consumer.cpp +) + +target_include_directories(SimpleStatusConsumer PRIVATE ${EXAMPLES_PRIVATE_INCLUDE_DIRS}) + +target_link_libraries(SimpleStatusConsumer + PRIVATE + livekit + spdlog::spdlog +) + # --- bridge_human_robot examples (robot + human; use livekit_bridge and SDL3) --- add_executable(BridgeRobot diff --git a/examples/simple_status/consumer.cpp b/examples/simple_status/consumer.cpp new file mode 100644 index 00000000..3fc65318 --- /dev/null +++ b/examples/simple_status/consumer.cpp @@ -0,0 +1,124 @@ +/* + * Copyright 2025 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// Consumer participant: prints each incoming message on the `producer-status` +/// text stream topic. Use a token whose identity is `consumer`. + +#include "livekit/livekit.h" +#include "livekit/lk_log.h" + +#include +#include +#include +#include +#include +#include +#include + +using namespace livekit; + +namespace { + +constexpr const char *kTopic = "producer-status"; + +std::atomic g_running{true}; + +void handleSignal(int) { g_running.store(false); } + +std::string getenvOrEmpty(const char *name) { + const char *v = std::getenv(name); + return v ? std::string(v) : std::string{}; +} + +void handleStatusMessage(std::shared_ptr reader, + const std::string &participant_identity) { + try { + const std::string text = reader->readAll(); + LK_LOG_INFO("[from {}] {}", participant_identity, text); + } catch (const std::exception &e) { + LK_LOG_ERROR("Error reading text stream from {}: {}", participant_identity, + e.what()); + } +} + +} // namespace + +int main(int argc, char *argv[]) { + std::string url = getenvOrEmpty("LIVEKIT_URL"); + std::string token = getenvOrEmpty("LIVEKIT_TOKEN"); + + if (argc >= 3) { + url = argv[1]; + token = argv[2]; + } + + if (url.empty() || token.empty()) { + LK_LOG_ERROR("LIVEKIT_URL and LIVEKIT_TOKEN (or ) are " + "required"); + return 1; + } + + std::signal(SIGINT, handleSignal); +#ifdef SIGTERM + std::signal(SIGTERM, handleSignal); +#endif + + livekit::initialize(livekit::LogLevel::Info, livekit::LogSink::kConsole); + + auto room = std::make_unique(); + RoomOptions options; + options.auto_subscribe = true; + options.dynacast = false; + + if (!room->Connect(url, token, options)) { + LK_LOG_ERROR("Failed to connect to room"); + livekit::shutdown(); + return 1; + } + + LocalParticipant *lp = room->localParticipant(); + if (!lp) { + LK_LOG_ERROR("No local participant after connect"); + room->setDelegate(nullptr); + room.reset(); + livekit::shutdown(); + return 1; + } + + LK_LOG_INFO("consumer connected as identity='{}' room='{}'", + lp->identity(), room->room_info().name); + + room->registerTextStreamHandler( + kTopic, [](std::shared_ptr reader, + const std::string &participant_identity) { + std::thread t(handleStatusMessage, std::move(reader), + participant_identity); + t.detach(); + }); + + LK_LOG_INFO("listening on topic '{}'; Ctrl-C to exit", kTopic); + + while (g_running.load()) { + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + + LK_LOG_INFO("shutting down"); + room->unregisterTextStreamHandler(kTopic); + room->setDelegate(nullptr); + room.reset(); + livekit::shutdown(); + return 0; +} diff --git a/examples/simple_status/producer.cpp b/examples/simple_status/producer.cpp new file mode 100644 index 00000000..46663792 --- /dev/null +++ b/examples/simple_status/producer.cpp @@ -0,0 +1,152 @@ +/* + * Copyright 2025 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// Producer participant: publishes periodic status on the `producer-status` +/// text stream topic (4 Hz). Use a token whose identity is `producer`. + +#include "livekit/livekit.h" +#include "livekit/lk_log.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace livekit; + +namespace { + +constexpr const char *kTopic = "producer-status"; + +std::atomic g_running{true}; + +void handleSignal(int) { g_running.store(false); } + +std::string getenvOrEmpty(const char *name) { + const char *v = std::getenv(name); + return v ? std::string(v) : std::string{}; +} + +std::string randomHexId(std::size_t nbytes = 16) { + static thread_local std::mt19937_64 rng{std::random_device{}()}; + std::ostringstream oss; + for (std::size_t i = 0; i < nbytes; ++i) { + std::uint8_t b = static_cast(rng() & 0xFF); + const char *hex = "0123456789abcdef"; + oss << hex[(b >> 4) & 0xF] << hex[b & 0xF]; + } + return oss.str(); +} + +} // namespace + +int main(int argc, char *argv[]) { + std::string url = getenvOrEmpty("LIVEKIT_URL"); + std::string token = getenvOrEmpty("LIVEKIT_TOKEN"); + + if (argc >= 3) { + url = argv[1]; + token = argv[2]; + } + + if (url.empty() || token.empty()) { + LK_LOG_ERROR("LIVEKIT_URL and LIVEKIT_TOKEN (or ) are " + "required"); + return 1; + } + + std::signal(SIGINT, handleSignal); +#ifdef SIGTERM + std::signal(SIGTERM, handleSignal); +#endif + + livekit::initialize(livekit::LogLevel::Info, livekit::LogSink::kConsole); + + auto room = std::make_unique(); + RoomOptions options; + options.auto_subscribe = true; + options.dynacast = false; + + if (!room->Connect(url, token, options)) { + LK_LOG_ERROR("Failed to connect to room"); + livekit::shutdown(); + return 1; + } + + LocalParticipant *lp = room->localParticipant(); + if (!lp) { + LK_LOG_ERROR("No local participant after connect"); + room->setDelegate(nullptr); + room.reset(); + livekit::shutdown(); + return 1; + } + + LK_LOG_INFO("producer connected as identity='{}' room='{}'", + lp->identity(), room->room_info().name); + + const std::string sender_id = + !lp->identity().empty() ? lp->identity() : std::string("producer"); + + using clock = std::chrono::steady_clock; + const auto start = clock::now(); + const auto period = std::chrono::milliseconds(250); + auto next_deadline = clock::now(); + std::uint64_t count = 0; + + while (g_running.load()) { + const auto now = clock::now(); + const double elapsed_sec = + std::chrono::duration(now - start).count(); + + std::ostringstream body; + body << std::fixed << std::setprecision(2) << elapsed_sec; + const std::string text = std::string("[time-since-start]: ") + body.str() + + " count: " + std::to_string(count); + + try { + const std::string stream_id = randomHexId(); + std::map attrs; + const std::vector dest; + const std::string reply_to_id; + TextStreamWriter writer(*lp, kTopic, attrs, stream_id, text.size(), + reply_to_id, dest, sender_id); + writer.write(text); + writer.close(); + } catch (const std::exception &e) { + LK_LOG_ERROR("Failed to send status: {}", e.what()); + } + + LK_LOG_DEBUG("sent: {}", text); + ++count; + + next_deadline += period; + std::this_thread::sleep_until(next_deadline); + } + + LK_LOG_INFO("shutting down"); + room->setDelegate(nullptr); + room.reset(); + livekit::shutdown(); + return 0; +} From a6791b76cb7f867ddc56fb26e1946e555a68ff43 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Tue, 24 Mar 2026 15:50:54 -0600 Subject: [PATCH 14/34] multiple subscriptions --- examples/simple_status/consumer.cpp | 53 +++++++++--------- examples/simple_status/producer.cpp | 51 ++++++++---------- include/livekit/room.h | 37 ++++++------- src/room.cpp | 84 +++++++++++++++-------------- 4 files changed, 110 insertions(+), 115 deletions(-) diff --git a/examples/simple_status/consumer.cpp b/examples/simple_status/consumer.cpp index 3fc65318..b22fbe8f 100644 --- a/examples/simple_status/consumer.cpp +++ b/examples/simple_status/consumer.cpp @@ -9,13 +9,14 @@ * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OF ANY KIND, either express or implied. + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ -/// Consumer participant: prints each incoming message on the `producer-status` -/// text stream topic. Use a token whose identity is `consumer`. +/// Consumer participant: creates 3 independent data track subscriptions to the +/// producer's "status" data track and logs each frame with the subscriber +/// index. Use a token whose identity is `consumer`. #include "livekit/livekit.h" #include "livekit/lk_log.h" @@ -24,15 +25,17 @@ #include #include #include -#include #include #include +#include using namespace livekit; namespace { -constexpr const char *kTopic = "producer-status"; +constexpr const char *kProducerIdentity = "producer"; +constexpr const char *kTrackName = "status"; +constexpr int kNumSubscribers = 3; std::atomic g_running{true}; @@ -43,17 +46,6 @@ std::string getenvOrEmpty(const char *name) { return v ? std::string(v) : std::string{}; } -void handleStatusMessage(std::shared_ptr reader, - const std::string &participant_identity) { - try { - const std::string text = reader->readAll(); - LK_LOG_INFO("[from {}] {}", participant_identity, text); - } catch (const std::exception &e) { - LK_LOG_ERROR("Error reading text stream from {}: {}", participant_identity, - e.what()); - } -} - } // namespace int main(int argc, char *argv[]) { @@ -101,22 +93,33 @@ int main(int argc, char *argv[]) { LK_LOG_INFO("consumer connected as identity='{}' room='{}'", lp->identity(), room->room_info().name); - room->registerTextStreamHandler( - kTopic, [](std::shared_ptr reader, - const std::string &participant_identity) { - std::thread t(handleStatusMessage, std::move(reader), - participant_identity); - t.detach(); - }); + std::vector sub_ids; + sub_ids.reserve(kNumSubscribers); + + for (int i = 0; i < kNumSubscribers; ++i) { + auto id = room->addOnDataFrameCallback( + kProducerIdentity, kTrackName, + [i](const std::vector &payload, + std::optional /*user_timestamp*/) { + std::string text(payload.begin(), payload.end()); + LK_LOG_INFO("[subscriber {}] {}", i, text); + }); + sub_ids.push_back(id); + LK_LOG_INFO("registered subscriber {} (id={})", i, id); + } - LK_LOG_INFO("listening on topic '{}'; Ctrl-C to exit", kTopic); + LK_LOG_INFO("listening for data track '{}' from '{}' with {} subscribers; " + "Ctrl-C to exit", + kTrackName, kProducerIdentity, kNumSubscribers); while (g_running.load()) { std::this_thread::sleep_for(std::chrono::milliseconds(50)); } LK_LOG_INFO("shutting down"); - room->unregisterTextStreamHandler(kTopic); + for (auto id : sub_ids) { + room->removeOnDataFrameCallback(id); + } room->setDelegate(nullptr); room.reset(); livekit::shutdown(); diff --git a/examples/simple_status/producer.cpp b/examples/simple_status/producer.cpp index 46663792..164bb644 100644 --- a/examples/simple_status/producer.cpp +++ b/examples/simple_status/producer.cpp @@ -9,13 +9,14 @@ * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OF ANY KIND, either express or implied. + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ -/// Producer participant: publishes periodic status on the `producer-status` -/// text stream topic (4 Hz). Use a token whose identity is `producer`. +/// Producer participant: publishes a data track named "status" and pushes +/// periodic binary status frames (4 Hz). Use a token whose identity is +/// `producer`. #include "livekit/livekit.h" #include "livekit/lk_log.h" @@ -25,8 +26,6 @@ #include #include #include -#include -#include #include #include #include @@ -36,7 +35,7 @@ using namespace livekit; namespace { -constexpr const char *kTopic = "producer-status"; +constexpr const char *kTrackName = "status"; std::atomic g_running{true}; @@ -47,17 +46,6 @@ std::string getenvOrEmpty(const char *name) { return v ? std::string(v) : std::string{}; } -std::string randomHexId(std::size_t nbytes = 16) { - static thread_local std::mt19937_64 rng{std::random_device{}()}; - std::ostringstream oss; - for (std::size_t i = 0; i < nbytes; ++i) { - std::uint8_t b = static_cast(rng() & 0xFF); - const char *hex = "0123456789abcdef"; - oss << hex[(b >> 4) & 0xF] << hex[b & 0xF]; - } - return oss.str(); -} - } // namespace int main(int argc, char *argv[]) { @@ -105,8 +93,18 @@ int main(int argc, char *argv[]) { LK_LOG_INFO("producer connected as identity='{}' room='{}'", lp->identity(), room->room_info().name); - const std::string sender_id = - !lp->identity().empty() ? lp->identity() : std::string("producer"); + std::shared_ptr data_track; + try { + data_track = lp->publishDataTrack(kTrackName); + } catch (const std::exception &e) { + LK_LOG_ERROR("Failed to publish data track: {}", e.what()); + room->setDelegate(nullptr); + room.reset(); + livekit::shutdown(); + return 1; + } + + LK_LOG_INFO("published data track '{}'", kTrackName); using clock = std::chrono::steady_clock; const auto start = clock::now(); @@ -124,17 +122,9 @@ int main(int argc, char *argv[]) { const std::string text = std::string("[time-since-start]: ") + body.str() + " count: " + std::to_string(count); - try { - const std::string stream_id = randomHexId(); - std::map attrs; - const std::vector dest; - const std::string reply_to_id; - TextStreamWriter writer(*lp, kTopic, attrs, stream_id, text.size(), - reply_to_id, dest, sender_id); - writer.write(text); - writer.close(); - } catch (const std::exception &e) { - LK_LOG_ERROR("Failed to send status: {}", e.what()); + std::vector payload(text.begin(), text.end()); + if (!data_track->tryPush(payload)) { + LK_LOG_WARN("Failed to push data frame"); } LK_LOG_DEBUG("sent: {}", text); @@ -145,6 +135,7 @@ int main(int argc, char *argv[]) { } LK_LOG_INFO("shutting down"); + data_track->unpublishDataTrack(); room->setDelegate(nullptr); room.reset(); livekit::shutdown(); diff --git a/include/livekit/room.h b/include/livekit/room.h index 88b0cc96..8d43b249 100644 --- a/include/livekit/room.h +++ b/include/livekit/room.h @@ -300,38 +300,35 @@ class Room { TrackSource source); /** - * Set a callback for data frames from a specific remote participant's + * Add a callback for data frames from a specific remote participant's * data track. * - * The callback fires on a background thread whenever a new data frame is - * received. If the remote data track has not yet been published, the - * callback is stored and auto-wired when the track appears (via - * DataTrackPublished). + * Multiple callbacks may be registered for the same (participant, + * track_name) pair; each one creates an independent FFI subscription. * - * Data tracks are keyed by (participant_identity, track_name) rather - * than TrackSource, since data tracks don't have a TrackSource enum. - * - * Only one callback may exist per (participant, track_name) pair. - * Re-calling with the same pair replaces the previous callback. + * The callback fires on a dedicated background thread. If the remote + * data track has not yet been published, the callback is stored and + * auto-wired when the track appears (via DataTrackPublished). * * @param participant_identity Identity of the remote participant. * @param track_name Name of the remote data track. * @param callback Function to invoke per data frame. + * @return An opaque ID that can later be passed to + * removeOnDataFrameCallback() to tear down this subscription. */ - void setOnDataFrameCallback(const std::string &participant_identity, - const std::string &track_name, - DataFrameCallback callback); + DataFrameCallbackId addOnDataFrameCallback( + const std::string &participant_identity, + const std::string &track_name, DataFrameCallback callback); /** - * Clear the data frame callback for a specific (participant, track_name) - * pair. Stops and joins any active reader thread. - * No-op if no callback is registered for this key. + * Remove a data frame callback previously registered via + * addOnDataFrameCallback(). Stops and joins the active reader thread + * for this subscription. + * No-op if the ID is not (or no longer) registered. * - * @param participant_identity Identity of the remote participant. - * @param track_name Name of the remote data track. + * @param id The identifier returned by addOnDataFrameCallback(). */ - void clearOnDataFrameCallback(const std::string &participant_identity, - const std::string &track_name); + void removeOnDataFrameCallback(DataFrameCallbackId id); private: friend class RoomCallbackTest; diff --git a/src/room.cpp b/src/room.cpp index a47cfdff..3da0dffc 100644 --- a/src/room.cpp +++ b/src/room.cpp @@ -304,38 +304,36 @@ void Room::clearOnVideoFrameCallback(const std::string &participant_identity, } } -void Room::setOnDataFrameCallback(const std::string &participant_identity, - const std::string &track_name, - DataFrameCallback callback) { +DataFrameCallbackId +Room::addOnDataFrameCallback(const std::string &participant_identity, + const std::string &track_name, + DataFrameCallback callback) { std::thread old_thread; + DataFrameCallbackId id; { std::lock_guard lock(lock_); + id = next_data_callback_id_++; DataCallbackKey key{participant_identity, track_name}; - data_callbacks_[key] = std::move(callback); + data_callbacks_[id] = RegisteredDataCallback{key, std::move(callback)}; - auto pending_it = pending_remote_data_tracks_.find(key); - if (pending_it != pending_remote_data_tracks_.end()) { - auto track = std::move(pending_it->second); - pending_remote_data_tracks_.erase(pending_it); - auto cb_it = data_callbacks_.find(key); - if (cb_it != data_callbacks_.end()) { - old_thread = startDataReader(key, track, cb_it->second); - } + auto track_it = remote_data_tracks_.find(key); + if (track_it != remote_data_tracks_.end()) { + old_thread = startDataReader(id, key, track_it->second, + data_callbacks_[id].callback); } } if (old_thread.joinable()) { old_thread.join(); } + return id; } -void Room::clearOnDataFrameCallback(const std::string &participant_identity, - const std::string &track_name) { +void Room::removeOnDataFrameCallback(DataFrameCallbackId id) { std::thread old_thread; { std::lock_guard lock(lock_); - DataCallbackKey key{participant_identity, track_name}; - data_callbacks_.erase(key); - old_thread = extractDataReaderThread(key); + data_callbacks_.erase(id); + old_thread = extractDataReaderThread(id); } if (old_thread.joinable()) { old_thread.join(); @@ -375,10 +373,11 @@ std::thread Room::extractDataReaderThread(const DataCallbackKey &key) { return std::move(reader->thread); } -std::thread Room::startDataReader(const DataCallbackKey &key, +std::thread Room::startDataReader(DataFrameCallbackId id, + const DataCallbackKey &key, const std::shared_ptr &track, DataFrameCallback cb) { - auto old_thread = extractDataReaderThread(key); + auto old_thread = extractDataReaderThread(id); if (static_cast(active_readers_.size() + active_data_readers_.size()) >= kMaxActiveReaders) { @@ -434,7 +433,7 @@ std::thread Room::startDataReader(const DataCallbackKey &key, LK_LOG_INFO("[Room] Data reader thread exiting for \"{}\" track=\"{}\"", identity, track_name); }); - active_data_readers_[key] = reader; + active_data_readers_[id] = reader; return old_thread; } @@ -804,21 +803,24 @@ void Room::OnEvent(const FfiEvent &event) { remote_track->info().name, remote_track->publisherIdentity(), remote_track->info().sid); - // Auto-wire data callback if one is registered - std::thread old_thread; + std::vector old_threads; { std::lock_guard guard(lock_); DataCallbackKey key{remote_track->publisherIdentity(), remote_track->info().name}; - auto it = data_callbacks_.find(key); - if (it != data_callbacks_.end()) { - old_thread = startDataReader(key, remote_track, it->second); - } else { - pending_remote_data_tracks_[key] = remote_track; + remote_data_tracks_[key] = remote_track; + + for (auto &[id, reg] : data_callbacks_) { + if (reg.key == key) { + auto t = startDataReader(id, key, remote_track, reg.callback); + if (t.joinable()) { + old_threads.push_back(std::move(t)); + } + } } } - if (old_thread.joinable()) { - old_thread.join(); + for (auto &t : old_threads) { + t.join(); } DataTrackPublishedEvent ev; @@ -832,12 +834,11 @@ void Room::OnEvent(const FfiEvent &event) { const auto &dtu = re.data_track_unpublished(); LK_LOG_INFO("[Room] RoomEvent::kDataTrackUnpublished: sid={}", dtu.sid()); - // Tear down active data reader or remove pending track by SID - std::thread old_thread; + std::vector old_threads; { std::lock_guard guard(lock_); for (auto it = active_data_readers_.begin(); - it != active_data_readers_.end(); ++it) { + it != active_data_readers_.end();) { auto &reader = it->second; if (reader->remote_track && reader->remote_track->info().sid == dtu.sid()) { @@ -847,21 +848,24 @@ void Room::OnEvent(const FfiEvent &event) { reader->subscription->close(); } } - old_thread = std::move(reader->thread); - active_data_readers_.erase(it); - break; + if (reader->thread.joinable()) { + old_threads.push_back(std::move(reader->thread)); + } + it = active_data_readers_.erase(it); + } else { + ++it; } } - for (auto it = pending_remote_data_tracks_.begin(); - it != pending_remote_data_tracks_.end(); ++it) { + for (auto it = remote_data_tracks_.begin(); + it != remote_data_tracks_.end(); ++it) { if (it->second && it->second->info().sid == dtu.sid()) { - pending_remote_data_tracks_.erase(it); + remote_data_tracks_.erase(it); break; } } } - if (old_thread.joinable()) { - old_thread.join(); + for (auto &t : old_threads) { + t.join(); } DataTrackUnpublishedEvent ev; From f701056a7f07dffbd28a200a557237263eac6fc1 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Tue, 24 Mar 2026 20:44:35 -0600 Subject: [PATCH 15/34] hello_livekit example for a video track and data tracl --- examples/CMakeLists.txt | 15 ++ examples/hello_livekit/hello_livekit.cpp | 230 +++++++++++++++++++++++ src/data_track_subscription.cpp | 3 - 3 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 examples/hello_livekit/hello_livekit.cpp diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 4691ca74..73461a53 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -45,6 +45,7 @@ set(EXAMPLES_ALL SimpleDataStream SimpleStatusProducer SimpleStatusConsumer + HelloLivekit LoggingLevelsBasicUsage LoggingLevelsCustomSinks BridgeRobot @@ -270,6 +271,20 @@ target_link_libraries(SimpleStatusConsumer spdlog::spdlog ) +# --- hello_livekit (minimal synthetic video + data publish / subscribe) --- + +add_executable(HelloLivekit + hello_livekit/hello_livekit.cpp +) + +target_include_directories(HelloLivekit PRIVATE ${EXAMPLES_PRIVATE_INCLUDE_DIRS}) + +target_link_libraries(HelloLivekit + PRIVATE + livekit + spdlog::spdlog +) + # --- bridge_human_robot examples (robot + human; use livekit_bridge and SDL3) --- add_executable(BridgeRobot diff --git a/examples/hello_livekit/hello_livekit.cpp b/examples/hello_livekit/hello_livekit.cpp new file mode 100644 index 00000000..0f6bdd93 --- /dev/null +++ b/examples/hello_livekit/hello_livekit.cpp @@ -0,0 +1,230 @@ +/* + * Copyright 2025 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// Combined hello_livekit example: instantiates both a sender and a receiver +/// peer in the same process on separate threads. +/// +/// The sender publishes synthetic RGBA video and a data track. The receiver +/// subscribes and logs every 10th video frame plus every data message. +/// +/// Usage: +/// HelloLivekit +/// +/// Or via environment variables: +/// LIVEKIT_URL, LIVEKIT_SENDER_TOKEN, LIVEKIT_RECEIVER_TOKEN + +#include "livekit/livekit.h" + +#include +#include +#include +#include +#include +#include + +using namespace livekit; + +constexpr int kWidth = 640; +constexpr int kHeight = 480; +constexpr const char *kVideoTrackName = "camera0"; +constexpr const char *kDataTrackName = "app-data"; + +std::atomic g_running{true}; + +void handleSignal(int) { g_running.store(false); } + +std::string getenvOrEmpty(const char *name) { + const char *v = std::getenv(name); + return v ? std::string(v) : std::string{}; +} + +// --------------------------------------------------------------------------- +// Sender peer +// --------------------------------------------------------------------------- +void runSender(const std::string &url, const std::string &token, + std::atomic &sender_identity_out) { + auto room = std::make_unique(); + RoomOptions options; + options.auto_subscribe = true; + options.dynacast = false; + + if (!room->Connect(url, token, options)) { + LK_LOG_ERROR("[sender] Failed to connect"); + return; + } + + LocalParticipant *lp = room->localParticipant(); + assert(lp); + + auto *identity = new std::string(lp->identity()); + sender_identity_out.store(identity); + + LK_LOG_INFO("[sender] Connected as identity='{}' room='{}'", lp->identity(), + room->room_info().name); + + auto video_source = std::make_shared(kWidth, kHeight); + + std::shared_ptr video_track = lp->publishVideoTrack( + kVideoTrackName, video_source, TrackSource::SOURCE_CAMERA); + + std::shared_ptr data_track = + lp->publishDataTrack(kDataTrackName); + + const auto t0 = std::chrono::steady_clock::now(); + std::uint64_t count = 0; + + LK_LOG_INFO( + "[sender] Publishing synthetic video + data on '{}'; Ctrl-C to exit", + kDataTrackName); + + while (g_running.load()) { + VideoFrame vf = VideoFrame::create(kWidth, kHeight, VideoBufferType::RGBA); + video_source->captureFrame(std::move(vf)); + + const auto now = std::chrono::steady_clock::now(); + const double ms = + std::chrono::duration(now - t0).count(); + std::ostringstream oss; + oss << std::fixed << std::setprecision(2) << ms << " ms, count: " << count; + const std::string msg = oss.str(); + data_track->tryPush(std::vector(msg.begin(), msg.end())); + + ++count; + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + + LK_LOG_INFO("[sender] Disconnecting"); + room.reset(); +} + +// --------------------------------------------------------------------------- +// Receiver peer +// --------------------------------------------------------------------------- +void runReceiver(const std::string &url, const std::string &token, + std::atomic &sender_identity_out) { + // Wait for the sender to connect so we know its identity. + std::string sender_identity; + while (g_running.load()) { + std::string *id = sender_identity_out.load(); + if (id) { + sender_identity = *id; + break; + } + LK_LOG_INFO("[receiver] Waiting for sender to connect"); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + if (!g_running.load()) + return; + + auto room = std::make_unique(); + RoomOptions options; + options.auto_subscribe = true; + options.dynacast = false; + + if (!room->Connect(url, token, options)) { + LK_LOG_ERROR("[receiver] Failed to connect"); + return; + } + + LocalParticipant *lp = room->localParticipant(); + assert(lp); + + LK_LOG_INFO("[receiver] Connected as identity='{}' room='{}'; expecting " + "sender identity='{}'", + lp->identity(), room->room_info().name, sender_identity); + + // set the video frame callback for the sender's camera track + int video_frame_count = 0; + room->setOnVideoFrameCallback( + sender_identity, TrackSource::SOURCE_CAMERA, + [&video_frame_count](const VideoFrame &frame, std::int64_t timestamp_us) { + const auto ts_ms = + std::chrono::duration(timestamp_us).count(); + const int n = video_frame_count++; + if (n % 10 == 0) { + LK_LOG_INFO("[receiver] Video frame #{} {}x{} ts_ms={}", n, + frame.width(), frame.height(), ts_ms); + } + }); + + // Add a callback for the sender's data track. DataTracks can have mutliple + // subscribers to fan out callbacks + int data_frame_count = 0; + room->addOnDataFrameCallback( + sender_identity, kDataTrackName, + [&data_frame_count](const std::vector &payload, + std::optional user_ts) { + const int n = data_frame_count++; + if (n % 10 == 0) { + LK_LOG_INFO("[receiver] Data frame #{}", n); + } + }); + + LK_LOG_INFO( + "[receiver] Listening for camera + data track '{}'; Ctrl-C to exit", + kDataTrackName); + + while (g_running.load()) { + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + + LK_LOG_INFO("[receiver`] Shutting down"); + room.reset(); +} + +int main(int argc, char *argv[]) { + std::string url = getenvOrEmpty("LIVEKIT_URL"); + std::string sender_token = getenvOrEmpty("LIVEKIT_SENDER_TOKEN"); + std::string receiver_token = getenvOrEmpty("LIVEKIT_RECEIVER_TOKEN"); + + if (argc >= 4) { + url = argv[1]; + sender_token = argv[2]; + receiver_token = argv[3]; + } + + if (url.empty() || sender_token.empty() || receiver_token.empty()) { + LK_LOG_ERROR( + "Usage: HelloLivekit \n" + " or set LIVEKIT_URL, LIVEKIT_SENDER_TOKEN, LIVEKIT_RECEIVER_TOKEN"); + return 1; + } + + std::signal(SIGINT, handleSignal); +#ifdef SIGTERM + std::signal(SIGTERM, handleSignal); +#endif + + livekit::initialize(livekit::LogLevel::Info, livekit::LogSink::kConsole); + + // Shared: sender publishes its identity here so the receiver knows who + // to subscribe to (no hardcoded identity required). + std::atomic sender_identity{nullptr}; + + std::thread sender_thread(runSender, std::cref(url), std::cref(sender_token), + std::ref(sender_identity)); + std::thread receiver_thread(runReceiver, std::cref(url), + std::cref(receiver_token), + std::ref(sender_identity)); + + sender_thread.join(); + receiver_thread.join(); + + delete sender_identity.load(); + + livekit::shutdown(); + return 0; +} diff --git a/src/data_track_subscription.cpp b/src/data_track_subscription.cpp index db8ffede..6ae54b88 100644 --- a/src/data_track_subscription.cpp +++ b/src/data_track_subscription.cpp @@ -135,9 +135,6 @@ void DataTrackSubscription::onFfiEvent(const FfiEvent &event) { return; } - LK_LOG_INFO("[DataTrackSubscription] Received event for handle {}", - static_cast(subscription_handle_.get())); - if (dts.has_frame_received()) { const auto &fr = dts.frame_received().frame(); DataFrame frame; From 88a9cfb3272158f6e8dfcd528d5566f76bac01d4 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Wed, 25 Mar 2026 10:12:45 -0600 Subject: [PATCH 16/34] move data track callback functionality to subscription_thread_dispatcher --- include/livekit/room.h | 12 +- .../livekit/subscription_thread_dispatcher.h | 140 ++++++++- src/room.cpp | 189 +----------- src/subscription_thread_dispatcher.cpp | 245 +++++++++++++++- .../test_subscription_thread_dispatcher.cpp | 271 ++++++++++++++++++ 5 files changed, 659 insertions(+), 198 deletions(-) diff --git a/include/livekit/room.h b/include/livekit/room.h index 8d43b249..d2cc934e 100644 --- a/include/livekit/room.h +++ b/include/livekit/room.h @@ -18,7 +18,6 @@ #define LIVEKIT_ROOM_H #include "livekit/data_stream.h" -#include "livekit/data_track_subscription.h" #include "livekit/e2ee.h" #include "livekit/ffi_handle.h" #include "livekit/room_event_types.h" @@ -31,8 +30,6 @@ namespace livekit { class RoomDelegate; -class RemoteDataTrack; -class DataTrackSubscription; struct RoomInfoData; namespace proto { class FfiEvent; @@ -315,10 +312,13 @@ class Room { * @param callback Function to invoke per data frame. * @return An opaque ID that can later be passed to * removeOnDataFrameCallback() to tear down this subscription. + * If the subscription thread dispatcher is not available, returns + * std::numeric_limits::max(). */ - DataFrameCallbackId addOnDataFrameCallback( - const std::string &participant_identity, - const std::string &track_name, DataFrameCallback callback); + DataFrameCallbackId + addOnDataFrameCallback(const std::string &participant_identity, + const std::string &track_name, + DataFrameCallback callback); /** * Remove a data frame callback previously registered via diff --git a/include/livekit/subscription_thread_dispatcher.h b/include/livekit/subscription_thread_dispatcher.h index 3e843541..d9d4f232 100644 --- a/include/livekit/subscription_thread_dispatcher.h +++ b/include/livekit/subscription_thread_dispatcher.h @@ -24,13 +24,17 @@ #include #include #include +#include #include #include #include +#include namespace livekit { class AudioFrame; +class DataTrackSubscription; +class RemoteDataTrack; class Track; class VideoFrame; @@ -43,6 +47,18 @@ using AudioFrameCallback = std::function; using VideoFrameCallback = std::function; +/// Callback type for incoming data track frames. +/// Invoked on a dedicated reader thread per subscription. +/// @param payload Raw binary data received. +/// @param user_timestamp Optional application-defined timestamp from sender. +using DataFrameCallback = + std::function &payload, + std::optional user_timestamp)>; + +/// Opaque identifier returned by addOnDataFrameCallback, used to remove an +/// individual subscription via removeOnDataFrameCallback. +using DataFrameCallbackId = std::uint64_t; + /** * Owns subscription callback registration and per-subscription reader threads. * @@ -163,6 +179,63 @@ class SubscriptionThreadDispatcher { void handleTrackUnsubscribed(const std::string &participant_identity, TrackSource source); + // --------------------------------------------------------------- + // Data track callbacks + // --------------------------------------------------------------- + + /** + * Add a callback for data frames from a specific remote participant's + * data track. + * + * Multiple callbacks may be registered for the same (participant, + * track_name) pair; each one creates an independent FFI subscription. + * + * The callback fires on a dedicated background thread. If the remote + * data track has not yet been published, the callback is stored and + * auto-wired when the track appears (via handleDataTrackPublished). + * + * @param participant_identity Identity of the remote participant. + * @param track_name Name of the remote data track. + * @param callback Function to invoke per data frame. + * @return An opaque ID that can later be passed to + * removeOnDataFrameCallback() to tear down this subscription. + */ + DataFrameCallbackId + addOnDataFrameCallback(const std::string &participant_identity, + const std::string &track_name, + DataFrameCallback callback); + + /** + * Remove a data frame callback previously registered via + * addOnDataFrameCallback(). Stops and joins the active reader thread + * for this subscription. + * No-op if the ID is not (or no longer) registered. + * + * @param id The identifier returned by addOnDataFrameCallback(). + */ + void removeOnDataFrameCallback(DataFrameCallbackId id); + + /** + * Notify the dispatcher that a remote data track has been published. + * + * \ref Room calls this when it receives a kDataTrackPublished event. + * For every registered callback whose (participant, track_name) matches, + * a reader thread is launched. + * + * @param track The newly published remote data track. + */ + void handleDataTrackPublished(const std::shared_ptr &track); + + /** + * Notify the dispatcher that a remote data track has been unpublished. + * + * \ref Room calls this when it receives a kDataTrackUnpublished event. + * Any active data reader threads for this track SID are closed and joined. + * + * @param sid The SID of the unpublished data track. + */ + void handleDataTrackUnpublished(const std::string &sid); + /** * Stop all readers and clear all callback registrations. * @@ -194,13 +267,47 @@ class SubscriptionThreadDispatcher { } }; - /// Active read-side resources for one subscription dispatch slot. + /// Active read-side resources for one audio/video subscription dispatch slot. struct ActiveReader { std::shared_ptr audio_stream; std::shared_ptr video_stream; std::thread thread; }; + /// Compound lookup key for a remote participant identity and data track name. + struct DataCallbackKey { + std::string participant_identity; + std::string track_name; + + bool operator==(const DataCallbackKey &o) const { + return participant_identity == o.participant_identity && + track_name == o.track_name; + } + }; + + /// Hash function for \ref DataCallbackKey. + struct DataCallbackKeyHash { + std::size_t operator()(const DataCallbackKey &k) const { + auto h1 = std::hash{}(k.participant_identity); + auto h2 = std::hash{}(k.track_name); + return h1 ^ (h2 << 1); + } + }; + + /// Stored data callback registration. + struct RegisteredDataCallback { + DataCallbackKey key; + DataFrameCallback callback; + }; + + /// Active read-side resources for one data track subscription. + struct ActiveDataReader { + std::shared_ptr remote_track; + std::mutex sub_mutex; + std::shared_ptr subscription; // guarded by sub_mutex + std::thread thread; + }; + /// Stored audio callback registration plus stream-construction options. struct RegisteredAudioCallback { AudioFrameCallback callback; @@ -243,6 +350,21 @@ class SubscriptionThreadDispatcher { VideoFrameCallback cb, const VideoStream::Options &opts); + /// Extract and close the data reader for a given callback ID, returning its + /// thread. Must be called with \ref lock_ held. + std::thread extractDataReaderThreadLocked(DataFrameCallbackId id); + + /// Extract and close the data reader for a given (participant, track_name) + /// key, returning its thread. Must be called with \ref lock_ held. + std::thread extractDataReaderThreadLocked(const DataCallbackKey &key); + + /// Start a data reader thread for the given callback ID, key, and track. + /// Must be called with \ref lock_ held. + std::thread + startDataReaderLocked(DataFrameCallbackId id, const DataCallbackKey &key, + const std::shared_ptr &track, + DataFrameCallback cb); + /// Protects callback registration maps and active reader state. mutable std::mutex lock_; @@ -258,6 +380,22 @@ class SubscriptionThreadDispatcher { std::unordered_map active_readers_; + /// Next auto-increment ID for data frame callbacks. + DataFrameCallbackId next_data_callback_id_; + + /// Registered data frame callbacks keyed by opaque callback ID. + std::unordered_map + data_callbacks_; + + /// Active data reader threads keyed by callback ID. + std::unordered_map> + active_data_readers_; + + /// Currently published remote data tracks, keyed by (participant, name). + std::unordered_map, + DataCallbackKeyHash> + remote_data_tracks_; + /// Hard limit on concurrently active per-subscription reader threads. static constexpr int kMaxActiveReaders = 20; }; diff --git a/src/room.cpp b/src/room.cpp index 3da0dffc..88ca39bf 100644 --- a/src/room.cpp +++ b/src/room.cpp @@ -17,8 +17,6 @@ #include "livekit/room.h" #include "livekit/audio_stream.h" -#include "livekit/data_frame.h" -#include "livekit/data_track_subscription.h" #include "livekit/e2ee.h" #include "livekit/local_data_track.h" #include "livekit/local_participant.h" @@ -308,133 +306,17 @@ DataFrameCallbackId Room::addOnDataFrameCallback(const std::string &participant_identity, const std::string &track_name, DataFrameCallback callback) { - std::thread old_thread; - DataFrameCallbackId id; - { - std::lock_guard lock(lock_); - id = next_data_callback_id_++; - DataCallbackKey key{participant_identity, track_name}; - data_callbacks_[id] = RegisteredDataCallback{key, std::move(callback)}; - - auto track_it = remote_data_tracks_.find(key); - if (track_it != remote_data_tracks_.end()) { - old_thread = startDataReader(id, key, track_it->second, - data_callbacks_[id].callback); - } - } - if (old_thread.joinable()) { - old_thread.join(); + if (subscription_thread_dispatcher_) { + return subscription_thread_dispatcher_->addOnDataFrameCallback( + participant_identity, track_name, std::move(callback)); } - return id; + return std::numeric_limits::max(); } void Room::removeOnDataFrameCallback(DataFrameCallbackId id) { - std::thread old_thread; - { - std::lock_guard lock(lock_); - data_callbacks_.erase(id); - old_thread = extractDataReaderThread(id); - } - if (old_thread.joinable()) { - old_thread.join(); - } -} - -std::thread Room::extractReaderThread(const CallbackKey &key) { - auto it = active_readers_.find(key); - if (it == active_readers_.end()) { - return {}; - } - ActiveReader reader = std::move(it->second); - active_readers_.erase(it); - - if (reader.audio_stream) { - reader.audio_stream->close(); - } - if (reader.video_stream) { - reader.video_stream->close(); - } - return std::move(reader.thread); -} - -std::thread Room::extractDataReaderThread(const DataCallbackKey &key) { - auto it = active_data_readers_.find(key); - if (it == active_data_readers_.end()) { - return {}; - } - auto reader = std::move(it->second); - active_data_readers_.erase(it); - { - std::lock_guard guard(reader->sub_mutex); - if (reader->subscription) { - reader->subscription->close(); - } + if (subscription_thread_dispatcher_) { + subscription_thread_dispatcher_->removeOnDataFrameCallback(id); } - return std::move(reader->thread); -} - -std::thread Room::startDataReader(DataFrameCallbackId id, - const DataCallbackKey &key, - const std::shared_ptr &track, - DataFrameCallback cb) { - auto old_thread = extractDataReaderThread(id); - - if (static_cast(active_readers_.size() + active_data_readers_.size()) >= - kMaxActiveReaders) { - LK_LOG_ERROR("Cannot start data reader for {} track={}: active reader " - "limit ({}) reached", - key.participant_identity, key.track_name, kMaxActiveReaders); - return old_thread; - } - - LK_LOG_INFO("[Room] Starting data reader for \"{}\" track=\"{}\"", - key.participant_identity, key.track_name); - - // subscribe() is async over FFI — it sends a request and blocks on a future - // whose response arrives via LivekitFfiCallback. If we are already on the - // FFI callback thread (e.g. inside kDataTrackPublished handling), blocking - // here would deadlock. The reader thread performs subscribe() + read loop - // so the callback thread is never blocked. - auto reader = std::make_shared(); - reader->remote_track = track; - auto identity = key.participant_identity; - auto track_name = key.track_name; - reader->thread = std::thread([reader, track, cb, identity, track_name]() { - LK_LOG_INFO("[Room] Data reader thread: subscribing to \"{}\" " - "track=\"{}\"", - identity, track_name); - std::shared_ptr subscription; - try { - subscription = track->subscribe(); - } catch (const std::exception &e) { - LK_LOG_ERROR("Failed to subscribe to data track \"{}\" from \"{}\": {}", - track_name, identity, e.what()); - return; - } - LK_LOG_INFO("[Room] Data reader thread: subscribed to \"{}\" track=\"{}\"", - identity, track_name); - - { - std::lock_guard guard(reader->sub_mutex); - reader->subscription = subscription; - } - - LK_LOG_INFO("[Room] Data reader thread: entering read loop for \"{}\" " - "track=\"{}\"", - identity, track_name); - DataFrame frame; - while (subscription->read(frame)) { - try { - cb(frame.payload, frame.user_timestamp); - } catch (const std::exception &e) { - LK_LOG_ERROR("Data frame callback exception: {}", e.what()); - } - } - LK_LOG_INFO("[Room] Data reader thread exiting for \"{}\" track=\"{}\"", - identity, track_name); - }); - active_data_readers_[id] = reader; - return old_thread; } void Room::OnEvent(const FfiEvent &event) { @@ -798,29 +680,9 @@ void Room::OnEvent(const FfiEvent &event) { const auto &rdtp = re.data_track_published(); auto remote_track = std::shared_ptr(new RemoteDataTrack(rdtp.track())); - LK_LOG_INFO("[Room] RoomEvent::kDataTrackPublished: \"{}\" from " - "\"{}\" (sid={})", - remote_track->info().name, remote_track->publisherIdentity(), - remote_track->info().sid); - std::vector old_threads; - { - std::lock_guard guard(lock_); - DataCallbackKey key{remote_track->publisherIdentity(), - remote_track->info().name}; - remote_data_tracks_[key] = remote_track; - - for (auto &[id, reg] : data_callbacks_) { - if (reg.key == key) { - auto t = startDataReader(id, key, remote_track, reg.callback); - if (t.joinable()) { - old_threads.push_back(std::move(t)); - } - } - } - } - for (auto &t : old_threads) { - t.join(); + if (subscription_thread_dispatcher_) { + subscription_thread_dispatcher_->handleDataTrackPublished(remote_track); } DataTrackPublishedEvent ev; @@ -832,40 +694,9 @@ void Room::OnEvent(const FfiEvent &event) { } case proto::RoomEvent::kDataTrackUnpublished: { const auto &dtu = re.data_track_unpublished(); - LK_LOG_INFO("[Room] RoomEvent::kDataTrackUnpublished: sid={}", dtu.sid()); - std::vector old_threads; - { - std::lock_guard guard(lock_); - for (auto it = active_data_readers_.begin(); - it != active_data_readers_.end();) { - auto &reader = it->second; - if (reader->remote_track && - reader->remote_track->info().sid == dtu.sid()) { - { - std::lock_guard sub_guard(reader->sub_mutex); - if (reader->subscription) { - reader->subscription->close(); - } - } - if (reader->thread.joinable()) { - old_threads.push_back(std::move(reader->thread)); - } - it = active_data_readers_.erase(it); - } else { - ++it; - } - } - for (auto it = remote_data_tracks_.begin(); - it != remote_data_tracks_.end(); ++it) { - if (it->second && it->second->info().sid == dtu.sid()) { - remote_data_tracks_.erase(it); - break; - } - } - } - for (auto &t : old_threads) { - t.join(); + if (subscription_thread_dispatcher_) { + subscription_thread_dispatcher_->handleDataTrackUnpublished(dtu.sid()); } DataTrackUnpublishedEvent ev; diff --git a/src/subscription_thread_dispatcher.cpp b/src/subscription_thread_dispatcher.cpp index a7f9a2a7..8b764e20 100644 --- a/src/subscription_thread_dispatcher.cpp +++ b/src/subscription_thread_dispatcher.cpp @@ -16,7 +16,10 @@ #include "livekit/subscription_thread_dispatcher.h" +#include "livekit/data_frame.h" +#include "livekit/data_track_subscription.h" #include "livekit/lk_log.h" +#include "livekit/remote_data_track.h" #include "livekit/track.h" #include @@ -40,7 +43,8 @@ const char *trackKindName(TrackKind kind) { } // namespace -SubscriptionThreadDispatcher::SubscriptionThreadDispatcher() = default; +SubscriptionThreadDispatcher::SubscriptionThreadDispatcher() + : next_data_callback_id_(1) {} SubscriptionThreadDispatcher::~SubscriptionThreadDispatcher() { LK_LOG_DEBUG("Destroying SubscriptionThreadDispatcher"); @@ -158,23 +162,127 @@ void SubscriptionThreadDispatcher::handleTrackUnsubscribed( } } +// ------------------------------------------------------------------- +// Data track callback registration +// ------------------------------------------------------------------- + +DataFrameCallbackId SubscriptionThreadDispatcher::addOnDataFrameCallback( + const std::string &participant_identity, const std::string &track_name, + DataFrameCallback callback) { + std::thread old_thread; + DataFrameCallbackId id; + { + std::lock_guard lock(lock_); + id = next_data_callback_id_++; + DataCallbackKey key{participant_identity, track_name}; + data_callbacks_[id] = RegisteredDataCallback{key, std::move(callback)}; + + auto track_it = remote_data_tracks_.find(key); + if (track_it != remote_data_tracks_.end()) { + old_thread = startDataReaderLocked(id, key, track_it->second, + data_callbacks_[id].callback); + } + } + if (old_thread.joinable()) { + old_thread.join(); + } + return id; +} + +void SubscriptionThreadDispatcher::removeOnDataFrameCallback( + DataFrameCallbackId id) { + std::thread old_thread; + { + std::lock_guard lock(lock_); + data_callbacks_.erase(id); + old_thread = extractDataReaderThreadLocked(id); + } + if (old_thread.joinable()) { + old_thread.join(); + } +} + +void SubscriptionThreadDispatcher::handleDataTrackPublished( + const std::shared_ptr &track) { + if (!track) { + LK_LOG_WARN("handleDataTrackPublished called with null track"); + return; + } + + LK_LOG_INFO("Handling data track published: \"{}\" from \"{}\" (sid={})", + track->info().name, track->publisherIdentity(), + track->info().sid); + + std::vector old_threads; + { + std::lock_guard lock(lock_); + DataCallbackKey key{track->publisherIdentity(), track->info().name}; + remote_data_tracks_[key] = track; + + for (auto &[id, reg] : data_callbacks_) { + if (reg.key == key) { + auto t = startDataReaderLocked(id, key, track, reg.callback); + if (t.joinable()) { + old_threads.push_back(std::move(t)); + } + } + } + } + for (auto &t : old_threads) { + t.join(); + } +} + +void SubscriptionThreadDispatcher::handleDataTrackUnpublished( + const std::string &sid) { + LK_LOG_INFO("Handling data track unpublished: sid={}", sid); + + std::vector old_threads; + { + std::lock_guard lock(lock_); + for (auto it = active_data_readers_.begin(); + it != active_data_readers_.end();) { + auto &reader = it->second; + if (reader->remote_track && reader->remote_track->info().sid == sid) { + { + std::lock_guard sub_guard(reader->sub_mutex); + if (reader->subscription) { + reader->subscription->close(); + } + } + if (reader->thread.joinable()) { + old_threads.push_back(std::move(reader->thread)); + } + it = active_data_readers_.erase(it); + } else { + ++it; + } + } + for (auto it = remote_data_tracks_.begin(); it != remote_data_tracks_.end(); + ++it) { + if (it->second && it->second->info().sid == sid) { + remote_data_tracks_.erase(it); + break; + } + } + } + for (auto &t : old_threads) { + t.join(); + } +} + void SubscriptionThreadDispatcher::stopAll() { std::vector threads; - std::size_t active_reader_count = 0; - std::size_t audio_callback_count = 0; - std::size_t video_callback_count = 0; { std::lock_guard lock(lock_); - active_reader_count = active_readers_.size(); - audio_callback_count = audio_callbacks_.size(); - video_callback_count = video_callbacks_.size(); LK_LOG_DEBUG("Stopping all subscription readers active_readers={} " - "audio_callbacks={} video_callbacks={}", - active_reader_count, audio_callback_count, - video_callback_count); + "active_data_readers={} audio_callbacks={} " + "video_callbacks={} data_callbacks={}", + active_readers_.size(), active_data_readers_.size(), + audio_callbacks_.size(), video_callbacks_.size(), + data_callbacks_.size()); + for (auto &[key, reader] : active_readers_) { - LK_LOG_TRACE("Closing active reader for participant={} source={}", - key.participant_identity, static_cast(key.source)); if (reader.audio_stream) { reader.audio_stream->close(); } @@ -188,6 +296,21 @@ void SubscriptionThreadDispatcher::stopAll() { active_readers_.clear(); audio_callbacks_.clear(); video_callbacks_.clear(); + + for (auto &[id, reader] : active_data_readers_) { + { + std::lock_guard sub_guard(reader->sub_mutex); + if (reader->subscription) { + reader->subscription->close(); + } + } + if (reader->thread.joinable()) { + threads.push_back(std::move(reader->thread)); + } + } + active_data_readers_.clear(); + data_callbacks_.clear(); + remote_data_tracks_.clear(); } for (auto &thread : threads) { thread.join(); @@ -359,4 +482,102 @@ std::thread SubscriptionThreadDispatcher::startVideoReaderLocked( return old_thread; } +// ------------------------------------------------------------------- +// Data track reader helpers +// ------------------------------------------------------------------- + +std::thread SubscriptionThreadDispatcher::extractDataReaderThreadLocked( + DataFrameCallbackId id) { + auto it = active_data_readers_.find(id); + if (it == active_data_readers_.end()) { + return {}; + } + auto reader = std::move(it->second); + active_data_readers_.erase(it); + { + std::lock_guard guard(reader->sub_mutex); + if (reader->subscription) { + reader->subscription->close(); + } + } + return std::move(reader->thread); +} + +std::thread SubscriptionThreadDispatcher::extractDataReaderThreadLocked( + const DataCallbackKey &key) { + for (auto it = active_data_readers_.begin(); it != active_data_readers_.end(); + ++it) { + if (it->second && it->second->remote_track && + it->second->remote_track->publisherIdentity() == + key.participant_identity && + it->second->remote_track->info().name == key.track_name) { + auto reader = std::move(it->second); + active_data_readers_.erase(it); + { + std::lock_guard guard(reader->sub_mutex); + if (reader->subscription) { + reader->subscription->close(); + } + } + return std::move(reader->thread); + } + } + return {}; +} + +std::thread SubscriptionThreadDispatcher::startDataReaderLocked( + DataFrameCallbackId id, const DataCallbackKey &key, + const std::shared_ptr &track, DataFrameCallback cb) { + auto old_thread = extractDataReaderThreadLocked(id); + + int total_active = static_cast(active_readers_.size()) + + static_cast(active_data_readers_.size()); + if (total_active >= kMaxActiveReaders) { + LK_LOG_ERROR("Cannot start data reader for {} track={}: active reader " + "limit ({}) reached", + key.participant_identity, key.track_name, kMaxActiveReaders); + return old_thread; + } + + LK_LOG_INFO("Starting data reader for \"{}\" track=\"{}\"", + key.participant_identity, key.track_name); + + auto reader = std::make_shared(); + reader->remote_track = track; + auto identity = key.participant_identity; + auto track_name = key.track_name; + reader->thread = std::thread([reader, track, cb, identity, track_name]() { + LK_LOG_INFO("Data reader thread: subscribing to \"{}\" track=\"{}\"", + identity, track_name); + std::shared_ptr subscription; + try { + subscription = track->subscribe(); + } catch (const std::exception &e) { + LK_LOG_ERROR("Failed to subscribe to data track \"{}\" from \"{}\": {}", + track_name, identity, e.what()); + return; + } + LK_LOG_INFO("Data reader thread: subscribed to \"{}\" track=\"{}\"", + identity, track_name); + + { + std::lock_guard guard(reader->sub_mutex); + reader->subscription = subscription; + } + + DataFrame frame; + while (subscription->read(frame)) { + try { + cb(frame.payload, frame.user_timestamp); + } catch (const std::exception &e) { + LK_LOG_ERROR("Data frame callback exception: {}", e.what()); + } + } + LK_LOG_INFO("Data reader thread exiting for \"{}\" track=\"{}\"", identity, + track_name); + }); + active_data_readers_[id] = reader; + return old_thread; +} + } // namespace livekit diff --git a/src/tests/integration/test_subscription_thread_dispatcher.cpp b/src/tests/integration/test_subscription_thread_dispatcher.cpp index 71601a18..45e2bb11 100644 --- a/src/tests/integration/test_subscription_thread_dispatcher.cpp +++ b/src/tests/integration/test_subscription_thread_dispatcher.cpp @@ -36,6 +36,8 @@ class SubscriptionThreadDispatcherTest : public ::testing::Test { using CallbackKey = SubscriptionThreadDispatcher::CallbackKey; using CallbackKeyHash = SubscriptionThreadDispatcher::CallbackKeyHash; + using DataCallbackKey = SubscriptionThreadDispatcher::DataCallbackKey; + using DataCallbackKeyHash = SubscriptionThreadDispatcher::DataCallbackKeyHash; static auto &audioCallbacks(SubscriptionThreadDispatcher &dispatcher) { return dispatcher.audio_callbacks_; @@ -46,6 +48,15 @@ class SubscriptionThreadDispatcherTest : public ::testing::Test { static auto &activeReaders(SubscriptionThreadDispatcher &dispatcher) { return dispatcher.active_readers_; } + static auto &dataCallbacks(SubscriptionThreadDispatcher &dispatcher) { + return dispatcher.data_callbacks_; + } + static auto &activeDataReaders(SubscriptionThreadDispatcher &dispatcher) { + return dispatcher.active_data_readers_; + } + static auto &remoteDataTracks(SubscriptionThreadDispatcher &dispatcher) { + return dispatcher.remote_data_tracks_; + } static int maxActiveReaders() { return SubscriptionThreadDispatcher::kMaxActiveReaders; } @@ -380,4 +391,264 @@ TEST_F(SubscriptionThreadDispatcherTest, ManyDistinctCallbacksCanBeRegistered) { EXPECT_EQ(audioCallbacks(dispatcher).size(), 0u); } +// ============================================================================ +// DataCallbackKey equality +// ============================================================================ + +TEST_F(SubscriptionThreadDispatcherTest, DataCallbackKeyEqualKeysCompareEqual) { + DataCallbackKey a{"alice", "my-track"}; + DataCallbackKey b{"alice", "my-track"}; + EXPECT_TRUE(a == b); +} + +TEST_F(SubscriptionThreadDispatcherTest, + DataCallbackKeyDifferentIdentityNotEqual) { + DataCallbackKey a{"alice", "my-track"}; + DataCallbackKey b{"bob", "my-track"}; + EXPECT_FALSE(a == b); +} + +TEST_F(SubscriptionThreadDispatcherTest, + DataCallbackKeyDifferentTrackNameNotEqual) { + DataCallbackKey a{"alice", "track-a"}; + DataCallbackKey b{"alice", "track-b"}; + EXPECT_FALSE(a == b); +} + +// ============================================================================ +// DataCallbackKeyHash +// ============================================================================ + +TEST_F(SubscriptionThreadDispatcherTest, + DataCallbackKeyHashEqualKeysProduceSameHash) { + DataCallbackKey a{"alice", "my-track"}; + DataCallbackKey b{"alice", "my-track"}; + DataCallbackKeyHash hasher; + EXPECT_EQ(hasher(a), hasher(b)); +} + +TEST_F(SubscriptionThreadDispatcherTest, + DataCallbackKeyHashDifferentKeysLikelyDifferentHash) { + DataCallbackKeyHash hasher; + DataCallbackKey a{"alice", "track-a"}; + DataCallbackKey b{"alice", "track-b"}; + DataCallbackKey c{"bob", "track-a"}; + EXPECT_NE(hasher(a), hasher(b)); + EXPECT_NE(hasher(a), hasher(c)); +} + +TEST_F(SubscriptionThreadDispatcherTest, + DataCallbackKeyWorksAsUnorderedMapKey) { + std::unordered_map map; + + DataCallbackKey k1{"alice", "track-a"}; + DataCallbackKey k2{"bob", "track-b"}; + DataCallbackKey k3{"alice", "track-b"}; + + map[k1] = 1; + map[k2] = 2; + map[k3] = 3; + + EXPECT_EQ(map.size(), 3u); + EXPECT_EQ(map[k1], 1); + EXPECT_EQ(map[k2], 2); + EXPECT_EQ(map[k3], 3); + + map[k1] = 42; + EXPECT_EQ(map[k1], 42); + EXPECT_EQ(map.size(), 3u); + + map.erase(k2); + EXPECT_EQ(map.size(), 2u); + EXPECT_EQ(map.count(k2), 0u); +} + +// ============================================================================ +// Data callback registration and clearing +// ============================================================================ + +TEST_F(SubscriptionThreadDispatcherTest, + AddDataFrameCallbackStoresRegistration) { + SubscriptionThreadDispatcher dispatcher; + auto id = dispatcher.addOnDataFrameCallback( + "alice", "my-track", + [](const std::vector &, std::optional) {}); + + EXPECT_NE(id, 0u); + EXPECT_EQ(dataCallbacks(dispatcher).size(), 1u); +} + +TEST_F(SubscriptionThreadDispatcherTest, + RemoveDataFrameCallbackRemovesRegistration) { + SubscriptionThreadDispatcher dispatcher; + auto id = dispatcher.addOnDataFrameCallback( + "alice", "my-track", + [](const std::vector &, std::optional) {}); + ASSERT_EQ(dataCallbacks(dispatcher).size(), 1u); + + dispatcher.removeOnDataFrameCallback(id); + EXPECT_EQ(dataCallbacks(dispatcher).size(), 0u); +} + +TEST_F(SubscriptionThreadDispatcherTest, + RemoveNonExistentDataCallbackIsNoOp) { + SubscriptionThreadDispatcher dispatcher; + EXPECT_NO_THROW(dispatcher.removeOnDataFrameCallback(999)); +} + +TEST_F(SubscriptionThreadDispatcherTest, + MultipleDataCallbacksForSameKeyAreIndependent) { + SubscriptionThreadDispatcher dispatcher; + auto cb = [](const std::vector &, + std::optional) {}; + auto id1 = dispatcher.addOnDataFrameCallback("alice", "track", cb); + auto id2 = dispatcher.addOnDataFrameCallback("alice", "track", cb); + + EXPECT_NE(id1, id2); + EXPECT_EQ(dataCallbacks(dispatcher).size(), 2u); + + dispatcher.removeOnDataFrameCallback(id1); + EXPECT_EQ(dataCallbacks(dispatcher).size(), 1u); +} + +TEST_F(SubscriptionThreadDispatcherTest, + DataCallbackIdsAreMonotonicallyIncreasing) { + SubscriptionThreadDispatcher dispatcher; + auto cb = [](const std::vector &, + std::optional) {}; + auto id1 = dispatcher.addOnDataFrameCallback("alice", "t1", cb); + auto id2 = dispatcher.addOnDataFrameCallback("bob", "t2", cb); + auto id3 = dispatcher.addOnDataFrameCallback("carol", "t3", cb); + + EXPECT_LT(id1, id2); + EXPECT_LT(id2, id3); +} + +// ============================================================================ +// Data track active readers (no real tracks, just map state) +// ============================================================================ + +TEST_F(SubscriptionThreadDispatcherTest, NoActiveDataReadersInitially) { + SubscriptionThreadDispatcher dispatcher; + EXPECT_TRUE(activeDataReaders(dispatcher).empty()); +} + +TEST_F(SubscriptionThreadDispatcherTest, + ActiveDataReadersEmptyAfterCallbackRegistration) { + SubscriptionThreadDispatcher dispatcher; + dispatcher.addOnDataFrameCallback( + "alice", "my-track", + [](const std::vector &, std::optional) {}); + EXPECT_TRUE(activeDataReaders(dispatcher).empty()) + << "Registering a callback without a published track should not spawn " + "readers"; +} + +TEST_F(SubscriptionThreadDispatcherTest, NoRemoteDataTracksInitially) { + SubscriptionThreadDispatcher dispatcher; + EXPECT_TRUE(remoteDataTracks(dispatcher).empty()); +} + +// ============================================================================ +// Data track destruction safety +// ============================================================================ + +TEST_F(SubscriptionThreadDispatcherTest, + DestroyDispatcherWithDataCallbacksIsSafe) { + EXPECT_NO_THROW({ + SubscriptionThreadDispatcher dispatcher; + dispatcher.addOnDataFrameCallback( + "alice", "track-a", + [](const std::vector &, + std::optional) {}); + dispatcher.addOnDataFrameCallback( + "bob", "track-b", + [](const std::vector &, + std::optional) {}); + }); +} + +TEST_F(SubscriptionThreadDispatcherTest, + DestroyDispatcherAfterRemovingDataCallbacksIsSafe) { + EXPECT_NO_THROW({ + SubscriptionThreadDispatcher dispatcher; + auto id = dispatcher.addOnDataFrameCallback( + "alice", "track-a", + [](const std::vector &, + std::optional) {}); + dispatcher.removeOnDataFrameCallback(id); + }); +} + +// ============================================================================ +// Mixed audio/video/data registration +// ============================================================================ + +TEST_F(SubscriptionThreadDispatcherTest, + MixedAudioVideoDataCallbacksAreIndependent) { + SubscriptionThreadDispatcher dispatcher; + dispatcher.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, + [](const AudioFrame &) {}); + dispatcher.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, + [](const VideoFrame &, std::int64_t) {}); + dispatcher.addOnDataFrameCallback( + "alice", "data-track", + [](const std::vector &, std::optional) {}); + + EXPECT_EQ(audioCallbacks(dispatcher).size(), 1u); + EXPECT_EQ(videoCallbacks(dispatcher).size(), 1u); + EXPECT_EQ(dataCallbacks(dispatcher).size(), 1u); +} + +TEST_F(SubscriptionThreadDispatcherTest, + StopAllClearsDataCallbacksAndReaders) { + SubscriptionThreadDispatcher dispatcher; + dispatcher.addOnDataFrameCallback( + "alice", "track-a", + [](const std::vector &, std::optional) {}); + dispatcher.addOnDataFrameCallback( + "bob", "track-b", + [](const std::vector &, std::optional) {}); + + dispatcher.stopAll(); + + EXPECT_EQ(dataCallbacks(dispatcher).size(), 0u); + EXPECT_TRUE(activeDataReaders(dispatcher).empty()); + EXPECT_TRUE(remoteDataTracks(dispatcher).empty()); +} + +// ============================================================================ +// Concurrent data callback registration +// ============================================================================ + +TEST_F(SubscriptionThreadDispatcherTest, + ConcurrentDataCallbackRegistrationDoesNotCrash) { + SubscriptionThreadDispatcher dispatcher; + constexpr int kThreads = 8; + constexpr int kIterations = 100; + + std::vector threads; + threads.reserve(kThreads); + + for (int t = 0; t < kThreads; ++t) { + threads.emplace_back([&dispatcher, t]() { + for (int i = 0; i < kIterations; ++i) { + auto id = dispatcher.addOnDataFrameCallback( + "participant-" + std::to_string(t), "track", + [](const std::vector &, + std::optional) {}); + dispatcher.removeOnDataFrameCallback(id); + } + }); + } + + for (auto &thread : threads) { + thread.join(); + } + + EXPECT_TRUE(dataCallbacks(dispatcher).empty()) + << "All data callbacks should be cleared after concurrent " + "register/remove"; +} + } // namespace livekit From e5c4362d3681cd3cc9971037a11dd015ec58610b Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Wed, 25 Mar 2026 14:29:43 -0600 Subject: [PATCH 17/34] hello_livekit: sender.cpp/receiver.cpp --- examples/CMakeLists.txt | 23 ++- examples/hello_livekit/hello_livekit.cpp | 230 ----------------------- examples/hello_livekit/receiver.cpp | 129 +++++++++++++ examples/hello_livekit/sender.cpp | 129 +++++++++++++ 4 files changed, 276 insertions(+), 235 deletions(-) delete mode 100644 examples/hello_livekit/hello_livekit.cpp create mode 100644 examples/hello_livekit/receiver.cpp create mode 100644 examples/hello_livekit/sender.cpp diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 73461a53..da98cb33 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -45,7 +45,8 @@ set(EXAMPLES_ALL SimpleDataStream SimpleStatusProducer SimpleStatusConsumer - HelloLivekit + HelloLivekitSender + HelloLivekitReceiver LoggingLevelsBasicUsage LoggingLevelsCustomSinks BridgeRobot @@ -273,13 +274,25 @@ target_link_libraries(SimpleStatusConsumer # --- hello_livekit (minimal synthetic video + data publish / subscribe) --- -add_executable(HelloLivekit - hello_livekit/hello_livekit.cpp +add_executable(HelloLivekitSender + hello_livekit/sender.cpp ) -target_include_directories(HelloLivekit PRIVATE ${EXAMPLES_PRIVATE_INCLUDE_DIRS}) +target_include_directories(HelloLivekitSender PRIVATE ${EXAMPLES_PRIVATE_INCLUDE_DIRS}) -target_link_libraries(HelloLivekit +target_link_libraries(HelloLivekitSender + PRIVATE + livekit + spdlog::spdlog +) + +add_executable(HelloLivekitReceiver + hello_livekit/receiver.cpp +) + +target_include_directories(HelloLivekitReceiver PRIVATE ${EXAMPLES_PRIVATE_INCLUDE_DIRS}) + +target_link_libraries(HelloLivekitReceiver PRIVATE livekit spdlog::spdlog diff --git a/examples/hello_livekit/hello_livekit.cpp b/examples/hello_livekit/hello_livekit.cpp deleted file mode 100644 index 0f6bdd93..00000000 --- a/examples/hello_livekit/hello_livekit.cpp +++ /dev/null @@ -1,230 +0,0 @@ -/* - * Copyright 2025 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/// Combined hello_livekit example: instantiates both a sender and a receiver -/// peer in the same process on separate threads. -/// -/// The sender publishes synthetic RGBA video and a data track. The receiver -/// subscribes and logs every 10th video frame plus every data message. -/// -/// Usage: -/// HelloLivekit -/// -/// Or via environment variables: -/// LIVEKIT_URL, LIVEKIT_SENDER_TOKEN, LIVEKIT_RECEIVER_TOKEN - -#include "livekit/livekit.h" - -#include -#include -#include -#include -#include -#include - -using namespace livekit; - -constexpr int kWidth = 640; -constexpr int kHeight = 480; -constexpr const char *kVideoTrackName = "camera0"; -constexpr const char *kDataTrackName = "app-data"; - -std::atomic g_running{true}; - -void handleSignal(int) { g_running.store(false); } - -std::string getenvOrEmpty(const char *name) { - const char *v = std::getenv(name); - return v ? std::string(v) : std::string{}; -} - -// --------------------------------------------------------------------------- -// Sender peer -// --------------------------------------------------------------------------- -void runSender(const std::string &url, const std::string &token, - std::atomic &sender_identity_out) { - auto room = std::make_unique(); - RoomOptions options; - options.auto_subscribe = true; - options.dynacast = false; - - if (!room->Connect(url, token, options)) { - LK_LOG_ERROR("[sender] Failed to connect"); - return; - } - - LocalParticipant *lp = room->localParticipant(); - assert(lp); - - auto *identity = new std::string(lp->identity()); - sender_identity_out.store(identity); - - LK_LOG_INFO("[sender] Connected as identity='{}' room='{}'", lp->identity(), - room->room_info().name); - - auto video_source = std::make_shared(kWidth, kHeight); - - std::shared_ptr video_track = lp->publishVideoTrack( - kVideoTrackName, video_source, TrackSource::SOURCE_CAMERA); - - std::shared_ptr data_track = - lp->publishDataTrack(kDataTrackName); - - const auto t0 = std::chrono::steady_clock::now(); - std::uint64_t count = 0; - - LK_LOG_INFO( - "[sender] Publishing synthetic video + data on '{}'; Ctrl-C to exit", - kDataTrackName); - - while (g_running.load()) { - VideoFrame vf = VideoFrame::create(kWidth, kHeight, VideoBufferType::RGBA); - video_source->captureFrame(std::move(vf)); - - const auto now = std::chrono::steady_clock::now(); - const double ms = - std::chrono::duration(now - t0).count(); - std::ostringstream oss; - oss << std::fixed << std::setprecision(2) << ms << " ms, count: " << count; - const std::string msg = oss.str(); - data_track->tryPush(std::vector(msg.begin(), msg.end())); - - ++count; - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - } - - LK_LOG_INFO("[sender] Disconnecting"); - room.reset(); -} - -// --------------------------------------------------------------------------- -// Receiver peer -// --------------------------------------------------------------------------- -void runReceiver(const std::string &url, const std::string &token, - std::atomic &sender_identity_out) { - // Wait for the sender to connect so we know its identity. - std::string sender_identity; - while (g_running.load()) { - std::string *id = sender_identity_out.load(); - if (id) { - sender_identity = *id; - break; - } - LK_LOG_INFO("[receiver] Waiting for sender to connect"); - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - } - if (!g_running.load()) - return; - - auto room = std::make_unique(); - RoomOptions options; - options.auto_subscribe = true; - options.dynacast = false; - - if (!room->Connect(url, token, options)) { - LK_LOG_ERROR("[receiver] Failed to connect"); - return; - } - - LocalParticipant *lp = room->localParticipant(); - assert(lp); - - LK_LOG_INFO("[receiver] Connected as identity='{}' room='{}'; expecting " - "sender identity='{}'", - lp->identity(), room->room_info().name, sender_identity); - - // set the video frame callback for the sender's camera track - int video_frame_count = 0; - room->setOnVideoFrameCallback( - sender_identity, TrackSource::SOURCE_CAMERA, - [&video_frame_count](const VideoFrame &frame, std::int64_t timestamp_us) { - const auto ts_ms = - std::chrono::duration(timestamp_us).count(); - const int n = video_frame_count++; - if (n % 10 == 0) { - LK_LOG_INFO("[receiver] Video frame #{} {}x{} ts_ms={}", n, - frame.width(), frame.height(), ts_ms); - } - }); - - // Add a callback for the sender's data track. DataTracks can have mutliple - // subscribers to fan out callbacks - int data_frame_count = 0; - room->addOnDataFrameCallback( - sender_identity, kDataTrackName, - [&data_frame_count](const std::vector &payload, - std::optional user_ts) { - const int n = data_frame_count++; - if (n % 10 == 0) { - LK_LOG_INFO("[receiver] Data frame #{}", n); - } - }); - - LK_LOG_INFO( - "[receiver] Listening for camera + data track '{}'; Ctrl-C to exit", - kDataTrackName); - - while (g_running.load()) { - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - } - - LK_LOG_INFO("[receiver`] Shutting down"); - room.reset(); -} - -int main(int argc, char *argv[]) { - std::string url = getenvOrEmpty("LIVEKIT_URL"); - std::string sender_token = getenvOrEmpty("LIVEKIT_SENDER_TOKEN"); - std::string receiver_token = getenvOrEmpty("LIVEKIT_RECEIVER_TOKEN"); - - if (argc >= 4) { - url = argv[1]; - sender_token = argv[2]; - receiver_token = argv[3]; - } - - if (url.empty() || sender_token.empty() || receiver_token.empty()) { - LK_LOG_ERROR( - "Usage: HelloLivekit \n" - " or set LIVEKIT_URL, LIVEKIT_SENDER_TOKEN, LIVEKIT_RECEIVER_TOKEN"); - return 1; - } - - std::signal(SIGINT, handleSignal); -#ifdef SIGTERM - std::signal(SIGTERM, handleSignal); -#endif - - livekit::initialize(livekit::LogLevel::Info, livekit::LogSink::kConsole); - - // Shared: sender publishes its identity here so the receiver knows who - // to subscribe to (no hardcoded identity required). - std::atomic sender_identity{nullptr}; - - std::thread sender_thread(runSender, std::cref(url), std::cref(sender_token), - std::ref(sender_identity)); - std::thread receiver_thread(runReceiver, std::cref(url), - std::cref(receiver_token), - std::ref(sender_identity)); - - sender_thread.join(); - receiver_thread.join(); - - delete sender_identity.load(); - - livekit::shutdown(); - return 0; -} diff --git a/examples/hello_livekit/receiver.cpp b/examples/hello_livekit/receiver.cpp new file mode 100644 index 00000000..c086e0b4 --- /dev/null +++ b/examples/hello_livekit/receiver.cpp @@ -0,0 +1,129 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// Subscribes to the sender's camera video and data track. Run +/// HelloLivekitSender first; use the identity it prints, or the sender's known +/// participant name. +/// +/// Usage: +/// HelloLivekitReceiver +/// +/// Or via environment variables: +/// LIVEKIT_URL, LIVEKIT_RECEIVER_TOKEN, LIVEKIT_SENDER_IDENTITY + +#include "livekit/livekit.h" + +#include +#include +#include +#include +#include + +using namespace livekit; + +constexpr const char *kDataTrackName = "app-data"; + +std::atomic g_running{true}; + +void handleSignal(int) { g_running.store(false); } + +std::string getenvOrEmpty(const char *name) { + const char *v = std::getenv(name); + return v ? std::string(v) : std::string{}; +} + +int main(int argc, char *argv[]) { + std::string url = getenvOrEmpty("LIVEKIT_URL"); + std::string receiver_token = getenvOrEmpty("LIVEKIT_RECEIVER_TOKEN"); + std::string sender_identity = getenvOrEmpty("LIVEKIT_SENDER_IDENTITY"); + + if (argc >= 4) { + url = argv[1]; + receiver_token = argv[2]; + sender_identity = argv[3]; + } + + if (url.empty() || receiver_token.empty() || sender_identity.empty()) { + LK_LOG_ERROR("Usage: HelloLivekitReceiver " + "\n" + " or set LIVEKIT_URL, LIVEKIT_RECEIVER_TOKEN, " + "LIVEKIT_SENDER_IDENTITY"); + return 1; + } + + std::signal(SIGINT, handleSignal); +#ifdef SIGTERM + std::signal(SIGTERM, handleSignal); +#endif + + livekit::initialize(livekit::LogLevel::Info, livekit::LogSink::kConsole); + + auto room = std::make_unique(); + RoomOptions options; + options.auto_subscribe = true; + options.dynacast = false; + + if (!room->Connect(url, receiver_token, options)) { + LK_LOG_ERROR("[receiver] Failed to connect"); + livekit::shutdown(); + return 1; + } + + LocalParticipant *lp = room->localParticipant(); + assert(lp); + + LK_LOG_INFO("[receiver] Connected as identity='{}' room='{}'; subscribing " + "to sender identity='{}'", + lp->identity(), room->room_info().name, sender_identity); + + int video_frame_count = 0; + room->setOnVideoFrameCallback( + sender_identity, TrackSource::SOURCE_CAMERA, + [&video_frame_count](const VideoFrame &frame, std::int64_t timestamp_us) { + const auto ts_ms = + std::chrono::duration(timestamp_us).count(); + const int n = video_frame_count++; + if (n % 10 == 0) { + LK_LOG_INFO("[receiver] Video frame #{} {}x{} ts_ms={}", n, + frame.width(), frame.height(), ts_ms); + } + }); + + int data_frame_count = 0; + room->addOnDataFrameCallback( + sender_identity, kDataTrackName, + [&data_frame_count](const std::vector &payload, + std::optional user_ts) { + const int n = data_frame_count++; + if (n % 10 == 0) { + LK_LOG_INFO("[receiver] Data frame #{}", n); + } + }); + + LK_LOG_INFO( + "[receiver] Listening for camera + data track '{}'; Ctrl-C to exit", + kDataTrackName); + + while (g_running.load()) { + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + + LK_LOG_INFO("[receiver] Shutting down"); + room.reset(); + + livekit::shutdown(); + return 0; +} diff --git a/examples/hello_livekit/sender.cpp b/examples/hello_livekit/sender.cpp new file mode 100644 index 00000000..3ca70d5f --- /dev/null +++ b/examples/hello_livekit/sender.cpp @@ -0,0 +1,129 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// Publishes synthetic RGBA video and a data track. Run the receiver in another +/// process and pass this participant's identity (printed after connect). +/// +/// Usage: +/// HelloLivekitSender +/// +/// Or via environment variables: +/// LIVEKIT_URL, LIVEKIT_SENDER_TOKEN + +#include "livekit/livekit.h" + +#include +#include +#include +#include +#include +#include +#include + +using namespace livekit; + +constexpr int kWidth = 640; +constexpr int kHeight = 480; +constexpr const char *kVideoTrackName = "camera0"; +constexpr const char *kDataTrackName = "app-data"; + +std::atomic g_running{true}; + +void handleSignal(int) { g_running.store(false); } + +std::string getenvOrEmpty(const char *name) { + const char *v = std::getenv(name); + return v ? std::string(v) : std::string{}; +} + +int main(int argc, char *argv[]) { + std::string url = getenvOrEmpty("LIVEKIT_URL"); + std::string sender_token = getenvOrEmpty("LIVEKIT_SENDER_TOKEN"); + + if (argc >= 3) { + url = argv[1]; + sender_token = argv[2]; + } + + if (url.empty() || sender_token.empty()) { + LK_LOG_ERROR( + "Usage: HelloLivekitSender \n" + " or set LIVEKIT_URL, LIVEKIT_SENDER_TOKEN"); + return 1; + } + + std::signal(SIGINT, handleSignal); +#ifdef SIGTERM + std::signal(SIGTERM, handleSignal); +#endif + + livekit::initialize(livekit::LogLevel::Info, livekit::LogSink::kConsole); + + auto room = std::make_unique(); + RoomOptions options; + options.auto_subscribe = true; + options.dynacast = false; + + if (!room->Connect(url, sender_token, options)) { + LK_LOG_ERROR("[sender] Failed to connect"); + livekit::shutdown(); + return 1; + } + + LocalParticipant *lp = room->localParticipant(); + assert(lp); + + LK_LOG_INFO("[sender] Connected as identity='{}' room='{}' — pass this " + "identity to HelloLivekitReceiver", + lp->identity(), room->room_info().name); + + auto video_source = std::make_shared(kWidth, kHeight); + + std::shared_ptr video_track = lp->publishVideoTrack( + kVideoTrackName, video_source, TrackSource::SOURCE_CAMERA); + + std::shared_ptr data_track = + lp->publishDataTrack(kDataTrackName); + + const auto t0 = std::chrono::steady_clock::now(); + std::uint64_t count = 0; + + LK_LOG_INFO( + "[sender] Publishing synthetic video + data on '{}'; Ctrl-C to exit", + kDataTrackName); + + while (g_running.load()) { + VideoFrame vf = VideoFrame::create(kWidth, kHeight, VideoBufferType::RGBA); + video_source->captureFrame(std::move(vf)); + + const auto now = std::chrono::steady_clock::now(); + const double ms = + std::chrono::duration(now - t0).count(); + std::ostringstream oss; + oss << std::fixed << std::setprecision(2) << ms << " ms, count: " << count; + const std::string msg = oss.str(); + data_track->tryPush(std::vector(msg.begin(), msg.end())); + + ++count; + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + + LK_LOG_INFO("[sender] Disconnecting"); + room.reset(); + + livekit::shutdown(); + return 0; +} From d19e53977091dd1cd6465469e5005e8e8429ff19 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Wed, 25 Mar 2026 15:20:07 -0600 Subject: [PATCH 18/34] some data_tracks tests --- examples/tokens/README.md | 7 + examples/tokens/gen_and_set.bash | 169 ++++++ examples/tokens/set_test_tokens.bash | 126 ++++ src/e2ee.cpp | 1 + src/room.cpp | 9 +- src/tests/common/test_common.h | 118 ++++ src/tests/integration/test_data_track.cpp | 573 ++++++++++++++++++ src/tests/integration/test_room_callbacks.cpp | 322 ++++------ 8 files changed, 1104 insertions(+), 221 deletions(-) create mode 100644 examples/tokens/README.md create mode 100755 examples/tokens/gen_and_set.bash create mode 100755 examples/tokens/set_test_tokens.bash create mode 100644 src/tests/integration/test_data_track.cpp diff --git a/examples/tokens/README.md b/examples/tokens/README.md new file mode 100644 index 00000000..a00edd39 --- /dev/null +++ b/examples/tokens/README.md @@ -0,0 +1,7 @@ +# Overview +Examples of generating tokens + +## gen_and_set.bash +generate tokens and then set them as env vars for the current terminal session + +## diff --git a/examples/tokens/gen_and_set.bash b/examples/tokens/gen_and_set.bash new file mode 100755 index 00000000..82336fd2 --- /dev/null +++ b/examples/tokens/gen_and_set.bash @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# Copyright 2025 LiveKit, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Generate a LiveKit access token via `lk` and set LIVEKIT_TOKEN (and LIVEKIT_URL) +# for your current shell session. +# +# source examples/tokens/gen_and_set.bash --id PARTICIPANT_ID --room ROOM_NAME [--view-token] +# eval "$(bash examples/tokens/gen_and_set.bash --id ID --room ROOM [--view-token])" +# +# Optional env: LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_VALID_FOR. + +# When sourced, we must NOT enable errexit/pipefail on the interactive shell — a +# failing pipeline (e.g. sed|head SIGPIPE) or any error would close your terminal. + +_sourced=0 +if [[ -n "${BASH_VERSION:-}" ]] && [[ "${BASH_SOURCE[0]}" != "${0}" ]]; then + _sourced=1 +elif [[ -n "${ZSH_VERSION:-}" ]] && [[ "${ZSH_EVAL_CONTEXT:-}" == *:file* ]]; then + _sourced=1 +fi + +_fail() { + echo "gen_and_set.bash: $1" >&2 + if [[ "$_sourced" -eq 1 ]]; then + return "${2:-1}" + fi + exit "${2:-1}" +} + +_usage() { + echo "Usage: ${0##*/} --id PARTICIPANT_IDENTITY --room ROOM_NAME [--view-token]" >&2 + echo " --id LiveKit participant identity (required)" >&2 + echo " --room Room name (required; not read from env)" >&2 + echo " --view-token Print the JWT to stderr after generating" >&2 +} + +if [[ "$_sourced" -eq 0 ]]; then + set -euo pipefail +fi + +_view_token=0 +LIVEKIT_IDENTITY="" +LIVEKIT_ROOM="robo_room" +while [[ $# -gt 0 ]]; do + case "$1" in + --view-token) + _view_token=1 + shift + ;; + --id) + if [[ $# -lt 2 ]]; then + _usage + _fail "--id requires a value" 2 + fi + LIVEKIT_IDENTITY="$2" + shift 2 + ;; + --room) + if [[ $# -lt 2 ]]; then + _usage + _fail "--room requires a value" 2 + fi + LIVEKIT_ROOM="$2" + shift 2 + ;; + -h | --help) + _usage + if [[ "$_sourced" -eq 1 ]]; then + return 0 + fi + exit 0 + ;; + *) + _usage + _fail "unknown argument: $1" 2 + ;; + esac +done + +if [[ -z "$LIVEKIT_IDENTITY" ]]; then + _usage + _fail "--id is required" 2 +fi +if [[ -z "$LIVEKIT_ROOM" ]]; then + _usage + _fail "--room is required" 2 +fi + +LIVEKIT_API_KEY="${LIVEKIT_API_KEY:-devkey}" +LIVEKIT_API_SECRET="${LIVEKIT_API_SECRET:-secret}" +LIVEKIT_VALID_FOR="${LIVEKIT_VALID_FOR:-99999h}" +_grant_json='{"canPublish":true,"canSubscribe":true,"canPublishData":true}' + +if ! command -v lk >/dev/null 2>&1; then + _fail "'lk' CLI not found. Install: https://docs.livekit.io/home/cli/" 2 +fi + +# Run lk inside bash so --grant JSON (with embedded ") is safe when this file is +# sourced from zsh; zsh misparses --grant "$json" on the same line. +_out="$( + bash -c ' + lk token create \ + --api-key "$1" \ + --api-secret "$2" \ + -i "$3" \ + --join \ + --valid-for "$4" \ + --room "$5" \ + --grant "$6" 2>&1 + ' _ "$LIVEKIT_API_KEY" "$LIVEKIT_API_SECRET" "$LIVEKIT_IDENTITY" \ + "$LIVEKIT_VALID_FOR" "$LIVEKIT_ROOM" "$_grant_json" +)" +_lk_st=$? +if [[ "$_lk_st" -ne 0 ]]; then + echo "$_out" >&2 + _fail "lk token create failed" 1 +fi + +# Avoid sed|head pipelines (pipefail + SIGPIPE can kill a sourced shell). +LIVEKIT_TOKEN="" +LIVEKIT_URL="" +while IFS= read -r _line || [[ -n "${_line}" ]]; do + if [[ "$_line" == "Access token: "* ]]; then + LIVEKIT_TOKEN="${_line#Access token: }" + elif [[ "$_line" == "Project URL: "* ]]; then + LIVEKIT_URL="${_line#Project URL: }" + fi +done <<< "$_out" + +if [[ -z "$LIVEKIT_TOKEN" ]]; then + echo "gen_and_set.bash: could not parse Access token from lk output:" >&2 + echo "$_out" >&2 + _fail "missing Access token line" 1 +fi + +if [[ "$_view_token" -eq 1 ]]; then + echo "$LIVEKIT_TOKEN" >&2 +fi + +_apply() { + export LIVEKIT_TOKEN + export LIVEKIT_URL +} + +_emit_eval() { + printf 'export LIVEKIT_TOKEN=%q\n' "$LIVEKIT_TOKEN" + [[ -n "$LIVEKIT_URL" ]] && printf 'export LIVEKIT_URL=%q\n' "$LIVEKIT_URL" +} + +if [[ "$_sourced" -eq 1 ]]; then + _apply + echo "LIVEKIT_TOKEN and LIVEKIT_URL set for this shell." >&2 + [[ -n "$LIVEKIT_URL" ]] || echo "gen_and_set.bash: warning: no Project URL in output; set LIVEKIT_URL manually." >&2 +else + _emit_eval + echo "gen_and_set.bash: for this shell run: source $0 --id ... --room ... or: eval \"\$(bash $0 ...)\"" >&2 +fi diff --git a/examples/tokens/set_test_tokens.bash b/examples/tokens/set_test_tokens.bash new file mode 100755 index 00000000..dcd96a6a --- /dev/null +++ b/examples/tokens/set_test_tokens.bash @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# Copyright 2026 LiveKit, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Generate two LiveKit access tokens via `lk` and set the environment variables +# required by src/tests/integration/test_data_track.cpp. +# +# source examples/tokens/set_test_tokens.bash +# eval "$(bash examples/tokens/set_test_tokens.bash)" +# +# Exports: +# LK_TOKEN_TEST_A +# LK_TOKEN_TEST_B +# LIVEKIT_URL=ws://localhost:7880 +# + +_sourced=0 +if [[ -n "${BASH_VERSION:-}" ]] && [[ "${BASH_SOURCE[0]}" != "${0}" ]]; then + _sourced=1 +elif [[ -n "${ZSH_VERSION:-}" ]] && [[ "${ZSH_EVAL_CONTEXT:-}" == *:file* ]]; then + _sourced=1 +fi + +_fail() { + echo "set_test_tokens.bash: $1" >&2 + if [[ "$_sourced" -eq 1 ]]; then + return "${2:-1}" + fi + exit "${2:-1}" +} + +if [[ "$_sourced" -eq 0 ]]; then + set -euo pipefail +fi + +LIVEKIT_ROOM="cpp_data_track_test" +LIVEKIT_IDENTITY_A="cpp-test-a" +LIVEKIT_IDENTITY_B="cpp-test-b" + +if [[ $# -ne 0 ]]; then + _fail "this script is hard-coded and does not accept arguments" 2 +fi + +LIVEKIT_API_KEY="devkey" +LIVEKIT_API_SECRET="secret" +LIVEKIT_VALID_FOR="99999h" +LIVEKIT_URL="ws://localhost:7880" +_grant_json='{"canPublish":true,"canSubscribe":true,"canPublishData":true}' + +if ! command -v lk >/dev/null 2>&1; then + _fail "'lk' CLI not found. Install: https://docs.livekit.io/home/cli/" 2 +fi + +_create_token() { + local identity="$1" + local output="" + local command_status=0 + local token="" + + output="$( + bash -c ' + lk token create \ + --api-key "$1" \ + --api-secret "$2" \ + -i "$3" \ + --join \ + --valid-for "$4" \ + --room "$5" \ + --grant "$6" 2>&1 + ' _ "$LIVEKIT_API_KEY" "$LIVEKIT_API_SECRET" "$identity" \ + "$LIVEKIT_VALID_FOR" "$LIVEKIT_ROOM" "$_grant_json" + )" + command_status=$? + if [[ "$command_status" -ne 0 ]]; then + echo "$output" >&2 + _fail "lk token create failed for identity '$identity'" 1 + fi + + while IFS= read -r line || [[ -n "${line}" ]]; do + if [[ "$line" == "Access token: "* ]]; then + token="${line#Access token: }" + break + fi + done <<< "$output" + + if [[ -z "$token" ]]; then + echo "$output" >&2 + _fail "could not parse Access token for identity '$identity'" 1 + fi + + printf '%s' "$token" +} + +LK_TOKEN_TEST_A="$(_create_token "$LIVEKIT_IDENTITY_A")" +LK_TOKEN_TEST_B="$(_create_token "$LIVEKIT_IDENTITY_B")" + +_apply() { + export LK_TOKEN_TEST_A + export LK_TOKEN_TEST_B + export LIVEKIT_URL +} + +_emit_eval() { + printf 'export LK_TOKEN_TEST_A=%q\n' "$LK_TOKEN_TEST_A" + printf 'export LK_TOKEN_TEST_B=%q\n' "$LK_TOKEN_TEST_B" + printf 'export LIVEKIT_URL=%q\n' "$LIVEKIT_URL" +} + +if [[ "$_sourced" -eq 1 ]]; then + _apply + echo "LK_TOKEN_TEST_A, LK_TOKEN_TEST_B, and LIVEKIT_URL set for this shell." >&2 +else + _emit_eval + echo "set_test_tokens.bash: for this shell run: source $0 or: eval \"\$(bash $0 ...)\"" >&2 +fi diff --git a/src/e2ee.cpp b/src/e2ee.cpp index dc95252f..ae46bf79 100644 --- a/src/e2ee.cpp +++ b/src/e2ee.cpp @@ -166,6 +166,7 @@ void E2EEManager::setEnabled(bool enabled) { req.mutable_e2ee()->set_room_handle(room_handle_); req.mutable_e2ee()->mutable_manager_set_enabled()->set_enabled(enabled); FfiClient::instance().sendRequest(req); + enabled_ = enabled; } E2EEManager::KeyProvider *E2EEManager::keyProvider() { return &key_provider_; } diff --git a/src/room.cpp b/src/room.cpp index 88ca39bf..10550675 100644 --- a/src/room.cpp +++ b/src/room.cpp @@ -171,8 +171,8 @@ bool Room::Connect(const std::string &url, const std::string &token, std::unique_ptr new_e2ee_manager; if (options.encryption) { LK_LOG_INFO("creating E2eeManager"); - e2ee_manager_ = std::unique_ptr( - new E2EEManager(room_handle_->get(), options.encryption.value())); + new_e2ee_manager = std::unique_ptr( + new E2EEManager(new_room_handle->get(), options.encryption.value())); } // Publish all state atomically under lock @@ -230,6 +230,11 @@ Room::remoteParticipants() const { return out; } +E2EEManager *Room::e2eeManager() const { + std::lock_guard g(lock_); + return e2ee_manager_.get(); +} + void Room::registerTextStreamHandler(const std::string &topic, TextStreamHandler handler) { std::lock_guard g(lock_); diff --git a/src/tests/common/test_common.h b/src/tests/common/test_common.h index 0298e1f6..b217d6a8 100644 --- a/src/tests/common/test_common.h +++ b/src/tests/common/test_common.h @@ -17,6 +17,7 @@ #pragma once #include +#include #include #include #include @@ -25,8 +26,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -46,6 +49,9 @@ constexpr int kDefaultTestIterations = 10; // Default stress test duration in seconds constexpr int kDefaultStressDurationSeconds = 600; // 10 minutes +// Local SFU URL used by end-to-end data track tests. +constexpr char kLocalTestLiveKitUrl[] = "ws://localhost:7880"; + // ============================================================================= // Common Test Configuration // ============================================================================= @@ -97,6 +103,11 @@ struct TestConfig { } }; +struct TestRoomConnectionOptions { + RoomOptions room_options; + RoomDelegate *delegate = nullptr; +}; + // ============================================================================= // Utility Functions // ============================================================================= @@ -121,6 +132,113 @@ inline bool waitForParticipant(Room *room, const std::string &identity, return false; } +inline std::array getDataTrackTestTokens() { + const char *token_a = std::getenv("LK_TOKEN_TEST_A"); + if (token_a == nullptr || std::string(token_a).empty()) { + throw std::runtime_error( + "LK_TOKEN_TEST_A must be present and non-empty for data track E2E " + "tests"); + } + + const char *token_b = std::getenv("LK_TOKEN_TEST_B"); + if (token_b == nullptr || std::string(token_b).empty()) { + throw std::runtime_error( + "LK_TOKEN_TEST_B must be present and non-empty for data track E2E " + "tests"); + } + + return {token_a, token_b}; +} + +inline void waitForParticipantVisibility( + const std::vector> &rooms, + std::chrono::milliseconds timeout = 5s) { + std::vector participant_identities; + participant_identities.reserve(rooms.size()); + for (const auto &room : rooms) { + if (!room || room->localParticipant() == nullptr) { + throw std::runtime_error( + "Test room is missing a local participant after connect"); + } + participant_identities.push_back(room->localParticipant()->identity()); + } + + auto start = std::chrono::steady_clock::now(); + while (std::chrono::steady_clock::now() - start < timeout) { + bool all_visible = true; + for (size_t i = 0; i < rooms.size(); ++i) { + const auto &room = rooms[i]; + if (!room || room->localParticipant() == nullptr) { + throw std::runtime_error( + "Test room is missing a local participant after connect"); + } + + for (size_t j = 0; j < participant_identities.size(); ++j) { + if (i == j) { + continue; + } + + if (room->remoteParticipant(participant_identities[j]) == nullptr) { + all_visible = false; + break; + } + } + + if (!all_visible) { + break; + } + } + + if (all_visible) { + return; + } + + std::this_thread::sleep_for(10ms); + } + + throw std::runtime_error("Not all test participants became visible"); +} + +inline std::vector> +testRooms(const std::vector &room_configs) { + if (room_configs.empty()) { + throw std::invalid_argument("testRooms requires at least one room"); + } + + if (room_configs.size() > 2) { + throw std::invalid_argument( + "testRooms supports at most two rooms with LK_TOKEN_TEST_A/B"); + } + + auto tokens = getDataTrackTestTokens(); + + std::vector> rooms; + rooms.reserve(room_configs.size()); + + for (size_t i = 0; i < room_configs.size(); ++i) { + auto room = std::make_unique(); + if (room_configs[i].delegate != nullptr) { + room->setDelegate(room_configs[i].delegate); + } + + if (!room->Connect(kLocalTestLiveKitUrl, tokens[i], + room_configs[i].room_options)) { + throw std::runtime_error("Failed to connect test room " + + std::to_string(i)); + } + + rooms.push_back(std::move(room)); + } + + waitForParticipantVisibility(rooms); + return rooms; +} + +inline std::vector> testRooms(size_t count) { + std::vector room_configs(count); + return testRooms(room_configs); +} + // ============================================================================= // Statistics Collection // ============================================================================= diff --git a/src/tests/integration/test_data_track.cpp b/src/tests/integration/test_data_track.cpp new file mode 100644 index 00000000..d1d7ca68 --- /dev/null +++ b/src/tests/integration/test_data_track.cpp @@ -0,0 +1,573 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// This test is used to verify that data tracks are published and received +// correctly. It is the same implementation as the rust +// client-sdk-rust/livekit/tests/data_track_test.rs test. To run this test, run +// a local SFU, set credentials examples/tokens/set_test_tokens.bash, and run: +// ./build-debug/bin/livekit_integration_tests + +#include "../common/test_common.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace livekit { +namespace test { + +using namespace std::chrono_literals; + +namespace { + +constexpr char kTrackNamePrefix[] = "data_track_e2e"; +constexpr auto kPublishDuration = 5s; +constexpr auto kTrackWaitTimeout = 10s; +constexpr auto kReadTimeout = 30s; +constexpr auto kPollingInterval = 10ms; +constexpr float kMinimumReceivedPercent = 0.95f; +constexpr int kResubscribeIterations = 10; +constexpr int kPublishManyTrackCount = 256; +constexpr auto kPublishManyTimeout = 5s; +constexpr std::size_t kLargeFramePayloadBytes = 196608; +constexpr char kE2EESharedSecret[] = "password"; +constexpr int kE2EEFrameCount = 5; + +std::string makeTrackName(const std::string &suffix) { + return std::string(kTrackNamePrefix) + "_" + suffix + "_" + + std::to_string(getTimestampUs()); +} + +template +bool waitForCondition(Predicate &&predicate, std::chrono::milliseconds timeout, + std::chrono::milliseconds interval = kPollingInterval) { + auto start = std::chrono::steady_clock::now(); + while (std::chrono::steady_clock::now() - start < timeout) { + if (predicate()) { + return true; + } + std::this_thread::sleep_for(interval); + } + return false; +} + +class DataTrackPublishedDelegate : public RoomDelegate { +public: + void onDataTrackPublished(Room &, + const DataTrackPublishedEvent &event) override { + if (!event.track) { + return; + } + + std::lock_guard lock(mutex_); + tracks_.push_back(event.track); + cv_.notify_all(); + } + + std::shared_ptr + waitForTrack(std::chrono::milliseconds timeout) { + std::unique_lock lock(mutex_); + if (!cv_.wait_for(lock, timeout, [this] { return !tracks_.empty(); })) { + return nullptr; + } + return tracks_.front(); + } + +private: + std::mutex mutex_; + std::condition_variable cv_; + std::vector> tracks_; +}; + +DataFrame +readFrameWithTimeout(const std::shared_ptr &subscription, + std::chrono::milliseconds timeout) { + std::promise frame_promise; + auto future = frame_promise.get_future(); + + std::thread reader([subscription, + promise = std::move(frame_promise)]() mutable { + try { + DataFrame frame; + if (!subscription->read(frame)) { + throw std::runtime_error("Subscription ended before a frame arrived"); + } + promise.set_value(std::move(frame)); + } catch (...) { + promise.set_exception(std::current_exception()); + } + }); + + if (future.wait_for(timeout) != std::future_status::ready) { + subscription->close(); + } + + reader.join(); + return future.get(); +} + +} // namespace + +class DataTrackE2ETest : public LiveKitTestBase {}; + +class DataTrackTransportTest + : public DataTrackE2ETest, + public ::testing::WithParamInterface> {}; + +TEST_P(DataTrackTransportTest, PublishesAndReceivesFramesEndToEnd) { + const auto publish_fps = std::get<0>(GetParam()); + const auto payload_len = std::get<1>(GetParam()); + const auto track_name = makeTrackName("transport"); + const auto frame_count = static_cast(std::llround( + std::chrono::duration(kPublishDuration).count() * publish_fps)); + + DataTrackPublishedDelegate subscriber_delegate; + std::vector room_configs(2); + room_configs[1].delegate = &subscriber_delegate; + + auto rooms = testRooms(room_configs); + auto &publisher_room = rooms[0]; + const auto publisher_identity = + publisher_room->localParticipant()->identity(); + + std::exception_ptr publish_error; + std::thread publisher([&]() { + try { + auto track = + publisher_room->localParticipant()->publishDataTrack(track_name); + if (!track || !track->isPublished()) { + throw std::runtime_error("Publisher failed to publish data track"); + } + if (track->info().uses_e2ee) { + throw std::runtime_error("Unexpected E2EE on test data track"); + } + if (track->info().name != track_name) { + throw std::runtime_error("Published track name mismatch"); + } + + const auto frame_interval = + std::chrono::duration_cast( + std::chrono::duration(1.0 / publish_fps)); + auto next_send = std::chrono::steady_clock::now(); + + std::cout << "Publishing " << frame_count + << " frames with payload length " << payload_len << std::endl; + for (size_t index = 0; index < frame_count; ++index) { + std::vector payload(payload_len, + static_cast(index)); + if (!track->tryPush(payload)) { + throw std::runtime_error("Failed to push data frame"); + } + + next_send += frame_interval; + std::this_thread::sleep_until(next_send); + } + + track->unpublishDataTrack(); + } catch (...) { + publish_error = std::current_exception(); + } + }); + + auto remote_track = subscriber_delegate.waitForTrack(kTrackWaitTimeout); + ASSERT_NE(remote_track, nullptr) << "Timed out waiting for remote data track"; + EXPECT_TRUE(remote_track->isPublished()); + EXPECT_FALSE(remote_track->info().uses_e2ee); + EXPECT_EQ(remote_track->info().name, track_name); + EXPECT_EQ(remote_track->publisherIdentity(), publisher_identity); + + auto subscription = remote_track->subscribe(); + ASSERT_NE(subscription, nullptr); + + std::promise receive_count_promise; + auto receive_count_future = receive_count_promise.get_future(); + std::exception_ptr subscribe_error; + std::thread subscriber([&]() { + try { + size_t received_count = 0; + DataFrame frame; + while (subscription->read(frame) && received_count < frame_count) { + if (frame.payload.empty()) { + throw std::runtime_error("Received empty data frame"); + } + + const auto first_byte = frame.payload.front(); + if (!std::all_of(frame.payload.begin(), frame.payload.end(), + [first_byte](std::uint8_t byte) { + return byte == first_byte; + })) { + throw std::runtime_error("Received frame with inconsistent payload"); + } + if (frame.user_timestamp.has_value()) { + throw std::runtime_error( + "Received unexpected user timestamp in transport test"); + } + + ++received_count; + } + + receive_count_promise.set_value(received_count); + } catch (...) { + subscribe_error = std::current_exception(); + receive_count_promise.set_exception(std::current_exception()); + } + }); + + if (receive_count_future.wait_for(kReadTimeout) != + std::future_status::ready) { + subscription->close(); + ADD_FAILURE() << "Timed out waiting for data frames"; + } + + subscriber.join(); + publisher.join(); + + if (publish_error) { + std::rethrow_exception(publish_error); + } + if (subscribe_error) { + std::rethrow_exception(subscribe_error); + } + + const auto received_count = receive_count_future.get(); + const auto received_percent = + static_cast(received_count) / static_cast(frame_count); + std::cout << "Received " << received_count << "/" << frame_count + << " frames (" << received_percent * 100.0f << "%)" << std::endl; + + EXPECT_GE(received_percent, kMinimumReceivedPercent) + << "Received " << received_count << "/" << frame_count << " frames"; +} + +TEST_F(DataTrackE2ETest, UnpublishUpdatesPublishedStateEndToEnd) { + const auto track_name = makeTrackName("published_state"); + + DataTrackPublishedDelegate subscriber_delegate; + std::vector room_configs(2); + room_configs[1].delegate = &subscriber_delegate; + + auto rooms = testRooms(room_configs); + auto &publisher_room = rooms[0]; + + auto local_track = + publisher_room->localParticipant()->publishDataTrack(track_name); + ASSERT_NE(local_track, nullptr); + ASSERT_TRUE(local_track->isPublished()); + + auto remote_track = subscriber_delegate.waitForTrack(kTrackWaitTimeout); + ASSERT_NE(remote_track, nullptr) << "Timed out waiting for remote data track"; + EXPECT_TRUE(remote_track->isPublished()); + + std::this_thread::sleep_for(500ms); + local_track->unpublishDataTrack(); + + EXPECT_FALSE(local_track->isPublished()); + EXPECT_TRUE( + waitForCondition([&]() { return !remote_track->isPublished(); }, 2s)) + << "Remote track did not report unpublished state"; +} + +TEST_F(DataTrackE2ETest, PublishManyTracks) { + auto rooms = testRooms(1); + auto &room = rooms[0]; + + std::vector> tracks; + tracks.reserve(kPublishManyTrackCount); + + const auto start = std::chrono::steady_clock::now(); + for (int index = 0; index < kPublishManyTrackCount; ++index) { + const auto track_name = "track_" + std::to_string(index); + auto track = room->localParticipant()->publishDataTrack(track_name); + + ASSERT_NE(track, nullptr) << "Failed to publish track " << track_name; + EXPECT_TRUE(track->isPublished()) + << "Track was not published: " << track_name; + EXPECT_EQ(track->info().name, track_name); + + tracks.push_back(std::move(track)); + } + const auto elapsed = std::chrono::steady_clock::now() - start; + + std::cout + << "Publishing " << kPublishManyTrackCount << " tracks took " + << std::chrono::duration_cast(elapsed).count() + << " ms" << std::endl; + EXPECT_LT(elapsed, kPublishManyTimeout); + + // This test intentionally creates bursty data-track traffic by pushing a + // large frame on every published track in quick succession. The RTC sender + // path uses bounded queues, so under this load not every packet is expected + // to make it onto the transport and "Failed to enqueue data track packet" + // logs are expected. The purpose of this test is to verify publish/push + // behavior and local track state, not end-to-end delivery of every packet. + for (const auto &track : tracks) { + EXPECT_TRUE(track->tryPush( + std::vector(kLargeFramePayloadBytes, 0xFA))) + << "Failed to push large frame on track " << track->info().name; + std::this_thread::sleep_for(50ms); + } + + for (const auto &track : tracks) { + track->unpublishDataTrack(); + EXPECT_FALSE(track->isPublished()); + } +} + +TEST_F(DataTrackE2ETest, PublishDuplicateName) { + auto rooms = testRooms(1); + auto &room = rooms[0]; + + auto first_track = room->localParticipant()->publishDataTrack("first"); + ASSERT_NE(first_track, nullptr); + ASSERT_TRUE(first_track->isPublished()); + + try { + (void)room->localParticipant()->publishDataTrack("first"); + FAIL() << "Expected duplicate data-track name to be rejected"; + } catch (const std::runtime_error &error) { + const std::string message = error.what(); + EXPECT_FALSE(message.empty()); + } + + first_track->unpublishDataTrack(); +} + +TEST_F(DataTrackE2ETest, CanResubscribeToRemoteDataTrack) { + const auto track_name = makeTrackName("resubscribe"); + + DataTrackPublishedDelegate subscriber_delegate; + std::vector room_configs(2); + room_configs[1].delegate = &subscriber_delegate; + + auto rooms = testRooms(room_configs); + auto &publisher_room = rooms[0]; + + std::atomic keep_publishing{true}; + std::exception_ptr publish_error; + std::thread publisher([&]() { + try { + auto track = + publisher_room->localParticipant()->publishDataTrack(track_name); + if (!track || !track->isPublished()) { + throw std::runtime_error("Publisher failed to publish data track"); + } + + while (keep_publishing.load()) { + if (!track->tryPush(std::vector(64, 0xFA))) { + throw std::runtime_error("Failed to push resubscribe test frame"); + } + std::this_thread::sleep_for(50ms); + } + + track->unpublishDataTrack(); + } catch (...) { + publish_error = std::current_exception(); + } + }); + + auto remote_track = subscriber_delegate.waitForTrack(kTrackWaitTimeout); + ASSERT_NE(remote_track, nullptr) << "Timed out waiting for remote data track"; + + for (int iteration = 0; iteration < kResubscribeIterations; ++iteration) { + auto subscription = remote_track->subscribe(); + ASSERT_NE(subscription, nullptr); + + auto frame = readFrameWithTimeout(subscription, 5s); + EXPECT_FALSE(frame.payload.empty()) << "Iteration " << iteration; + + subscription->close(); + std::this_thread::sleep_for(50ms); + } + + keep_publishing.store(false); + publisher.join(); + + if (publish_error) { + std::rethrow_exception(publish_error); + } +} + +TEST_F(DataTrackE2ETest, PreservesUserTimestampEndToEnd) { + const auto track_name = makeTrackName("user_timestamp"); + const auto sent_timestamp = getTimestampUs(); + + DataTrackPublishedDelegate subscriber_delegate; + std::vector room_configs(2); + room_configs[1].delegate = &subscriber_delegate; + + auto rooms = testRooms(room_configs); + auto &publisher_room = rooms[0]; + + auto local_track = + publisher_room->localParticipant()->publishDataTrack(track_name); + ASSERT_NE(local_track, nullptr); + ASSERT_TRUE(local_track->isPublished()); + + auto remote_track = subscriber_delegate.waitForTrack(kTrackWaitTimeout); + ASSERT_NE(remote_track, nullptr) << "Timed out waiting for remote data track"; + + auto subscription = remote_track->subscribe(); + ASSERT_NE(subscription, nullptr); + + std::promise frame_promise; + auto frame_future = frame_promise.get_future(); + std::thread reader([&]() { + try { + DataFrame frame; + if (!subscription->read(frame)) { + throw std::runtime_error( + "Subscription ended before timestamped frame arrived"); + } + frame_promise.set_value(std::move(frame)); + } catch (...) { + frame_promise.set_exception(std::current_exception()); + } + }); + + const bool push_ok = + local_track->tryPush(std::vector(64, 0xFA), sent_timestamp); + const auto frame_status = frame_future.wait_for(5s); + + if (frame_status != std::future_status::ready) { + subscription->close(); + } + + subscription->close(); + reader.join(); + local_track->unpublishDataTrack(); + + ASSERT_TRUE(push_ok) << "Failed to push timestamped data frame"; + ASSERT_EQ(frame_status, std::future_status::ready) + << "Timed out waiting for timestamped frame"; + + DataFrame frame; + try { + frame = frame_future.get(); + } catch (const std::exception &e) { + FAIL() << e.what(); + } + + ASSERT_FALSE(frame.payload.empty()); + ASSERT_TRUE(frame.user_timestamp.has_value()); + EXPECT_EQ(frame.user_timestamp.value(), sent_timestamp); +} + +TEST_F(DataTrackE2ETest, PublishesAndReceivesEncryptedFramesEndToEnd) { + const auto track_name = makeTrackName("e2ee_transport"); + + DataTrackPublishedDelegate subscriber_delegate; + auto room_configs = encryptedRoomConfigs(&subscriber_delegate); + auto rooms = testRooms(room_configs); + auto &publisher_room = rooms[0]; + auto &subscriber_room = rooms[1]; + + ASSERT_NE(publisher_room->e2eeManager(), nullptr); + ASSERT_NE(subscriber_room->e2eeManager(), nullptr); + ASSERT_NE(publisher_room->e2eeManager()->keyProvider(), nullptr); + ASSERT_NE(subscriber_room->e2eeManager()->keyProvider(), nullptr); + EXPECT_EQ(publisher_room->e2eeManager()->keyProvider()->exportSharedKey(), + e2eeSharedKey()); + EXPECT_EQ(subscriber_room->e2eeManager()->keyProvider()->exportSharedKey(), + e2eeSharedKey()); + + auto local_track = + publisher_room->localParticipant()->publishDataTrack(track_name); + ASSERT_NE(local_track, nullptr); + ASSERT_TRUE(local_track->isPublished()); + EXPECT_TRUE(local_track->info().uses_e2ee); + + auto remote_track = subscriber_delegate.waitForTrack(kTrackWaitTimeout); + ASSERT_NE(remote_track, nullptr) << "Timed out waiting for remote data track"; + EXPECT_TRUE(remote_track->isPublished()); + EXPECT_TRUE(remote_track->info().uses_e2ee); + EXPECT_EQ(remote_track->info().name, track_name); + + auto subscription = remote_track->subscribe(); + ASSERT_NE(subscription, nullptr); + + for (int index = 0; index < kE2EEFrameCount; ++index) { + std::vector payload(64, + static_cast(index + 1)); + ASSERT_TRUE(local_track->tryPush(payload)) + << "Failed to push encrypted frame " << index; + + const auto frame = readFrameWithTimeout(subscription, 5s); + EXPECT_EQ(frame.payload, payload) + << "Encrypted payload mismatch for frame " << index; + EXPECT_FALSE(frame.user_timestamp.has_value()) + << "Unexpected user timestamp on encrypted frame " << index; + } + + subscription->close(); + local_track->unpublishDataTrack(); +} + +TEST_F(DataTrackE2ETest, PreservesUserTimestampOnEncryptedDataTrack) { + const auto track_name = makeTrackName("e2ee_user_timestamp"); + const auto sent_timestamp = getTimestampUs(); + const std::vector payload(64, 0xFA); + + DataTrackPublishedDelegate subscriber_delegate; + auto room_configs = encryptedRoomConfigs(&subscriber_delegate); + auto rooms = testRooms(room_configs); + auto &publisher_room = rooms[0]; + + auto local_track = + publisher_room->localParticipant()->publishDataTrack(track_name); + ASSERT_NE(local_track, nullptr); + ASSERT_TRUE(local_track->isPublished()); + EXPECT_TRUE(local_track->info().uses_e2ee); + + auto remote_track = subscriber_delegate.waitForTrack(kTrackWaitTimeout); + ASSERT_NE(remote_track, nullptr) << "Timed out waiting for remote data track"; + EXPECT_TRUE(remote_track->info().uses_e2ee); + + auto subscription = remote_track->subscribe(); + ASSERT_NE(subscription, nullptr); + + ASSERT_TRUE(local_track->tryPush(payload, sent_timestamp)) + << "Failed to push timestamped encrypted frame"; + + const auto frame = readFrameWithTimeout(subscription, 5s); + EXPECT_EQ(frame.payload, payload); + ASSERT_TRUE(frame.user_timestamp.has_value()); + EXPECT_EQ(frame.user_timestamp.value(), sent_timestamp); + + subscription->close(); + local_track->unpublishDataTrack(); +} + +std::string dataTrackParamName( + const ::testing::TestParamInfo> &info) { + if (std::get<0>(info.param) > 100.0) { + return "HighFpsSinglePacket"; + } + return "LowFpsMultiPacket"; +} + +INSTANTIATE_TEST_SUITE_P(DataTrackScenarios, DataTrackTransportTest, + ::testing::Values(std::make_tuple(120.0, size_t{8192}), + std::make_tuple(10.0, + size_t{196608})), + dataTrackParamName); + +} // namespace test +} // namespace livekit diff --git a/src/tests/integration/test_room_callbacks.cpp b/src/tests/integration/test_room_callbacks.cpp index 5791166e..4e368e79 100644 --- a/src/tests/integration/test_room_callbacks.cpp +++ b/src/tests/integration/test_room_callbacks.cpp @@ -15,14 +15,14 @@ */ /// @file test_room_callbacks.cpp -/// @brief Unit tests for Room frame callback registration and internals. +/// @brief Public API tests for Room callback registration. #include #include #include +#include #include -#include #include namespace livekit { @@ -34,251 +34,139 @@ class RoomCallbackTest : public ::testing::Test { } void TearDown() override { livekit::shutdown(); } - - using CallbackKey = Room::CallbackKey; - using CallbackKeyHash = Room::CallbackKeyHash; - - static auto &audioCallbacks(Room &room) { return room.audio_callbacks_; } - static auto &videoCallbacks(Room &room) { return room.video_callbacks_; } - static auto &activeReaders(Room &room) { return room.active_readers_; } - static int maxActiveReaders() { return Room::kMaxActiveReaders; } }; -// ============================================================================ -// CallbackKey equality -// ============================================================================ - -TEST_F(RoomCallbackTest, CallbackKeyEqualKeysCompareEqual) { - CallbackKey a{"alice", TrackSource::SOURCE_MICROPHONE}; - CallbackKey b{"alice", TrackSource::SOURCE_MICROPHONE}; - EXPECT_TRUE(a == b); -} - -TEST_F(RoomCallbackTest, CallbackKeyDifferentIdentityNotEqual) { - CallbackKey a{"alice", TrackSource::SOURCE_MICROPHONE}; - CallbackKey b{"bob", TrackSource::SOURCE_MICROPHONE}; - EXPECT_FALSE(a == b); -} - -TEST_F(RoomCallbackTest, CallbackKeyDifferentSourceNotEqual) { - CallbackKey a{"alice", TrackSource::SOURCE_MICROPHONE}; - CallbackKey b{"alice", TrackSource::SOURCE_CAMERA}; - EXPECT_FALSE(a == b); -} - -// ============================================================================ -// CallbackKeyHash -// ============================================================================ - -TEST_F(RoomCallbackTest, CallbackKeyHashEqualKeysProduceSameHash) { - CallbackKey a{"alice", TrackSource::SOURCE_MICROPHONE}; - CallbackKey b{"alice", TrackSource::SOURCE_MICROPHONE}; - CallbackKeyHash hasher; - EXPECT_EQ(hasher(a), hasher(b)); -} - -TEST_F(RoomCallbackTest, CallbackKeyHashDifferentKeysLikelyDifferentHash) { - CallbackKeyHash hasher; - CallbackKey mic{"alice", TrackSource::SOURCE_MICROPHONE}; - CallbackKey cam{"alice", TrackSource::SOURCE_CAMERA}; - CallbackKey bob{"bob", TrackSource::SOURCE_MICROPHONE}; - - EXPECT_NE(hasher(mic), hasher(cam)); - EXPECT_NE(hasher(mic), hasher(bob)); -} - -TEST_F(RoomCallbackTest, CallbackKeyWorksAsUnorderedMapKey) { - std::unordered_map map; - - CallbackKey k1{"alice", TrackSource::SOURCE_MICROPHONE}; - CallbackKey k2{"bob", TrackSource::SOURCE_CAMERA}; - CallbackKey k3{"alice", TrackSource::SOURCE_CAMERA}; - - map[k1] = 1; - map[k2] = 2; - map[k3] = 3; - - EXPECT_EQ(map.size(), 3u); - EXPECT_EQ(map[k1], 1); - EXPECT_EQ(map[k2], 2); - EXPECT_EQ(map[k3], 3); - - map[k1] = 42; - EXPECT_EQ(map[k1], 42); - EXPECT_EQ(map.size(), 3u); - - map.erase(k2); - EXPECT_EQ(map.size(), 2u); - EXPECT_EQ(map.count(k2), 0u); -} - -TEST_F(RoomCallbackTest, CallbackKeyEmptyIdentityWorks) { - CallbackKey a{"", TrackSource::SOURCE_UNKNOWN}; - CallbackKey b{"", TrackSource::SOURCE_UNKNOWN}; - CallbackKeyHash hasher; - EXPECT_TRUE(a == b); - EXPECT_EQ(hasher(a), hasher(b)); -} - -// ============================================================================ -// kMaxActiveReaders -// ============================================================================ - -TEST_F(RoomCallbackTest, MaxActiveReadersIs20) { - EXPECT_EQ(maxActiveReaders(), 20); -} - -// ============================================================================ -// Registration and clearing (pre-connection, no server needed) -// ============================================================================ - -TEST_F(RoomCallbackTest, SetAudioCallbackStoresRegistration) { +TEST_F(RoomCallbackTest, AudioCallbackRegistrationIsAccepted) { Room room; - room.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, - [](const AudioFrame &) {}); - EXPECT_EQ(audioCallbacks(room).size(), 1u); + EXPECT_NO_THROW(room.setOnAudioFrameCallback( + "alice", TrackSource::SOURCE_MICROPHONE, "", + [](const AudioFrame &) {})); } -TEST_F(RoomCallbackTest, SetVideoCallbackStoresRegistration) { +TEST_F(RoomCallbackTest, VideoCallbackRegistrationIsAccepted) { Room room; - room.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, - [](const VideoFrame &, std::int64_t) {}); - EXPECT_EQ(videoCallbacks(room).size(), 1u); + EXPECT_NO_THROW(room.setOnVideoFrameCallback( + "alice", TrackSource::SOURCE_CAMERA, "", + [](const VideoFrame &, std::int64_t) {})); } -TEST_F(RoomCallbackTest, ClearAudioCallbackRemovesRegistration) { +TEST_F(RoomCallbackTest, ClearingMissingCallbacksIsNoOp) { Room room; - room.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, - [](const AudioFrame &) {}); - ASSERT_EQ(audioCallbacks(room).size(), 1u); - - room.clearOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE); - EXPECT_EQ(audioCallbacks(room).size(), 0u); -} -TEST_F(RoomCallbackTest, ClearVideoCallbackRemovesRegistration) { - Room room; - room.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, - [](const VideoFrame &, std::int64_t) {}); - ASSERT_EQ(videoCallbacks(room).size(), 1u); - - room.clearOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA); - EXPECT_EQ(videoCallbacks(room).size(), 0u); -} - -TEST_F(RoomCallbackTest, ClearNonExistentCallbackIsNoOp) { - Room room; EXPECT_NO_THROW( room.clearOnAudioFrameCallback("nobody", TrackSource::SOURCE_MICROPHONE)); EXPECT_NO_THROW( room.clearOnVideoFrameCallback("nobody", TrackSource::SOURCE_CAMERA)); } -TEST_F(RoomCallbackTest, OverwriteAudioCallbackKeepsSingleEntry) { +TEST_F(RoomCallbackTest, ReRegisteringSameAudioKeyDoesNotThrow) { Room room; std::atomic counter1{0}; std::atomic counter2{0}; - room.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, - [&counter1](const AudioFrame &) { counter1++; }); - room.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, - [&counter2](const AudioFrame &) { counter2++; }); - - EXPECT_EQ(audioCallbacks(room).size(), 1u) - << "Re-registering with the same key should overwrite, not add"; + EXPECT_NO_THROW(room.setOnAudioFrameCallback( + "alice", TrackSource::SOURCE_MICROPHONE, "", + [&counter1](const AudioFrame &) { counter1++; })); + EXPECT_NO_THROW(room.setOnAudioFrameCallback( + "alice", TrackSource::SOURCE_MICROPHONE, "", + [&counter2](const AudioFrame &) { counter2++; })); } -TEST_F(RoomCallbackTest, OverwriteVideoCallbackKeepsSingleEntry) { +TEST_F(RoomCallbackTest, ReRegisteringSameVideoKeyDoesNotThrow) { Room room; - room.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, - [](const VideoFrame &, std::int64_t) {}); - room.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, - [](const VideoFrame &, std::int64_t) {}); - EXPECT_EQ(videoCallbacks(room).size(), 1u); + EXPECT_NO_THROW(room.setOnVideoFrameCallback( + "alice", TrackSource::SOURCE_CAMERA, "", + [](const VideoFrame &, std::int64_t) {})); + EXPECT_NO_THROW(room.setOnVideoFrameCallback( + "alice", TrackSource::SOURCE_CAMERA, "", + [](const VideoFrame &, std::int64_t) {})); } -TEST_F(RoomCallbackTest, MultipleDistinctCallbacksAreIndependent) { +TEST_F(RoomCallbackTest, DistinctAudioAndVideoCallbacksCanCoexist) { Room room; - room.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, - [](const AudioFrame &) {}); - room.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, - [](const VideoFrame &, std::int64_t) {}); - room.setOnAudioFrameCallback("bob", TrackSource::SOURCE_MICROPHONE, - [](const AudioFrame &) {}); - room.setOnVideoFrameCallback("bob", TrackSource::SOURCE_CAMERA, - [](const VideoFrame &, std::int64_t) {}); - - EXPECT_EQ(audioCallbacks(room).size(), 2u); - EXPECT_EQ(videoCallbacks(room).size(), 2u); - - room.clearOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE); - EXPECT_EQ(audioCallbacks(room).size(), 1u); - EXPECT_EQ(videoCallbacks(room).size(), 2u); -} -TEST_F(RoomCallbackTest, ClearingOneSourceDoesNotAffectOther) { + EXPECT_NO_THROW(room.setOnAudioFrameCallback( + "alice", TrackSource::SOURCE_MICROPHONE, "", + [](const AudioFrame &) {})); + EXPECT_NO_THROW(room.setOnVideoFrameCallback( + "alice", TrackSource::SOURCE_CAMERA, "", + [](const VideoFrame &, std::int64_t) {})); + EXPECT_NO_THROW(room.setOnAudioFrameCallback( + "bob", TrackSource::SOURCE_MICROPHONE, "", + [](const AudioFrame &) {})); + EXPECT_NO_THROW(room.setOnVideoFrameCallback( + "bob", TrackSource::SOURCE_CAMERA, "", + [](const VideoFrame &, std::int64_t) {})); +} + +TEST_F(RoomCallbackTest, SameSourceDifferentTrackNamesAreAccepted) { Room room; - room.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, - [](const AudioFrame &) {}); - room.setOnAudioFrameCallback("alice", TrackSource::SOURCE_SCREENSHARE_AUDIO, - [](const AudioFrame &) {}); - ASSERT_EQ(audioCallbacks(room).size(), 2u); - - room.clearOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE); - EXPECT_EQ(audioCallbacks(room).size(), 1u); - CallbackKey remaining{"alice", TrackSource::SOURCE_SCREENSHARE_AUDIO}; - EXPECT_EQ(audioCallbacks(room).count(remaining), 1u); + EXPECT_NO_THROW(room.setOnVideoFrameCallback( + "alice", TrackSource::SOURCE_CAMERA, "cam-1", + [](const VideoFrame &, std::int64_t) {})); + EXPECT_NO_THROW(room.setOnVideoFrameCallback( + "alice", TrackSource::SOURCE_CAMERA, "cam-2", + [](const VideoFrame &, std::int64_t) {})); } -// ============================================================================ -// Active readers state (no real streams, just map state) -// ============================================================================ - -TEST_F(RoomCallbackTest, NoActiveReadersInitially) { +TEST_F(RoomCallbackTest, DataCallbackRegistrationReturnsUsableIds) { Room room; - EXPECT_TRUE(activeReaders(room).empty()); + + const auto id1 = room.addOnDataFrameCallback( + "alice", "track-a", + [](const std::vector &, std::optional) {}); + const auto id2 = room.addOnDataFrameCallback( + "alice", "track-a", + [](const std::vector &, std::optional) {}); + + EXPECT_NE(id1, std::numeric_limits::max()); + EXPECT_NE(id2, std::numeric_limits::max()); + EXPECT_NE(id1, id2); + + EXPECT_NO_THROW(room.removeOnDataFrameCallback(id1)); + EXPECT_NO_THROW(room.removeOnDataFrameCallback(id2)); } -TEST_F(RoomCallbackTest, ActiveReadersEmptyAfterCallbackRegistration) { +TEST_F(RoomCallbackTest, RemovingUnknownDataCallbackIsNoOp) { Room room; - room.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, - [](const AudioFrame &) {}); - EXPECT_TRUE(activeReaders(room).empty()) - << "Registering a callback without a subscribed track should not spawn " - "readers"; -} -// ============================================================================ -// Destruction safety -// ============================================================================ + EXPECT_NO_THROW(room.removeOnDataFrameCallback( + std::numeric_limits::max())); +} TEST_F(RoomCallbackTest, DestroyRoomWithRegisteredCallbacksIsSafe) { EXPECT_NO_THROW({ Room room; - room.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, + room.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, "", [](const AudioFrame &) {}); - room.setOnVideoFrameCallback("bob", TrackSource::SOURCE_CAMERA, + room.setOnVideoFrameCallback("bob", TrackSource::SOURCE_CAMERA, "", [](const VideoFrame &, std::int64_t) {}); + room.addOnDataFrameCallback( + "carol", "track", + [](const std::vector &, std::optional) { + }); }); } TEST_F(RoomCallbackTest, DestroyRoomAfterClearingCallbacksIsSafe) { EXPECT_NO_THROW({ Room room; - room.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, + room.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, "", [](const AudioFrame &) {}); room.clearOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE); + + const auto id = room.addOnDataFrameCallback( + "alice", "track", + [](const std::vector &, std::optional) { + }); + room.removeOnDataFrameCallback(id); }); } -// ============================================================================ -// Thread-safety of registration/clearing -// ============================================================================ - TEST_F(RoomCallbackTest, ConcurrentRegistrationDoesNotCrash) { Room room; constexpr int kThreads = 8; @@ -290,71 +178,67 @@ TEST_F(RoomCallbackTest, ConcurrentRegistrationDoesNotCrash) { for (int t = 0; t < kThreads; ++t) { threads.emplace_back([&room, t]() { for (int i = 0; i < kIterations; ++i) { - std::string id = "participant-" + std::to_string(t); - room.setOnAudioFrameCallback(id, TrackSource::SOURCE_MICROPHONE, + const std::string id = "participant-" + std::to_string(t); + room.setOnAudioFrameCallback(id, TrackSource::SOURCE_MICROPHONE, "", [](const AudioFrame &) {}); room.clearOnAudioFrameCallback(id, TrackSource::SOURCE_MICROPHONE); } }); } - for (auto &t : threads) { - t.join(); + for (auto &thread : threads) { + thread.join(); } - EXPECT_TRUE(audioCallbacks(room).empty()) - << "All callbacks should be cleared after concurrent register/clear"; + SUCCEED(); } -TEST_F(RoomCallbackTest, ConcurrentMixedAudioVideoRegistration) { +TEST_F(RoomCallbackTest, ConcurrentMixedRegistrationDoesNotCrash) { Room room; constexpr int kThreads = 4; constexpr int kIterations = 50; std::vector threads; + threads.reserve(kThreads); for (int t = 0; t < kThreads; ++t) { threads.emplace_back([&room, t]() { - std::string id = "p-" + std::to_string(t); + const std::string id = "p-" + std::to_string(t); for (int i = 0; i < kIterations; ++i) { - room.setOnAudioFrameCallback(id, TrackSource::SOURCE_MICROPHONE, + room.setOnAudioFrameCallback(id, TrackSource::SOURCE_MICROPHONE, "", [](const AudioFrame &) {}); - room.setOnVideoFrameCallback(id, TrackSource::SOURCE_CAMERA, + room.setOnVideoFrameCallback(id, TrackSource::SOURCE_CAMERA, "", [](const VideoFrame &, std::int64_t) {}); + const auto data_id = room.addOnDataFrameCallback( + id, "track", + [](const std::vector &, + std::optional) {}); + room.removeOnDataFrameCallback(data_id); } }); } - for (auto &t : threads) { - t.join(); + for (auto &thread : threads) { + thread.join(); } - EXPECT_EQ(audioCallbacks(room).size(), static_cast(kThreads)); - EXPECT_EQ(videoCallbacks(room).size(), static_cast(kThreads)); + SUCCEED(); } -// ============================================================================ -// Bulk registration -// ============================================================================ - -TEST_F(RoomCallbackTest, ManyDistinctCallbacksCanBeRegistered) { +TEST_F(RoomCallbackTest, ManyDistinctAudioCallbacksCanBeRegisteredAndCleared) { Room room; constexpr int kCount = 50; for (int i = 0; i < kCount; ++i) { - room.setOnAudioFrameCallback("participant-" + std::to_string(i), - TrackSource::SOURCE_MICROPHONE, - [](const AudioFrame &) {}); + EXPECT_NO_THROW(room.setOnAudioFrameCallback( + "participant-" + std::to_string(i), TrackSource::SOURCE_MICROPHONE, "", + [](const AudioFrame &) {})); } - EXPECT_EQ(audioCallbacks(room).size(), static_cast(kCount)); - for (int i = 0; i < kCount; ++i) { - room.clearOnAudioFrameCallback("participant-" + std::to_string(i), - TrackSource::SOURCE_MICROPHONE); + EXPECT_NO_THROW(room.clearOnAudioFrameCallback( + "participant-" + std::to_string(i), TrackSource::SOURCE_MICROPHONE)); } - - EXPECT_EQ(audioCallbacks(room).size(), 0u); } } // namespace livekit From dd0f986411812743bbde6d01386663fa870e8a2c Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Wed, 25 Mar 2026 15:41:07 -0600 Subject: [PATCH 19/34] test_data_tracks.cpp --- src/tests/integration/test_data_track.cpp | 125 ++++++++++++++++-- src/tests/integration/test_room_callbacks.cpp | 83 ++++++------ 2 files changed, 151 insertions(+), 57 deletions(-) diff --git a/src/tests/integration/test_data_track.cpp b/src/tests/integration/test_data_track.cpp index d1d7ca68..dac13bd2 100644 --- a/src/tests/integration/test_data_track.cpp +++ b/src/tests/integration/test_data_track.cpp @@ -56,6 +56,26 @@ std::string makeTrackName(const std::string &suffix) { std::to_string(getTimestampUs()); } +std::vector e2eeSharedKey() { + return std::vector( + kE2EESharedSecret, kE2EESharedSecret + sizeof(kE2EESharedSecret) - 1); +} + +E2EEOptions makeE2EEOptions() { + E2EEOptions options; + options.key_provider_options.shared_key = e2eeSharedKey(); + return options; +} + +std::vector +encryptedRoomConfigs(RoomDelegate *subscriber_delegate) { + std::vector room_configs(2); + room_configs[0].room_options.encryption = makeE2EEOptions(); + room_configs[1].room_options.encryption = makeE2EEOptions(); + room_configs[1].delegate = subscriber_delegate; + return room_configs; +} + template bool waitForCondition(Predicate &&predicate, std::chrono::milliseconds timeout, std::chrono::milliseconds interval = kPollingInterval) { @@ -483,6 +503,8 @@ TEST_F(DataTrackE2ETest, PublishesAndReceivesEncryptedFramesEndToEnd) { ASSERT_NE(subscriber_room->e2eeManager(), nullptr); ASSERT_NE(publisher_room->e2eeManager()->keyProvider(), nullptr); ASSERT_NE(subscriber_room->e2eeManager()->keyProvider(), nullptr); + publisher_room->e2eeManager()->setEnabled(true); + subscriber_room->e2eeManager()->setEnabled(true); EXPECT_EQ(publisher_room->e2eeManager()->keyProvider()->exportSharedKey(), e2eeSharedKey()); EXPECT_EQ(subscriber_room->e2eeManager()->keyProvider()->exportSharedKey(), @@ -503,19 +525,56 @@ TEST_F(DataTrackE2ETest, PublishesAndReceivesEncryptedFramesEndToEnd) { auto subscription = remote_track->subscribe(); ASSERT_NE(subscription, nullptr); - for (int index = 0; index < kE2EEFrameCount; ++index) { - std::vector payload(64, + std::promise frame_promise; + auto frame_future = frame_promise.get_future(); + std::thread reader([&]() { + try { + DataFrame frame; + if (!subscription->read(frame)) { + throw std::runtime_error( + "Subscription ended before an encrypted frame arrived"); + } + frame_promise.set_value(std::move(frame)); + } catch (...) { + frame_promise.set_exception(std::current_exception()); + } + }); + + bool pushed = false; + for (int index = 0; index < 200; ++index) { + std::vector payload(kLargeFramePayloadBytes, static_cast(index + 1)); - ASSERT_TRUE(local_track->tryPush(payload)) - << "Failed to push encrypted frame " << index; - - const auto frame = readFrameWithTimeout(subscription, 5s); - EXPECT_EQ(frame.payload, payload) - << "Encrypted payload mismatch for frame " << index; - EXPECT_FALSE(frame.user_timestamp.has_value()) - << "Unexpected user timestamp on encrypted frame " << index; + pushed = local_track->tryPush(payload) || pushed; + if (frame_future.wait_for(25ms) == std::future_status::ready) { + break; + } } + const auto frame_status = frame_future.wait_for(5s); + if (frame_status != std::future_status::ready) { + subscription->close(); + } + reader.join(); + ASSERT_TRUE(pushed) << "Failed to push encrypted data frames"; + ASSERT_EQ(frame_status, std::future_status::ready) + << "Timed out waiting for encrypted frame delivery"; + + DataFrame frame; + try { + frame = frame_future.get(); + } catch (const std::exception &e) { + FAIL() << e.what(); + } + ASSERT_FALSE(frame.payload.empty()); + const auto first_byte = frame.payload.front(); + EXPECT_TRUE(std::all_of(frame.payload.begin(), frame.payload.end(), + [first_byte](std::uint8_t byte) { + return byte == first_byte; + })) + << "Encrypted payload is not byte-consistent"; + EXPECT_FALSE(frame.user_timestamp.has_value()) + << "Unexpected user timestamp on encrypted frame"; + subscription->close(); local_track->unpublishDataTrack(); } @@ -529,6 +588,12 @@ TEST_F(DataTrackE2ETest, PreservesUserTimestampOnEncryptedDataTrack) { auto room_configs = encryptedRoomConfigs(&subscriber_delegate); auto rooms = testRooms(room_configs); auto &publisher_room = rooms[0]; + auto &subscriber_room = rooms[1]; + + ASSERT_NE(publisher_room->e2eeManager(), nullptr); + ASSERT_NE(subscriber_room->e2eeManager(), nullptr); + publisher_room->e2eeManager()->setEnabled(true); + subscriber_room->e2eeManager()->setEnabled(true); auto local_track = publisher_room->localParticipant()->publishDataTrack(track_name); @@ -543,10 +608,44 @@ TEST_F(DataTrackE2ETest, PreservesUserTimestampOnEncryptedDataTrack) { auto subscription = remote_track->subscribe(); ASSERT_NE(subscription, nullptr); - ASSERT_TRUE(local_track->tryPush(payload, sent_timestamp)) - << "Failed to push timestamped encrypted frame"; + std::promise frame_promise; + auto frame_future = frame_promise.get_future(); + std::thread reader([&]() { + try { + DataFrame incoming_frame; + if (!subscription->read(incoming_frame)) { + throw std::runtime_error( + "Subscription ended before timestamped encrypted frame arrived"); + } + frame_promise.set_value(std::move(incoming_frame)); + } catch (...) { + frame_promise.set_exception(std::current_exception()); + } + }); - const auto frame = readFrameWithTimeout(subscription, 5s); + bool pushed = false; + for (int attempt = 0; attempt < 200; ++attempt) { + pushed = local_track->tryPush(payload, sent_timestamp) || pushed; + if (frame_future.wait_for(25ms) == std::future_status::ready) { + break; + } + } + const auto frame_status = frame_future.wait_for(5s); + if (frame_status != std::future_status::ready) { + subscription->close(); + } + + reader.join(); + ASSERT_TRUE(pushed) << "Failed to push timestamped encrypted frame"; + ASSERT_EQ(frame_status, std::future_status::ready) + << "Timed out waiting for timestamped encrypted frame"; + + DataFrame frame; + try { + frame = frame_future.get(); + } catch (const std::exception &e) { + FAIL() << e.what(); + } EXPECT_EQ(frame.payload, payload); ASSERT_TRUE(frame.user_timestamp.has_value()); EXPECT_EQ(frame.user_timestamp.value(), sent_timestamp); diff --git a/src/tests/integration/test_room_callbacks.cpp b/src/tests/integration/test_room_callbacks.cpp index 4e368e79..d0dc6bf8 100644 --- a/src/tests/integration/test_room_callbacks.cpp +++ b/src/tests/integration/test_room_callbacks.cpp @@ -40,16 +40,15 @@ TEST_F(RoomCallbackTest, AudioCallbackRegistrationIsAccepted) { Room room; EXPECT_NO_THROW(room.setOnAudioFrameCallback( - "alice", TrackSource::SOURCE_MICROPHONE, "", - [](const AudioFrame &) {})); + "alice", TrackSource::SOURCE_MICROPHONE, [](const AudioFrame &) {})); } TEST_F(RoomCallbackTest, VideoCallbackRegistrationIsAccepted) { Room room; - EXPECT_NO_THROW(room.setOnVideoFrameCallback( - "alice", TrackSource::SOURCE_CAMERA, "", - [](const VideoFrame &, std::int64_t) {})); + EXPECT_NO_THROW( + room.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, + [](const VideoFrame &, std::int64_t) {})); } TEST_F(RoomCallbackTest, ClearingMissingCallbacksIsNoOp) { @@ -67,50 +66,48 @@ TEST_F(RoomCallbackTest, ReRegisteringSameAudioKeyDoesNotThrow) { std::atomic counter2{0}; EXPECT_NO_THROW(room.setOnAudioFrameCallback( - "alice", TrackSource::SOURCE_MICROPHONE, "", + "alice", TrackSource::SOURCE_MICROPHONE, [&counter1](const AudioFrame &) { counter1++; })); EXPECT_NO_THROW(room.setOnAudioFrameCallback( - "alice", TrackSource::SOURCE_MICROPHONE, "", + "alice", TrackSource::SOURCE_MICROPHONE, [&counter2](const AudioFrame &) { counter2++; })); } TEST_F(RoomCallbackTest, ReRegisteringSameVideoKeyDoesNotThrow) { Room room; - EXPECT_NO_THROW(room.setOnVideoFrameCallback( - "alice", TrackSource::SOURCE_CAMERA, "", - [](const VideoFrame &, std::int64_t) {})); - EXPECT_NO_THROW(room.setOnVideoFrameCallback( - "alice", TrackSource::SOURCE_CAMERA, "", - [](const VideoFrame &, std::int64_t) {})); + EXPECT_NO_THROW( + room.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, + [](const VideoFrame &, std::int64_t) {})); + EXPECT_NO_THROW( + room.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, + [](const VideoFrame &, std::int64_t) {})); } TEST_F(RoomCallbackTest, DistinctAudioAndVideoCallbacksCanCoexist) { Room room; EXPECT_NO_THROW(room.setOnAudioFrameCallback( - "alice", TrackSource::SOURCE_MICROPHONE, "", - [](const AudioFrame &) {})); - EXPECT_NO_THROW(room.setOnVideoFrameCallback( - "alice", TrackSource::SOURCE_CAMERA, "", - [](const VideoFrame &, std::int64_t) {})); + "alice", TrackSource::SOURCE_MICROPHONE, [](const AudioFrame &) {})); + EXPECT_NO_THROW( + room.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, + [](const VideoFrame &, std::int64_t) {})); EXPECT_NO_THROW(room.setOnAudioFrameCallback( - "bob", TrackSource::SOURCE_MICROPHONE, "", - [](const AudioFrame &) {})); - EXPECT_NO_THROW(room.setOnVideoFrameCallback( - "bob", TrackSource::SOURCE_CAMERA, "", - [](const VideoFrame &, std::int64_t) {})); + "bob", TrackSource::SOURCE_MICROPHONE, [](const AudioFrame &) {})); + EXPECT_NO_THROW( + room.setOnVideoFrameCallback("bob", TrackSource::SOURCE_CAMERA, + [](const VideoFrame &, std::int64_t) {})); } TEST_F(RoomCallbackTest, SameSourceDifferentTrackNamesAreAccepted) { Room room; - EXPECT_NO_THROW(room.setOnVideoFrameCallback( - "alice", TrackSource::SOURCE_CAMERA, "cam-1", - [](const VideoFrame &, std::int64_t) {})); - EXPECT_NO_THROW(room.setOnVideoFrameCallback( - "alice", TrackSource::SOURCE_CAMERA, "cam-2", - [](const VideoFrame &, std::int64_t) {})); + EXPECT_NO_THROW( + room.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, + [](const VideoFrame &, std::int64_t) {})); + EXPECT_NO_THROW( + room.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, + [](const VideoFrame &, std::int64_t) {})); } TEST_F(RoomCallbackTest, DataCallbackRegistrationReturnsUsableIds) { @@ -141,28 +138,26 @@ TEST_F(RoomCallbackTest, RemovingUnknownDataCallbackIsNoOp) { TEST_F(RoomCallbackTest, DestroyRoomWithRegisteredCallbacksIsSafe) { EXPECT_NO_THROW({ Room room; - room.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, "", + room.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, [](const AudioFrame &) {}); - room.setOnVideoFrameCallback("bob", TrackSource::SOURCE_CAMERA, "", + room.setOnVideoFrameCallback("bob", TrackSource::SOURCE_CAMERA, [](const VideoFrame &, std::int64_t) {}); room.addOnDataFrameCallback( "carol", "track", - [](const std::vector &, std::optional) { - }); + [](const std::vector &, std::optional) {}); }); } TEST_F(RoomCallbackTest, DestroyRoomAfterClearingCallbacksIsSafe) { EXPECT_NO_THROW({ Room room; - room.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, "", + room.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, [](const AudioFrame &) {}); room.clearOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE); const auto id = room.addOnDataFrameCallback( "alice", "track", - [](const std::vector &, std::optional) { - }); + [](const std::vector &, std::optional) {}); room.removeOnDataFrameCallback(id); }); } @@ -179,7 +174,7 @@ TEST_F(RoomCallbackTest, ConcurrentRegistrationDoesNotCrash) { threads.emplace_back([&room, t]() { for (int i = 0; i < kIterations; ++i) { const std::string id = "participant-" + std::to_string(t); - room.setOnAudioFrameCallback(id, TrackSource::SOURCE_MICROPHONE, "", + room.setOnAudioFrameCallback(id, TrackSource::SOURCE_MICROPHONE, [](const AudioFrame &) {}); room.clearOnAudioFrameCallback(id, TrackSource::SOURCE_MICROPHONE); } @@ -205,14 +200,14 @@ TEST_F(RoomCallbackTest, ConcurrentMixedRegistrationDoesNotCrash) { threads.emplace_back([&room, t]() { const std::string id = "p-" + std::to_string(t); for (int i = 0; i < kIterations; ++i) { - room.setOnAudioFrameCallback(id, TrackSource::SOURCE_MICROPHONE, "", + room.setOnAudioFrameCallback(id, TrackSource::SOURCE_MICROPHONE, [](const AudioFrame &) {}); - room.setOnVideoFrameCallback(id, TrackSource::SOURCE_CAMERA, "", + room.setOnVideoFrameCallback(id, TrackSource::SOURCE_CAMERA, [](const VideoFrame &, std::int64_t) {}); - const auto data_id = room.addOnDataFrameCallback( - id, "track", - [](const std::vector &, - std::optional) {}); + const auto data_id = + room.addOnDataFrameCallback(id, "track", + [](const std::vector &, + std::optional) {}); room.removeOnDataFrameCallback(data_id); } }); @@ -231,7 +226,7 @@ TEST_F(RoomCallbackTest, ManyDistinctAudioCallbacksCanBeRegisteredAndCleared) { for (int i = 0; i < kCount; ++i) { EXPECT_NO_THROW(room.setOnAudioFrameCallback( - "participant-" + std::to_string(i), TrackSource::SOURCE_MICROPHONE, "", + "participant-" + std::to_string(i), TrackSource::SOURCE_MICROPHONE, [](const AudioFrame &) {})); } From 183eb7a51140442d4582fdc0cd6b724343c348f3 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Wed, 25 Mar 2026 15:53:10 -0600 Subject: [PATCH 20/34] setOnAudio/VideoFrameCallback() change signature to use track name --- bridge/README.md | 14 +- .../include/livekit_bridge/livekit_bridge.h | 34 +++-- bridge/src/livekit_bridge.cpp | 16 +-- bridge/tests/test_livekit_bridge.cpp | 17 +-- examples/bridge_human_robot/human.cpp | 8 +- examples/bridge_mute_unmute/caller.cpp | 4 +- examples/hello_livekit/receiver.cpp | 8 +- examples/hello_livekit/sender.cpp | 5 +- include/livekit/room.h | 35 +++-- .../livekit/subscription_thread_dispatcher.h | 57 ++++---- src/room.cpp | 29 ++-- src/subscription_thread_dispatcher.cpp | 134 +++++++++--------- src/tests/integration/test_room_callbacks.cpp | 54 ++++--- .../test_subscription_thread_dispatcher.cpp | 112 +++++++-------- 14 files changed, 260 insertions(+), 267 deletions(-) diff --git a/bridge/README.md b/bridge/README.md index 5f7c54cf..66222e35 100644 --- a/bridge/README.md +++ b/bridge/README.md @@ -36,12 +36,12 @@ mic->pushFrame(pcm_data, samples_per_channel); cam->pushFrame(rgba_data, timestamp_us); // 4. Receive frames from a remote participant -bridge.setOnAudioFrameCallback("remote-peer", livekit::TrackSource::SOURCE_MICROPHONE, +bridge.setOnAudioFrameCallback("remote-peer", "mic", [](const livekit::AudioFrame& frame) { // Called on a background reader thread }); -bridge.setOnVideoFrameCallback("remote-peer", livekit::TrackSource::SOURCE_CAMERA, +bridge.setOnVideoFrameCallback("remote-peer", "cam", [](const livekit::VideoFrame& frame, int64_t timestamp_us) { // Called on a background reader thread }); @@ -126,7 +126,7 @@ This means the typical pattern is: livekit::RoomOptions options; options.auto_subscribe = true; bridge.connect(url, token, options); -bridge.setOnAudioFrameCallback("robot-1", livekit::TrackSource::SOURCE_MICROPHONE, my_callback); +bridge.setOnAudioFrameCallback("robot-1", "robot-mic", my_callback); // When robot-1 joins and publishes a mic track, my_callback starts firing. ``` @@ -147,10 +147,10 @@ bridge.setOnAudioFrameCallback("robot-1", livekit::TrackSource::SOURCE_MICROPHON | `isConnected()` | Returns whether the bridge is currently connected. | | `createAudioTrack(name, sample_rate, num_channels, source)` | Create and publish a local audio track with the given `TrackSource` (e.g. `SOURCE_MICROPHONE`, `SOURCE_SCREENSHARE_AUDIO`). Returns an RAII `shared_ptr`. | | `createVideoTrack(name, width, height, source)` | Create and publish a local video track with the given `TrackSource` (e.g. `SOURCE_CAMERA`, `SOURCE_SCREENSHARE`). Returns an RAII `shared_ptr`. | -| `setOnAudioFrameCallback(identity, source, callback)` | Register a callback for audio frames from a specific remote participant + track source. | -| `setOnVideoFrameCallback(identity, source, callback)` | Register a callback for video frames from a specific remote participant + track source. | -| `clearOnAudioFrameCallback(identity, source)` | Clear the audio callback for a specific remote participant + track source. Stops and joins the reader thread if active. | -| `clearOnVideoFrameCallback(identity, source)` | Clear the video callback for a specific remote participant + track source. Stops and joins the reader thread if active. | +| `setOnAudioFrameCallback(identity, track_name, callback)` | Register a callback for audio frames from a specific remote participant + track name. | +| `setOnVideoFrameCallback(identity, track_name, callback)` | Register a callback for video frames from a specific remote participant + track name. | +| `clearOnAudioFrameCallback(identity, track_name)` | Clear the audio callback for a specific remote participant + track name. Stops and joins the reader thread if active. | +| `clearOnVideoFrameCallback(identity, track_name)` | Clear the video callback for a specific remote participant + track name. Stops and joins the reader thread if active. | | `performRpc(destination_identity, method, payload, response_timeout?)` | Blocking RPC call to a remote participant. Returns the response payload. Throws `livekit::RpcError` on failure. | | `registerRpcMethod(method_name, handler)` | Register a handler for incoming RPC invocations. The handler returns an optional response payload or throws `livekit::RpcError`. | | `unregisterRpcMethod(method_name)` | Unregister a previously registered RPC handler. | diff --git a/bridge/include/livekit_bridge/livekit_bridge.h b/bridge/include/livekit_bridge/livekit_bridge.h index 47f13d46..a424af11 100644 --- a/bridge/include/livekit_bridge/livekit_bridge.h +++ b/bridge/include/livekit_bridge/livekit_bridge.h @@ -89,12 +89,10 @@ using VideoFrameCallback = livekit::VideoFrameCallback; * mic->pushFrame(pcm_data, samples_per_channel); * cam->pushFrame(rgba_data, timestamp_us); * - * bridge.setOnAudioFrameCallback("remote-participant", - * livekit::TrackSource::SOURCE_MICROPHONE, + * bridge.setOnAudioFrameCallback("remote-participant", "mic", * [](const livekit::AudioFrame& f) { process(f); }); * - * bridge.setOnVideoFrameCallback("remote-participant", - * livekit::TrackSource::SOURCE_CAMERA, + * bridge.setOnVideoFrameCallback("remote-participant", "cam", * [](const livekit::VideoFrame& f, int64_t ts) { render(f); }); * * // Unpublish a single track mid-session: @@ -208,59 +206,59 @@ class LiveKitBridge { /** * Set the callback for audio frames from a specific remote participant - * and track source. + * and track name. * * Delegates to Room::setOnAudioFrameCallback. The callback fires on a * dedicated reader thread owned by Room whenever a new audio frame is * received. * - * @note Only **one** callback may be registered per (participant, source) - * pair. Calling this again with the same identity and source will + * @note Only **one** callback may be registered per (participant, track_name) + * pair. Calling this again with the same identity and track_name will * silently replace the previous callback. * * @param participant_identity Identity of the remote participant. - * @param source Track source (e.g. SOURCE_MICROPHONE). + * @param track_name Track name to subscribe to. * @param callback Function to invoke per audio frame. */ void setOnAudioFrameCallback(const std::string &participant_identity, - livekit::TrackSource source, + const std::string &track_name, AudioFrameCallback callback); /** * Register a callback for video frames from a specific remote participant - * and track source. + * and track name. * * Delegates to Room::setOnVideoFrameCallback. * - * @note Only **one** callback may be registered per (participant, source) - * pair. Calling this again with the same identity and source will + * @note Only **one** callback may be registered per (participant, track_name) + * pair. Calling this again with the same identity and track_name will * silently replace the previous callback. * * @param participant_identity Identity of the remote participant. - * @param source Track source (e.g. SOURCE_CAMERA). + * @param track_name Track name to subscribe to. * @param callback Function to invoke per video frame. */ void setOnVideoFrameCallback(const std::string &participant_identity, - livekit::TrackSource source, + const std::string &track_name, VideoFrameCallback callback); /** * Clear the audio frame callback for a specific remote participant + track - * source. + * name. * * Delegates to Room::clearOnAudioFrameCallback. */ void clearOnAudioFrameCallback(const std::string &participant_identity, - livekit::TrackSource source); + const std::string &track_name); /** * Clear the video frame callback for a specific remote participant + track - * source. + * name. * * Delegates to Room::clearOnVideoFrameCallback. */ void clearOnVideoFrameCallback(const std::string &participant_identity, - livekit::TrackSource source); + const std::string &track_name); // --------------------------------------------------------------- // RPC (Remote Procedure Call) diff --git a/bridge/src/livekit_bridge.cpp b/bridge/src/livekit_bridge.cpp index 9f782904..ffc36227 100644 --- a/bridge/src/livekit_bridge.cpp +++ b/bridge/src/livekit_bridge.cpp @@ -237,45 +237,45 @@ LiveKitBridge::createVideoTrack(const std::string &name, int width, int height, // --------------------------------------------------------------- void LiveKitBridge::setOnAudioFrameCallback( - const std::string &participant_identity, livekit::TrackSource source, + const std::string &participant_identity, const std::string &track_name, AudioFrameCallback callback) { std::lock_guard lock(mutex_); if (!room_) { LK_LOG_WARN("setOnAudioFrameCallback called before connect(); ignored"); return; } - room_->setOnAudioFrameCallback(participant_identity, source, + room_->setOnAudioFrameCallback(participant_identity, track_name, std::move(callback)); } void LiveKitBridge::setOnVideoFrameCallback( - const std::string &participant_identity, livekit::TrackSource source, + const std::string &participant_identity, const std::string &track_name, VideoFrameCallback callback) { std::lock_guard lock(mutex_); if (!room_) { LK_LOG_WARN("setOnVideoFrameCallback called before connect(); ignored"); return; } - room_->setOnVideoFrameCallback(participant_identity, source, + room_->setOnVideoFrameCallback(participant_identity, track_name, std::move(callback)); } void LiveKitBridge::clearOnAudioFrameCallback( - const std::string &participant_identity, livekit::TrackSource source) { + const std::string &participant_identity, const std::string &track_name) { std::lock_guard lock(mutex_); if (!room_) { return; } - room_->clearOnAudioFrameCallback(participant_identity, source); + room_->clearOnAudioFrameCallback(participant_identity, track_name); } void LiveKitBridge::clearOnVideoFrameCallback( - const std::string &participant_identity, livekit::TrackSource source) { + const std::string &participant_identity, const std::string &track_name) { std::lock_guard lock(mutex_); if (!room_) { return; } - room_->clearOnVideoFrameCallback(participant_identity, source); + room_->clearOnVideoFrameCallback(participant_identity, track_name); } // --------------------------------------------------------------- diff --git a/bridge/tests/test_livekit_bridge.cpp b/bridge/tests/test_livekit_bridge.cpp index 43c8f6fb..9781e050 100644 --- a/bridge/tests/test_livekit_bridge.cpp +++ b/bridge/tests/test_livekit_bridge.cpp @@ -101,12 +101,10 @@ TEST_F(LiveKitBridgeTest, SetAndClearAudioCallbackBeforeConnectDoesNotCrash) { LiveKitBridge bridge; EXPECT_NO_THROW({ - bridge.setOnAudioFrameCallback("remote-participant", - livekit::TrackSource::SOURCE_MICROPHONE, + bridge.setOnAudioFrameCallback("remote-participant", "microphone", [](const livekit::AudioFrame &) {}); - bridge.clearOnAudioFrameCallback("remote-participant", - livekit::TrackSource::SOURCE_MICROPHONE); + bridge.clearOnAudioFrameCallback("remote-participant", "microphone"); }) << "set/clear audio callback before connect should be safe (warns)"; } @@ -115,11 +113,10 @@ TEST_F(LiveKitBridgeTest, SetAndClearVideoCallbackBeforeConnectDoesNotCrash) { EXPECT_NO_THROW({ bridge.setOnVideoFrameCallback( - "remote-participant", livekit::TrackSource::SOURCE_CAMERA, + "remote-participant", "camera", [](const livekit::VideoFrame &, std::int64_t) {}); - bridge.clearOnVideoFrameCallback("remote-participant", - livekit::TrackSource::SOURCE_CAMERA); + bridge.clearOnVideoFrameCallback("remote-participant", "camera"); }) << "set/clear video callback before connect should be safe (warns)"; } @@ -127,10 +124,8 @@ TEST_F(LiveKitBridgeTest, ClearNonExistentCallbackIsNoOp) { LiveKitBridge bridge; EXPECT_NO_THROW({ - bridge.clearOnAudioFrameCallback("nonexistent", - livekit::TrackSource::SOURCE_MICROPHONE); - bridge.clearOnVideoFrameCallback("nonexistent", - livekit::TrackSource::SOURCE_CAMERA); + bridge.clearOnAudioFrameCallback("nonexistent", "microphone"); + bridge.clearOnVideoFrameCallback("nonexistent", "camera"); }) << "Clearing a callback that was never registered should be a no-op"; } diff --git a/examples/bridge_human_robot/human.cpp b/examples/bridge_human_robot/human.cpp index 81989eb5..5491f13f 100644 --- a/examples/bridge_human_robot/human.cpp +++ b/examples/bridge_human_robot/human.cpp @@ -232,7 +232,7 @@ int main(int argc, char *argv[]) { // ----- Set audio callbacks using Room::setOnAudioFrameCallback ----- room->setOnAudioFrameCallback( - "robot", livekit::TrackSource::SOURCE_MICROPHONE, + "robot", "robot-mic", [playAudio, no_audio](const livekit::AudioFrame &frame) { g_audio_frames.fetch_add(1, std::memory_order_relaxed); if (!no_audio && g_selected_source.load(std::memory_order_relaxed) == @@ -242,7 +242,7 @@ int main(int argc, char *argv[]) { }); room->setOnAudioFrameCallback( - "robot", livekit::TrackSource::SOURCE_SCREENSHARE_AUDIO, + "robot", "robot-sim-audio", [playAudio, no_audio](const livekit::AudioFrame &frame) { g_audio_frames.fetch_add(1, std::memory_order_relaxed); if (!no_audio && g_selected_source.load(std::memory_order_relaxed) == @@ -253,7 +253,7 @@ int main(int argc, char *argv[]) { // ----- Set video callbacks using Room::setOnVideoFrameCallback ----- room->setOnVideoFrameCallback( - "robot", livekit::TrackSource::SOURCE_CAMERA, + "robot", "robot-cam", [](const livekit::VideoFrame &frame, std::int64_t /*timestamp_us*/) { g_video_frames.fetch_add(1, std::memory_order_relaxed); if (g_selected_source.load(std::memory_order_relaxed) == @@ -263,7 +263,7 @@ int main(int argc, char *argv[]) { }); room->setOnVideoFrameCallback( - "robot", livekit::TrackSource::SOURCE_SCREENSHARE, + "robot", "robot-sim-frame", [](const livekit::VideoFrame &frame, std::int64_t /*timestamp_us*/) { g_video_frames.fetch_add(1, std::memory_order_relaxed); if (g_selected_source.load(std::memory_order_relaxed) == diff --git a/examples/bridge_mute_unmute/caller.cpp b/examples/bridge_mute_unmute/caller.cpp index a47b2e11..3ea6f528 100644 --- a/examples/bridge_mute_unmute/caller.cpp +++ b/examples/bridge_mute_unmute/caller.cpp @@ -165,7 +165,7 @@ int main(int argc, char *argv[]) { // ----- Subscribe to receiver's audio ----- bridge.setOnAudioFrameCallback( - receiver_identity, livekit::TrackSource::SOURCE_MICROPHONE, + receiver_identity, "mic", [&speaker, &speaker_mutex](const livekit::AudioFrame &frame) { const auto &samples = frame.data(); if (samples.empty()) @@ -188,7 +188,7 @@ int main(int argc, char *argv[]) { // ----- Subscribe to receiver's video ----- bridge.setOnVideoFrameCallback( - receiver_identity, livekit::TrackSource::SOURCE_CAMERA, + receiver_identity, "cam", [](const livekit::VideoFrame &frame, std::int64_t /*timestamp_us*/) { storeFrame(frame); }); diff --git a/examples/hello_livekit/receiver.cpp b/examples/hello_livekit/receiver.cpp index c086e0b4..332ee766 100644 --- a/examples/hello_livekit/receiver.cpp +++ b/examples/hello_livekit/receiver.cpp @@ -35,6 +35,7 @@ using namespace livekit; constexpr const char *kDataTrackName = "app-data"; +constexpr const char *kVideoTrackName = "camera0"; std::atomic g_running{true}; @@ -91,7 +92,7 @@ int main(int argc, char *argv[]) { int video_frame_count = 0; room->setOnVideoFrameCallback( - sender_identity, TrackSource::SOURCE_CAMERA, + sender_identity, kVideoTrackName, [&video_frame_count](const VideoFrame &frame, std::int64_t timestamp_us) { const auto ts_ms = std::chrono::duration(timestamp_us).count(); @@ -114,8 +115,9 @@ int main(int argc, char *argv[]) { }); LK_LOG_INFO( - "[receiver] Listening for camera + data track '{}'; Ctrl-C to exit", - kDataTrackName); + "[receiver] Listening for video track '{}' + data track '{}'; Ctrl-C to " + "exit", + kVideoTrackName, kDataTrackName); while (g_running.load()) { std::this_thread::sleep_for(std::chrono::milliseconds(50)); diff --git a/examples/hello_livekit/sender.cpp b/examples/hello_livekit/sender.cpp index 3ca70d5f..39a183f3 100644 --- a/examples/hello_livekit/sender.cpp +++ b/examples/hello_livekit/sender.cpp @@ -59,9 +59,8 @@ int main(int argc, char *argv[]) { } if (url.empty() || sender_token.empty()) { - LK_LOG_ERROR( - "Usage: HelloLivekitSender \n" - " or set LIVEKIT_URL, LIVEKIT_SENDER_TOKEN"); + LK_LOG_ERROR("Usage: HelloLivekitSender \n" + " or set LIVEKIT_URL, LIVEKIT_SENDER_TOKEN"); return 1; } diff --git a/include/livekit/room.h b/include/livekit/room.h index d2cc934e..334c459e 100644 --- a/include/livekit/room.h +++ b/include/livekit/room.h @@ -1,5 +1,5 @@ /* - * Copyright 2025 LiveKit + * Copyright 2026 LiveKit * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -242,59 +242,64 @@ class Room { /** * Set a callback for audio frames from a specific remote participant and - * track source. + * track name. * - * A dedicated reader thread is spawned for each (participant, source) pair + * A dedicated reader thread is spawned for each (participant, track_name) + * pair * when the track is subscribed. If the track is already subscribed, the * reader starts immediately. If not, it starts when the track arrives. * - * Only one callback may exist per (participant, source) pair. Re-calling + * Only one callback may exist per (participant, track_name) pair. Re-calling * with the same pair replaces the previous callback. * * @param participant_identity Identity of the remote participant. - * @param source Track source (e.g. SOURCE_MICROPHONE). + * @param track_name Track name to subscribe to. * @param callback Function invoked per audio frame. * @param opts AudioStream options (capacity, noise * cancellation). */ void setOnAudioFrameCallback(const std::string &participant_identity, - TrackSource source, AudioFrameCallback callback, + const std::string &track_name, + AudioFrameCallback callback, AudioStream::Options opts = {}); /** * Set a callback for video frames from a specific remote participant and - * track source. + * track name. * * @see setOnAudioFrameCallback for threading and lifecycle semantics. * * @param participant_identity Identity of the remote participant. - * @param source Track source (e.g. SOURCE_CAMERA). + * @param track_name Track name to subscribe to. * @param callback Function invoked per video frame. * @param opts VideoStream options (capacity, pixel format). */ void setOnVideoFrameCallback(const std::string &participant_identity, - TrackSource source, VideoFrameCallback callback, + const std::string &track_name, + VideoFrameCallback callback, VideoStream::Options opts = {}); /** - * Clear the audio frame callback for a specific (participant, source) pair. + * Clear the audio frame callback for a specific (participant, track_name) + * pair. * Stops and joins any active reader thread. * No-op if no callback is registered for this key. * @param participant_identity Identity of the remote participant. - * @param source Track source (e.g. SOURCE_MICROPHONE). + * @param track_name Track name to clear. */ void clearOnAudioFrameCallback(const std::string &participant_identity, - TrackSource source); + const std::string &track_name); /** - * Clear the video frame callback for a specific (participant, source) pair. + * Clear the video frame callback for a specific (participant, track_name) + * pair. * Stops and joins any active reader thread. * No-op if no callback is registered for this key. * @param participant_identity Identity of the remote participant. - * @param source Track source (e.g. SOURCE_CAMERA). + * @param track_name Track name to clear. */ void clearOnVideoFrameCallback(const std::string &participant_identity, - TrackSource source); + const std::string &track_name); /** * Add a callback for data frames from a specific remote participant's diff --git a/include/livekit/subscription_thread_dispatcher.h b/include/livekit/subscription_thread_dispatcher.h index d9d4f232..b30355fd 100644 --- a/include/livekit/subscription_thread_dispatcher.h +++ b/include/livekit/subscription_thread_dispatcher.h @@ -1,5 +1,5 @@ /* - * Copyright 2025 LiveKit + * Copyright 2026 LiveKit * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -39,11 +39,11 @@ class Track; class VideoFrame; /// Callback type for incoming audio frames. -/// Invoked on a dedicated reader thread per (participant, source) pair. +/// Invoked on a dedicated reader thread per (participant, track_name) pair. using AudioFrameCallback = std::function; /// Callback type for incoming video frames. -/// Invoked on a dedicated reader thread per (participant, source) pair. +/// Invoked on a dedicated reader thread per (participant, track_name) pair. using VideoFrameCallback = std::function; @@ -67,7 +67,7 @@ using DataFrameCallbackId = std::uint64_t; * registration requests here, and then calls \ref handleTrackSubscribed and * \ref handleTrackUnsubscribed as room events arrive. * - * For each registered `(participant identity, TrackSource)` pair, this class + * For each registered `(participant identity, track_name)` pair, this class * may create a dedicated \ref AudioStream or \ref VideoStream and a matching * reader thread. That thread blocks on stream reads and invokes the * registered callback with decoded frames. @@ -92,35 +92,37 @@ class SubscriptionThreadDispatcher { /** * Register or replace an audio frame callback for a remote subscription. * - * The callback is keyed by remote participant identity plus \p source. + * The callback is keyed by remote participant identity plus \p track_name. * If the matching remote audio track is already subscribed, \ref Room may * immediately call \ref handleTrackSubscribed to start a reader. * * @param participant_identity Identity of the remote participant. - * @param source Track source to match. + * @param track_name Track name to match. * @param callback Function invoked for each decoded audio frame. * @param opts Options used when creating the backing * \ref AudioStream. */ void setOnAudioFrameCallback(const std::string &participant_identity, - TrackSource source, AudioFrameCallback callback, + const std::string &track_name, + AudioFrameCallback callback, AudioStream::Options opts = {}); /** * Register or replace a video frame callback for a remote subscription. * - * The callback is keyed by remote participant identity plus \p source. + * The callback is keyed by remote participant identity plus \p track_name. * If the matching remote video track is already subscribed, \ref Room may * immediately call \ref handleTrackSubscribed to start a reader. * * @param participant_identity Identity of the remote participant. - * @param source Track source to match. + * @param track_name Track name to match. * @param callback Function invoked for each decoded video frame. * @param opts Options used when creating the backing * \ref VideoStream. */ void setOnVideoFrameCallback(const std::string &participant_identity, - TrackSource source, VideoFrameCallback callback, + const std::string &track_name, + VideoFrameCallback callback, VideoStream::Options opts = {}); /** @@ -130,10 +132,10 @@ class SubscriptionThreadDispatcher { * closed and the thread is joined before this call returns. * * @param participant_identity Identity of the remote participant. - * @param source Track source to clear. + * @param track_name Track name to clear. */ void clearOnAudioFrameCallback(const std::string &participant_identity, - TrackSource source); + const std::string &track_name); /** * Remove a video callback registration and stop any active reader. @@ -142,10 +144,10 @@ class SubscriptionThreadDispatcher { * closed and the thread is joined before this call returns. * * @param participant_identity Identity of the remote participant. - * @param source Track source to clear. + * @param track_name Track name to clear. */ void clearOnVideoFrameCallback(const std::string &participant_identity, - TrackSource source); + const std::string &track_name); /** * Start or restart reader dispatch for a newly subscribed remote track. @@ -153,31 +155,32 @@ class SubscriptionThreadDispatcher { * \ref Room calls this after it has processed a track-subscription event and * updated its publication state. If a matching callback registration exists, * the dispatcher creates the appropriate stream type and launches a reader - * thread for the `(participant, source)` key. + * thread for the `(participant, track_name)` key. * * If no matching callback is registered, this is a no-op. * * @param participant_identity Identity of the remote participant. - * @param source Track source associated with the subscription. + * @param track_name Track name associated with the subscription. * @param track Subscribed remote track to read from. */ void handleTrackSubscribed(const std::string &participant_identity, - TrackSource source, + const std::string &track_name, const std::shared_ptr &track); /** * Stop reader dispatch for an unsubscribed remote track. * * \ref Room calls this when a remote track is unsubscribed. Any active - * reader stream for the given `(participant, source)` key is closed and its + * reader stream for the given `(participant, track_name)` key is closed and + * its * thread is joined. Callback registration is preserved so future * re-subscription can start dispatch again automatically. * * @param participant_identity Identity of the remote participant. - * @param source Track source associated with the subscription. + * @param track_name Track name associated with the subscription. */ void handleTrackUnsubscribed(const std::string &participant_identity, - TrackSource source); + const std::string &track_name); // --------------------------------------------------------------- // Data track callbacks @@ -247,14 +250,14 @@ class SubscriptionThreadDispatcher { private: friend class SubscriptionThreadDispatcherTest; - /// Compound lookup key for a remote participant identity and track source. + /// Compound lookup key for a remote participant identity and track name. struct CallbackKey { std::string participant_identity; - TrackSource source; + std::string track_name; bool operator==(const CallbackKey &o) const { return participant_identity == o.participant_identity && - source == o.source; + track_name == o.track_name; } }; @@ -262,7 +265,7 @@ class SubscriptionThreadDispatcher { struct CallbackKeyHash { std::size_t operator()(const CallbackKey &k) const { auto h1 = std::hash{}(k.participant_identity); - auto h2 = std::hash{}(static_cast(k.source)); + auto h2 = std::hash{}(k.track_name); return h1 ^ (h2 << 1); } }; @@ -368,15 +371,15 @@ class SubscriptionThreadDispatcher { /// Protects callback registration maps and active reader state. mutable std::mutex lock_; - /// Registered audio frame callbacks keyed by `(participant, source)`. + /// Registered audio frame callbacks keyed by `(participant, track_name)`. std::unordered_map audio_callbacks_; - /// Registered video frame callbacks keyed by `(participant, source)`. + /// Registered video frame callbacks keyed by `(participant, track_name)`. std::unordered_map video_callbacks_; - /// Active stream/thread state keyed by `(participant, source)`. + /// Active stream/thread state keyed by `(participant, track_name)`. std::unordered_map active_readers_; diff --git a/src/room.cpp b/src/room.cpp index 10550675..165d027f 100644 --- a/src/room.cpp +++ b/src/room.cpp @@ -272,38 +272,38 @@ void Room::unregisterByteStreamHandler(const std::string &topic) { // ------------------------------------------------------------------- void Room::setOnAudioFrameCallback(const std::string &participant_identity, - TrackSource source, + const std::string &track_name, AudioFrameCallback callback, AudioStream::Options opts) { if (subscription_thread_dispatcher_) { subscription_thread_dispatcher_->setOnAudioFrameCallback( - participant_identity, source, std::move(callback), std::move(opts)); + participant_identity, track_name, std::move(callback), std::move(opts)); } } void Room::setOnVideoFrameCallback(const std::string &participant_identity, - TrackSource source, + const std::string &track_name, VideoFrameCallback callback, VideoStream::Options opts) { if (subscription_thread_dispatcher_) { subscription_thread_dispatcher_->setOnVideoFrameCallback( - participant_identity, source, std::move(callback), std::move(opts)); + participant_identity, track_name, std::move(callback), std::move(opts)); } } void Room::clearOnAudioFrameCallback(const std::string &participant_identity, - TrackSource source) { + const std::string &track_name) { if (subscription_thread_dispatcher_) { subscription_thread_dispatcher_->clearOnAudioFrameCallback( - participant_identity, source); + participant_identity, track_name); } } void Room::clearOnVideoFrameCallback(const std::string &participant_identity, - TrackSource source) { + const std::string &track_name) { if (subscription_thread_dispatcher_) { subscription_thread_dispatcher_->clearOnVideoFrameCallback( - participant_identity, source); + participant_identity, track_name); } } @@ -611,13 +611,13 @@ void Room::OnEvent(const FfiEvent &event) { if (subscription_thread_dispatcher_ && remote_track && rpublication) { subscription_thread_dispatcher_->handleTrackSubscribed( - identity, rpublication->source(), remote_track); + identity, rpublication->name(), remote_track); } break; } case proto::RoomEvent::kTrackUnsubscribed: { TrackUnsubscribedEvent ev; - TrackSource unsub_source = TrackSource::SOURCE_UNKNOWN; + std::string unsub_track_name; std::string unsub_identity; { std::lock_guard guard(lock_); @@ -640,7 +640,7 @@ void Room::OnEvent(const FfiEvent &event) { break; } auto publication = pubIt->second; - unsub_source = publication->source(); + unsub_track_name = publication->name(); auto track = publication->track(); publication->setTrack(nullptr); publication->setSubscribed(false); @@ -653,10 +653,9 @@ void Room::OnEvent(const FfiEvent &event) { delegate_snapshot->onTrackUnsubscribed(*this, ev); } - if (subscription_thread_dispatcher_ && - unsub_source != TrackSource::SOURCE_UNKNOWN) { - subscription_thread_dispatcher_->handleTrackUnsubscribed(unsub_identity, - unsub_source); + if (subscription_thread_dispatcher_ && !unsub_track_name.empty()) { + subscription_thread_dispatcher_->handleTrackUnsubscribed( + unsub_identity, unsub_track_name); } break; } diff --git a/src/subscription_thread_dispatcher.cpp b/src/subscription_thread_dispatcher.cpp index 8b764e20..0601ee47 100644 --- a/src/subscription_thread_dispatcher.cpp +++ b/src/subscription_thread_dispatcher.cpp @@ -1,5 +1,5 @@ /* - * Copyright 2025 LiveKit + * Copyright 2026 LiveKit * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -52,36 +52,36 @@ SubscriptionThreadDispatcher::~SubscriptionThreadDispatcher() { } void SubscriptionThreadDispatcher::setOnAudioFrameCallback( - const std::string &participant_identity, TrackSource source, + const std::string &participant_identity, const std::string &track_name, AudioFrameCallback callback, AudioStream::Options opts) { - CallbackKey key{participant_identity, source}; + CallbackKey key{participant_identity, track_name}; std::lock_guard lock(lock_); const bool replacing = audio_callbacks_.find(key) != audio_callbacks_.end(); audio_callbacks_[key] = RegisteredAudioCallback{std::move(callback), std::move(opts)}; - LK_LOG_DEBUG("Registered audio frame callback for participant={} source={} " + LK_LOG_DEBUG("Registered audio frame callback for participant={} track={} " "replacing_existing={} total_audio_callbacks={}", - participant_identity, static_cast(source), replacing, + participant_identity, track_name, replacing, audio_callbacks_.size()); } void SubscriptionThreadDispatcher::setOnVideoFrameCallback( - const std::string &participant_identity, TrackSource source, + const std::string &participant_identity, const std::string &track_name, VideoFrameCallback callback, VideoStream::Options opts) { - CallbackKey key{participant_identity, source}; + CallbackKey key{participant_identity, track_name}; std::lock_guard lock(lock_); const bool replacing = video_callbacks_.find(key) != video_callbacks_.end(); video_callbacks_[key] = RegisteredVideoCallback{std::move(callback), std::move(opts)}; - LK_LOG_DEBUG("Registered video frame callback for participant={} source={} " + LK_LOG_DEBUG("Registered video frame callback for participant={} track={} " "replacing_existing={} total_video_callbacks={}", - participant_identity, static_cast(source), replacing, + participant_identity, track_name, replacing, video_callbacks_.size()); } void SubscriptionThreadDispatcher::clearOnAudioFrameCallback( - const std::string &participant_identity, TrackSource source) { - CallbackKey key{participant_identity, source}; + const std::string &participant_identity, const std::string &track_name) { + CallbackKey key{participant_identity, track_name}; std::thread old_thread; bool removed_callback = false; { @@ -89,9 +89,9 @@ void SubscriptionThreadDispatcher::clearOnAudioFrameCallback( removed_callback = audio_callbacks_.erase(key) > 0; old_thread = extractReaderThreadLocked(key); LK_LOG_DEBUG( - "Clearing audio frame callback for participant={} source={} " + "Clearing audio frame callback for participant={} track={} " "removed_callback={} stopped_reader={} remaining_audio_callbacks={}", - participant_identity, static_cast(source), removed_callback, + participant_identity, track_name, removed_callback, old_thread.joinable(), audio_callbacks_.size()); } if (old_thread.joinable()) { @@ -100,8 +100,8 @@ void SubscriptionThreadDispatcher::clearOnAudioFrameCallback( } void SubscriptionThreadDispatcher::clearOnVideoFrameCallback( - const std::string &participant_identity, TrackSource source) { - CallbackKey key{participant_identity, source}; + const std::string &participant_identity, const std::string &track_name) { + CallbackKey key{participant_identity, track_name}; std::thread old_thread; bool removed_callback = false; { @@ -109,9 +109,9 @@ void SubscriptionThreadDispatcher::clearOnVideoFrameCallback( removed_callback = video_callbacks_.erase(key) > 0; old_thread = extractReaderThreadLocked(key); LK_LOG_DEBUG( - "Clearing video frame callback for participant={} source={} " + "Clearing video frame callback for participant={} track={} " "removed_callback={} stopped_reader={} remaining_video_callbacks={}", - participant_identity, static_cast(source), removed_callback, + participant_identity, track_name, removed_callback, old_thread.joinable(), video_callbacks_.size()); } if (old_thread.joinable()) { @@ -120,21 +120,21 @@ void SubscriptionThreadDispatcher::clearOnVideoFrameCallback( } void SubscriptionThreadDispatcher::handleTrackSubscribed( - const std::string &participant_identity, TrackSource source, + const std::string &participant_identity, const std::string &track_name, const std::shared_ptr &track) { if (!track) { LK_LOG_WARN( - "Ignoring subscribed track dispatch for participant={} source={} " + "Ignoring subscribed track dispatch for participant={} track={} " "because track is null", - participant_identity, static_cast(source)); + participant_identity, track_name); return; } - LK_LOG_DEBUG("Handling subscribed track for participant={} source={} kind={}", - participant_identity, static_cast(source), + LK_LOG_DEBUG("Handling subscribed track for participant={} track={} kind={}", + participant_identity, track_name, trackKindName(track->kind())); - CallbackKey key{participant_identity, source}; + CallbackKey key{participant_identity, track_name}; std::thread old_thread; { std::lock_guard lock(lock_); @@ -146,15 +146,15 @@ void SubscriptionThreadDispatcher::handleTrackSubscribed( } void SubscriptionThreadDispatcher::handleTrackUnsubscribed( - const std::string &participant_identity, TrackSource source) { - CallbackKey key{participant_identity, source}; + const std::string &participant_identity, const std::string &track_name) { + CallbackKey key{participant_identity, track_name}; std::thread old_thread; { std::lock_guard lock(lock_); old_thread = extractReaderThreadLocked(key); - LK_LOG_DEBUG("Handling unsubscribed track for participant={} source={} " + LK_LOG_DEBUG("Handling unsubscribed track for participant={} track={} " "stopped_reader={}", - participant_identity, static_cast(source), + participant_identity, track_name, old_thread.joinable()); } if (old_thread.joinable()) { @@ -322,13 +322,13 @@ std::thread SubscriptionThreadDispatcher::extractReaderThreadLocked( const CallbackKey &key) { auto it = active_readers_.find(key); if (it == active_readers_.end()) { - LK_LOG_TRACE("No active reader to extract for participant={} source={}", - key.participant_identity, static_cast(key.source)); + LK_LOG_TRACE("No active reader to extract for participant={} track={}", + key.participant_identity, key.track_name); return {}; } - LK_LOG_DEBUG("Extracting active reader for participant={} source={}", - key.participant_identity, static_cast(key.source)); + LK_LOG_DEBUG("Extracting active reader for participant={} track={}", + key.participant_identity, key.track_name); ActiveReader reader = std::move(it->second); active_readers_.erase(it); @@ -346,9 +346,9 @@ std::thread SubscriptionThreadDispatcher::startReaderLocked( if (track->kind() == TrackKind::KIND_AUDIO) { auto it = audio_callbacks_.find(key); if (it == audio_callbacks_.end()) { - LK_LOG_TRACE("Skipping audio reader start for participant={} source={} " + LK_LOG_TRACE("Skipping audio reader start for participant={} track={} " "because no audio callback is registered", - key.participant_identity, static_cast(key.source)); + key.participant_identity, key.track_name); return {}; } return startAudioReaderLocked(key, track, it->second.callback, @@ -357,9 +357,9 @@ std::thread SubscriptionThreadDispatcher::startReaderLocked( if (track->kind() == TrackKind::KIND_VIDEO) { auto it = video_callbacks_.find(key); if (it == video_callbacks_.end()) { - LK_LOG_TRACE("Skipping video reader start for participant={} source={} " + LK_LOG_TRACE("Skipping video reader start for participant={} track={} " "because no video callback is registered", - key.participant_identity, static_cast(key.source)); + key.participant_identity, key.track_name); return {}; } return startVideoReaderLocked(key, track, it->second.callback, @@ -367,39 +367,39 @@ std::thread SubscriptionThreadDispatcher::startReaderLocked( } if (track->kind() == TrackKind::KIND_UNKNOWN) { LK_LOG_WARN( - "Skipping reader start for participant={} source={} because track " + "Skipping reader start for participant={} track={} because track " "kind is unknown", - key.participant_identity, static_cast(key.source)); + key.participant_identity, key.track_name); return {}; } LK_LOG_WARN( - "Skipping reader start for participant={} source={} because track kind " + "Skipping reader start for participant={} track={} because track kind " "is unsupported", - key.participant_identity, static_cast(key.source)); + key.participant_identity, key.track_name); return {}; } std::thread SubscriptionThreadDispatcher::startAudioReaderLocked( const CallbackKey &key, const std::shared_ptr &track, AudioFrameCallback cb, const AudioStream::Options &opts) { - LK_LOG_DEBUG("Starting audio reader for participant={} source={}", - key.participant_identity, static_cast(key.source)); + LK_LOG_DEBUG("Starting audio reader for participant={} track={}", + key.participant_identity, key.track_name); auto old_thread = extractReaderThreadLocked(key); if (static_cast(active_readers_.size()) >= kMaxActiveReaders) { LK_LOG_ERROR( - "Cannot start audio reader for {} source={}: active reader limit ({}) " + "Cannot start audio reader for {} track={}: active reader limit ({}) " "reached", - key.participant_identity, static_cast(key.source), + key.participant_identity, key.track_name, kMaxActiveReaders); return old_thread; } auto stream = AudioStream::fromTrack(track, opts); if (!stream) { - LK_LOG_ERROR("Failed to create AudioStream for {} source={}", - key.participant_identity, static_cast(key.source)); + LK_LOG_ERROR("Failed to create AudioStream for {} track={}", + key.participant_identity, key.track_name); return old_thread; } @@ -407,11 +407,11 @@ std::thread SubscriptionThreadDispatcher::startAudioReaderLocked( reader.audio_stream = stream; auto stream_copy = stream; const std::string participant_identity = key.participant_identity; - const TrackSource source = key.source; + const std::string track_name = key.track_name; reader.thread = - std::thread([stream_copy, cb, participant_identity, source]() { - LK_LOG_DEBUG("Audio reader thread started for participant={} source={}", - participant_identity, static_cast(source)); + std::thread([stream_copy, cb, participant_identity, track_name]() { + LK_LOG_DEBUG("Audio reader thread started for participant={} track={}", + participant_identity, track_name); AudioFrameEvent ev; while (stream_copy->read(ev)) { try { @@ -420,13 +420,13 @@ std::thread SubscriptionThreadDispatcher::startAudioReaderLocked( LK_LOG_ERROR("Audio frame callback exception: {}", e.what()); } } - LK_LOG_DEBUG("Audio reader thread exiting for participant={} source={}", - participant_identity, static_cast(source)); + LK_LOG_DEBUG("Audio reader thread exiting for participant={} track={}", + participant_identity, track_name); }); active_readers_[key] = std::move(reader); - LK_LOG_DEBUG("Started audio reader for participant={} source={} " + LK_LOG_DEBUG("Started audio reader for participant={} track={} " "active_readers={}", - key.participant_identity, static_cast(key.source), + key.participant_identity, key.track_name, active_readers_.size()); return old_thread; } @@ -434,23 +434,23 @@ std::thread SubscriptionThreadDispatcher::startAudioReaderLocked( std::thread SubscriptionThreadDispatcher::startVideoReaderLocked( const CallbackKey &key, const std::shared_ptr &track, VideoFrameCallback cb, const VideoStream::Options &opts) { - LK_LOG_DEBUG("Starting video reader for participant={} source={}", - key.participant_identity, static_cast(key.source)); + LK_LOG_DEBUG("Starting video reader for participant={} track={}", + key.participant_identity, key.track_name); auto old_thread = extractReaderThreadLocked(key); if (static_cast(active_readers_.size()) >= kMaxActiveReaders) { LK_LOG_ERROR( - "Cannot start video reader for {} source={}: active reader limit ({}) " + "Cannot start video reader for {} track={}: active reader limit ({}) " "reached", - key.participant_identity, static_cast(key.source), + key.participant_identity, key.track_name, kMaxActiveReaders); return old_thread; } auto stream = VideoStream::fromTrack(track, opts); if (!stream) { - LK_LOG_ERROR("Failed to create VideoStream for {} source={}", - key.participant_identity, static_cast(key.source)); + LK_LOG_ERROR("Failed to create VideoStream for {} track={}", + key.participant_identity, key.track_name); return old_thread; } @@ -458,11 +458,11 @@ std::thread SubscriptionThreadDispatcher::startVideoReaderLocked( reader.video_stream = stream; auto stream_copy = stream; const std::string participant_identity = key.participant_identity; - const TrackSource source = key.source; + const std::string track_name = key.track_name; reader.thread = - std::thread([stream_copy, cb, participant_identity, source]() { - LK_LOG_DEBUG("Video reader thread started for participant={} source={}", - participant_identity, static_cast(source)); + std::thread([stream_copy, cb, participant_identity, track_name]() { + LK_LOG_DEBUG("Video reader thread started for participant={} track={}", + participant_identity, track_name); VideoFrameEvent ev; while (stream_copy->read(ev)) { try { @@ -471,13 +471,13 @@ std::thread SubscriptionThreadDispatcher::startVideoReaderLocked( LK_LOG_ERROR("Video frame callback exception: {}", e.what()); } } - LK_LOG_DEBUG("Video reader thread exiting for participant={} source={}", - participant_identity, static_cast(source)); + LK_LOG_DEBUG("Video reader thread exiting for participant={} track={}", + participant_identity, track_name); }); active_readers_[key] = std::move(reader); - LK_LOG_DEBUG("Started video reader for participant={} source={} " + LK_LOG_DEBUG("Started video reader for participant={} track={} " "active_readers={}", - key.participant_identity, static_cast(key.source), + key.participant_identity, key.track_name, active_readers_.size()); return old_thread; } diff --git a/src/tests/integration/test_room_callbacks.cpp b/src/tests/integration/test_room_callbacks.cpp index d0dc6bf8..6234c20f 100644 --- a/src/tests/integration/test_room_callbacks.cpp +++ b/src/tests/integration/test_room_callbacks.cpp @@ -1,5 +1,5 @@ /* - * Copyright 2025 LiveKit + * Copyright 2026 LiveKit * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -40,24 +40,22 @@ TEST_F(RoomCallbackTest, AudioCallbackRegistrationIsAccepted) { Room room; EXPECT_NO_THROW(room.setOnAudioFrameCallback( - "alice", TrackSource::SOURCE_MICROPHONE, [](const AudioFrame &) {})); + "alice", "microphone", [](const AudioFrame &) {})); } TEST_F(RoomCallbackTest, VideoCallbackRegistrationIsAccepted) { Room room; EXPECT_NO_THROW( - room.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, + room.setOnVideoFrameCallback("alice", "camera", [](const VideoFrame &, std::int64_t) {})); } TEST_F(RoomCallbackTest, ClearingMissingCallbacksIsNoOp) { Room room; - EXPECT_NO_THROW( - room.clearOnAudioFrameCallback("nobody", TrackSource::SOURCE_MICROPHONE)); - EXPECT_NO_THROW( - room.clearOnVideoFrameCallback("nobody", TrackSource::SOURCE_CAMERA)); + EXPECT_NO_THROW(room.clearOnAudioFrameCallback("nobody", "microphone")); + EXPECT_NO_THROW(room.clearOnVideoFrameCallback("nobody", "camera")); } TEST_F(RoomCallbackTest, ReRegisteringSameAudioKeyDoesNotThrow) { @@ -66,10 +64,10 @@ TEST_F(RoomCallbackTest, ReRegisteringSameAudioKeyDoesNotThrow) { std::atomic counter2{0}; EXPECT_NO_THROW(room.setOnAudioFrameCallback( - "alice", TrackSource::SOURCE_MICROPHONE, + "alice", "microphone", [&counter1](const AudioFrame &) { counter1++; })); EXPECT_NO_THROW(room.setOnAudioFrameCallback( - "alice", TrackSource::SOURCE_MICROPHONE, + "alice", "microphone", [&counter2](const AudioFrame &) { counter2++; })); } @@ -77,10 +75,10 @@ TEST_F(RoomCallbackTest, ReRegisteringSameVideoKeyDoesNotThrow) { Room room; EXPECT_NO_THROW( - room.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, + room.setOnVideoFrameCallback("alice", "camera", [](const VideoFrame &, std::int64_t) {})); EXPECT_NO_THROW( - room.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, + room.setOnVideoFrameCallback("alice", "camera", [](const VideoFrame &, std::int64_t) {})); } @@ -88,25 +86,25 @@ TEST_F(RoomCallbackTest, DistinctAudioAndVideoCallbacksCanCoexist) { Room room; EXPECT_NO_THROW(room.setOnAudioFrameCallback( - "alice", TrackSource::SOURCE_MICROPHONE, [](const AudioFrame &) {})); + "alice", "microphone", [](const AudioFrame &) {})); EXPECT_NO_THROW( - room.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, + room.setOnVideoFrameCallback("alice", "camera", [](const VideoFrame &, std::int64_t) {})); EXPECT_NO_THROW(room.setOnAudioFrameCallback( - "bob", TrackSource::SOURCE_MICROPHONE, [](const AudioFrame &) {})); + "bob", "microphone", [](const AudioFrame &) {})); EXPECT_NO_THROW( - room.setOnVideoFrameCallback("bob", TrackSource::SOURCE_CAMERA, + room.setOnVideoFrameCallback("bob", "camera", [](const VideoFrame &, std::int64_t) {})); } -TEST_F(RoomCallbackTest, SameSourceDifferentTrackNamesAreAccepted) { +TEST_F(RoomCallbackTest, SameIdentityDifferentTrackNamesAreAccepted) { Room room; EXPECT_NO_THROW( - room.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, + room.setOnVideoFrameCallback("alice", "camera-main", [](const VideoFrame &, std::int64_t) {})); EXPECT_NO_THROW( - room.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, + room.setOnVideoFrameCallback("alice", "camera-secondary", [](const VideoFrame &, std::int64_t) {})); } @@ -138,9 +136,9 @@ TEST_F(RoomCallbackTest, RemovingUnknownDataCallbackIsNoOp) { TEST_F(RoomCallbackTest, DestroyRoomWithRegisteredCallbacksIsSafe) { EXPECT_NO_THROW({ Room room; - room.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, + room.setOnAudioFrameCallback("alice", "microphone", [](const AudioFrame &) {}); - room.setOnVideoFrameCallback("bob", TrackSource::SOURCE_CAMERA, + room.setOnVideoFrameCallback("bob", "camera", [](const VideoFrame &, std::int64_t) {}); room.addOnDataFrameCallback( "carol", "track", @@ -151,9 +149,9 @@ TEST_F(RoomCallbackTest, DestroyRoomWithRegisteredCallbacksIsSafe) { TEST_F(RoomCallbackTest, DestroyRoomAfterClearingCallbacksIsSafe) { EXPECT_NO_THROW({ Room room; - room.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, + room.setOnAudioFrameCallback("alice", "microphone", [](const AudioFrame &) {}); - room.clearOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE); + room.clearOnAudioFrameCallback("alice", "microphone"); const auto id = room.addOnDataFrameCallback( "alice", "track", @@ -174,9 +172,9 @@ TEST_F(RoomCallbackTest, ConcurrentRegistrationDoesNotCrash) { threads.emplace_back([&room, t]() { for (int i = 0; i < kIterations; ++i) { const std::string id = "participant-" + std::to_string(t); - room.setOnAudioFrameCallback(id, TrackSource::SOURCE_MICROPHONE, + room.setOnAudioFrameCallback(id, "microphone", [](const AudioFrame &) {}); - room.clearOnAudioFrameCallback(id, TrackSource::SOURCE_MICROPHONE); + room.clearOnAudioFrameCallback(id, "microphone"); } }); } @@ -200,9 +198,9 @@ TEST_F(RoomCallbackTest, ConcurrentMixedRegistrationDoesNotCrash) { threads.emplace_back([&room, t]() { const std::string id = "p-" + std::to_string(t); for (int i = 0; i < kIterations; ++i) { - room.setOnAudioFrameCallback(id, TrackSource::SOURCE_MICROPHONE, + room.setOnAudioFrameCallback(id, "microphone", [](const AudioFrame &) {}); - room.setOnVideoFrameCallback(id, TrackSource::SOURCE_CAMERA, + room.setOnVideoFrameCallback(id, "camera", [](const VideoFrame &, std::int64_t) {}); const auto data_id = room.addOnDataFrameCallback(id, "track", @@ -226,13 +224,13 @@ TEST_F(RoomCallbackTest, ManyDistinctAudioCallbacksCanBeRegisteredAndCleared) { for (int i = 0; i < kCount; ++i) { EXPECT_NO_THROW(room.setOnAudioFrameCallback( - "participant-" + std::to_string(i), TrackSource::SOURCE_MICROPHONE, + "participant-" + std::to_string(i), "microphone", [](const AudioFrame &) {})); } for (int i = 0; i < kCount; ++i) { EXPECT_NO_THROW(room.clearOnAudioFrameCallback( - "participant-" + std::to_string(i), TrackSource::SOURCE_MICROPHONE)); + "participant-" + std::to_string(i), "microphone")); } } diff --git a/src/tests/integration/test_subscription_thread_dispatcher.cpp b/src/tests/integration/test_subscription_thread_dispatcher.cpp index 45e2bb11..ff172889 100644 --- a/src/tests/integration/test_subscription_thread_dispatcher.cpp +++ b/src/tests/integration/test_subscription_thread_dispatcher.cpp @@ -1,5 +1,5 @@ /* - * Copyright 2025 LiveKit + * Copyright 2026 LiveKit * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -67,21 +67,21 @@ class SubscriptionThreadDispatcherTest : public ::testing::Test { // ============================================================================ TEST_F(SubscriptionThreadDispatcherTest, CallbackKeyEqualKeysCompareEqual) { - CallbackKey a{"alice", TrackSource::SOURCE_MICROPHONE}; - CallbackKey b{"alice", TrackSource::SOURCE_MICROPHONE}; + CallbackKey a{"alice", "microphone"}; + CallbackKey b{"alice", "microphone"}; EXPECT_TRUE(a == b); } TEST_F(SubscriptionThreadDispatcherTest, CallbackKeyDifferentIdentityNotEqual) { - CallbackKey a{"alice", TrackSource::SOURCE_MICROPHONE}; - CallbackKey b{"bob", TrackSource::SOURCE_MICROPHONE}; + CallbackKey a{"alice", "microphone"}; + CallbackKey b{"bob", "microphone"}; EXPECT_FALSE(a == b); } -TEST_F(SubscriptionThreadDispatcherTest, CallbackKeyDifferentSourceNotEqual) { - CallbackKey a{"alice", TrackSource::SOURCE_MICROPHONE}; - CallbackKey b{"alice", TrackSource::SOURCE_CAMERA}; +TEST_F(SubscriptionThreadDispatcherTest, CallbackKeyDifferentTrackNotEqual) { + CallbackKey a{"alice", "microphone"}; + CallbackKey b{"alice", "camera"}; EXPECT_FALSE(a == b); } @@ -91,8 +91,8 @@ TEST_F(SubscriptionThreadDispatcherTest, CallbackKeyDifferentSourceNotEqual) { TEST_F(SubscriptionThreadDispatcherTest, CallbackKeyHashEqualKeysProduceSameHash) { - CallbackKey a{"alice", TrackSource::SOURCE_MICROPHONE}; - CallbackKey b{"alice", TrackSource::SOURCE_MICROPHONE}; + CallbackKey a{"alice", "microphone"}; + CallbackKey b{"alice", "microphone"}; CallbackKeyHash hasher; EXPECT_EQ(hasher(a), hasher(b)); } @@ -100,9 +100,9 @@ TEST_F(SubscriptionThreadDispatcherTest, TEST_F(SubscriptionThreadDispatcherTest, CallbackKeyHashDifferentKeysLikelyDifferentHash) { CallbackKeyHash hasher; - CallbackKey mic{"alice", TrackSource::SOURCE_MICROPHONE}; - CallbackKey cam{"alice", TrackSource::SOURCE_CAMERA}; - CallbackKey bob{"bob", TrackSource::SOURCE_MICROPHONE}; + CallbackKey mic{"alice", "microphone"}; + CallbackKey cam{"alice", "camera"}; + CallbackKey bob{"bob", "microphone"}; EXPECT_NE(hasher(mic), hasher(cam)); EXPECT_NE(hasher(mic), hasher(bob)); @@ -111,9 +111,9 @@ TEST_F(SubscriptionThreadDispatcherTest, TEST_F(SubscriptionThreadDispatcherTest, CallbackKeyWorksAsUnorderedMapKey) { std::unordered_map map; - CallbackKey k1{"alice", TrackSource::SOURCE_MICROPHONE}; - CallbackKey k2{"bob", TrackSource::SOURCE_CAMERA}; - CallbackKey k3{"alice", TrackSource::SOURCE_CAMERA}; + CallbackKey k1{"alice", "microphone"}; + CallbackKey k2{"bob", "camera"}; + CallbackKey k3{"alice", "camera"}; map[k1] = 1; map[k2] = 2; @@ -134,8 +134,8 @@ TEST_F(SubscriptionThreadDispatcherTest, CallbackKeyWorksAsUnorderedMapKey) { } TEST_F(SubscriptionThreadDispatcherTest, CallbackKeyEmptyIdentityWorks) { - CallbackKey a{"", TrackSource::SOURCE_UNKNOWN}; - CallbackKey b{"", TrackSource::SOURCE_UNKNOWN}; + CallbackKey a{"", ""}; + CallbackKey b{"", ""}; CallbackKeyHash hasher; EXPECT_TRUE(a == b); EXPECT_EQ(hasher(a), hasher(b)); @@ -155,7 +155,7 @@ TEST_F(SubscriptionThreadDispatcherTest, MaxActiveReadersIs20) { TEST_F(SubscriptionThreadDispatcherTest, SetAudioCallbackStoresRegistration) { SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, + dispatcher.setOnAudioFrameCallback("alice", "microphone", [](const AudioFrame &) {}); EXPECT_EQ(audioCallbacks(dispatcher).size(), 1u); @@ -163,7 +163,7 @@ TEST_F(SubscriptionThreadDispatcherTest, SetAudioCallbackStoresRegistration) { TEST_F(SubscriptionThreadDispatcherTest, SetVideoCallbackStoresRegistration) { SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, + dispatcher.setOnVideoFrameCallback("alice", "camera", [](const VideoFrame &, std::int64_t) {}); EXPECT_EQ(videoCallbacks(dispatcher).size(), 1u); @@ -171,30 +171,29 @@ TEST_F(SubscriptionThreadDispatcherTest, SetVideoCallbackStoresRegistration) { TEST_F(SubscriptionThreadDispatcherTest, ClearAudioCallbackRemovesRegistration) { SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, + dispatcher.setOnAudioFrameCallback("alice", "microphone", [](const AudioFrame &) {}); ASSERT_EQ(audioCallbacks(dispatcher).size(), 1u); - dispatcher.clearOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE); + dispatcher.clearOnAudioFrameCallback("alice", "microphone"); EXPECT_EQ(audioCallbacks(dispatcher).size(), 0u); } TEST_F(SubscriptionThreadDispatcherTest, ClearVideoCallbackRemovesRegistration) { SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, + dispatcher.setOnVideoFrameCallback("alice", "camera", [](const VideoFrame &, std::int64_t) {}); ASSERT_EQ(videoCallbacks(dispatcher).size(), 1u); - dispatcher.clearOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA); + dispatcher.clearOnVideoFrameCallback("alice", "camera"); EXPECT_EQ(videoCallbacks(dispatcher).size(), 0u); } TEST_F(SubscriptionThreadDispatcherTest, ClearNonExistentCallbackIsNoOp) { SubscriptionThreadDispatcher dispatcher; EXPECT_NO_THROW(dispatcher.clearOnAudioFrameCallback( - "nobody", TrackSource::SOURCE_MICROPHONE)); - EXPECT_NO_THROW( - dispatcher.clearOnVideoFrameCallback("nobody", TrackSource::SOURCE_CAMERA)); + "nobody", "microphone")); + EXPECT_NO_THROW(dispatcher.clearOnVideoFrameCallback("nobody", "camera")); } TEST_F(SubscriptionThreadDispatcherTest, OverwriteAudioCallbackKeepsSingleEntry) { @@ -203,10 +202,10 @@ TEST_F(SubscriptionThreadDispatcherTest, OverwriteAudioCallbackKeepsSingleEntry) std::atomic counter2{0}; dispatcher.setOnAudioFrameCallback( - "alice", TrackSource::SOURCE_MICROPHONE, + "alice", "microphone", [&counter1](const AudioFrame &) { counter1++; }); dispatcher.setOnAudioFrameCallback( - "alice", TrackSource::SOURCE_MICROPHONE, + "alice", "microphone", [&counter2](const AudioFrame &) { counter2++; }); EXPECT_EQ(audioCallbacks(dispatcher).size(), 1u) @@ -215,9 +214,9 @@ TEST_F(SubscriptionThreadDispatcherTest, OverwriteAudioCallbackKeepsSingleEntry) TEST_F(SubscriptionThreadDispatcherTest, OverwriteVideoCallbackKeepsSingleEntry) { SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, + dispatcher.setOnVideoFrameCallback("alice", "camera", [](const VideoFrame &, std::int64_t) {}); - dispatcher.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, + dispatcher.setOnVideoFrameCallback("alice", "camera", [](const VideoFrame &, std::int64_t) {}); EXPECT_EQ(videoCallbacks(dispatcher).size(), 1u); @@ -226,38 +225,35 @@ TEST_F(SubscriptionThreadDispatcherTest, OverwriteVideoCallbackKeepsSingleEntry) TEST_F(SubscriptionThreadDispatcherTest, MultipleDistinctCallbacksAreIndependent) { SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, + dispatcher.setOnAudioFrameCallback("alice", "microphone", [](const AudioFrame &) {}); - dispatcher.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, + dispatcher.setOnVideoFrameCallback("alice", "camera", [](const VideoFrame &, std::int64_t) {}); - dispatcher.setOnAudioFrameCallback("bob", TrackSource::SOURCE_MICROPHONE, + dispatcher.setOnAudioFrameCallback("bob", "microphone", [](const AudioFrame &) {}); - dispatcher.setOnVideoFrameCallback("bob", TrackSource::SOURCE_CAMERA, + dispatcher.setOnVideoFrameCallback("bob", "camera", [](const VideoFrame &, std::int64_t) {}); EXPECT_EQ(audioCallbacks(dispatcher).size(), 2u); EXPECT_EQ(videoCallbacks(dispatcher).size(), 2u); - dispatcher.clearOnAudioFrameCallback("alice", - TrackSource::SOURCE_MICROPHONE); + dispatcher.clearOnAudioFrameCallback("alice", "microphone"); EXPECT_EQ(audioCallbacks(dispatcher).size(), 1u); EXPECT_EQ(videoCallbacks(dispatcher).size(), 2u); } -TEST_F(SubscriptionThreadDispatcherTest, ClearingOneSourceDoesNotAffectOther) { +TEST_F(SubscriptionThreadDispatcherTest, ClearingOneTrackDoesNotAffectOther) { SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, + dispatcher.setOnAudioFrameCallback("alice", "microphone", [](const AudioFrame &) {}); - dispatcher.setOnAudioFrameCallback("alice", - TrackSource::SOURCE_SCREENSHARE_AUDIO, + dispatcher.setOnAudioFrameCallback("alice", "screenshare-audio", [](const AudioFrame &) {}); ASSERT_EQ(audioCallbacks(dispatcher).size(), 2u); - dispatcher.clearOnAudioFrameCallback("alice", - TrackSource::SOURCE_MICROPHONE); + dispatcher.clearOnAudioFrameCallback("alice", "microphone"); EXPECT_EQ(audioCallbacks(dispatcher).size(), 1u); - CallbackKey remaining{"alice", TrackSource::SOURCE_SCREENSHARE_AUDIO}; + CallbackKey remaining{"alice", "screenshare-audio"}; EXPECT_EQ(audioCallbacks(dispatcher).count(remaining), 1u); } @@ -273,7 +269,7 @@ TEST_F(SubscriptionThreadDispatcherTest, NoActiveReadersInitially) { TEST_F(SubscriptionThreadDispatcherTest, ActiveReadersEmptyAfterCallbackRegistration) { SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, + dispatcher.setOnAudioFrameCallback("alice", "microphone", [](const AudioFrame &) {}); EXPECT_TRUE(activeReaders(dispatcher).empty()) << "Registering a callback without a subscribed track should not spawn " @@ -288,9 +284,9 @@ TEST_F(SubscriptionThreadDispatcherTest, DestroyDispatcherWithRegisteredCallbacksIsSafe) { EXPECT_NO_THROW({ SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, + dispatcher.setOnAudioFrameCallback("alice", "microphone", [](const AudioFrame &) {}); - dispatcher.setOnVideoFrameCallback("bob", TrackSource::SOURCE_CAMERA, + dispatcher.setOnVideoFrameCallback("bob", "camera", [](const VideoFrame &, std::int64_t) {}); }); } @@ -299,10 +295,9 @@ TEST_F(SubscriptionThreadDispatcherTest, DestroyDispatcherAfterClearingCallbacksIsSafe) { EXPECT_NO_THROW({ SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, + dispatcher.setOnAudioFrameCallback("alice", "microphone", [](const AudioFrame &) {}); - dispatcher.clearOnAudioFrameCallback("alice", - TrackSource::SOURCE_MICROPHONE); + dispatcher.clearOnAudioFrameCallback("alice", "microphone"); }); } @@ -322,10 +317,9 @@ TEST_F(SubscriptionThreadDispatcherTest, ConcurrentRegistrationDoesNotCrash) { threads.emplace_back([&dispatcher, t]() { for (int i = 0; i < kIterations; ++i) { std::string id = "participant-" + std::to_string(t); - dispatcher.setOnAudioFrameCallback(id, TrackSource::SOURCE_MICROPHONE, + dispatcher.setOnAudioFrameCallback(id, "microphone", [](const AudioFrame &) {}); - dispatcher.clearOnAudioFrameCallback(id, - TrackSource::SOURCE_MICROPHONE); + dispatcher.clearOnAudioFrameCallback(id, "microphone"); } }); } @@ -350,9 +344,9 @@ TEST_F(SubscriptionThreadDispatcherTest, threads.emplace_back([&dispatcher, t]() { std::string id = "p-" + std::to_string(t); for (int i = 0; i < kIterations; ++i) { - dispatcher.setOnAudioFrameCallback(id, TrackSource::SOURCE_MICROPHONE, + dispatcher.setOnAudioFrameCallback(id, "microphone", [](const AudioFrame &) {}); - dispatcher.setOnVideoFrameCallback(id, TrackSource::SOURCE_CAMERA, + dispatcher.setOnVideoFrameCallback(id, "camera", [](const VideoFrame &, std::int64_t) {}); } @@ -377,7 +371,7 @@ TEST_F(SubscriptionThreadDispatcherTest, ManyDistinctCallbacksCanBeRegistered) { for (int i = 0; i < kCount; ++i) { dispatcher.setOnAudioFrameCallback("participant-" + std::to_string(i), - TrackSource::SOURCE_MICROPHONE, + "microphone", [](const AudioFrame &) {}); } @@ -385,7 +379,7 @@ TEST_F(SubscriptionThreadDispatcherTest, ManyDistinctCallbacksCanBeRegistered) { for (int i = 0; i < kCount; ++i) { dispatcher.clearOnAudioFrameCallback("participant-" + std::to_string(i), - TrackSource::SOURCE_MICROPHONE); + "microphone"); } EXPECT_EQ(audioCallbacks(dispatcher).size(), 0u); @@ -587,9 +581,9 @@ TEST_F(SubscriptionThreadDispatcherTest, TEST_F(SubscriptionThreadDispatcherTest, MixedAudioVideoDataCallbacksAreIndependent) { SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, + dispatcher.setOnAudioFrameCallback("alice", "microphone", [](const AudioFrame &) {}); - dispatcher.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, + dispatcher.setOnVideoFrameCallback("alice", "camera", [](const VideoFrame &, std::int64_t) {}); dispatcher.addOnDataFrameCallback( "alice", "data-track", From 8fa0814bf8892cf9c25b6828418b221ca900dd40 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Wed, 25 Mar 2026 16:19:42 -0600 Subject: [PATCH 21/34] setOnAudio/VideoFrameCallback() addition signature to use track name --- bridge/README.md | 14 +- .../include/livekit_bridge/livekit_bridge.h | 34 +-- bridge/src/livekit_bridge.cpp | 16 +- bridge/tests/test_livekit_bridge.cpp | 17 +- examples/bridge_human_robot/human.cpp | 13 +- examples/bridge_mute_unmute/caller.cpp | 4 +- examples/hello_livekit/receiver.cpp | 7 +- examples/hello_livekit/sender.cpp | 5 +- include/livekit/room.h | 93 +++----- .../livekit/subscription_thread_dispatcher.h | 95 ++++++-- src/room.cpp | 59 ++++- src/subscription_thread_dispatcher.cpp | 211 +++++++++++++----- src/tests/integration/test_room_callbacks.cpp | 54 ++--- .../test_subscription_thread_dispatcher.cpp | 112 +++++----- 14 files changed, 460 insertions(+), 274 deletions(-) diff --git a/bridge/README.md b/bridge/README.md index 66222e35..5f7c54cf 100644 --- a/bridge/README.md +++ b/bridge/README.md @@ -36,12 +36,12 @@ mic->pushFrame(pcm_data, samples_per_channel); cam->pushFrame(rgba_data, timestamp_us); // 4. Receive frames from a remote participant -bridge.setOnAudioFrameCallback("remote-peer", "mic", +bridge.setOnAudioFrameCallback("remote-peer", livekit::TrackSource::SOURCE_MICROPHONE, [](const livekit::AudioFrame& frame) { // Called on a background reader thread }); -bridge.setOnVideoFrameCallback("remote-peer", "cam", +bridge.setOnVideoFrameCallback("remote-peer", livekit::TrackSource::SOURCE_CAMERA, [](const livekit::VideoFrame& frame, int64_t timestamp_us) { // Called on a background reader thread }); @@ -126,7 +126,7 @@ This means the typical pattern is: livekit::RoomOptions options; options.auto_subscribe = true; bridge.connect(url, token, options); -bridge.setOnAudioFrameCallback("robot-1", "robot-mic", my_callback); +bridge.setOnAudioFrameCallback("robot-1", livekit::TrackSource::SOURCE_MICROPHONE, my_callback); // When robot-1 joins and publishes a mic track, my_callback starts firing. ``` @@ -147,10 +147,10 @@ bridge.setOnAudioFrameCallback("robot-1", "robot-mic", my_callback); | `isConnected()` | Returns whether the bridge is currently connected. | | `createAudioTrack(name, sample_rate, num_channels, source)` | Create and publish a local audio track with the given `TrackSource` (e.g. `SOURCE_MICROPHONE`, `SOURCE_SCREENSHARE_AUDIO`). Returns an RAII `shared_ptr`. | | `createVideoTrack(name, width, height, source)` | Create and publish a local video track with the given `TrackSource` (e.g. `SOURCE_CAMERA`, `SOURCE_SCREENSHARE`). Returns an RAII `shared_ptr`. | -| `setOnAudioFrameCallback(identity, track_name, callback)` | Register a callback for audio frames from a specific remote participant + track name. | -| `setOnVideoFrameCallback(identity, track_name, callback)` | Register a callback for video frames from a specific remote participant + track name. | -| `clearOnAudioFrameCallback(identity, track_name)` | Clear the audio callback for a specific remote participant + track name. Stops and joins the reader thread if active. | -| `clearOnVideoFrameCallback(identity, track_name)` | Clear the video callback for a specific remote participant + track name. Stops and joins the reader thread if active. | +| `setOnAudioFrameCallback(identity, source, callback)` | Register a callback for audio frames from a specific remote participant + track source. | +| `setOnVideoFrameCallback(identity, source, callback)` | Register a callback for video frames from a specific remote participant + track source. | +| `clearOnAudioFrameCallback(identity, source)` | Clear the audio callback for a specific remote participant + track source. Stops and joins the reader thread if active. | +| `clearOnVideoFrameCallback(identity, source)` | Clear the video callback for a specific remote participant + track source. Stops and joins the reader thread if active. | | `performRpc(destination_identity, method, payload, response_timeout?)` | Blocking RPC call to a remote participant. Returns the response payload. Throws `livekit::RpcError` on failure. | | `registerRpcMethod(method_name, handler)` | Register a handler for incoming RPC invocations. The handler returns an optional response payload or throws `livekit::RpcError`. | | `unregisterRpcMethod(method_name)` | Unregister a previously registered RPC handler. | diff --git a/bridge/include/livekit_bridge/livekit_bridge.h b/bridge/include/livekit_bridge/livekit_bridge.h index a424af11..47f13d46 100644 --- a/bridge/include/livekit_bridge/livekit_bridge.h +++ b/bridge/include/livekit_bridge/livekit_bridge.h @@ -89,10 +89,12 @@ using VideoFrameCallback = livekit::VideoFrameCallback; * mic->pushFrame(pcm_data, samples_per_channel); * cam->pushFrame(rgba_data, timestamp_us); * - * bridge.setOnAudioFrameCallback("remote-participant", "mic", + * bridge.setOnAudioFrameCallback("remote-participant", + * livekit::TrackSource::SOURCE_MICROPHONE, * [](const livekit::AudioFrame& f) { process(f); }); * - * bridge.setOnVideoFrameCallback("remote-participant", "cam", + * bridge.setOnVideoFrameCallback("remote-participant", + * livekit::TrackSource::SOURCE_CAMERA, * [](const livekit::VideoFrame& f, int64_t ts) { render(f); }); * * // Unpublish a single track mid-session: @@ -206,59 +208,59 @@ class LiveKitBridge { /** * Set the callback for audio frames from a specific remote participant - * and track name. + * and track source. * * Delegates to Room::setOnAudioFrameCallback. The callback fires on a * dedicated reader thread owned by Room whenever a new audio frame is * received. * - * @note Only **one** callback may be registered per (participant, track_name) - * pair. Calling this again with the same identity and track_name will + * @note Only **one** callback may be registered per (participant, source) + * pair. Calling this again with the same identity and source will * silently replace the previous callback. * * @param participant_identity Identity of the remote participant. - * @param track_name Track name to subscribe to. + * @param source Track source (e.g. SOURCE_MICROPHONE). * @param callback Function to invoke per audio frame. */ void setOnAudioFrameCallback(const std::string &participant_identity, - const std::string &track_name, + livekit::TrackSource source, AudioFrameCallback callback); /** * Register a callback for video frames from a specific remote participant - * and track name. + * and track source. * * Delegates to Room::setOnVideoFrameCallback. * - * @note Only **one** callback may be registered per (participant, track_name) - * pair. Calling this again with the same identity and track_name will + * @note Only **one** callback may be registered per (participant, source) + * pair. Calling this again with the same identity and source will * silently replace the previous callback. * * @param participant_identity Identity of the remote participant. - * @param track_name Track name to subscribe to. + * @param source Track source (e.g. SOURCE_CAMERA). * @param callback Function to invoke per video frame. */ void setOnVideoFrameCallback(const std::string &participant_identity, - const std::string &track_name, + livekit::TrackSource source, VideoFrameCallback callback); /** * Clear the audio frame callback for a specific remote participant + track - * name. + * source. * * Delegates to Room::clearOnAudioFrameCallback. */ void clearOnAudioFrameCallback(const std::string &participant_identity, - const std::string &track_name); + livekit::TrackSource source); /** * Clear the video frame callback for a specific remote participant + track - * name. + * source. * * Delegates to Room::clearOnVideoFrameCallback. */ void clearOnVideoFrameCallback(const std::string &participant_identity, - const std::string &track_name); + livekit::TrackSource source); // --------------------------------------------------------------- // RPC (Remote Procedure Call) diff --git a/bridge/src/livekit_bridge.cpp b/bridge/src/livekit_bridge.cpp index ffc36227..9f782904 100644 --- a/bridge/src/livekit_bridge.cpp +++ b/bridge/src/livekit_bridge.cpp @@ -237,45 +237,45 @@ LiveKitBridge::createVideoTrack(const std::string &name, int width, int height, // --------------------------------------------------------------- void LiveKitBridge::setOnAudioFrameCallback( - const std::string &participant_identity, const std::string &track_name, + const std::string &participant_identity, livekit::TrackSource source, AudioFrameCallback callback) { std::lock_guard lock(mutex_); if (!room_) { LK_LOG_WARN("setOnAudioFrameCallback called before connect(); ignored"); return; } - room_->setOnAudioFrameCallback(participant_identity, track_name, + room_->setOnAudioFrameCallback(participant_identity, source, std::move(callback)); } void LiveKitBridge::setOnVideoFrameCallback( - const std::string &participant_identity, const std::string &track_name, + const std::string &participant_identity, livekit::TrackSource source, VideoFrameCallback callback) { std::lock_guard lock(mutex_); if (!room_) { LK_LOG_WARN("setOnVideoFrameCallback called before connect(); ignored"); return; } - room_->setOnVideoFrameCallback(participant_identity, track_name, + room_->setOnVideoFrameCallback(participant_identity, source, std::move(callback)); } void LiveKitBridge::clearOnAudioFrameCallback( - const std::string &participant_identity, const std::string &track_name) { + const std::string &participant_identity, livekit::TrackSource source) { std::lock_guard lock(mutex_); if (!room_) { return; } - room_->clearOnAudioFrameCallback(participant_identity, track_name); + room_->clearOnAudioFrameCallback(participant_identity, source); } void LiveKitBridge::clearOnVideoFrameCallback( - const std::string &participant_identity, const std::string &track_name) { + const std::string &participant_identity, livekit::TrackSource source) { std::lock_guard lock(mutex_); if (!room_) { return; } - room_->clearOnVideoFrameCallback(participant_identity, track_name); + room_->clearOnVideoFrameCallback(participant_identity, source); } // --------------------------------------------------------------- diff --git a/bridge/tests/test_livekit_bridge.cpp b/bridge/tests/test_livekit_bridge.cpp index 9781e050..43c8f6fb 100644 --- a/bridge/tests/test_livekit_bridge.cpp +++ b/bridge/tests/test_livekit_bridge.cpp @@ -101,10 +101,12 @@ TEST_F(LiveKitBridgeTest, SetAndClearAudioCallbackBeforeConnectDoesNotCrash) { LiveKitBridge bridge; EXPECT_NO_THROW({ - bridge.setOnAudioFrameCallback("remote-participant", "microphone", + bridge.setOnAudioFrameCallback("remote-participant", + livekit::TrackSource::SOURCE_MICROPHONE, [](const livekit::AudioFrame &) {}); - bridge.clearOnAudioFrameCallback("remote-participant", "microphone"); + bridge.clearOnAudioFrameCallback("remote-participant", + livekit::TrackSource::SOURCE_MICROPHONE); }) << "set/clear audio callback before connect should be safe (warns)"; } @@ -113,10 +115,11 @@ TEST_F(LiveKitBridgeTest, SetAndClearVideoCallbackBeforeConnectDoesNotCrash) { EXPECT_NO_THROW({ bridge.setOnVideoFrameCallback( - "remote-participant", "camera", + "remote-participant", livekit::TrackSource::SOURCE_CAMERA, [](const livekit::VideoFrame &, std::int64_t) {}); - bridge.clearOnVideoFrameCallback("remote-participant", "camera"); + bridge.clearOnVideoFrameCallback("remote-participant", + livekit::TrackSource::SOURCE_CAMERA); }) << "set/clear video callback before connect should be safe (warns)"; } @@ -124,8 +127,10 @@ TEST_F(LiveKitBridgeTest, ClearNonExistentCallbackIsNoOp) { LiveKitBridge bridge; EXPECT_NO_THROW({ - bridge.clearOnAudioFrameCallback("nonexistent", "microphone"); - bridge.clearOnVideoFrameCallback("nonexistent", "camera"); + bridge.clearOnAudioFrameCallback("nonexistent", + livekit::TrackSource::SOURCE_MICROPHONE); + bridge.clearOnVideoFrameCallback("nonexistent", + livekit::TrackSource::SOURCE_CAMERA); }) << "Clearing a callback that was never registered should be a no-op"; } diff --git a/examples/bridge_human_robot/human.cpp b/examples/bridge_human_robot/human.cpp index 5491f13f..3e8c553d 100644 --- a/examples/bridge_human_robot/human.cpp +++ b/examples/bridge_human_robot/human.cpp @@ -103,6 +103,11 @@ static void renderFrame(const livekit::VideoFrame &frame) { static std::atomic g_audio_frames{0}; static std::atomic g_video_frames{0}; +constexpr const char *kRobotMicTrackName = "robot-mic"; +constexpr const char *kRobotSimAudioTrackName = "robot-sim-audio"; +constexpr const char *kRobotCamTrackName = "robot-cam"; +constexpr const char *kRobotSimVideoTrackName = "robot-sim-frame"; + int main(int argc, char *argv[]) { // ----- Parse args / env ----- bool no_audio = false; @@ -232,7 +237,7 @@ int main(int argc, char *argv[]) { // ----- Set audio callbacks using Room::setOnAudioFrameCallback ----- room->setOnAudioFrameCallback( - "robot", "robot-mic", + "robot", kRobotMicTrackName, [playAudio, no_audio](const livekit::AudioFrame &frame) { g_audio_frames.fetch_add(1, std::memory_order_relaxed); if (!no_audio && g_selected_source.load(std::memory_order_relaxed) == @@ -242,7 +247,7 @@ int main(int argc, char *argv[]) { }); room->setOnAudioFrameCallback( - "robot", "robot-sim-audio", + "robot", kRobotSimAudioTrackName, [playAudio, no_audio](const livekit::AudioFrame &frame) { g_audio_frames.fetch_add(1, std::memory_order_relaxed); if (!no_audio && g_selected_source.load(std::memory_order_relaxed) == @@ -253,7 +258,7 @@ int main(int argc, char *argv[]) { // ----- Set video callbacks using Room::setOnVideoFrameCallback ----- room->setOnVideoFrameCallback( - "robot", "robot-cam", + "robot", kRobotCamTrackName, [](const livekit::VideoFrame &frame, std::int64_t /*timestamp_us*/) { g_video_frames.fetch_add(1, std::memory_order_relaxed); if (g_selected_source.load(std::memory_order_relaxed) == @@ -263,7 +268,7 @@ int main(int argc, char *argv[]) { }); room->setOnVideoFrameCallback( - "robot", "robot-sim-frame", + "robot", kRobotSimVideoTrackName, [](const livekit::VideoFrame &frame, std::int64_t /*timestamp_us*/) { g_video_frames.fetch_add(1, std::memory_order_relaxed); if (g_selected_source.load(std::memory_order_relaxed) == diff --git a/examples/bridge_mute_unmute/caller.cpp b/examples/bridge_mute_unmute/caller.cpp index 3ea6f528..a47b2e11 100644 --- a/examples/bridge_mute_unmute/caller.cpp +++ b/examples/bridge_mute_unmute/caller.cpp @@ -165,7 +165,7 @@ int main(int argc, char *argv[]) { // ----- Subscribe to receiver's audio ----- bridge.setOnAudioFrameCallback( - receiver_identity, "mic", + receiver_identity, livekit::TrackSource::SOURCE_MICROPHONE, [&speaker, &speaker_mutex](const livekit::AudioFrame &frame) { const auto &samples = frame.data(); if (samples.empty()) @@ -188,7 +188,7 @@ int main(int argc, char *argv[]) { // ----- Subscribe to receiver's video ----- bridge.setOnVideoFrameCallback( - receiver_identity, "cam", + receiver_identity, livekit::TrackSource::SOURCE_CAMERA, [](const livekit::VideoFrame &frame, std::int64_t /*timestamp_us*/) { storeFrame(frame); }); diff --git a/examples/hello_livekit/receiver.cpp b/examples/hello_livekit/receiver.cpp index 332ee766..bc05e5f2 100644 --- a/examples/hello_livekit/receiver.cpp +++ b/examples/hello_livekit/receiver.cpp @@ -114,10 +114,9 @@ int main(int argc, char *argv[]) { } }); - LK_LOG_INFO( - "[receiver] Listening for video track '{}' + data track '{}'; Ctrl-C to " - "exit", - kVideoTrackName, kDataTrackName); + LK_LOG_INFO("[receiver] Listening for video track '{}' + data track '{}'; " + "Ctrl-C to exit", + kVideoTrackName, kDataTrackName); while (g_running.load()) { std::this_thread::sleep_for(std::chrono::milliseconds(50)); diff --git a/examples/hello_livekit/sender.cpp b/examples/hello_livekit/sender.cpp index 39a183f3..3ca70d5f 100644 --- a/examples/hello_livekit/sender.cpp +++ b/examples/hello_livekit/sender.cpp @@ -59,8 +59,9 @@ int main(int argc, char *argv[]) { } if (url.empty() || sender_token.empty()) { - LK_LOG_ERROR("Usage: HelloLivekitSender \n" - " or set LIVEKIT_URL, LIVEKIT_SENDER_TOKEN"); + LK_LOG_ERROR( + "Usage: HelloLivekitSender \n" + " or set LIVEKIT_URL, LIVEKIT_SENDER_TOKEN"); return 1; } diff --git a/include/livekit/room.h b/include/livekit/room.h index 334c459e..c8e501d3 100644 --- a/include/livekit/room.h +++ b/include/livekit/room.h @@ -1,5 +1,5 @@ /* - * Copyright 2026 LiveKit + * Copyright 2025 LiveKit * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -241,22 +241,14 @@ class Room { // --------------------------------------------------------------- /** - * Set a callback for audio frames from a specific remote participant and - * track name. - * - * A dedicated reader thread is spawned for each (participant, track_name) - * pair - * when the track is subscribed. If the track is already subscribed, the - * reader starts immediately. If not, it starts when the track arrives. - * - * Only one callback may exist per (participant, track_name) pair. Re-calling - * with the same pair replaces the previous callback. - * - * @param participant_identity Identity of the remote participant. - * @param track_name Track name to subscribe to. - * @param callback Function invoked per audio frame. - * @param opts AudioStream options (capacity, noise - * cancellation). + * @brief Sets the audio frame callback via SubscriptionThreadDispatcher. + */ + void setOnAudioFrameCallback(const std::string &participant_identity, + TrackSource source, AudioFrameCallback callback, + AudioStream::Options opts = {}); + + /** + * @brief Sets the audio frame callback via SubscriptionThreadDispatcher. */ void setOnAudioFrameCallback(const std::string &participant_identity, const std::string &track_name, @@ -264,15 +256,14 @@ class Room { AudioStream::Options opts = {}); /** - * Set a callback for video frames from a specific remote participant and - * track name. - * - * @see setOnAudioFrameCallback for threading and lifecycle semantics. - * - * @param participant_identity Identity of the remote participant. - * @param track_name Track name to subscribe to. - * @param callback Function invoked per video frame. - * @param opts VideoStream options (capacity, pixel format). + * @brief Sets the video frame callback via SubscriptionThreadDispatcher. + */ + void setOnVideoFrameCallback(const std::string &participant_identity, + TrackSource source, VideoFrameCallback callback, + VideoStream::Options opts = {}); + + /** + * @brief Sets the video frame callback via SubscriptionThreadDispatcher. */ void setOnVideoFrameCallback(const std::string &participant_identity, const std::string &track_name, @@ -280,45 +271,30 @@ class Room { VideoStream::Options opts = {}); /** - * Clear the audio frame callback for a specific (participant, track_name) - * pair. - * Stops and joins any active reader thread. - * No-op if no callback is registered for this key. - * @param participant_identity Identity of the remote participant. - * @param track_name Track name to clear. + * @brief Clears the audio frame callback via SubscriptionThreadDispatcher. + */ + void clearOnAudioFrameCallback(const std::string &participant_identity, + TrackSource source); + /** + * @brief Clears the audio frame callback via SubscriptionThreadDispatcher. */ void clearOnAudioFrameCallback(const std::string &participant_identity, const std::string &track_name); /** - * Clear the video frame callback for a specific (participant, track_name) - * pair. - * Stops and joins any active reader thread. - * No-op if no callback is registered for this key. - * @param participant_identity Identity of the remote participant. - * @param track_name Track name to clear. + * @brief Clears the video frame callback via SubscriptionThreadDispatcher. + */ + void clearOnVideoFrameCallback(const std::string &participant_identity, + TrackSource source); + + /** + * @brief Clears the video frame callback via SubscriptionThreadDispatcher. */ void clearOnVideoFrameCallback(const std::string &participant_identity, const std::string &track_name); /** - * Add a callback for data frames from a specific remote participant's - * data track. - * - * Multiple callbacks may be registered for the same (participant, - * track_name) pair; each one creates an independent FFI subscription. - * - * The callback fires on a dedicated background thread. If the remote - * data track has not yet been published, the callback is stored and - * auto-wired when the track appears (via DataTrackPublished). - * - * @param participant_identity Identity of the remote participant. - * @param track_name Name of the remote data track. - * @param callback Function to invoke per data frame. - * @return An opaque ID that can later be passed to - * removeOnDataFrameCallback() to tear down this subscription. - * If the subscription thread dispatcher is not available, returns - * std::numeric_limits::max(). + * @brief Adds a data frame callback via SubscriptionThreadDispatcher. */ DataFrameCallbackId addOnDataFrameCallback(const std::string &participant_identity, @@ -326,12 +302,7 @@ class Room { DataFrameCallback callback); /** - * Remove a data frame callback previously registered via - * addOnDataFrameCallback(). Stops and joins the active reader thread - * for this subscription. - * No-op if the ID is not (or no longer) registered. - * - * @param id The identifier returned by addOnDataFrameCallback(). + * @brief Removes the data frame callback via SubscriptionThreadDispatcher. */ void removeOnDataFrameCallback(DataFrameCallbackId id); diff --git a/include/livekit/subscription_thread_dispatcher.h b/include/livekit/subscription_thread_dispatcher.h index b30355fd..f7795fc2 100644 --- a/include/livekit/subscription_thread_dispatcher.h +++ b/include/livekit/subscription_thread_dispatcher.h @@ -1,5 +1,5 @@ /* - * Copyright 2026 LiveKit + * Copyright 2025 LiveKit * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -39,11 +39,11 @@ class Track; class VideoFrame; /// Callback type for incoming audio frames. -/// Invoked on a dedicated reader thread per (participant, track_name) pair. +/// Invoked on a dedicated reader thread per (participant, source) pair. using AudioFrameCallback = std::function; /// Callback type for incoming video frames. -/// Invoked on a dedicated reader thread per (participant, track_name) pair. +/// Invoked on a dedicated reader thread per (participant, source) pair. using VideoFrameCallback = std::function; @@ -67,7 +67,7 @@ using DataFrameCallbackId = std::uint64_t; * registration requests here, and then calls \ref handleTrackSubscribed and * \ref handleTrackUnsubscribed as room events arrive. * - * For each registered `(participant identity, track_name)` pair, this class + * For each registered `(participant identity, TrackSource)` pair, this class * may create a dedicated \ref AudioStream or \ref VideoStream and a matching * reader thread. That thread blocks on stream reads and invokes the * registered callback with decoded frames. @@ -89,6 +89,23 @@ class SubscriptionThreadDispatcher { /// Stops all active readers and clears all registered callbacks. ~SubscriptionThreadDispatcher(); + /** + * Register or replace an audio frame callback for a remote subscription. + * + * The callback is keyed by remote participant identity plus \p source. + * If the matching remote audio track is already subscribed, \ref Room may + * immediately call \ref handleTrackSubscribed to start a reader. + * + * @param participant_identity Identity of the remote participant. + * @param source Track source to match. + * @param callback Function invoked for each decoded audio frame. + * @param opts Options used when creating the backing + * \ref AudioStream. + */ + void setOnAudioFrameCallback(const std::string &participant_identity, + TrackSource source, AudioFrameCallback callback, + AudioStream::Options opts = {}); + /** * Register or replace an audio frame callback for a remote subscription. * @@ -107,6 +124,23 @@ class SubscriptionThreadDispatcher { AudioFrameCallback callback, AudioStream::Options opts = {}); + /** + * Register or replace a video frame callback for a remote subscription. + * + * The callback is keyed by remote participant identity plus \p source. + * If the matching remote video track is already subscribed, \ref Room may + * immediately call \ref handleTrackSubscribed to start a reader. + * + * @param participant_identity Identity of the remote participant. + * @param source Track source to match. + * @param callback Function invoked for each decoded video frame. + * @param opts Options used when creating the backing + * \ref VideoStream. + */ + void setOnVideoFrameCallback(const std::string &participant_identity, + TrackSource source, VideoFrameCallback callback, + VideoStream::Options opts = {}); + /** * Register or replace a video frame callback for a remote subscription. * @@ -125,6 +159,18 @@ class SubscriptionThreadDispatcher { VideoFrameCallback callback, VideoStream::Options opts = {}); + /** + * Remove an audio callback registration and stop any active reader. + * + * If an audio reader thread is active for the given key, its stream is + * closed and the thread is joined before this call returns. + * + * @param participant_identity Identity of the remote participant. + * @param source Track source to clear. + */ + void clearOnAudioFrameCallback(const std::string &participant_identity, + TrackSource source); + /** * Remove an audio callback registration and stop any active reader. * @@ -137,6 +183,18 @@ class SubscriptionThreadDispatcher { void clearOnAudioFrameCallback(const std::string &participant_identity, const std::string &track_name); + /** + * Remove a video callback registration and stop any active reader. + * + * If a video reader thread is active for the given key, its stream is + * closed and the thread is joined before this call returns. + * + * @param participant_identity Identity of the remote participant. + * @param source Track source to clear. + */ + void clearOnVideoFrameCallback(const std::string &participant_identity, + TrackSource source); + /** * Remove a video callback registration and stop any active reader. * @@ -155,31 +213,32 @@ class SubscriptionThreadDispatcher { * \ref Room calls this after it has processed a track-subscription event and * updated its publication state. If a matching callback registration exists, * the dispatcher creates the appropriate stream type and launches a reader - * thread for the `(participant, track_name)` key. + * thread for the `(participant, source)` key. * * If no matching callback is registered, this is a no-op. * * @param participant_identity Identity of the remote participant. - * @param track_name Track name associated with the subscription. + * @param source Track source associated with the subscription. * @param track Subscribed remote track to read from. */ void handleTrackSubscribed(const std::string &participant_identity, - const std::string &track_name, + TrackSource source, const std::string &track_name, const std::shared_ptr &track); /** * Stop reader dispatch for an unsubscribed remote track. * * \ref Room calls this when a remote track is unsubscribed. Any active - * reader stream for the given `(participant, track_name)` key is closed and - * its + * reader stream for the given `(participant, source)` key is closed and its * thread is joined. Callback registration is preserved so future * re-subscription can start dispatch again automatically. * * @param participant_identity Identity of the remote participant. + * @param source Track source associated with the subscription. * @param track_name Track name associated with the subscription. */ void handleTrackUnsubscribed(const std::string &participant_identity, + TrackSource source, const std::string &track_name); // --------------------------------------------------------------- @@ -250,14 +309,17 @@ class SubscriptionThreadDispatcher { private: friend class SubscriptionThreadDispatcherTest; - /// Compound lookup key for a remote participant identity and track name. + /// Compound lookup key for callback dispatch: + /// either `(participant, source, "")` or `(participant, SOURCE_UNKNOWN, + /// track_name)`. struct CallbackKey { std::string participant_identity; + TrackSource source; std::string track_name; bool operator==(const CallbackKey &o) const { return participant_identity == o.participant_identity && - track_name == o.track_name; + source == o.source && track_name == o.track_name; } }; @@ -265,8 +327,9 @@ class SubscriptionThreadDispatcher { struct CallbackKeyHash { std::size_t operator()(const CallbackKey &k) const { auto h1 = std::hash{}(k.participant_identity); - auto h2 = std::hash{}(k.track_name); - return h1 ^ (h2 << 1); + auto h2 = std::hash{}(static_cast(k.source)); + auto h3 = std::hash{}(k.track_name); + return h1 ^ (h2 << 1) ^ (h3 << 2); } }; @@ -371,15 +434,15 @@ class SubscriptionThreadDispatcher { /// Protects callback registration maps and active reader state. mutable std::mutex lock_; - /// Registered audio frame callbacks keyed by `(participant, track_name)`. + /// Registered audio frame callbacks keyed by \ref CallbackKey. std::unordered_map audio_callbacks_; - /// Registered video frame callbacks keyed by `(participant, track_name)`. + /// Registered video frame callbacks keyed by \ref CallbackKey. std::unordered_map video_callbacks_; - /// Active stream/thread state keyed by `(participant, track_name)`. + /// Active stream/thread state keyed by \ref CallbackKey. std::unordered_map active_readers_; diff --git a/src/room.cpp b/src/room.cpp index 165d027f..cd3d6e51 100644 --- a/src/room.cpp +++ b/src/room.cpp @@ -271,13 +271,34 @@ void Room::unregisterByteStreamHandler(const std::string &topic) { // Frame callback registration // ------------------------------------------------------------------- +void Room::setOnAudioFrameCallback(const std::string &participant_identity, + TrackSource source, + AudioFrameCallback callback, + AudioStream::Options opts) { + if (subscription_thread_dispatcher_) { + subscription_thread_dispatcher_->setOnAudioFrameCallback( + participant_identity, source, std::move(callback), std::move(opts)); + } +} + void Room::setOnAudioFrameCallback(const std::string &participant_identity, const std::string &track_name, AudioFrameCallback callback, AudioStream::Options opts) { if (subscription_thread_dispatcher_) { subscription_thread_dispatcher_->setOnAudioFrameCallback( - participant_identity, track_name, std::move(callback), std::move(opts)); + participant_identity, track_name, std::move(callback), + std::move(opts)); + } +} + +void Room::setOnVideoFrameCallback(const std::string &participant_identity, + TrackSource source, + VideoFrameCallback callback, + VideoStream::Options opts) { + if (subscription_thread_dispatcher_) { + subscription_thread_dispatcher_->setOnVideoFrameCallback( + participant_identity, source, std::move(callback), std::move(opts)); } } @@ -287,7 +308,16 @@ void Room::setOnVideoFrameCallback(const std::string &participant_identity, VideoStream::Options opts) { if (subscription_thread_dispatcher_) { subscription_thread_dispatcher_->setOnVideoFrameCallback( - participant_identity, track_name, std::move(callback), std::move(opts)); + participant_identity, track_name, std::move(callback), + std::move(opts)); + } +} + +void Room::clearOnAudioFrameCallback(const std::string &participant_identity, + TrackSource source) { + if (subscription_thread_dispatcher_) { + subscription_thread_dispatcher_->clearOnAudioFrameCallback( + participant_identity, source); } } @@ -299,6 +329,14 @@ void Room::clearOnAudioFrameCallback(const std::string &participant_identity, } } +void Room::clearOnVideoFrameCallback(const std::string &participant_identity, + TrackSource source) { + if (subscription_thread_dispatcher_) { + subscription_thread_dispatcher_->clearOnVideoFrameCallback( + participant_identity, source); + } +} + void Room::clearOnVideoFrameCallback(const std::string &participant_identity, const std::string &track_name) { if (subscription_thread_dispatcher_) { @@ -611,13 +649,13 @@ void Room::OnEvent(const FfiEvent &event) { if (subscription_thread_dispatcher_ && remote_track && rpublication) { subscription_thread_dispatcher_->handleTrackSubscribed( - identity, rpublication->name(), remote_track); + identity, rpublication->source(), rpublication->name(), remote_track); } break; } case proto::RoomEvent::kTrackUnsubscribed: { TrackUnsubscribedEvent ev; - std::string unsub_track_name; + TrackSource unsub_source = TrackSource::SOURCE_UNKNOWN; std::string unsub_identity; { std::lock_guard guard(lock_); @@ -640,7 +678,7 @@ void Room::OnEvent(const FfiEvent &event) { break; } auto publication = pubIt->second; - unsub_track_name = publication->name(); + unsub_source = publication->source(); auto track = publication->track(); publication->setTrack(nullptr); publication->setSubscribed(false); @@ -653,9 +691,14 @@ void Room::OnEvent(const FfiEvent &event) { delegate_snapshot->onTrackUnsubscribed(*this, ev); } - if (subscription_thread_dispatcher_ && !unsub_track_name.empty()) { - subscription_thread_dispatcher_->handleTrackUnsubscribed( - unsub_identity, unsub_track_name); + if (subscription_thread_dispatcher_ && + unsub_source != TrackSource::SOURCE_UNKNOWN) { + subscription_thread_dispatcher_->handleTrackUnsubscribed(unsub_identity, + unsub_source, + ev.publication + ? ev.publication + ->name() + : ""); } break; } diff --git a/src/subscription_thread_dispatcher.cpp b/src/subscription_thread_dispatcher.cpp index 0601ee47..6ea50e3c 100644 --- a/src/subscription_thread_dispatcher.cpp +++ b/src/subscription_thread_dispatcher.cpp @@ -1,5 +1,5 @@ /* - * Copyright 2026 LiveKit + * Copyright 2025 LiveKit * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -51,37 +51,85 @@ SubscriptionThreadDispatcher::~SubscriptionThreadDispatcher() { stopAll(); } +void SubscriptionThreadDispatcher::setOnAudioFrameCallback( + const std::string &participant_identity, TrackSource source, + AudioFrameCallback callback, AudioStream::Options opts) { + CallbackKey key{participant_identity, source, ""}; + std::lock_guard lock(lock_); + const bool replacing = audio_callbacks_.find(key) != audio_callbacks_.end(); + audio_callbacks_[key] = + RegisteredAudioCallback{std::move(callback), std::move(opts)}; + LK_LOG_DEBUG("Registered audio frame callback for participant={} source={} " + "replacing_existing={} total_audio_callbacks={}", + participant_identity, static_cast(source), replacing, + audio_callbacks_.size()); +} + void SubscriptionThreadDispatcher::setOnAudioFrameCallback( const std::string &participant_identity, const std::string &track_name, AudioFrameCallback callback, AudioStream::Options opts) { - CallbackKey key{participant_identity, track_name}; + CallbackKey key{participant_identity, TrackSource::SOURCE_UNKNOWN, track_name}; std::lock_guard lock(lock_); const bool replacing = audio_callbacks_.find(key) != audio_callbacks_.end(); audio_callbacks_[key] = RegisteredAudioCallback{std::move(callback), std::move(opts)}; - LK_LOG_DEBUG("Registered audio frame callback for participant={} track={} " + LK_LOG_DEBUG("Registered audio frame callback for participant={} track_name={} " "replacing_existing={} total_audio_callbacks={}", participant_identity, track_name, replacing, audio_callbacks_.size()); } +void SubscriptionThreadDispatcher::setOnVideoFrameCallback( + const std::string &participant_identity, TrackSource source, + VideoFrameCallback callback, VideoStream::Options opts) { + CallbackKey key{participant_identity, source, ""}; + std::lock_guard lock(lock_); + const bool replacing = video_callbacks_.find(key) != video_callbacks_.end(); + video_callbacks_[key] = + RegisteredVideoCallback{std::move(callback), std::move(opts)}; + LK_LOG_DEBUG("Registered video frame callback for participant={} source={} " + "replacing_existing={} total_video_callbacks={}", + participant_identity, static_cast(source), replacing, + video_callbacks_.size()); +} + void SubscriptionThreadDispatcher::setOnVideoFrameCallback( const std::string &participant_identity, const std::string &track_name, VideoFrameCallback callback, VideoStream::Options opts) { - CallbackKey key{participant_identity, track_name}; + CallbackKey key{participant_identity, TrackSource::SOURCE_UNKNOWN, track_name}; std::lock_guard lock(lock_); const bool replacing = video_callbacks_.find(key) != video_callbacks_.end(); video_callbacks_[key] = RegisteredVideoCallback{std::move(callback), std::move(opts)}; - LK_LOG_DEBUG("Registered video frame callback for participant={} track={} " + LK_LOG_DEBUG("Registered video frame callback for participant={} track_name={} " "replacing_existing={} total_video_callbacks={}", participant_identity, track_name, replacing, video_callbacks_.size()); } +void SubscriptionThreadDispatcher::clearOnAudioFrameCallback( + const std::string &participant_identity, TrackSource source) { + CallbackKey key{participant_identity, source, ""}; + std::thread old_thread; + bool removed_callback = false; + { + std::lock_guard lock(lock_); + removed_callback = audio_callbacks_.erase(key) > 0; + old_thread = extractReaderThreadLocked(key); + LK_LOG_DEBUG( + "Clearing audio frame callback for participant={} source={} " + "removed_callback={} stopped_reader={} remaining_audio_callbacks={}", + participant_identity, static_cast(source), removed_callback, + old_thread.joinable(), audio_callbacks_.size()); + } + if (old_thread.joinable()) { + old_thread.join(); + } +} + void SubscriptionThreadDispatcher::clearOnAudioFrameCallback( const std::string &participant_identity, const std::string &track_name) { - CallbackKey key{participant_identity, track_name}; + CallbackKey key{participant_identity, TrackSource::SOURCE_UNKNOWN, track_name}; std::thread old_thread; bool removed_callback = false; { @@ -89,7 +137,7 @@ void SubscriptionThreadDispatcher::clearOnAudioFrameCallback( removed_callback = audio_callbacks_.erase(key) > 0; old_thread = extractReaderThreadLocked(key); LK_LOG_DEBUG( - "Clearing audio frame callback for participant={} track={} " + "Clearing audio frame callback for participant={} track_name={} " "removed_callback={} stopped_reader={} remaining_audio_callbacks={}", participant_identity, track_name, removed_callback, old_thread.joinable(), audio_callbacks_.size()); @@ -99,9 +147,29 @@ void SubscriptionThreadDispatcher::clearOnAudioFrameCallback( } } +void SubscriptionThreadDispatcher::clearOnVideoFrameCallback( + const std::string &participant_identity, TrackSource source) { + CallbackKey key{participant_identity, source, ""}; + std::thread old_thread; + bool removed_callback = false; + { + std::lock_guard lock(lock_); + removed_callback = video_callbacks_.erase(key) > 0; + old_thread = extractReaderThreadLocked(key); + LK_LOG_DEBUG( + "Clearing video frame callback for participant={} source={} " + "removed_callback={} stopped_reader={} remaining_video_callbacks={}", + participant_identity, static_cast(source), removed_callback, + old_thread.joinable(), video_callbacks_.size()); + } + if (old_thread.joinable()) { + old_thread.join(); + } +} + void SubscriptionThreadDispatcher::clearOnVideoFrameCallback( const std::string &participant_identity, const std::string &track_name) { - CallbackKey key{participant_identity, track_name}; + CallbackKey key{participant_identity, TrackSource::SOURCE_UNKNOWN, track_name}; std::thread old_thread; bool removed_callback = false; { @@ -109,7 +177,7 @@ void SubscriptionThreadDispatcher::clearOnVideoFrameCallback( removed_callback = video_callbacks_.erase(key) > 0; old_thread = extractReaderThreadLocked(key); LK_LOG_DEBUG( - "Clearing video frame callback for participant={} track={} " + "Clearing video frame callback for participant={} track_name={} " "removed_callback={} stopped_reader={} remaining_video_callbacks={}", participant_identity, track_name, removed_callback, old_thread.joinable(), video_callbacks_.size()); @@ -120,24 +188,33 @@ void SubscriptionThreadDispatcher::clearOnVideoFrameCallback( } void SubscriptionThreadDispatcher::handleTrackSubscribed( - const std::string &participant_identity, const std::string &track_name, + const std::string &participant_identity, TrackSource source, + const std::string &track_name, const std::shared_ptr &track) { if (!track) { LK_LOG_WARN( - "Ignoring subscribed track dispatch for participant={} track={} " + "Ignoring subscribed track dispatch for participant={} source={} " "because track is null", - participant_identity, track_name); + participant_identity, static_cast(source)); return; } - LK_LOG_DEBUG("Handling subscribed track for participant={} track={} kind={}", - participant_identity, track_name, + LK_LOG_DEBUG("Handling subscribed track for participant={} source={} kind={}", + participant_identity, static_cast(source), trackKindName(track->kind())); - CallbackKey key{participant_identity, track_name}; + CallbackKey key{participant_identity, TrackSource::SOURCE_UNKNOWN, track_name}; + CallbackKey fallback_key{participant_identity, source, ""}; std::thread old_thread; { std::lock_guard lock(lock_); + if (track->kind() == TrackKind::KIND_AUDIO && + audio_callbacks_.find(key) == audio_callbacks_.end()) { + key = fallback_key; + } else if (track->kind() == TrackKind::KIND_VIDEO && + video_callbacks_.find(key) == video_callbacks_.end()) { + key = fallback_key; + } old_thread = startReaderLocked(key, track); } if (old_thread.joinable()) { @@ -146,20 +223,28 @@ void SubscriptionThreadDispatcher::handleTrackSubscribed( } void SubscriptionThreadDispatcher::handleTrackUnsubscribed( - const std::string &participant_identity, const std::string &track_name) { - CallbackKey key{participant_identity, track_name}; + const std::string &participant_identity, TrackSource source, + const std::string &track_name) { + CallbackKey key{participant_identity, TrackSource::SOURCE_UNKNOWN, track_name}; + CallbackKey fallback_key{participant_identity, source, ""}; std::thread old_thread; + std::thread fallback_old_thread; { std::lock_guard lock(lock_); old_thread = extractReaderThreadLocked(key); - LK_LOG_DEBUG("Handling unsubscribed track for participant={} track={} " - "stopped_reader={}", - participant_identity, track_name, - old_thread.joinable()); + fallback_old_thread = extractReaderThreadLocked(fallback_key); + LK_LOG_DEBUG("Handling unsubscribed track for participant={} source={} " + "track_name={} stopped_reader={} fallback_stopped_reader={}", + participant_identity, static_cast(source), + track_name, old_thread.joinable(), + fallback_old_thread.joinable()); } if (old_thread.joinable()) { old_thread.join(); } + if (fallback_old_thread.joinable()) { + fallback_old_thread.join(); + } } // ------------------------------------------------------------------- @@ -322,13 +407,17 @@ std::thread SubscriptionThreadDispatcher::extractReaderThreadLocked( const CallbackKey &key) { auto it = active_readers_.find(key); if (it == active_readers_.end()) { - LK_LOG_TRACE("No active reader to extract for participant={} track={}", - key.participant_identity, key.track_name); + LK_LOG_TRACE("No active reader to extract for participant={} source={} " + "track_name={}", + key.participant_identity, static_cast(key.source), + key.track_name); return {}; } - LK_LOG_DEBUG("Extracting active reader for participant={} track={}", - key.participant_identity, key.track_name); + LK_LOG_DEBUG("Extracting active reader for participant={} source={} " + "track_name={}", + key.participant_identity, static_cast(key.source), + key.track_name); ActiveReader reader = std::move(it->second); active_readers_.erase(it); @@ -346,9 +435,9 @@ std::thread SubscriptionThreadDispatcher::startReaderLocked( if (track->kind() == TrackKind::KIND_AUDIO) { auto it = audio_callbacks_.find(key); if (it == audio_callbacks_.end()) { - LK_LOG_TRACE("Skipping audio reader start for participant={} track={} " + LK_LOG_TRACE("Skipping audio reader start for participant={} source={} " "because no audio callback is registered", - key.participant_identity, key.track_name); + key.participant_identity, static_cast(key.source)); return {}; } return startAudioReaderLocked(key, track, it->second.callback, @@ -357,9 +446,9 @@ std::thread SubscriptionThreadDispatcher::startReaderLocked( if (track->kind() == TrackKind::KIND_VIDEO) { auto it = video_callbacks_.find(key); if (it == video_callbacks_.end()) { - LK_LOG_TRACE("Skipping video reader start for participant={} track={} " + LK_LOG_TRACE("Skipping video reader start for participant={} source={} " "because no video callback is registered", - key.participant_identity, key.track_name); + key.participant_identity, static_cast(key.source)); return {}; } return startVideoReaderLocked(key, track, it->second.callback, @@ -367,39 +456,39 @@ std::thread SubscriptionThreadDispatcher::startReaderLocked( } if (track->kind() == TrackKind::KIND_UNKNOWN) { LK_LOG_WARN( - "Skipping reader start for participant={} track={} because track " + "Skipping reader start for participant={} source={} because track " "kind is unknown", - key.participant_identity, key.track_name); + key.participant_identity, static_cast(key.source)); return {}; } LK_LOG_WARN( - "Skipping reader start for participant={} track={} because track kind " + "Skipping reader start for participant={} source={} because track kind " "is unsupported", - key.participant_identity, key.track_name); + key.participant_identity, static_cast(key.source)); return {}; } std::thread SubscriptionThreadDispatcher::startAudioReaderLocked( const CallbackKey &key, const std::shared_ptr &track, AudioFrameCallback cb, const AudioStream::Options &opts) { - LK_LOG_DEBUG("Starting audio reader for participant={} track={}", - key.participant_identity, key.track_name); + LK_LOG_DEBUG("Starting audio reader for participant={} source={}", + key.participant_identity, static_cast(key.source)); auto old_thread = extractReaderThreadLocked(key); if (static_cast(active_readers_.size()) >= kMaxActiveReaders) { LK_LOG_ERROR( - "Cannot start audio reader for {} track={}: active reader limit ({}) " + "Cannot start audio reader for {} source={}: active reader limit ({}) " "reached", - key.participant_identity, key.track_name, + key.participant_identity, static_cast(key.source), kMaxActiveReaders); return old_thread; } auto stream = AudioStream::fromTrack(track, opts); if (!stream) { - LK_LOG_ERROR("Failed to create AudioStream for {} track={}", - key.participant_identity, key.track_name); + LK_LOG_ERROR("Failed to create AudioStream for {} source={}", + key.participant_identity, static_cast(key.source)); return old_thread; } @@ -407,11 +496,11 @@ std::thread SubscriptionThreadDispatcher::startAudioReaderLocked( reader.audio_stream = stream; auto stream_copy = stream; const std::string participant_identity = key.participant_identity; - const std::string track_name = key.track_name; + const TrackSource source = key.source; reader.thread = - std::thread([stream_copy, cb, participant_identity, track_name]() { - LK_LOG_DEBUG("Audio reader thread started for participant={} track={}", - participant_identity, track_name); + std::thread([stream_copy, cb, participant_identity, source]() { + LK_LOG_DEBUG("Audio reader thread started for participant={} source={}", + participant_identity, static_cast(source)); AudioFrameEvent ev; while (stream_copy->read(ev)) { try { @@ -420,13 +509,13 @@ std::thread SubscriptionThreadDispatcher::startAudioReaderLocked( LK_LOG_ERROR("Audio frame callback exception: {}", e.what()); } } - LK_LOG_DEBUG("Audio reader thread exiting for participant={} track={}", - participant_identity, track_name); + LK_LOG_DEBUG("Audio reader thread exiting for participant={} source={}", + participant_identity, static_cast(source)); }); active_readers_[key] = std::move(reader); - LK_LOG_DEBUG("Started audio reader for participant={} track={} " + LK_LOG_DEBUG("Started audio reader for participant={} source={} " "active_readers={}", - key.participant_identity, key.track_name, + key.participant_identity, static_cast(key.source), active_readers_.size()); return old_thread; } @@ -434,23 +523,23 @@ std::thread SubscriptionThreadDispatcher::startAudioReaderLocked( std::thread SubscriptionThreadDispatcher::startVideoReaderLocked( const CallbackKey &key, const std::shared_ptr &track, VideoFrameCallback cb, const VideoStream::Options &opts) { - LK_LOG_DEBUG("Starting video reader for participant={} track={}", - key.participant_identity, key.track_name); + LK_LOG_DEBUG("Starting video reader for participant={} source={}", + key.participant_identity, static_cast(key.source)); auto old_thread = extractReaderThreadLocked(key); if (static_cast(active_readers_.size()) >= kMaxActiveReaders) { LK_LOG_ERROR( - "Cannot start video reader for {} track={}: active reader limit ({}) " + "Cannot start video reader for {} source={}: active reader limit ({}) " "reached", - key.participant_identity, key.track_name, + key.participant_identity, static_cast(key.source), kMaxActiveReaders); return old_thread; } auto stream = VideoStream::fromTrack(track, opts); if (!stream) { - LK_LOG_ERROR("Failed to create VideoStream for {} track={}", - key.participant_identity, key.track_name); + LK_LOG_ERROR("Failed to create VideoStream for {} source={}", + key.participant_identity, static_cast(key.source)); return old_thread; } @@ -458,11 +547,11 @@ std::thread SubscriptionThreadDispatcher::startVideoReaderLocked( reader.video_stream = stream; auto stream_copy = stream; const std::string participant_identity = key.participant_identity; - const std::string track_name = key.track_name; + const TrackSource source = key.source; reader.thread = - std::thread([stream_copy, cb, participant_identity, track_name]() { - LK_LOG_DEBUG("Video reader thread started for participant={} track={}", - participant_identity, track_name); + std::thread([stream_copy, cb, participant_identity, source]() { + LK_LOG_DEBUG("Video reader thread started for participant={} source={}", + participant_identity, static_cast(source)); VideoFrameEvent ev; while (stream_copy->read(ev)) { try { @@ -471,13 +560,13 @@ std::thread SubscriptionThreadDispatcher::startVideoReaderLocked( LK_LOG_ERROR("Video frame callback exception: {}", e.what()); } } - LK_LOG_DEBUG("Video reader thread exiting for participant={} track={}", - participant_identity, track_name); + LK_LOG_DEBUG("Video reader thread exiting for participant={} source={}", + participant_identity, static_cast(source)); }); active_readers_[key] = std::move(reader); - LK_LOG_DEBUG("Started video reader for participant={} track={} " + LK_LOG_DEBUG("Started video reader for participant={} source={} " "active_readers={}", - key.participant_identity, key.track_name, + key.participant_identity, static_cast(key.source), active_readers_.size()); return old_thread; } diff --git a/src/tests/integration/test_room_callbacks.cpp b/src/tests/integration/test_room_callbacks.cpp index 6234c20f..d0dc6bf8 100644 --- a/src/tests/integration/test_room_callbacks.cpp +++ b/src/tests/integration/test_room_callbacks.cpp @@ -1,5 +1,5 @@ /* - * Copyright 2026 LiveKit + * Copyright 2025 LiveKit * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -40,22 +40,24 @@ TEST_F(RoomCallbackTest, AudioCallbackRegistrationIsAccepted) { Room room; EXPECT_NO_THROW(room.setOnAudioFrameCallback( - "alice", "microphone", [](const AudioFrame &) {})); + "alice", TrackSource::SOURCE_MICROPHONE, [](const AudioFrame &) {})); } TEST_F(RoomCallbackTest, VideoCallbackRegistrationIsAccepted) { Room room; EXPECT_NO_THROW( - room.setOnVideoFrameCallback("alice", "camera", + room.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, [](const VideoFrame &, std::int64_t) {})); } TEST_F(RoomCallbackTest, ClearingMissingCallbacksIsNoOp) { Room room; - EXPECT_NO_THROW(room.clearOnAudioFrameCallback("nobody", "microphone")); - EXPECT_NO_THROW(room.clearOnVideoFrameCallback("nobody", "camera")); + EXPECT_NO_THROW( + room.clearOnAudioFrameCallback("nobody", TrackSource::SOURCE_MICROPHONE)); + EXPECT_NO_THROW( + room.clearOnVideoFrameCallback("nobody", TrackSource::SOURCE_CAMERA)); } TEST_F(RoomCallbackTest, ReRegisteringSameAudioKeyDoesNotThrow) { @@ -64,10 +66,10 @@ TEST_F(RoomCallbackTest, ReRegisteringSameAudioKeyDoesNotThrow) { std::atomic counter2{0}; EXPECT_NO_THROW(room.setOnAudioFrameCallback( - "alice", "microphone", + "alice", TrackSource::SOURCE_MICROPHONE, [&counter1](const AudioFrame &) { counter1++; })); EXPECT_NO_THROW(room.setOnAudioFrameCallback( - "alice", "microphone", + "alice", TrackSource::SOURCE_MICROPHONE, [&counter2](const AudioFrame &) { counter2++; })); } @@ -75,10 +77,10 @@ TEST_F(RoomCallbackTest, ReRegisteringSameVideoKeyDoesNotThrow) { Room room; EXPECT_NO_THROW( - room.setOnVideoFrameCallback("alice", "camera", + room.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, [](const VideoFrame &, std::int64_t) {})); EXPECT_NO_THROW( - room.setOnVideoFrameCallback("alice", "camera", + room.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, [](const VideoFrame &, std::int64_t) {})); } @@ -86,25 +88,25 @@ TEST_F(RoomCallbackTest, DistinctAudioAndVideoCallbacksCanCoexist) { Room room; EXPECT_NO_THROW(room.setOnAudioFrameCallback( - "alice", "microphone", [](const AudioFrame &) {})); + "alice", TrackSource::SOURCE_MICROPHONE, [](const AudioFrame &) {})); EXPECT_NO_THROW( - room.setOnVideoFrameCallback("alice", "camera", + room.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, [](const VideoFrame &, std::int64_t) {})); EXPECT_NO_THROW(room.setOnAudioFrameCallback( - "bob", "microphone", [](const AudioFrame &) {})); + "bob", TrackSource::SOURCE_MICROPHONE, [](const AudioFrame &) {})); EXPECT_NO_THROW( - room.setOnVideoFrameCallback("bob", "camera", + room.setOnVideoFrameCallback("bob", TrackSource::SOURCE_CAMERA, [](const VideoFrame &, std::int64_t) {})); } -TEST_F(RoomCallbackTest, SameIdentityDifferentTrackNamesAreAccepted) { +TEST_F(RoomCallbackTest, SameSourceDifferentTrackNamesAreAccepted) { Room room; EXPECT_NO_THROW( - room.setOnVideoFrameCallback("alice", "camera-main", + room.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, [](const VideoFrame &, std::int64_t) {})); EXPECT_NO_THROW( - room.setOnVideoFrameCallback("alice", "camera-secondary", + room.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, [](const VideoFrame &, std::int64_t) {})); } @@ -136,9 +138,9 @@ TEST_F(RoomCallbackTest, RemovingUnknownDataCallbackIsNoOp) { TEST_F(RoomCallbackTest, DestroyRoomWithRegisteredCallbacksIsSafe) { EXPECT_NO_THROW({ Room room; - room.setOnAudioFrameCallback("alice", "microphone", + room.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, [](const AudioFrame &) {}); - room.setOnVideoFrameCallback("bob", "camera", + room.setOnVideoFrameCallback("bob", TrackSource::SOURCE_CAMERA, [](const VideoFrame &, std::int64_t) {}); room.addOnDataFrameCallback( "carol", "track", @@ -149,9 +151,9 @@ TEST_F(RoomCallbackTest, DestroyRoomWithRegisteredCallbacksIsSafe) { TEST_F(RoomCallbackTest, DestroyRoomAfterClearingCallbacksIsSafe) { EXPECT_NO_THROW({ Room room; - room.setOnAudioFrameCallback("alice", "microphone", + room.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, [](const AudioFrame &) {}); - room.clearOnAudioFrameCallback("alice", "microphone"); + room.clearOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE); const auto id = room.addOnDataFrameCallback( "alice", "track", @@ -172,9 +174,9 @@ TEST_F(RoomCallbackTest, ConcurrentRegistrationDoesNotCrash) { threads.emplace_back([&room, t]() { for (int i = 0; i < kIterations; ++i) { const std::string id = "participant-" + std::to_string(t); - room.setOnAudioFrameCallback(id, "microphone", + room.setOnAudioFrameCallback(id, TrackSource::SOURCE_MICROPHONE, [](const AudioFrame &) {}); - room.clearOnAudioFrameCallback(id, "microphone"); + room.clearOnAudioFrameCallback(id, TrackSource::SOURCE_MICROPHONE); } }); } @@ -198,9 +200,9 @@ TEST_F(RoomCallbackTest, ConcurrentMixedRegistrationDoesNotCrash) { threads.emplace_back([&room, t]() { const std::string id = "p-" + std::to_string(t); for (int i = 0; i < kIterations; ++i) { - room.setOnAudioFrameCallback(id, "microphone", + room.setOnAudioFrameCallback(id, TrackSource::SOURCE_MICROPHONE, [](const AudioFrame &) {}); - room.setOnVideoFrameCallback(id, "camera", + room.setOnVideoFrameCallback(id, TrackSource::SOURCE_CAMERA, [](const VideoFrame &, std::int64_t) {}); const auto data_id = room.addOnDataFrameCallback(id, "track", @@ -224,13 +226,13 @@ TEST_F(RoomCallbackTest, ManyDistinctAudioCallbacksCanBeRegisteredAndCleared) { for (int i = 0; i < kCount; ++i) { EXPECT_NO_THROW(room.setOnAudioFrameCallback( - "participant-" + std::to_string(i), "microphone", + "participant-" + std::to_string(i), TrackSource::SOURCE_MICROPHONE, [](const AudioFrame &) {})); } for (int i = 0; i < kCount; ++i) { EXPECT_NO_THROW(room.clearOnAudioFrameCallback( - "participant-" + std::to_string(i), "microphone")); + "participant-" + std::to_string(i), TrackSource::SOURCE_MICROPHONE)); } } diff --git a/src/tests/integration/test_subscription_thread_dispatcher.cpp b/src/tests/integration/test_subscription_thread_dispatcher.cpp index ff172889..45e2bb11 100644 --- a/src/tests/integration/test_subscription_thread_dispatcher.cpp +++ b/src/tests/integration/test_subscription_thread_dispatcher.cpp @@ -1,5 +1,5 @@ /* - * Copyright 2026 LiveKit + * Copyright 2025 LiveKit * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -67,21 +67,21 @@ class SubscriptionThreadDispatcherTest : public ::testing::Test { // ============================================================================ TEST_F(SubscriptionThreadDispatcherTest, CallbackKeyEqualKeysCompareEqual) { - CallbackKey a{"alice", "microphone"}; - CallbackKey b{"alice", "microphone"}; + CallbackKey a{"alice", TrackSource::SOURCE_MICROPHONE}; + CallbackKey b{"alice", TrackSource::SOURCE_MICROPHONE}; EXPECT_TRUE(a == b); } TEST_F(SubscriptionThreadDispatcherTest, CallbackKeyDifferentIdentityNotEqual) { - CallbackKey a{"alice", "microphone"}; - CallbackKey b{"bob", "microphone"}; + CallbackKey a{"alice", TrackSource::SOURCE_MICROPHONE}; + CallbackKey b{"bob", TrackSource::SOURCE_MICROPHONE}; EXPECT_FALSE(a == b); } -TEST_F(SubscriptionThreadDispatcherTest, CallbackKeyDifferentTrackNotEqual) { - CallbackKey a{"alice", "microphone"}; - CallbackKey b{"alice", "camera"}; +TEST_F(SubscriptionThreadDispatcherTest, CallbackKeyDifferentSourceNotEqual) { + CallbackKey a{"alice", TrackSource::SOURCE_MICROPHONE}; + CallbackKey b{"alice", TrackSource::SOURCE_CAMERA}; EXPECT_FALSE(a == b); } @@ -91,8 +91,8 @@ TEST_F(SubscriptionThreadDispatcherTest, CallbackKeyDifferentTrackNotEqual) { TEST_F(SubscriptionThreadDispatcherTest, CallbackKeyHashEqualKeysProduceSameHash) { - CallbackKey a{"alice", "microphone"}; - CallbackKey b{"alice", "microphone"}; + CallbackKey a{"alice", TrackSource::SOURCE_MICROPHONE}; + CallbackKey b{"alice", TrackSource::SOURCE_MICROPHONE}; CallbackKeyHash hasher; EXPECT_EQ(hasher(a), hasher(b)); } @@ -100,9 +100,9 @@ TEST_F(SubscriptionThreadDispatcherTest, TEST_F(SubscriptionThreadDispatcherTest, CallbackKeyHashDifferentKeysLikelyDifferentHash) { CallbackKeyHash hasher; - CallbackKey mic{"alice", "microphone"}; - CallbackKey cam{"alice", "camera"}; - CallbackKey bob{"bob", "microphone"}; + CallbackKey mic{"alice", TrackSource::SOURCE_MICROPHONE}; + CallbackKey cam{"alice", TrackSource::SOURCE_CAMERA}; + CallbackKey bob{"bob", TrackSource::SOURCE_MICROPHONE}; EXPECT_NE(hasher(mic), hasher(cam)); EXPECT_NE(hasher(mic), hasher(bob)); @@ -111,9 +111,9 @@ TEST_F(SubscriptionThreadDispatcherTest, TEST_F(SubscriptionThreadDispatcherTest, CallbackKeyWorksAsUnorderedMapKey) { std::unordered_map map; - CallbackKey k1{"alice", "microphone"}; - CallbackKey k2{"bob", "camera"}; - CallbackKey k3{"alice", "camera"}; + CallbackKey k1{"alice", TrackSource::SOURCE_MICROPHONE}; + CallbackKey k2{"bob", TrackSource::SOURCE_CAMERA}; + CallbackKey k3{"alice", TrackSource::SOURCE_CAMERA}; map[k1] = 1; map[k2] = 2; @@ -134,8 +134,8 @@ TEST_F(SubscriptionThreadDispatcherTest, CallbackKeyWorksAsUnorderedMapKey) { } TEST_F(SubscriptionThreadDispatcherTest, CallbackKeyEmptyIdentityWorks) { - CallbackKey a{"", ""}; - CallbackKey b{"", ""}; + CallbackKey a{"", TrackSource::SOURCE_UNKNOWN}; + CallbackKey b{"", TrackSource::SOURCE_UNKNOWN}; CallbackKeyHash hasher; EXPECT_TRUE(a == b); EXPECT_EQ(hasher(a), hasher(b)); @@ -155,7 +155,7 @@ TEST_F(SubscriptionThreadDispatcherTest, MaxActiveReadersIs20) { TEST_F(SubscriptionThreadDispatcherTest, SetAudioCallbackStoresRegistration) { SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnAudioFrameCallback("alice", "microphone", + dispatcher.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, [](const AudioFrame &) {}); EXPECT_EQ(audioCallbacks(dispatcher).size(), 1u); @@ -163,7 +163,7 @@ TEST_F(SubscriptionThreadDispatcherTest, SetAudioCallbackStoresRegistration) { TEST_F(SubscriptionThreadDispatcherTest, SetVideoCallbackStoresRegistration) { SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnVideoFrameCallback("alice", "camera", + dispatcher.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, [](const VideoFrame &, std::int64_t) {}); EXPECT_EQ(videoCallbacks(dispatcher).size(), 1u); @@ -171,29 +171,30 @@ TEST_F(SubscriptionThreadDispatcherTest, SetVideoCallbackStoresRegistration) { TEST_F(SubscriptionThreadDispatcherTest, ClearAudioCallbackRemovesRegistration) { SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnAudioFrameCallback("alice", "microphone", + dispatcher.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, [](const AudioFrame &) {}); ASSERT_EQ(audioCallbacks(dispatcher).size(), 1u); - dispatcher.clearOnAudioFrameCallback("alice", "microphone"); + dispatcher.clearOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE); EXPECT_EQ(audioCallbacks(dispatcher).size(), 0u); } TEST_F(SubscriptionThreadDispatcherTest, ClearVideoCallbackRemovesRegistration) { SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnVideoFrameCallback("alice", "camera", + dispatcher.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, [](const VideoFrame &, std::int64_t) {}); ASSERT_EQ(videoCallbacks(dispatcher).size(), 1u); - dispatcher.clearOnVideoFrameCallback("alice", "camera"); + dispatcher.clearOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA); EXPECT_EQ(videoCallbacks(dispatcher).size(), 0u); } TEST_F(SubscriptionThreadDispatcherTest, ClearNonExistentCallbackIsNoOp) { SubscriptionThreadDispatcher dispatcher; EXPECT_NO_THROW(dispatcher.clearOnAudioFrameCallback( - "nobody", "microphone")); - EXPECT_NO_THROW(dispatcher.clearOnVideoFrameCallback("nobody", "camera")); + "nobody", TrackSource::SOURCE_MICROPHONE)); + EXPECT_NO_THROW( + dispatcher.clearOnVideoFrameCallback("nobody", TrackSource::SOURCE_CAMERA)); } TEST_F(SubscriptionThreadDispatcherTest, OverwriteAudioCallbackKeepsSingleEntry) { @@ -202,10 +203,10 @@ TEST_F(SubscriptionThreadDispatcherTest, OverwriteAudioCallbackKeepsSingleEntry) std::atomic counter2{0}; dispatcher.setOnAudioFrameCallback( - "alice", "microphone", + "alice", TrackSource::SOURCE_MICROPHONE, [&counter1](const AudioFrame &) { counter1++; }); dispatcher.setOnAudioFrameCallback( - "alice", "microphone", + "alice", TrackSource::SOURCE_MICROPHONE, [&counter2](const AudioFrame &) { counter2++; }); EXPECT_EQ(audioCallbacks(dispatcher).size(), 1u) @@ -214,9 +215,9 @@ TEST_F(SubscriptionThreadDispatcherTest, OverwriteAudioCallbackKeepsSingleEntry) TEST_F(SubscriptionThreadDispatcherTest, OverwriteVideoCallbackKeepsSingleEntry) { SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnVideoFrameCallback("alice", "camera", + dispatcher.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, [](const VideoFrame &, std::int64_t) {}); - dispatcher.setOnVideoFrameCallback("alice", "camera", + dispatcher.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, [](const VideoFrame &, std::int64_t) {}); EXPECT_EQ(videoCallbacks(dispatcher).size(), 1u); @@ -225,35 +226,38 @@ TEST_F(SubscriptionThreadDispatcherTest, OverwriteVideoCallbackKeepsSingleEntry) TEST_F(SubscriptionThreadDispatcherTest, MultipleDistinctCallbacksAreIndependent) { SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnAudioFrameCallback("alice", "microphone", + dispatcher.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, [](const AudioFrame &) {}); - dispatcher.setOnVideoFrameCallback("alice", "camera", + dispatcher.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, [](const VideoFrame &, std::int64_t) {}); - dispatcher.setOnAudioFrameCallback("bob", "microphone", + dispatcher.setOnAudioFrameCallback("bob", TrackSource::SOURCE_MICROPHONE, [](const AudioFrame &) {}); - dispatcher.setOnVideoFrameCallback("bob", "camera", + dispatcher.setOnVideoFrameCallback("bob", TrackSource::SOURCE_CAMERA, [](const VideoFrame &, std::int64_t) {}); EXPECT_EQ(audioCallbacks(dispatcher).size(), 2u); EXPECT_EQ(videoCallbacks(dispatcher).size(), 2u); - dispatcher.clearOnAudioFrameCallback("alice", "microphone"); + dispatcher.clearOnAudioFrameCallback("alice", + TrackSource::SOURCE_MICROPHONE); EXPECT_EQ(audioCallbacks(dispatcher).size(), 1u); EXPECT_EQ(videoCallbacks(dispatcher).size(), 2u); } -TEST_F(SubscriptionThreadDispatcherTest, ClearingOneTrackDoesNotAffectOther) { +TEST_F(SubscriptionThreadDispatcherTest, ClearingOneSourceDoesNotAffectOther) { SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnAudioFrameCallback("alice", "microphone", + dispatcher.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, [](const AudioFrame &) {}); - dispatcher.setOnAudioFrameCallback("alice", "screenshare-audio", + dispatcher.setOnAudioFrameCallback("alice", + TrackSource::SOURCE_SCREENSHARE_AUDIO, [](const AudioFrame &) {}); ASSERT_EQ(audioCallbacks(dispatcher).size(), 2u); - dispatcher.clearOnAudioFrameCallback("alice", "microphone"); + dispatcher.clearOnAudioFrameCallback("alice", + TrackSource::SOURCE_MICROPHONE); EXPECT_EQ(audioCallbacks(dispatcher).size(), 1u); - CallbackKey remaining{"alice", "screenshare-audio"}; + CallbackKey remaining{"alice", TrackSource::SOURCE_SCREENSHARE_AUDIO}; EXPECT_EQ(audioCallbacks(dispatcher).count(remaining), 1u); } @@ -269,7 +273,7 @@ TEST_F(SubscriptionThreadDispatcherTest, NoActiveReadersInitially) { TEST_F(SubscriptionThreadDispatcherTest, ActiveReadersEmptyAfterCallbackRegistration) { SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnAudioFrameCallback("alice", "microphone", + dispatcher.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, [](const AudioFrame &) {}); EXPECT_TRUE(activeReaders(dispatcher).empty()) << "Registering a callback without a subscribed track should not spawn " @@ -284,9 +288,9 @@ TEST_F(SubscriptionThreadDispatcherTest, DestroyDispatcherWithRegisteredCallbacksIsSafe) { EXPECT_NO_THROW({ SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnAudioFrameCallback("alice", "microphone", + dispatcher.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, [](const AudioFrame &) {}); - dispatcher.setOnVideoFrameCallback("bob", "camera", + dispatcher.setOnVideoFrameCallback("bob", TrackSource::SOURCE_CAMERA, [](const VideoFrame &, std::int64_t) {}); }); } @@ -295,9 +299,10 @@ TEST_F(SubscriptionThreadDispatcherTest, DestroyDispatcherAfterClearingCallbacksIsSafe) { EXPECT_NO_THROW({ SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnAudioFrameCallback("alice", "microphone", + dispatcher.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, [](const AudioFrame &) {}); - dispatcher.clearOnAudioFrameCallback("alice", "microphone"); + dispatcher.clearOnAudioFrameCallback("alice", + TrackSource::SOURCE_MICROPHONE); }); } @@ -317,9 +322,10 @@ TEST_F(SubscriptionThreadDispatcherTest, ConcurrentRegistrationDoesNotCrash) { threads.emplace_back([&dispatcher, t]() { for (int i = 0; i < kIterations; ++i) { std::string id = "participant-" + std::to_string(t); - dispatcher.setOnAudioFrameCallback(id, "microphone", + dispatcher.setOnAudioFrameCallback(id, TrackSource::SOURCE_MICROPHONE, [](const AudioFrame &) {}); - dispatcher.clearOnAudioFrameCallback(id, "microphone"); + dispatcher.clearOnAudioFrameCallback(id, + TrackSource::SOURCE_MICROPHONE); } }); } @@ -344,9 +350,9 @@ TEST_F(SubscriptionThreadDispatcherTest, threads.emplace_back([&dispatcher, t]() { std::string id = "p-" + std::to_string(t); for (int i = 0; i < kIterations; ++i) { - dispatcher.setOnAudioFrameCallback(id, "microphone", + dispatcher.setOnAudioFrameCallback(id, TrackSource::SOURCE_MICROPHONE, [](const AudioFrame &) {}); - dispatcher.setOnVideoFrameCallback(id, "camera", + dispatcher.setOnVideoFrameCallback(id, TrackSource::SOURCE_CAMERA, [](const VideoFrame &, std::int64_t) {}); } @@ -371,7 +377,7 @@ TEST_F(SubscriptionThreadDispatcherTest, ManyDistinctCallbacksCanBeRegistered) { for (int i = 0; i < kCount; ++i) { dispatcher.setOnAudioFrameCallback("participant-" + std::to_string(i), - "microphone", + TrackSource::SOURCE_MICROPHONE, [](const AudioFrame &) {}); } @@ -379,7 +385,7 @@ TEST_F(SubscriptionThreadDispatcherTest, ManyDistinctCallbacksCanBeRegistered) { for (int i = 0; i < kCount; ++i) { dispatcher.clearOnAudioFrameCallback("participant-" + std::to_string(i), - "microphone"); + TrackSource::SOURCE_MICROPHONE); } EXPECT_EQ(audioCallbacks(dispatcher).size(), 0u); @@ -581,9 +587,9 @@ TEST_F(SubscriptionThreadDispatcherTest, TEST_F(SubscriptionThreadDispatcherTest, MixedAudioVideoDataCallbacksAreIndependent) { SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnAudioFrameCallback("alice", "microphone", + dispatcher.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, [](const AudioFrame &) {}); - dispatcher.setOnVideoFrameCallback("alice", "camera", + dispatcher.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, [](const VideoFrame &, std::int64_t) {}); dispatcher.addOnDataFrameCallback( "alice", "data-track", From 0ffed0e9d913e8368df3aae58a6a6f16db1cb36c Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Wed, 25 Mar 2026 16:24:43 -0600 Subject: [PATCH 22/34] clean name --- examples/tokens/README.md | 5 +++-- ...est_tokens.bash => set_data_track_test_tokens.bash} | 8 ++++---- src/tests/integration/test_data_track.cpp | 10 +++++----- 3 files changed, 12 insertions(+), 11 deletions(-) rename examples/tokens/{set_test_tokens.bash => set_data_track_test_tokens.bash} (91%) diff --git a/examples/tokens/README.md b/examples/tokens/README.md index a00edd39..ebed99c1 100644 --- a/examples/tokens/README.md +++ b/examples/tokens/README.md @@ -2,6 +2,7 @@ Examples of generating tokens ## gen_and_set.bash -generate tokens and then set them as env vars for the current terminal session +Generate tokens and then set them as env vars for the current terminal session -## +## set_data_track_test_tokens.bash +Generate tokens for data track integration tests and set them as env vars for the current terminal session. \ No newline at end of file diff --git a/examples/tokens/set_test_tokens.bash b/examples/tokens/set_data_track_test_tokens.bash similarity index 91% rename from examples/tokens/set_test_tokens.bash rename to examples/tokens/set_data_track_test_tokens.bash index dcd96a6a..1cc8bb56 100755 --- a/examples/tokens/set_test_tokens.bash +++ b/examples/tokens/set_data_track_test_tokens.bash @@ -16,8 +16,8 @@ # Generate two LiveKit access tokens via `lk` and set the environment variables # required by src/tests/integration/test_data_track.cpp. # -# source examples/tokens/set_test_tokens.bash -# eval "$(bash examples/tokens/set_test_tokens.bash)" +# source examples/tokens/set_data_track_test_tokens.bash +# eval "$(bash examples/tokens/set_data_track_test_tokens.bash)" # # Exports: # LK_TOKEN_TEST_A @@ -33,7 +33,7 @@ elif [[ -n "${ZSH_VERSION:-}" ]] && [[ "${ZSH_EVAL_CONTEXT:-}" == *:file* ]]; th fi _fail() { - echo "set_test_tokens.bash: $1" >&2 + echo "set_data_track_test_tokens.bash: $1" >&2 if [[ "$_sourced" -eq 1 ]]; then return "${2:-1}" fi @@ -122,5 +122,5 @@ if [[ "$_sourced" -eq 1 ]]; then echo "LK_TOKEN_TEST_A, LK_TOKEN_TEST_B, and LIVEKIT_URL set for this shell." >&2 else _emit_eval - echo "set_test_tokens.bash: for this shell run: source $0 or: eval \"\$(bash $0 ...)\"" >&2 + echo "set_data_track_test_tokens.bash: for this shell run: source $0 or: eval \"\$(bash $0 ...)\"" >&2 fi diff --git a/src/tests/integration/test_data_track.cpp b/src/tests/integration/test_data_track.cpp index dac13bd2..59b36f2a 100644 --- a/src/tests/integration/test_data_track.cpp +++ b/src/tests/integration/test_data_track.cpp @@ -17,7 +17,8 @@ // This test is used to verify that data tracks are published and received // correctly. It is the same implementation as the rust // client-sdk-rust/livekit/tests/data_track_test.rs test. To run this test, run -// a local SFU, set credentials examples/tokens/set_test_tokens.bash, and run: +// a local SFU, set credentials examples/tokens/set_data_track_test_tokens.bash, +// and run: // ./build-debug/bin/livekit_integration_tests #include "../common/test_common.h" @@ -567,10 +568,9 @@ TEST_F(DataTrackE2ETest, PublishesAndReceivesEncryptedFramesEndToEnd) { } ASSERT_FALSE(frame.payload.empty()); const auto first_byte = frame.payload.front(); - EXPECT_TRUE(std::all_of(frame.payload.begin(), frame.payload.end(), - [first_byte](std::uint8_t byte) { - return byte == first_byte; - })) + EXPECT_TRUE(std::all_of( + frame.payload.begin(), frame.payload.end(), + [first_byte](std::uint8_t byte) { return byte == first_byte; })) << "Encrypted payload is not byte-consistent"; EXPECT_FALSE(frame.user_timestamp.has_value()) << "Unexpected user timestamp on encrypted frame"; From 2e1543b569c799350db69d30a95d8562d52a29ac Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Wed, 25 Mar 2026 16:29:11 -0600 Subject: [PATCH 23/34] testing for new API --- src/tests/integration/test_room_callbacks.cpp | 37 +++++- .../test_subscription_thread_dispatcher.cpp | 117 +++++++++++++++--- 2 files changed, 135 insertions(+), 19 deletions(-) diff --git a/src/tests/integration/test_room_callbacks.cpp b/src/tests/integration/test_room_callbacks.cpp index d0dc6bf8..a15a151d 100644 --- a/src/tests/integration/test_room_callbacks.cpp +++ b/src/tests/integration/test_room_callbacks.cpp @@ -51,6 +51,20 @@ TEST_F(RoomCallbackTest, VideoCallbackRegistrationIsAccepted) { [](const VideoFrame &, std::int64_t) {})); } +TEST_F(RoomCallbackTest, AudioCallbackRegistrationByTrackNameIsAccepted) { + Room room; + + EXPECT_NO_THROW(room.setOnAudioFrameCallback( + "alice", "mic-main", [](const AudioFrame &) {})); +} + +TEST_F(RoomCallbackTest, VideoCallbackRegistrationByTrackNameIsAccepted) { + Room room; + + EXPECT_NO_THROW(room.setOnVideoFrameCallback( + "alice", "cam-main", [](const VideoFrame &, std::int64_t) {})); +} + TEST_F(RoomCallbackTest, ClearingMissingCallbacksIsNoOp) { Room room; @@ -58,6 +72,8 @@ TEST_F(RoomCallbackTest, ClearingMissingCallbacksIsNoOp) { room.clearOnAudioFrameCallback("nobody", TrackSource::SOURCE_MICROPHONE)); EXPECT_NO_THROW( room.clearOnVideoFrameCallback("nobody", TrackSource::SOURCE_CAMERA)); + EXPECT_NO_THROW(room.clearOnAudioFrameCallback("nobody", "missing-audio")); + EXPECT_NO_THROW(room.clearOnVideoFrameCallback("nobody", "missing-video")); } TEST_F(RoomCallbackTest, ReRegisteringSameAudioKeyDoesNotThrow) { @@ -103,13 +119,30 @@ TEST_F(RoomCallbackTest, SameSourceDifferentTrackNamesAreAccepted) { Room room; EXPECT_NO_THROW( - room.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, + room.setOnVideoFrameCallback("alice", "cam-main", [](const VideoFrame &, std::int64_t) {})); EXPECT_NO_THROW( - room.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, + room.setOnVideoFrameCallback("alice", "cam-backup", [](const VideoFrame &, std::int64_t) {})); } +TEST_F(RoomCallbackTest, ClearingTrackNameCallbackIsAccepted) { + Room room; + + EXPECT_NO_THROW(room.setOnAudioFrameCallback( + "alice", "mic-main", [](const AudioFrame &) {})); + EXPECT_NO_THROW(room.clearOnAudioFrameCallback("alice", "mic-main")); +} + +TEST_F(RoomCallbackTest, SourceAndTrackNameCallbacksCanCoexist) { + Room room; + + EXPECT_NO_THROW(room.setOnAudioFrameCallback( + "alice", TrackSource::SOURCE_MICROPHONE, [](const AudioFrame &) {})); + EXPECT_NO_THROW(room.setOnAudioFrameCallback( + "alice", "mic-main", [](const AudioFrame &) {})); +} + TEST_F(RoomCallbackTest, DataCallbackRegistrationReturnsUsableIds) { Room room; diff --git a/src/tests/integration/test_subscription_thread_dispatcher.cpp b/src/tests/integration/test_subscription_thread_dispatcher.cpp index 45e2bb11..8bdee46a 100644 --- a/src/tests/integration/test_subscription_thread_dispatcher.cpp +++ b/src/tests/integration/test_subscription_thread_dispatcher.cpp @@ -67,21 +67,27 @@ class SubscriptionThreadDispatcherTest : public ::testing::Test { // ============================================================================ TEST_F(SubscriptionThreadDispatcherTest, CallbackKeyEqualKeysCompareEqual) { - CallbackKey a{"alice", TrackSource::SOURCE_MICROPHONE}; - CallbackKey b{"alice", TrackSource::SOURCE_MICROPHONE}; + CallbackKey a{"alice", TrackSource::SOURCE_MICROPHONE, ""}; + CallbackKey b{"alice", TrackSource::SOURCE_MICROPHONE, ""}; EXPECT_TRUE(a == b); } TEST_F(SubscriptionThreadDispatcherTest, CallbackKeyDifferentIdentityNotEqual) { - CallbackKey a{"alice", TrackSource::SOURCE_MICROPHONE}; - CallbackKey b{"bob", TrackSource::SOURCE_MICROPHONE}; + CallbackKey a{"alice", TrackSource::SOURCE_MICROPHONE, ""}; + CallbackKey b{"bob", TrackSource::SOURCE_MICROPHONE, ""}; EXPECT_FALSE(a == b); } TEST_F(SubscriptionThreadDispatcherTest, CallbackKeyDifferentSourceNotEqual) { - CallbackKey a{"alice", TrackSource::SOURCE_MICROPHONE}; - CallbackKey b{"alice", TrackSource::SOURCE_CAMERA}; + CallbackKey a{"alice", TrackSource::SOURCE_MICROPHONE, ""}; + CallbackKey b{"alice", TrackSource::SOURCE_CAMERA, ""}; + EXPECT_FALSE(a == b); +} + +TEST_F(SubscriptionThreadDispatcherTest, CallbackKeyDifferentTrackNameNotEqual) { + CallbackKey a{"alice", TrackSource::SOURCE_UNKNOWN, "cam-main"}; + CallbackKey b{"alice", TrackSource::SOURCE_UNKNOWN, "cam-backup"}; EXPECT_FALSE(a == b); } @@ -91,8 +97,8 @@ TEST_F(SubscriptionThreadDispatcherTest, CallbackKeyDifferentSourceNotEqual) { TEST_F(SubscriptionThreadDispatcherTest, CallbackKeyHashEqualKeysProduceSameHash) { - CallbackKey a{"alice", TrackSource::SOURCE_MICROPHONE}; - CallbackKey b{"alice", TrackSource::SOURCE_MICROPHONE}; + CallbackKey a{"alice", TrackSource::SOURCE_MICROPHONE, ""}; + CallbackKey b{"alice", TrackSource::SOURCE_MICROPHONE, ""}; CallbackKeyHash hasher; EXPECT_EQ(hasher(a), hasher(b)); } @@ -100,20 +106,22 @@ TEST_F(SubscriptionThreadDispatcherTest, TEST_F(SubscriptionThreadDispatcherTest, CallbackKeyHashDifferentKeysLikelyDifferentHash) { CallbackKeyHash hasher; - CallbackKey mic{"alice", TrackSource::SOURCE_MICROPHONE}; - CallbackKey cam{"alice", TrackSource::SOURCE_CAMERA}; - CallbackKey bob{"bob", TrackSource::SOURCE_MICROPHONE}; + CallbackKey mic{"alice", TrackSource::SOURCE_MICROPHONE, ""}; + CallbackKey cam{"alice", TrackSource::SOURCE_CAMERA, ""}; + CallbackKey bob{"bob", TrackSource::SOURCE_MICROPHONE, ""}; + CallbackKey named{"alice", TrackSource::SOURCE_UNKNOWN, "mic-main"}; EXPECT_NE(hasher(mic), hasher(cam)); EXPECT_NE(hasher(mic), hasher(bob)); + EXPECT_NE(hasher(mic), hasher(named)); } TEST_F(SubscriptionThreadDispatcherTest, CallbackKeyWorksAsUnorderedMapKey) { std::unordered_map map; - CallbackKey k1{"alice", TrackSource::SOURCE_MICROPHONE}; - CallbackKey k2{"bob", TrackSource::SOURCE_CAMERA}; - CallbackKey k3{"alice", TrackSource::SOURCE_CAMERA}; + CallbackKey k1{"alice", TrackSource::SOURCE_MICROPHONE, ""}; + CallbackKey k2{"bob", TrackSource::SOURCE_CAMERA, ""}; + CallbackKey k3{"alice", TrackSource::SOURCE_CAMERA, ""}; map[k1] = 1; map[k2] = 2; @@ -134,8 +142,8 @@ TEST_F(SubscriptionThreadDispatcherTest, CallbackKeyWorksAsUnorderedMapKey) { } TEST_F(SubscriptionThreadDispatcherTest, CallbackKeyEmptyIdentityWorks) { - CallbackKey a{"", TrackSource::SOURCE_UNKNOWN}; - CallbackKey b{"", TrackSource::SOURCE_UNKNOWN}; + CallbackKey a{"", TrackSource::SOURCE_UNKNOWN, ""}; + CallbackKey b{"", TrackSource::SOURCE_UNKNOWN, ""}; CallbackKeyHash hasher; EXPECT_TRUE(a == b); EXPECT_EQ(hasher(a), hasher(b)); @@ -161,6 +169,18 @@ TEST_F(SubscriptionThreadDispatcherTest, SetAudioCallbackStoresRegistration) { EXPECT_EQ(audioCallbacks(dispatcher).size(), 1u); } +TEST_F(SubscriptionThreadDispatcherTest, + SetAudioCallbackByTrackNameStoresRegistration) { + SubscriptionThreadDispatcher dispatcher; + dispatcher.setOnAudioFrameCallback("alice", "mic-main", + [](const AudioFrame &) {}); + + EXPECT_EQ(audioCallbacks(dispatcher).size(), 1u); + EXPECT_EQ(audioCallbacks(dispatcher).count( + CallbackKey{"alice", TrackSource::SOURCE_UNKNOWN, "mic-main"}), + 1u); +} + TEST_F(SubscriptionThreadDispatcherTest, SetVideoCallbackStoresRegistration) { SubscriptionThreadDispatcher dispatcher; dispatcher.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, @@ -169,6 +189,18 @@ TEST_F(SubscriptionThreadDispatcherTest, SetVideoCallbackStoresRegistration) { EXPECT_EQ(videoCallbacks(dispatcher).size(), 1u); } +TEST_F(SubscriptionThreadDispatcherTest, + SetVideoCallbackByTrackNameStoresRegistration) { + SubscriptionThreadDispatcher dispatcher; + dispatcher.setOnVideoFrameCallback("alice", "cam-main", + [](const VideoFrame &, std::int64_t) {}); + + EXPECT_EQ(videoCallbacks(dispatcher).size(), 1u); + EXPECT_EQ(videoCallbacks(dispatcher).count( + CallbackKey{"alice", TrackSource::SOURCE_UNKNOWN, "cam-main"}), + 1u); +} + TEST_F(SubscriptionThreadDispatcherTest, ClearAudioCallbackRemovesRegistration) { SubscriptionThreadDispatcher dispatcher; dispatcher.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, @@ -179,6 +211,17 @@ TEST_F(SubscriptionThreadDispatcherTest, ClearAudioCallbackRemovesRegistration) EXPECT_EQ(audioCallbacks(dispatcher).size(), 0u); } +TEST_F(SubscriptionThreadDispatcherTest, + ClearAudioCallbackByTrackNameRemovesRegistration) { + SubscriptionThreadDispatcher dispatcher; + dispatcher.setOnAudioFrameCallback("alice", "mic-main", + [](const AudioFrame &) {}); + ASSERT_EQ(audioCallbacks(dispatcher).size(), 1u); + + dispatcher.clearOnAudioFrameCallback("alice", "mic-main"); + EXPECT_EQ(audioCallbacks(dispatcher).size(), 0u); +} + TEST_F(SubscriptionThreadDispatcherTest, ClearVideoCallbackRemovesRegistration) { SubscriptionThreadDispatcher dispatcher; dispatcher.setOnVideoFrameCallback("alice", TrackSource::SOURCE_CAMERA, @@ -189,12 +232,25 @@ TEST_F(SubscriptionThreadDispatcherTest, ClearVideoCallbackRemovesRegistration) EXPECT_EQ(videoCallbacks(dispatcher).size(), 0u); } +TEST_F(SubscriptionThreadDispatcherTest, + ClearVideoCallbackByTrackNameRemovesRegistration) { + SubscriptionThreadDispatcher dispatcher; + dispatcher.setOnVideoFrameCallback("alice", "cam-main", + [](const VideoFrame &, std::int64_t) {}); + ASSERT_EQ(videoCallbacks(dispatcher).size(), 1u); + + dispatcher.clearOnVideoFrameCallback("alice", "cam-main"); + EXPECT_EQ(videoCallbacks(dispatcher).size(), 0u); +} + TEST_F(SubscriptionThreadDispatcherTest, ClearNonExistentCallbackIsNoOp) { SubscriptionThreadDispatcher dispatcher; EXPECT_NO_THROW(dispatcher.clearOnAudioFrameCallback( "nobody", TrackSource::SOURCE_MICROPHONE)); EXPECT_NO_THROW( dispatcher.clearOnVideoFrameCallback("nobody", TrackSource::SOURCE_CAMERA)); + EXPECT_NO_THROW(dispatcher.clearOnAudioFrameCallback("nobody", "missing")); + EXPECT_NO_THROW(dispatcher.clearOnVideoFrameCallback("nobody", "missing")); } TEST_F(SubscriptionThreadDispatcherTest, OverwriteAudioCallbackKeepsSingleEntry) { @@ -223,6 +279,17 @@ TEST_F(SubscriptionThreadDispatcherTest, OverwriteVideoCallbackKeepsSingleEntry) EXPECT_EQ(videoCallbacks(dispatcher).size(), 1u); } +TEST_F(SubscriptionThreadDispatcherTest, + OverwriteTrackNameAudioCallbackKeepsSingleEntry) { + SubscriptionThreadDispatcher dispatcher; + dispatcher.setOnAudioFrameCallback("alice", "mic-main", + [](const AudioFrame &) {}); + dispatcher.setOnAudioFrameCallback("alice", "mic-main", + [](const AudioFrame &) {}); + + EXPECT_EQ(audioCallbacks(dispatcher).size(), 1u); +} + TEST_F(SubscriptionThreadDispatcherTest, MultipleDistinctCallbacksAreIndependent) { SubscriptionThreadDispatcher dispatcher; @@ -257,10 +324,26 @@ TEST_F(SubscriptionThreadDispatcherTest, ClearingOneSourceDoesNotAffectOther) { TrackSource::SOURCE_MICROPHONE); EXPECT_EQ(audioCallbacks(dispatcher).size(), 1u); - CallbackKey remaining{"alice", TrackSource::SOURCE_SCREENSHARE_AUDIO}; + CallbackKey remaining{"alice", TrackSource::SOURCE_SCREENSHARE_AUDIO, ""}; EXPECT_EQ(audioCallbacks(dispatcher).count(remaining), 1u); } +TEST_F(SubscriptionThreadDispatcherTest, + SourceAndTrackNameAudioCallbacksAreIndependent) { + SubscriptionThreadDispatcher dispatcher; + dispatcher.setOnAudioFrameCallback("alice", TrackSource::SOURCE_MICROPHONE, + [](const AudioFrame &) {}); + dispatcher.setOnAudioFrameCallback("alice", "mic-main", + [](const AudioFrame &) {}); + ASSERT_EQ(audioCallbacks(dispatcher).size(), 2u); + + dispatcher.clearOnAudioFrameCallback("alice", "mic-main"); + EXPECT_EQ(audioCallbacks(dispatcher).size(), 1u); + EXPECT_EQ(audioCallbacks(dispatcher).count( + CallbackKey{"alice", TrackSource::SOURCE_MICROPHONE, ""}), + 1u); +} + // ============================================================================ // Active readers state (no real streams, just map state) // ============================================================================ From f8f7685b068e8dbef7d0484c9bd1eff38afe712e Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Wed, 25 Mar 2026 16:30:35 -0600 Subject: [PATCH 24/34] data_track_subscription: on pushFrame() called when rust sends new frame, assert(since that breaks cpp<->ffi<->rust agreement of rust handling buffering --- src/data_track_subscription.cpp | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/data_track_subscription.cpp b/src/data_track_subscription.cpp index 6ae54b88..b92480ab 100644 --- a/src/data_track_subscription.cpp +++ b/src/data_track_subscription.cpp @@ -160,11 +160,7 @@ void DataTrackSubscription::pushFrame(DataFrame &&frame) { } // rust side handles buffering, so we should only really ever have one item - if (frame_.has_value()) { - LK_LOG_ERROR("[DataTrackSubscription] Frame is already set, the " - "application cannot keep up with the data rate"); - return; - } + assert(!frame_.has_value()); frame_ = std::move(frame); From 953f20dea0fd07f9bbb02b51878c9f6d937cf412 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Thu, 26 Mar 2026 12:05:55 -0600 Subject: [PATCH 25/34] typo/year --- examples/simple_status/consumer.cpp | 6 +++--- examples/simple_status/producer.cpp | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/simple_status/consumer.cpp b/examples/simple_status/consumer.cpp index b22fbe8f..2859e8b9 100644 --- a/examples/simple_status/consumer.cpp +++ b/examples/simple_status/consumer.cpp @@ -1,5 +1,5 @@ /* - * Copyright 2025 LiveKit + * Copyright 2026 LiveKit * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -90,8 +90,8 @@ int main(int argc, char *argv[]) { return 1; } - LK_LOG_INFO("consumer connected as identity='{}' room='{}'", - lp->identity(), room->room_info().name); + LK_LOG_INFO("consumer connected as identity='{}' room='{}'", lp->identity(), + room->room_info().name); std::vector sub_ids; sub_ids.reserve(kNumSubscribers); diff --git a/examples/simple_status/producer.cpp b/examples/simple_status/producer.cpp index 164bb644..fa7e882c 100644 --- a/examples/simple_status/producer.cpp +++ b/examples/simple_status/producer.cpp @@ -1,5 +1,5 @@ /* - * Copyright 2025 LiveKit + * Copyright 2026 LiveKit * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -90,8 +90,8 @@ int main(int argc, char *argv[]) { return 1; } - LK_LOG_INFO("producer connected as identity='{}' room='{}'", - lp->identity(), room->room_info().name); + LK_LOG_INFO("producer connected as identity='{}' room='{}'", lp->identity(), + room->room_info().name); std::shared_ptr data_track; try { From 58c986aa3fe132874311ff7c318ccd265038e3b7 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Thu, 26 Mar 2026 13:01:58 -0600 Subject: [PATCH 26/34] copyright year --- examples/tokens/gen_and_set.bash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/tokens/gen_and_set.bash b/examples/tokens/gen_and_set.bash index 82336fd2..b933a24f 100755 --- a/examples/tokens/gen_and_set.bash +++ b/examples/tokens/gen_and_set.bash @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Copyright 2025 LiveKit, Inc. +# Copyright 2026 LiveKit, Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. From fc316df185b0c6ff7c4b4d8a1e86478e4a01187c Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Fri, 27 Mar 2026 11:55:00 -0600 Subject: [PATCH 27/34] DataTrack constructor operators for compiler efficiency. remove uneeded file --- include/livekit/data_frame.h | 9 ++++++ include/livekit/data_track_frame.h | 45 ------------------------------ include/livekit/local_data_track.h | 2 ++ src/data_track_subscription.cpp | 3 +- src/local_data_track.cpp | 14 ++++++++++ 5 files changed, 26 insertions(+), 47 deletions(-) delete mode 100644 include/livekit/data_track_frame.h diff --git a/include/livekit/data_frame.h b/include/livekit/data_frame.h index 6bfa5e0a..504c2cdf 100644 --- a/include/livekit/data_frame.h +++ b/include/livekit/data_frame.h @@ -40,6 +40,15 @@ struct DataFrame { * By convention the SDK examples use microseconds since the Unix epoch. */ std::optional user_timestamp; + DataFrame() = default; + DataFrame(const DataFrame&) = default; + DataFrame(DataFrame&&) noexcept = default; + DataFrame& operator=(const DataFrame&) = default; + DataFrame& operator=(DataFrame&&) noexcept = default; + + explicit DataFrame(std::vector&& p, + std::optional ts = std::nullopt) noexcept + : payload(std::move(p)), user_timestamp(ts) {} }; } // namespace livekit diff --git a/include/livekit/data_track_frame.h b/include/livekit/data_track_frame.h deleted file mode 100644 index 6bfa5e0a..00000000 --- a/include/livekit/data_track_frame.h +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright 2026 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#pragma once - -#include -#include -#include - -namespace livekit { - -/** - * A single frame of data published or received on a data track. - * - * Carries an arbitrary binary payload and an optional user-specified - * timestamp. The unit is application-defined; the SDK examples use - * microseconds since the Unix epoch (system_clock). - */ -struct DataFrame { - /** Arbitrary binary payload (the frame contents). */ - std::vector payload; - - /** - * Optional application-defined timestamp. - * - * The proto field is a bare uint64 with no prescribed unit. - * By convention the SDK examples use microseconds since the Unix epoch. - */ - std::optional user_timestamp; -}; - -} // namespace livekit diff --git a/include/livekit/local_data_track.h b/include/livekit/local_data_track.h index 2375d728..d28a3602 100644 --- a/include/livekit/local_data_track.h +++ b/include/livekit/local_data_track.h @@ -76,6 +76,8 @@ class LocalDataTrack { */ bool tryPush(const std::vector &payload, std::optional user_timestamp = std::nullopt); + bool tryPush(std::vector &&payload, + std::optional user_timestamp = std::nullopt); /** * Try to push a frame to all subscribers of this track. * diff --git a/src/data_track_subscription.cpp b/src/data_track_subscription.cpp index b92480ab..25146569 100644 --- a/src/data_track_subscription.cpp +++ b/src/data_track_subscription.cpp @@ -98,8 +98,7 @@ bool DataTrackSubscription::read(DataFrame &out) { return false; } - out = std::move(frame_.value()); - frame_.reset(); + out = std::move(*frame_); return true; } diff --git a/src/local_data_track.cpp b/src/local_data_track.cpp index 380deb57..e98e0eaa 100644 --- a/src/local_data_track.cpp +++ b/src/local_data_track.cpp @@ -65,6 +65,20 @@ bool LocalDataTrack::tryPush(const std::vector &payload, } } +bool LocalDataTrack::tryPush(std::vector &&payload, + std::optional user_timestamp) { + DataFrame frame; + frame.payload = std::move(payload); + frame.user_timestamp = user_timestamp; + + try { + return tryPush(frame); + } catch (const std::exception &e) { + LK_LOG_ERROR("[LocalDataTrack] tryPush error: {}", e.what()); + return false; + } +} + bool LocalDataTrack::tryPush(const std::uint8_t *data, std::size_t size, std::optional user_timestamp) { DataFrame frame; From 608c59d79d29f41133eee1399a618837a1d9ab49 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Fri, 27 Mar 2026 12:36:32 -0600 Subject: [PATCH 28/34] DataFrame::fromOwnedInfo to align with video/audio_stream.cpp --- include/livekit/data_frame.h | 6 ++++++ src/data_frame.cpp | 20 ++++++++++++++++++++ src/data_track_subscription.cpp | 10 +--------- 3 files changed, 27 insertions(+), 9 deletions(-) create mode 100644 src/data_frame.cpp diff --git a/include/livekit/data_frame.h b/include/livekit/data_frame.h index 504c2cdf..2f80dbf4 100644 --- a/include/livekit/data_frame.h +++ b/include/livekit/data_frame.h @@ -22,6 +22,10 @@ namespace livekit { +namespace proto { +class DataTrackFrame; +} // namespace proto + /** * A single frame of data published or received on a data track. * @@ -49,6 +53,8 @@ struct DataFrame { explicit DataFrame(std::vector&& p, std::optional ts = std::nullopt) noexcept : payload(std::move(p)), user_timestamp(ts) {} + + static DataFrame fromOwnedInfo(const proto::DataTrackFrame &owned); }; } // namespace livekit diff --git a/src/data_frame.cpp b/src/data_frame.cpp new file mode 100644 index 00000000..17a82722 --- /dev/null +++ b/src/data_frame.cpp @@ -0,0 +1,20 @@ +#include "livekit/data_frame.h" + +#include "data_track.pb.h" + +namespace livekit { + +DataFrame DataFrame::fromOwnedInfo(const proto::DataTrackFrame &owned) { + DataFrame frame; + const auto &payload_str = owned.payload(); + frame.payload.assign( + reinterpret_cast(payload_str.data()), + reinterpret_cast(payload_str.data()) + + payload_str.size()); + if (owned.has_user_timestamp()) { + frame.user_timestamp = owned.user_timestamp(); + } + return frame; +} + +} // namespace livekit diff --git a/src/data_track_subscription.cpp b/src/data_track_subscription.cpp index 25146569..16dcde24 100644 --- a/src/data_track_subscription.cpp +++ b/src/data_track_subscription.cpp @@ -136,15 +136,7 @@ void DataTrackSubscription::onFfiEvent(const FfiEvent &event) { if (dts.has_frame_received()) { const auto &fr = dts.frame_received().frame(); - DataFrame frame; - const auto &payload_str = fr.payload(); - frame.payload.assign( - reinterpret_cast(payload_str.data()), - reinterpret_cast(payload_str.data()) + - payload_str.size()); - if (fr.has_user_timestamp()) { - frame.user_timestamp = fr.user_timestamp(); - } + DataFrame frame = DataFrame::fromOwnedInfo(fr); pushFrame(std::move(frame)); } else if (dts.has_eos()) { pushEos(); From c138e664ea8cd9e09ed04c2b91e6c6fd61864e13 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Fri, 27 Mar 2026 13:15:57 -0600 Subject: [PATCH 29/34] DataTrackSubscription: remove move constructor since it introduces bad callback semantics and the public API returns a shared_ptr<>. Clean up race logic in read() by making the subscription read req while locked. close(): reset subscription handle, listener_id_, and closed_ under lock. Then call RemoveListener() ouside lock --- include/livekit/data_track_subscription.h | 8 ++- src/data_track_subscription.cpp | 79 +++++++---------------- 2 files changed, 29 insertions(+), 58 deletions(-) diff --git a/include/livekit/data_track_subscription.h b/include/livekit/data_track_subscription.h index 4f3619f3..c5417bac 100644 --- a/include/livekit/data_track_subscription.h +++ b/include/livekit/data_track_subscription.h @@ -60,8 +60,12 @@ class DataTrackSubscription { DataTrackSubscription(const DataTrackSubscription &) = delete; DataTrackSubscription &operator=(const DataTrackSubscription &) = delete; - DataTrackSubscription(DataTrackSubscription &&) noexcept; - DataTrackSubscription &operator=(DataTrackSubscription &&) noexcept; + // The FFI listener captures `this`, so moving the object would leave the + // registered callback pointing at the old address. + DataTrackSubscription(DataTrackSubscription &&) noexcept = delete; + // Instances are created and returned as std::shared_ptr, so value-move + // support is not required by the current API. + DataTrackSubscription &operator=(DataTrackSubscription &&) noexcept = delete; /** * Blocking read: waits until a DataFrame is available, or the diff --git a/src/data_track_subscription.cpp b/src/data_track_subscription.cpp index 16dcde24..6ec3bdae 100644 --- a/src/data_track_subscription.cpp +++ b/src/data_track_subscription.cpp @@ -29,44 +29,6 @@ using proto::FfiEvent; DataTrackSubscription::~DataTrackSubscription() { close(); } -DataTrackSubscription::DataTrackSubscription( - DataTrackSubscription &&other) noexcept { - std::lock_guard lock(other.mutex_); - frame_ = std::move(other.frame_); - eof_ = other.eof_; - closed_ = other.closed_; - subscription_handle_ = std::move(other.subscription_handle_); - listener_id_ = other.listener_id_; - - other.listener_id_ = 0; - other.closed_ = true; -} - -DataTrackSubscription & -DataTrackSubscription::operator=(DataTrackSubscription &&other) noexcept { - if (this == &other) { - return *this; - } - - close(); - - { - std::lock_guard lock_this(mutex_); - std::lock_guard lock_other(other.mutex_); - - frame_ = std::move(other.frame_); - eof_ = other.eof_; - closed_ = other.closed_; - subscription_handle_ = std::move(other.subscription_handle_); - listener_id_ = other.listener_id_; - - other.listener_id_ = 0; - other.closed_ = true; - } - - return *this; -} - void DataTrackSubscription::init(FfiHandle subscription_handle) { subscription_handle_ = std::move(subscription_handle); @@ -80,16 +42,18 @@ bool DataTrackSubscription::read(DataFrame &out) { if (closed_ || eof_) { return false; } - } - // Signal the Rust side that we're ready to receive the next frame. - // The Rust SubscriptionTask uses a demand-driven protocol: it won't pull - // from the underlying stream until notified via this request. - proto::FfiRequest req; - auto *msg = req.mutable_data_track_subscription_read(); - msg->set_subscription_handle( - static_cast(subscription_handle_.get())); - FfiClient::instance().sendRequest(req); + const auto subscription_handle = + static_cast(subscription_handle_.get()); + + // Signal the Rust side that we're ready to receive the next frame. + // The Rust SubscriptionTask uses a demand-driven protocol: it won't pull + // from the underlying stream until notified via this request. + proto::FfiRequest req; + auto *msg = req.mutable_data_track_subscription_read(); + msg->set_subscription_handle(subscription_handle); + FfiClient::instance().sendRequest(req); + } std::unique_lock lock(mutex_); cv_.wait(lock, [this] { return frame_.has_value() || eof_ || closed_; }); @@ -103,21 +67,20 @@ bool DataTrackSubscription::read(DataFrame &out) { } void DataTrackSubscription::close() { + std::int64_t listener_id = -1; { std::lock_guard lock(mutex_); if (closed_) { return; } closed_ = true; - } - - if (subscription_handle_.get() != 0) { subscription_handle_.reset(); + listener_id = listener_id_; + listener_id_ = 0; } - if (listener_id_ != 0) { - FfiClient::instance().RemoveListener(listener_id_); - listener_id_ = 0; + if (listener_id != -1) { + FfiClient::instance().RemoveListener(listener_id); } cv_.notify_all(); @@ -129,9 +92,13 @@ void DataTrackSubscription::onFfiEvent(const FfiEvent &event) { } const auto &dts = event.data_track_subscription_event(); - if (dts.subscription_handle() != - static_cast(subscription_handle_.get())) { - return; + { + std::lock_guard lock(mutex_); + if (closed_ || + dts.subscription_handle() != + static_cast(subscription_handle_.get())) { + return; + } } if (dts.has_frame_received()) { From 51d535d91bb77f29df0789d198ca2b6c7159aea4 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Fri, 27 Mar 2026 17:22:22 -0600 Subject: [PATCH 30/34] result.h file for floating up specific errors provided by the FFI --- CMakeLists.txt | 2 + examples/hello_livekit/sender.cpp | 22 ++- examples/simple_status/producer.cpp | 19 ++- include/livekit/data_track_error.h | 57 ++++++++ include/livekit/data_track_subscription.h | 11 +- include/livekit/local_data_track.h | 44 +++--- include/livekit/local_participant.h | 7 +- include/livekit/remote_data_track.h | 17 ++- include/livekit/result.h | 138 ++++++++++++++++++ src/data_track_error.cpp | 40 ++++++ src/ffi_client.cpp | 87 +++++++++--- src/ffi_client.h | 10 +- src/local_data_track.cpp | 84 +++++------ src/local_participant.cpp | 18 ++- src/remote_data_track.cpp | 19 ++- src/subscription_thread_dispatcher.cpp | 14 +- src/tests/integration/test_data_track.cpp | 166 ++++++++++++++++------ 17 files changed, 589 insertions(+), 166 deletions(-) create mode 100644 include/livekit/data_track_error.h create mode 100644 include/livekit/result.h create mode 100644 src/data_track_error.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 30ecbcf8..504e9cc1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -324,7 +324,9 @@ add_library(livekit SHARED src/audio_processing_module.cpp src/audio_source.cpp src/audio_stream.cpp + src/data_frame.cpp src/data_stream.cpp + src/data_track_error.cpp src/data_track_subscription.cpp src/e2ee.cpp src/ffi_handle.cpp diff --git a/examples/hello_livekit/sender.cpp b/examples/hello_livekit/sender.cpp index 3ca70d5f..189e59be 100644 --- a/examples/hello_livekit/sender.cpp +++ b/examples/hello_livekit/sender.cpp @@ -95,8 +95,17 @@ int main(int argc, char *argv[]) { std::shared_ptr video_track = lp->publishVideoTrack( kVideoTrackName, video_source, TrackSource::SOURCE_CAMERA); - std::shared_ptr data_track = - lp->publishDataTrack(kDataTrackName); + auto publish_result = lp->publishDataTrack(kDataTrackName); + if (!publish_result) { + const auto &error = publish_result.error(); + LK_LOG_ERROR("Failed to publish data track: code={} retryable={} message={}", + static_cast(error.code), error.retryable, + error.message); + room.reset(); + livekit::shutdown(); + return 1; + } + std::shared_ptr data_track = publish_result.value(); const auto t0 = std::chrono::steady_clock::now(); std::uint64_t count = 0; @@ -115,7 +124,14 @@ int main(int argc, char *argv[]) { std::ostringstream oss; oss << std::fixed << std::setprecision(2) << ms << " ms, count: " << count; const std::string msg = oss.str(); - data_track->tryPush(std::vector(msg.begin(), msg.end())); + auto push_result = + data_track->tryPush(std::vector(msg.begin(), msg.end())); + if (!push_result) { + const auto &error = push_result.error(); + LK_LOG_WARN("Failed to push data frame: code={} retryable={} message={}", + static_cast(error.code), error.retryable, + error.message); + } ++count; std::this_thread::sleep_for(std::chrono::milliseconds(100)); diff --git a/examples/simple_status/producer.cpp b/examples/simple_status/producer.cpp index fa7e882c..0f06aad6 100644 --- a/examples/simple_status/producer.cpp +++ b/examples/simple_status/producer.cpp @@ -94,15 +94,18 @@ int main(int argc, char *argv[]) { room->room_info().name); std::shared_ptr data_track; - try { - data_track = lp->publishDataTrack(kTrackName); - } catch (const std::exception &e) { - LK_LOG_ERROR("Failed to publish data track: {}", e.what()); + auto publish_result = lp->publishDataTrack(kTrackName); + if (!publish_result) { + const auto &error = publish_result.error(); + LK_LOG_ERROR("Failed to publish data track: code={} retryable={} message={}", + static_cast(error.code), error.retryable, + error.message); room->setDelegate(nullptr); room.reset(); livekit::shutdown(); return 1; } + data_track = publish_result.value(); LK_LOG_INFO("published data track '{}'", kTrackName); @@ -123,8 +126,12 @@ int main(int argc, char *argv[]) { " count: " + std::to_string(count); std::vector payload(text.begin(), text.end()); - if (!data_track->tryPush(payload)) { - LK_LOG_WARN("Failed to push data frame"); + auto push_result = data_track->tryPush(payload); + if (!push_result) { + const auto &error = push_result.error(); + LK_LOG_WARN("Failed to push data frame: code={} retryable={} message={}", + static_cast(error.code), error.retryable, + error.message); } LK_LOG_DEBUG("sent: {}", text); diff --git a/include/livekit/data_track_error.h b/include/livekit/data_track_error.h new file mode 100644 index 00000000..fe8fe401 --- /dev/null +++ b/include/livekit/data_track_error.h @@ -0,0 +1,57 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an “AS IS” BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef LIVEKIT_DATA_TRACK_ERROR_H +#define LIVEKIT_DATA_TRACK_ERROR_H + +#include +#include + +namespace livekit { + +namespace proto { +class DataTrackError; +} + +/// Stable error codes for data-track operations. +enum class DataTrackErrorCode : std::uint32_t { + UNKNOWN = 0, + INVALID_HANDLE = 1, + DUPLICATE_TRACK_NAME = 2, + TRACK_UNPUBLISHED = 3, + BUFFER_FULL = 4, + SUBSCRIPTION_CLOSED = 5, + CANCELLED = 6, + PROTOCOL_ERROR = 7, + INTERNAL = 8, +}; + +/// Structured failure returned by non-throwing data-track APIs. +struct DataTrackError { + /// Machine-readable error code. + DataTrackErrorCode code{DataTrackErrorCode::UNKNOWN}; + /// Human-readable description from the backend or SDK. + std::string message; + /// Whether retrying the operation may succeed. + bool retryable{false}; + + /// Convert the FFI proto representation into the public SDK type. + static DataTrackError fromProto(const proto::DataTrackError &error); +}; + +} // namespace livekit + +#endif // LIVEKIT_DATA_TRACK_ERROR_H \ No newline at end of file diff --git a/include/livekit/data_track_subscription.h b/include/livekit/data_track_subscription.h index c5417bac..cfac2b24 100644 --- a/include/livekit/data_track_subscription.h +++ b/include/livekit/data_track_subscription.h @@ -43,10 +43,13 @@ class FfiEvent; * * Typical usage: * - * auto sub = remoteDataTrack->subscribe(); - * DataFrame frame; - * while (sub->read(frame)) { - * // process frame.payload + * auto sub_result = remoteDataTrack->subscribe(); + * if (sub_result) { + * auto sub = sub_result.value(); + * DataFrame frame; + * while (sub->read(frame)) { + * // process frame.payload + * } * } */ class DataTrackSubscription { diff --git a/include/livekit/local_data_track.h b/include/livekit/local_data_track.h index d28a3602..044698d7 100644 --- a/include/livekit/local_data_track.h +++ b/include/livekit/local_data_track.h @@ -17,8 +17,10 @@ #pragma once #include "livekit/data_frame.h" +#include "livekit/data_track_error.h" #include "livekit/data_track_info.h" #include "livekit/ffi_handle.h" +#include "livekit/result.h" #include #include @@ -44,11 +46,14 @@ class OwnedLocalDataTrack; * Typical usage: * * auto lp = room->localParticipant(); - * auto dt = lp->publishDataTrack("sensor-data"); - * DataFrame frame; - * frame.payload = {0x01, 0x02, 0x03}; - * dt->tryPush(frame); - * dt->unpublishDataTrack(); + * auto result = lp->publishDataTrack("sensor-data"); + * if (result) { + * auto dt = result.value(); + * DataFrame frame; + * frame.payload = {0x01, 0x02, 0x03}; + * (void)dt->tryPush(frame); + * dt->unpublishDataTrack(); + * } */ class LocalDataTrack { public: @@ -63,29 +68,32 @@ class LocalDataTrack { /** * Try to push a frame to all subscribers of this track. * - * @return true on success, false if the push failed (e.g. back-pressure - * or the track has been unpublished). + * @return success on delivery acceptance, or a typed error describing why + * the frame could not be queued. */ - bool tryPush(const DataFrame &frame); + Result tryPush(const DataFrame &frame); /** * Try to push a frame to all subscribers of this track. * - * @return true on success, false if the push failed (e.g. back-pressure - * or the track has been unpublished). + * @return success on delivery acceptance, or a typed error describing why + * the frame could not be queued. */ - bool tryPush(const std::vector &payload, - std::optional user_timestamp = std::nullopt); - bool tryPush(std::vector &&payload, - std::optional user_timestamp = std::nullopt); + Result + tryPush(const std::vector &payload, + std::optional user_timestamp = std::nullopt); + Result + tryPush(std::vector &&payload, + std::optional user_timestamp = std::nullopt); /** * Try to push a frame to all subscribers of this track. * - * @return true on success, false if the push failed (e.g. back-pressure - * or the track has been unpublished). + * @return success on delivery acceptance, or a typed error describing why + * the frame could not be queued. */ - bool tryPush(const std::uint8_t *data, std::size_t size, - std::optional user_timestamp = std::nullopt); + Result + tryPush(const std::uint8_t *data, std::size_t size, + std::optional user_timestamp = std::nullopt); /// Whether the track is still published in the room. bool isPublished() const; diff --git a/include/livekit/local_participant.h b/include/livekit/local_participant.h index fed3a190..da67557a 100644 --- a/include/livekit/local_participant.h +++ b/include/livekit/local_participant.h @@ -181,10 +181,11 @@ class LocalParticipant : public Participant { * LocalParticipant::unpublishDataTrack(). * * @param name Unique track name visible to other participants. - * @return Shared pointer to the published data track. - * @throws std::runtime_error on FFI or publish failure. + * @return The published track on success, or a typed error describing why + * publication failed. */ - std::shared_ptr publishDataTrack(const std::string &name); + Result, DataTrackError> + publishDataTrack(const std::string &name); /** * Unpublish a data track from the room. diff --git a/include/livekit/remote_data_track.h b/include/livekit/remote_data_track.h index ce755a06..ee9d75ba 100644 --- a/include/livekit/remote_data_track.h +++ b/include/livekit/remote_data_track.h @@ -18,7 +18,9 @@ #include "livekit/data_track_info.h" #include "livekit/data_track_subscription.h" +#include "livekit/data_track_error.h" #include "livekit/ffi_handle.h" +#include "livekit/result.h" #include #include @@ -39,10 +41,13 @@ class OwnedRemoteDataTrack; * Typical usage: * * // In RoomDelegate::onDataTrackPublished callback: - * auto sub = remoteDataTrack->subscribe(); - * DataFrame frame; - * while (sub->read(frame)) { - * // process frame + * auto sub_result = remoteDataTrack->subscribe(); + * if (sub_result) { + * auto sub = sub_result.value(); + * DataFrame frame; + * while (sub->read(frame)) { + * // process frame + * } * } */ class RemoteDataTrack { @@ -68,10 +73,8 @@ class RemoteDataTrack { * * Returns a DataTrackSubscription that delivers frames via blocking * read(). Destroy the subscription to unsubscribe. - * - * @throws std::runtime_error if the FFI subscribe call fails. */ - std::shared_ptr + Result, DataTrackError> subscribe(const DataTrackSubscription::Options &options = {}); private: diff --git a/include/livekit/result.h b/include/livekit/result.h new file mode 100644 index 00000000..a0028496 --- /dev/null +++ b/include/livekit/result.h @@ -0,0 +1,138 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an “AS IS” BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef LIVEKIT_RESULT_H +#define LIVEKIT_RESULT_H + +#include +#include +#include +#include + +namespace livekit { + +/** + * Lightweight success-or-error return type for non-exceptional API failures. + * + * This is intended for SDK operations where callers are expected to branch on + * success vs. failure, such as back-pressure or an unpublished track. + * + * `Result` stores either: + * - a success value of type `T`, or + * - an error value of type `E` + * + * Accessors are intentionally non-throwing. Calling `value()` on an error + * result, or `error()` on a success result, is a programmer error and will + * trip the debug assertion. + */ +template class Result { +public: + /// Construct a successful result containing a value. + static Result success(T value) { + return Result(std::variant(std::in_place_index<0>, + std::move(value))); + } + + /// Construct a failed result containing an error. + static Result failure(E error) { + return Result( + std::variant(std::in_place_index<1>, std::move(error))); + } + + /// True when the result contains a success value. + bool ok() const noexcept { return storage_.index() == 0; } + /// True when the result contains an error. + bool has_error() const noexcept { return !ok(); } + /// Allows `if (result)` style success checks. + explicit operator bool() const noexcept { return ok(); } + + /// Access the success value. Requires `ok() == true`. + T &value() noexcept { + assert(ok()); + return std::get<0>(storage_); + } + + /// Access the success value. Requires `ok() == true`. + const T &value() const noexcept { + assert(ok()); + return std::get<0>(storage_); + } + + /// Access the error value. Requires `has_error() == true`. + E &error() noexcept { + assert(has_error()); + return std::get<1>(storage_); + } + + /// Access the error value. Requires `has_error() == true`. + const E &error() const noexcept { + assert(has_error()); + return std::get<1>(storage_); + } + +private: + explicit Result(std::variant storage) : storage_(std::move(storage)) {} + + std::variant storage_; +}; + +/** + * `void` specialization for operations that only report success or failure. + * + * This keeps the same calling style as `Result` without forcing callers + * to invent a dummy success payload. + */ +template class Result { +public: + /// Construct a successful result with no payload. + static Result success() { return Result(std::nullopt); } + + /// Construct a failed result containing an error. + static Result failure(E error) { + return Result(std::optional(std::move(error))); + } + + /// True when the operation succeeded. + bool ok() const noexcept { return !error_.has_value(); } + /// True when the operation failed. + bool has_error() const noexcept { return error_.has_value(); } + /// Allows `if (result)` style success checks. + explicit operator bool() const noexcept { return ok(); } + + /// Validates success in debug builds. Mirrors the `value()` API shape. + void value() const noexcept { assert(ok()); } + + /// Access the error value. Requires `has_error() == true`. + E &error() noexcept { + assert(has_error()); + return *error_; + } + + /// Access the error value. Requires `has_error() == true`. + const E &error() const noexcept { + assert(has_error()); + return *error_; + } + +private: + explicit Result(std::optional error) : error_(std::move(error)) {} + + std::optional error_; +}; + +} // namespace livekit + +#endif // LIVEKIT_RESULT_H diff --git a/src/data_track_error.cpp b/src/data_track_error.cpp new file mode 100644 index 00000000..566f1d9a --- /dev/null +++ b/src/data_track_error.cpp @@ -0,0 +1,40 @@ +#include "livekit/data_track_error.h" + +#include "data_track.pb.h" + +namespace livekit { + +namespace { + +DataTrackErrorCode fromProtoCode(proto::DataTrackErrorCode code) { + switch (code) { + case proto::DATA_TRACK_ERROR_CODE_INVALID_HANDLE: + return DataTrackErrorCode::INVALID_HANDLE; + case proto::DATA_TRACK_ERROR_CODE_DUPLICATE_TRACK_NAME: + return DataTrackErrorCode::DUPLICATE_TRACK_NAME; + case proto::DATA_TRACK_ERROR_CODE_TRACK_UNPUBLISHED: + return DataTrackErrorCode::TRACK_UNPUBLISHED; + case proto::DATA_TRACK_ERROR_CODE_BUFFER_FULL: + return DataTrackErrorCode::BUFFER_FULL; + case proto::DATA_TRACK_ERROR_CODE_SUBSCRIPTION_CLOSED: + return DataTrackErrorCode::SUBSCRIPTION_CLOSED; + case proto::DATA_TRACK_ERROR_CODE_CANCELLED: + return DataTrackErrorCode::CANCELLED; + case proto::DATA_TRACK_ERROR_CODE_PROTOCOL_ERROR: + return DataTrackErrorCode::PROTOCOL_ERROR; + case proto::DATA_TRACK_ERROR_CODE_INTERNAL: + return DataTrackErrorCode::INTERNAL; + case proto::DATA_TRACK_ERROR_CODE_UNKNOWN: + default: + return DataTrackErrorCode::UNKNOWN; + } +} + +} // namespace + +DataTrackError DataTrackError::fromProto(const proto::DataTrackError &error) { + return DataTrackError{fromProtoCode(error.code()), error.message(), + error.retryable()}; +} + +} // namespace livekit diff --git a/src/ffi_client.cpp b/src/ffi_client.cpp index 78ed9a5b..572a7f87 100644 --- a/src/ffi_client.cpp +++ b/src/ffi_client.cpp @@ -44,6 +44,11 @@ inline void logAndThrow(const std::string &error_msg) { throw std::runtime_error(error_msg); } +DataTrackError makeDataTrackError(DataTrackErrorCode code, std::string message, + bool retryable = false) { + return DataTrackError{code, std::move(message), retryable}; +} + std::optional ExtractAsyncId(const proto::FfiEvent &event) { using E = proto::FfiEvent; switch (event.message_case()) { @@ -621,32 +626,36 @@ std::future FfiClient::publishDataAsync( return fut; } -std::future +std::future> FfiClient::publishDataTrackAsync(std::uint64_t local_participant_handle, const std::string &track_name) { const AsyncId async_id = generateAsyncId(); - auto fut = registerAsync( + auto fut = registerAsync>( async_id, [async_id](const proto::FfiEvent &event) { return event.has_publish_data_track() && event.publish_data_track().async_id() == async_id; }, [](const proto::FfiEvent &event, - std::promise &pr) { + std::promise> &pr) { const auto &cb = event.publish_data_track(); - if (cb.has_error() && !cb.error().empty()) { - pr.set_exception( - std::make_exception_ptr(std::runtime_error(cb.error()))); + if (cb.has_error()) { + pr.set_value(Result::failure( + DataTrackError::fromProto(cb.error()))); return; } if (!cb.has_track()) { - pr.set_exception(std::make_exception_ptr( - std::runtime_error("PublishDataTrackCallback missing track"))); + pr.set_value(Result::failure(makeDataTrackError( + DataTrackErrorCode::PROTOCOL_ERROR, + "PublishDataTrackCallback missing track"))); return; } proto::OwnedLocalDataTrack track = cb.track(); - pr.set_value(std::move(track)); + pr.set_value(Result::success(std::move(track))); }); proto::FfiRequest req; @@ -658,42 +667,61 @@ FfiClient::publishDataTrackAsync(std::uint64_t local_participant_handle, try { proto::FfiResponse resp = sendRequest(req); if (!resp.has_publish_data_track()) { - logAndThrow("FfiResponse missing publish_data_track"); + cancelPendingByAsyncId(async_id); + std::promise> pr; + pr.set_value(Result::failure( + makeDataTrackError(DataTrackErrorCode::PROTOCOL_ERROR, + "FfiResponse missing publish_data_track"))); + return pr.get_future(); } } catch (...) { cancelPendingByAsyncId(async_id); - throw; + std::promise> pr; + try { + throw; + } catch (const std::exception &e) { + pr.set_value(Result::failure( + makeDataTrackError(DataTrackErrorCode::INTERNAL, e.what()))); + } + return pr.get_future(); } return fut; } -std::future +std::future> FfiClient::subscribeDataTrackAsync(std::uint64_t track_handle, std::optional buffer_size) { const AsyncId async_id = generateAsyncId(); - auto fut = registerAsync( + auto fut = + registerAsync>( async_id, [async_id](const proto::FfiEvent &event) { return event.has_subscribe_data_track() && event.subscribe_data_track().async_id() == async_id; }, [](const proto::FfiEvent &event, - std::promise &pr) { + std::promise< + Result> &pr) { const auto &cb = event.subscribe_data_track(); - if (cb.has_error() && !cb.error().empty()) { - pr.set_exception( - std::make_exception_ptr(std::runtime_error(cb.error()))); + if (cb.has_error()) { + pr.set_value(Result::failure( + DataTrackError::fromProto(cb.error()))); return; } if (!cb.has_subscription()) { - pr.set_exception(std::make_exception_ptr(std::runtime_error( - "SubscribeDataTrackCallback missing subscription"))); + pr.set_value( + Result:: + failure(makeDataTrackError( + DataTrackErrorCode::PROTOCOL_ERROR, + "SubscribeDataTrackCallback missing subscription"))); return; } proto::OwnedDataTrackSubscription sub = cb.subscription(); - pr.set_value(std::move(sub)); + pr.set_value(Result::success(std::move(sub))); }); proto::FfiRequest req; @@ -708,11 +736,26 @@ FfiClient::subscribeDataTrackAsync(std::uint64_t track_handle, try { proto::FfiResponse resp = sendRequest(req); if (!resp.has_subscribe_data_track()) { - logAndThrow("FfiResponse missing subscribe_data_track"); + cancelPendingByAsyncId(async_id); + std::promise> + pr; + pr.set_value( + Result::failure( + makeDataTrackError(DataTrackErrorCode::PROTOCOL_ERROR, + "FfiResponse missing subscribe_data_track"))); + return pr.get_future(); } } catch (...) { cancelPendingByAsyncId(async_id); - throw; + std::promise> pr; + try { + throw; + } catch (const std::exception &e) { + pr.set_value( + Result::failure( + makeDataTrackError(DataTrackErrorCode::INTERNAL, e.what()))); + } + return pr.get_future(); } return fut; diff --git a/src/ffi_client.h b/src/ffi_client.h index ff23d090..2a45abf3 100644 --- a/src/ffi_client.h +++ b/src/ffi_client.h @@ -28,7 +28,10 @@ #include #include +#include "data_track.pb.h" #include "livekit/stats.h" +#include "livekit/data_track_error.h" +#include "livekit/result.h" #include "room.pb.h" namespace livekit { @@ -128,10 +131,13 @@ class FfiClient { std::optional response_timeout_ms = std::nullopt); // Data Track APIs - std::future + std::future> publishDataTrackAsync(std::uint64_t local_participant_handle, const std::string &track_name); - std::future subscribeDataTrackAsync( + + // TODO(sderosa): the subscription model for data tracks has been changed to sync, need to update + std::future> + subscribeDataTrackAsync( std::uint64_t track_handle, std::optional buffer_size = std::nullopt); diff --git a/src/local_data_track.cpp b/src/local_data_track.cpp index e98e0eaa..55506104 100644 --- a/src/local_data_track.cpp +++ b/src/local_data_track.cpp @@ -24,6 +24,14 @@ namespace livekit { +namespace { + +DataTrackError makeInternalDataTrackError(const std::string &message) { + return DataTrackError{DataTrackErrorCode::INTERNAL, message, false}; +} + +} // namespace + LocalDataTrack::LocalDataTrack(const proto::OwnedLocalDataTrack &owned) : handle_(static_cast(owned.handle().id())) { const auto &pi = owned.info(); @@ -32,65 +40,61 @@ LocalDataTrack::LocalDataTrack(const proto::OwnedLocalDataTrack &owned) info_.uses_e2ee = pi.uses_e2ee(); } -bool LocalDataTrack::tryPush(const DataFrame &frame) { +Result LocalDataTrack::tryPush(const DataFrame &frame) { if (!handle_.valid()) { - return false; + return Result::failure( + DataTrackError{DataTrackErrorCode::INVALID_HANDLE, + "LocalDataTrack::tryPush: invalid FFI handle", false}); } - proto::FfiRequest req; - auto *msg = req.mutable_local_data_track_try_push(); - msg->set_track_handle(static_cast(handle_.get())); - auto *pf = msg->mutable_frame(); - pf->set_payload(frame.payload.data(), frame.payload.size()); - if (frame.user_timestamp.has_value()) { - pf->set_user_timestamp(frame.user_timestamp.value()); + try { + proto::FfiRequest req; + auto *msg = req.mutable_local_data_track_try_push(); + msg->set_track_handle(static_cast(handle_.get())); + auto *pf = msg->mutable_frame(); + pf->set_payload(frame.payload.data(), frame.payload.size()); + if (frame.user_timestamp.has_value()) { + pf->set_user_timestamp(frame.user_timestamp.value()); + } + + proto::FfiResponse resp = FfiClient::instance().sendRequest(req); + const auto &r = resp.local_data_track_try_push(); + if (r.has_error()) { + return Result::failure( + DataTrackError::fromProto(r.error())); + } + return Result::success(); + } catch (const std::exception &e) { + return Result::failure( + makeInternalDataTrackError(e.what())); } - - proto::FfiResponse resp = FfiClient::instance().sendRequest(req); - const auto &r = resp.local_data_track_try_push(); - return !r.has_error(); } -bool LocalDataTrack::tryPush(const std::vector &payload, - std::optional user_timestamp) { +Result +LocalDataTrack::tryPush(const std::vector &payload, + std::optional user_timestamp) { DataFrame frame; frame.payload = payload; frame.user_timestamp = user_timestamp; - - try { - return tryPush(frame); - } catch (const std::exception &e) { - LK_LOG_ERROR("[LocalDataTrack] tryPush error: {}", e.what()); - return false; - } + return tryPush(frame); } -bool LocalDataTrack::tryPush(std::vector &&payload, - std::optional user_timestamp) { +Result +LocalDataTrack::tryPush(std::vector &&payload, + std::optional user_timestamp) { DataFrame frame; frame.payload = std::move(payload); frame.user_timestamp = user_timestamp; - - try { - return tryPush(frame); - } catch (const std::exception &e) { - LK_LOG_ERROR("[LocalDataTrack] tryPush error: {}", e.what()); - return false; - } + return tryPush(frame); } -bool LocalDataTrack::tryPush(const std::uint8_t *data, std::size_t size, - std::optional user_timestamp) { +Result +LocalDataTrack::tryPush(const std::uint8_t *data, std::size_t size, + std::optional user_timestamp) { DataFrame frame; frame.payload.assign(data, data + size); frame.user_timestamp = user_timestamp; - - try { - return tryPush(frame); - } catch (const std::exception &e) { - LK_LOG_ERROR("[LocalDataTrack] tryPush error: {}", e.what()); - return false; - } + return tryPush(frame); } bool LocalDataTrack::isPublished() const { diff --git a/src/local_participant.cpp b/src/local_participant.cpp index 9b5061e8..db921bf6 100644 --- a/src/local_participant.cpp +++ b/src/local_participant.cpp @@ -288,19 +288,27 @@ LocalParticipant::PublicationMap LocalParticipant::trackPublications() const { return out; } -std::shared_ptr +Result, DataTrackError> LocalParticipant::publishDataTrack(const std::string &name) { auto handle_id = ffiHandleId(); if (handle_id == 0) { - throw std::runtime_error( - "LocalParticipant::publishDataTrack: invalid FFI handle"); + return Result, DataTrackError>::failure( + DataTrackError{DataTrackErrorCode::INVALID_HANDLE, + "LocalParticipant::publishDataTrack: invalid FFI handle", + false}); } auto fut = FfiClient::instance().publishDataTrackAsync( static_cast(handle_id), name); - proto::OwnedLocalDataTrack owned = fut.get(); - return std::shared_ptr(new LocalDataTrack(owned)); + auto result = fut.get(); + if (!result) { + return Result, DataTrackError>::failure( + result.error()); + } + + return Result, DataTrackError>::success( + std::shared_ptr(new LocalDataTrack(result.value()))); } void LocalParticipant::unpublishDataTrack( diff --git a/src/remote_data_track.cpp b/src/remote_data_track.cpp index 1b58beed..ee2fa173 100644 --- a/src/remote_data_track.cpp +++ b/src/remote_data_track.cpp @@ -46,23 +46,34 @@ bool RemoteDataTrack::isPublished() const { return resp.remote_data_track_is_published().is_published(); } -std::shared_ptr +Result, DataTrackError> RemoteDataTrack::subscribe(const DataTrackSubscription::Options &options) { if (!handle_.valid()) { - throw std::runtime_error("RemoteDataTrack::subscribe: invalid FFI handle"); + return Result, + DataTrackError>::failure( + DataTrackError{DataTrackErrorCode::INVALID_HANDLE, + "RemoteDataTrack::subscribe: invalid FFI handle", + false}); } auto fut = FfiClient::instance().subscribeDataTrackAsync( static_cast(handle_.get()), options.buffer_size); - proto::OwnedDataTrackSubscription owned_sub = fut.get(); + auto result = fut.get(); + if (!result) { + return Result, + DataTrackError>::failure(result.error()); + } + + proto::OwnedDataTrackSubscription owned_sub = result.value(); FfiHandle sub_handle(static_cast(owned_sub.handle().id())); auto subscription = std::shared_ptr(new DataTrackSubscription()); subscription->init(std::move(sub_handle)); - return subscription; + return Result, + DataTrackError>::success(std::move(subscription)); } } // namespace livekit diff --git a/src/subscription_thread_dispatcher.cpp b/src/subscription_thread_dispatcher.cpp index 6ea50e3c..32b2e6a2 100644 --- a/src/subscription_thread_dispatcher.cpp +++ b/src/subscription_thread_dispatcher.cpp @@ -639,13 +639,17 @@ std::thread SubscriptionThreadDispatcher::startDataReaderLocked( LK_LOG_INFO("Data reader thread: subscribing to \"{}\" track=\"{}\"", identity, track_name); std::shared_ptr subscription; - try { - subscription = track->subscribe(); - } catch (const std::exception &e) { - LK_LOG_ERROR("Failed to subscribe to data track \"{}\" from \"{}\": {}", - track_name, identity, e.what()); + auto subscribe_result = track->subscribe(); + if (!subscribe_result) { + const auto &error = subscribe_result.error(); + LK_LOG_ERROR( + "Failed to subscribe to data track \"{}\" from \"{}\": code={} " + "retryable={} message={}", + track_name, identity, static_cast(error.code), + error.retryable, error.message); return; } + subscription = subscribe_result.value(); LK_LOG_INFO("Data reader thread: subscribed to \"{}\" track=\"{}\"", identity, track_name); diff --git a/src/tests/integration/test_data_track.cpp b/src/tests/integration/test_data_track.cpp index 59b36f2a..b6c942b1 100644 --- a/src/tests/integration/test_data_track.cpp +++ b/src/tests/integration/test_data_track.cpp @@ -90,6 +90,40 @@ bool waitForCondition(Predicate &&predicate, std::chrono::milliseconds timeout, return false; } +std::string describeDataTrackError(const DataTrackError &error) { + return "code=" + std::to_string(static_cast(error.code)) + + " retryable=" + (error.retryable ? "true" : "false") + + " message=" + error.message; +} + +std::shared_ptr +requirePublishedTrack(LocalParticipant *participant, const std::string &name) { + auto result = participant->publishDataTrack(name); + if (!result) { + throw std::runtime_error("Failed to publish data track: " + + describeDataTrackError(result.error())); + } + return result.value(); +} + +std::shared_ptr +requireSubscription(const std::shared_ptr &track) { + auto result = track->subscribe(); + if (!result) { + throw std::runtime_error("Failed to subscribe to data track: " + + describeDataTrackError(result.error())); + } + return result.value(); +} + +void requirePushSuccess(const Result &result, + const std::string &context) { + if (!result) { + throw std::runtime_error(context + ": " + + describeDataTrackError(result.error())); + } +} + class DataTrackPublishedDelegate : public RoomDelegate { public: void onDataTrackPublished(Room &, @@ -173,8 +207,8 @@ TEST_P(DataTrackTransportTest, PublishesAndReceivesFramesEndToEnd) { std::thread publisher([&]() { try { auto track = - publisher_room->localParticipant()->publishDataTrack(track_name); - if (!track || !track->isPublished()) { + requirePublishedTrack(publisher_room->localParticipant(), track_name); + if (!track->isPublished()) { throw std::runtime_error("Publisher failed to publish data track"); } if (track->info().uses_e2ee) { @@ -194,9 +228,7 @@ TEST_P(DataTrackTransportTest, PublishesAndReceivesFramesEndToEnd) { for (size_t index = 0; index < frame_count; ++index) { std::vector payload(payload_len, static_cast(index)); - if (!track->tryPush(payload)) { - throw std::runtime_error("Failed to push data frame"); - } + requirePushSuccess(track->tryPush(payload), "Failed to push data frame"); next_send += frame_interval; std::this_thread::sleep_until(next_send); @@ -215,8 +247,11 @@ TEST_P(DataTrackTransportTest, PublishesAndReceivesFramesEndToEnd) { EXPECT_EQ(remote_track->info().name, track_name); EXPECT_EQ(remote_track->publisherIdentity(), publisher_identity); - auto subscription = remote_track->subscribe(); - ASSERT_NE(subscription, nullptr); + auto subscribe_result = remote_track->subscribe(); + if (!subscribe_result) { + FAIL() << describeDataTrackError(subscribe_result.error()); + } + auto subscription = subscribe_result.value(); std::promise receive_count_promise; auto receive_count_future = receive_count_promise.get_future(); @@ -288,9 +323,12 @@ TEST_F(DataTrackE2ETest, UnpublishUpdatesPublishedStateEndToEnd) { auto rooms = testRooms(room_configs); auto &publisher_room = rooms[0]; - auto local_track = + auto publish_result = publisher_room->localParticipant()->publishDataTrack(track_name); - ASSERT_NE(local_track, nullptr); + if (!publish_result) { + FAIL() << describeDataTrackError(publish_result.error()); + } + auto local_track = publish_result.value(); ASSERT_TRUE(local_track->isPublished()); auto remote_track = subscriber_delegate.waitForTrack(kTrackWaitTimeout); @@ -316,9 +354,12 @@ TEST_F(DataTrackE2ETest, PublishManyTracks) { const auto start = std::chrono::steady_clock::now(); for (int index = 0; index < kPublishManyTrackCount; ++index) { const auto track_name = "track_" + std::to_string(index); - auto track = room->localParticipant()->publishDataTrack(track_name); - - ASSERT_NE(track, nullptr) << "Failed to publish track " << track_name; + auto publish_result = room->localParticipant()->publishDataTrack(track_name); + if (!publish_result) { + FAIL() << "Failed to publish track " << track_name << ": " + << describeDataTrackError(publish_result.error()); + } + auto track = publish_result.value(); EXPECT_TRUE(track->isPublished()) << "Track was not published: " << track_name; EXPECT_EQ(track->info().name, track_name); @@ -340,9 +381,13 @@ TEST_F(DataTrackE2ETest, PublishManyTracks) { // logs are expected. The purpose of this test is to verify publish/push // behavior and local track state, not end-to-end delivery of every packet. for (const auto &track : tracks) { - EXPECT_TRUE(track->tryPush( - std::vector(kLargeFramePayloadBytes, 0xFA))) - << "Failed to push large frame on track " << track->info().name; + auto push_result = + track->tryPush(std::vector(kLargeFramePayloadBytes, 0xFA)); + if (!push_result) { + ADD_FAILURE() << "Failed to push large frame on track " + << track->info().name << ": " + << describeDataTrackError(push_result.error()); + } std::this_thread::sleep_for(50ms); } @@ -356,17 +401,19 @@ TEST_F(DataTrackE2ETest, PublishDuplicateName) { auto rooms = testRooms(1); auto &room = rooms[0]; - auto first_track = room->localParticipant()->publishDataTrack("first"); - ASSERT_NE(first_track, nullptr); + auto first_track_result = room->localParticipant()->publishDataTrack("first"); + if (!first_track_result) { + FAIL() << describeDataTrackError(first_track_result.error()); + } + auto first_track = first_track_result.value(); ASSERT_TRUE(first_track->isPublished()); - try { - (void)room->localParticipant()->publishDataTrack("first"); - FAIL() << "Expected duplicate data-track name to be rejected"; - } catch (const std::runtime_error &error) { - const std::string message = error.what(); - EXPECT_FALSE(message.empty()); - } + auto duplicate_result = room->localParticipant()->publishDataTrack("first"); + ASSERT_FALSE(duplicate_result) + << "Expected duplicate data-track name to be rejected"; + EXPECT_EQ(duplicate_result.error().code, + DataTrackErrorCode::DUPLICATE_TRACK_NAME); + EXPECT_FALSE(duplicate_result.error().message.empty()); first_track->unpublishDataTrack(); } @@ -386,15 +433,14 @@ TEST_F(DataTrackE2ETest, CanResubscribeToRemoteDataTrack) { std::thread publisher([&]() { try { auto track = - publisher_room->localParticipant()->publishDataTrack(track_name); - if (!track || !track->isPublished()) { + requirePublishedTrack(publisher_room->localParticipant(), track_name); + if (!track->isPublished()) { throw std::runtime_error("Publisher failed to publish data track"); } while (keep_publishing.load()) { - if (!track->tryPush(std::vector(64, 0xFA))) { - throw std::runtime_error("Failed to push resubscribe test frame"); - } + requirePushSuccess(track->tryPush(std::vector(64, 0xFA)), + "Failed to push resubscribe test frame"); std::this_thread::sleep_for(50ms); } @@ -408,8 +454,11 @@ TEST_F(DataTrackE2ETest, CanResubscribeToRemoteDataTrack) { ASSERT_NE(remote_track, nullptr) << "Timed out waiting for remote data track"; for (int iteration = 0; iteration < kResubscribeIterations; ++iteration) { - auto subscription = remote_track->subscribe(); - ASSERT_NE(subscription, nullptr); + auto subscribe_result = remote_track->subscribe(); + if (!subscribe_result) { + FAIL() << describeDataTrackError(subscribe_result.error()); + } + auto subscription = subscribe_result.value(); auto frame = readFrameWithTimeout(subscription, 5s); EXPECT_FALSE(frame.payload.empty()) << "Iteration " << iteration; @@ -437,16 +486,22 @@ TEST_F(DataTrackE2ETest, PreservesUserTimestampEndToEnd) { auto rooms = testRooms(room_configs); auto &publisher_room = rooms[0]; - auto local_track = + auto publish_result = publisher_room->localParticipant()->publishDataTrack(track_name); - ASSERT_NE(local_track, nullptr); + if (!publish_result) { + FAIL() << describeDataTrackError(publish_result.error()); + } + auto local_track = publish_result.value(); ASSERT_TRUE(local_track->isPublished()); auto remote_track = subscriber_delegate.waitForTrack(kTrackWaitTimeout); ASSERT_NE(remote_track, nullptr) << "Timed out waiting for remote data track"; - auto subscription = remote_track->subscribe(); - ASSERT_NE(subscription, nullptr); + auto subscribe_result = remote_track->subscribe(); + if (!subscribe_result) { + FAIL() << describeDataTrackError(subscribe_result.error()); + } + auto subscription = subscribe_result.value(); std::promise frame_promise; auto frame_future = frame_promise.get_future(); @@ -463,7 +518,7 @@ TEST_F(DataTrackE2ETest, PreservesUserTimestampEndToEnd) { } }); - const bool push_ok = + const auto push_result = local_track->tryPush(std::vector(64, 0xFA), sent_timestamp); const auto frame_status = frame_future.wait_for(5s); @@ -475,7 +530,10 @@ TEST_F(DataTrackE2ETest, PreservesUserTimestampEndToEnd) { reader.join(); local_track->unpublishDataTrack(); - ASSERT_TRUE(push_ok) << "Failed to push timestamped data frame"; + if (!push_result) { + FAIL() << "Failed to push timestamped data frame: " + << describeDataTrackError(push_result.error()); + } ASSERT_EQ(frame_status, std::future_status::ready) << "Timed out waiting for timestamped frame"; @@ -511,9 +569,12 @@ TEST_F(DataTrackE2ETest, PublishesAndReceivesEncryptedFramesEndToEnd) { EXPECT_EQ(subscriber_room->e2eeManager()->keyProvider()->exportSharedKey(), e2eeSharedKey()); - auto local_track = + auto publish_result = publisher_room->localParticipant()->publishDataTrack(track_name); - ASSERT_NE(local_track, nullptr); + if (!publish_result) { + FAIL() << describeDataTrackError(publish_result.error()); + } + auto local_track = publish_result.value(); ASSERT_TRUE(local_track->isPublished()); EXPECT_TRUE(local_track->info().uses_e2ee); @@ -523,8 +584,11 @@ TEST_F(DataTrackE2ETest, PublishesAndReceivesEncryptedFramesEndToEnd) { EXPECT_TRUE(remote_track->info().uses_e2ee); EXPECT_EQ(remote_track->info().name, track_name); - auto subscription = remote_track->subscribe(); - ASSERT_NE(subscription, nullptr); + auto subscribe_result = remote_track->subscribe(); + if (!subscribe_result) { + FAIL() << describeDataTrackError(subscribe_result.error()); + } + auto subscription = subscribe_result.value(); std::promise frame_promise; auto frame_future = frame_promise.get_future(); @@ -545,7 +609,8 @@ TEST_F(DataTrackE2ETest, PublishesAndReceivesEncryptedFramesEndToEnd) { for (int index = 0; index < 200; ++index) { std::vector payload(kLargeFramePayloadBytes, static_cast(index + 1)); - pushed = local_track->tryPush(payload) || pushed; + auto push_result = local_track->tryPush(payload); + pushed = static_cast(push_result) || pushed; if (frame_future.wait_for(25ms) == std::future_status::ready) { break; } @@ -595,9 +660,12 @@ TEST_F(DataTrackE2ETest, PreservesUserTimestampOnEncryptedDataTrack) { publisher_room->e2eeManager()->setEnabled(true); subscriber_room->e2eeManager()->setEnabled(true); - auto local_track = + auto publish_result = publisher_room->localParticipant()->publishDataTrack(track_name); - ASSERT_NE(local_track, nullptr); + if (!publish_result) { + FAIL() << describeDataTrackError(publish_result.error()); + } + auto local_track = publish_result.value(); ASSERT_TRUE(local_track->isPublished()); EXPECT_TRUE(local_track->info().uses_e2ee); @@ -605,8 +673,11 @@ TEST_F(DataTrackE2ETest, PreservesUserTimestampOnEncryptedDataTrack) { ASSERT_NE(remote_track, nullptr) << "Timed out waiting for remote data track"; EXPECT_TRUE(remote_track->info().uses_e2ee); - auto subscription = remote_track->subscribe(); - ASSERT_NE(subscription, nullptr); + auto subscribe_result = remote_track->subscribe(); + if (!subscribe_result) { + FAIL() << describeDataTrackError(subscribe_result.error()); + } + auto subscription = subscribe_result.value(); std::promise frame_promise; auto frame_future = frame_promise.get_future(); @@ -625,7 +696,8 @@ TEST_F(DataTrackE2ETest, PreservesUserTimestampOnEncryptedDataTrack) { bool pushed = false; for (int attempt = 0; attempt < 200; ++attempt) { - pushed = local_track->tryPush(payload, sent_timestamp) || pushed; + auto push_result = local_track->tryPush(payload, sent_timestamp); + pushed = static_cast(push_result) || pushed; if (frame_future.wait_for(25ms) == std::future_status::ready) { break; } From 1f628b4b78365337ff68d47234b3dbfa7e945ee7 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Sun, 29 Mar 2026 22:33:25 -0600 Subject: [PATCH 31/34] BUG FIX: reset the frame after usage --- src/data_track_subscription.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/data_track_subscription.cpp b/src/data_track_subscription.cpp index 6ec3bdae..177ed0cb 100644 --- a/src/data_track_subscription.cpp +++ b/src/data_track_subscription.cpp @@ -63,6 +63,7 @@ bool DataTrackSubscription::read(DataFrame &out) { } out = std::move(*frame_); + frame_.reset(); return true; } From a1f272e2839b6908a8350c828ac16444be04f353 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Sun, 29 Mar 2026 22:34:17 -0600 Subject: [PATCH 32/34] remove simple_status. add ping_pong for one way/round trip latency --- examples/CMakeLists.txt | 43 +++-- examples/ping_pong/constants.h | 36 +++++ examples/ping_pong/json_converters.cpp | 69 ++++++++ examples/ping_pong/json_converters.h | 31 ++++ examples/ping_pong/messages.h | 48 ++++++ examples/ping_pong/ping.cpp | 214 +++++++++++++++++++++++++ examples/ping_pong/pong.cpp | 151 +++++++++++++++++ examples/ping_pong/utils.h | 45 ++++++ examples/simple_status/consumer.cpp | 127 --------------- examples/simple_status/producer.cpp | 150 ----------------- 10 files changed, 625 insertions(+), 289 deletions(-) create mode 100644 examples/ping_pong/constants.h create mode 100644 examples/ping_pong/json_converters.cpp create mode 100644 examples/ping_pong/json_converters.h create mode 100644 examples/ping_pong/messages.h create mode 100644 examples/ping_pong/ping.cpp create mode 100644 examples/ping_pong/pong.cpp create mode 100644 examples/ping_pong/utils.h delete mode 100644 examples/simple_status/consumer.cpp delete mode 100644 examples/simple_status/producer.cpp diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index da98cb33..6d7ab0a5 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -43,8 +43,8 @@ set(EXAMPLES_ALL SimpleJoystickSender SimpleJoystickReceiver SimpleDataStream - SimpleStatusProducer - SimpleStatusConsumer + PingPongPing + PingPongPong HelloLivekitSender HelloLivekitReceiver LoggingLevelsBasicUsage @@ -246,28 +246,47 @@ add_custom_command( $/data ) -# --- simple_status (producer + consumer text stream on producer-status) --- +# --- ping_pong (request/response latency measurement over data tracks) --- -add_executable(SimpleStatusProducer - simple_status/producer.cpp +add_library(ping_pong_support STATIC + ping_pong/json_converters.cpp + ping_pong/json_converters.h + ping_pong/constants.h + ping_pong/messages.h + ping_pong/utils.h ) -target_include_directories(SimpleStatusProducer PRIVATE ${EXAMPLES_PRIVATE_INCLUDE_DIRS}) +target_include_directories(ping_pong_support PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/ping_pong +) -target_link_libraries(SimpleStatusProducer +target_link_libraries(ping_pong_support PRIVATE + nlohmann_json::nlohmann_json +) + +add_executable(PingPongPing + ping_pong/ping.cpp +) + +target_include_directories(PingPongPing PRIVATE ${EXAMPLES_PRIVATE_INCLUDE_DIRS}) + +target_link_libraries(PingPongPing + PRIVATE + ping_pong_support livekit spdlog::spdlog ) -add_executable(SimpleStatusConsumer - simple_status/consumer.cpp +add_executable(PingPongPong + ping_pong/pong.cpp ) -target_include_directories(SimpleStatusConsumer PRIVATE ${EXAMPLES_PRIVATE_INCLUDE_DIRS}) +target_include_directories(PingPongPong PRIVATE ${EXAMPLES_PRIVATE_INCLUDE_DIRS}) -target_link_libraries(SimpleStatusConsumer +target_link_libraries(PingPongPong PRIVATE + ping_pong_support livekit spdlog::spdlog ) @@ -454,4 +473,4 @@ if(UNIX) foreach(EXAMPLE ${EXAMPLES_BRIDGE}) add_dependencies(${EXAMPLE} copy_bridge_to_bin) endforeach() -endif() \ No newline at end of file +endif() diff --git a/examples/ping_pong/constants.h b/examples/ping_pong/constants.h new file mode 100644 index 00000000..da3c9b53 --- /dev/null +++ b/examples/ping_pong/constants.h @@ -0,0 +1,36 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +namespace ping_pong { + +inline constexpr char kPingParticipantIdentity[] = "ping"; +inline constexpr char kPongParticipantIdentity[] = "pong"; + +inline constexpr char kPingTrackName[] = "ping"; +inline constexpr char kPongTrackName[] = "pong"; + +inline constexpr char kPingIdKey[] = "id"; +inline constexpr char kReceivedIdKey[] = "rec_id"; +inline constexpr char kTimestampKey[] = "ts"; + +inline constexpr auto kPingPeriod = std::chrono::seconds(1); +inline constexpr auto kPollPeriod = std::chrono::milliseconds(50); + +} // namespace ping_pong diff --git a/examples/ping_pong/json_converters.cpp b/examples/ping_pong/json_converters.cpp new file mode 100644 index 00000000..24f89b14 --- /dev/null +++ b/examples/ping_pong/json_converters.cpp @@ -0,0 +1,69 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "json_converters.h" + +#include "constants.h" + +#include + +#include + +namespace ping_pong { + +std::string pingMessageToJson(const PingMessage &message) { + nlohmann::json json; + json[kPingIdKey] = message.id; + json[kTimestampKey] = message.ts_ns; + return json.dump(); +} + +PingMessage pingMessageFromJson(const std::string &json_text) { + try { + const auto json = nlohmann::json::parse(json_text); + + PingMessage message; + message.id = json.at(kPingIdKey).get(); + message.ts_ns = json.at(kTimestampKey).get(); + return message; + } catch (const nlohmann::json::exception &e) { + throw std::runtime_error(std::string("Failed to parse ping JSON: ") + + e.what()); + } +} + +std::string pongMessageToJson(const PongMessage &message) { + nlohmann::json json; + json[kReceivedIdKey] = message.rec_id; + json[kTimestampKey] = message.ts_ns; + return json.dump(); +} + +PongMessage pongMessageFromJson(const std::string &json_text) { + try { + const auto json = nlohmann::json::parse(json_text); + + PongMessage message; + message.rec_id = json.at(kReceivedIdKey).get(); + message.ts_ns = json.at(kTimestampKey).get(); + return message; + } catch (const nlohmann::json::exception &e) { + throw std::runtime_error(std::string("Failed to parse pong JSON: ") + + e.what()); + } +} + +} // namespace ping_pong diff --git a/examples/ping_pong/json_converters.h b/examples/ping_pong/json_converters.h new file mode 100644 index 00000000..3491ef6c --- /dev/null +++ b/examples/ping_pong/json_converters.h @@ -0,0 +1,31 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "messages.h" + +#include + +namespace ping_pong { + +std::string pingMessageToJson(const PingMessage &message); +PingMessage pingMessageFromJson(const std::string &json); + +std::string pongMessageToJson(const PongMessage &message); +PongMessage pongMessageFromJson(const std::string &json); + +} // namespace ping_pong diff --git a/examples/ping_pong/messages.h b/examples/ping_pong/messages.h new file mode 100644 index 00000000..d4212ed6 --- /dev/null +++ b/examples/ping_pong/messages.h @@ -0,0 +1,48 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +namespace ping_pong { + +struct PingMessage { + std::uint64_t id = 0; + std::int64_t ts_ns = 0; +}; + +struct PongMessage { + std::uint64_t rec_id = 0; + std::int64_t ts_ns = 0; +}; + +struct LatencyMetrics { + std::uint64_t id = 0; + std::int64_t ping_sent_ts_ns = 0; + std::int64_t pong_sent_ts_ns = 0; + std::int64_t ping_received_ts_ns = 0; + std::int64_t round_trip_time_ns = 0; + std::int64_t pong_to_ping_time_ns = 0; + std::int64_t ping_to_pong_and_processing_ns = 0; + double estimated_one_way_latency_ns = 0.0; + double round_trip_time_ms = 0.0; + double pong_to_ping_time_ms = 0.0; + double ping_to_pong_and_processing_ms = 0.0; + double estimated_one_way_latency_ms = 0.0; +}; + +} // namespace ping_pong diff --git a/examples/ping_pong/ping.cpp b/examples/ping_pong/ping.cpp new file mode 100644 index 00000000..2071e025 --- /dev/null +++ b/examples/ping_pong/ping.cpp @@ -0,0 +1,214 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// Ping participant: publishes on the "ping" data track, listens on "pong", +/// and logs latency metrics for each matched response. Use a token whose +/// identity is `ping`. + +#include "constants.h" +#include "json_converters.h" +#include "livekit/livekit.h" +#include "livekit/lk_log.h" +#include "messages.h" +#include "utils.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace livekit; + +namespace { + +std::atomic g_running{true}; + +void handleSignal(int) { g_running.store(false); } + +ping_pong::LatencyMetrics +calculateLatencyMetrics(const ping_pong::PingMessage &ping_message, + const ping_pong::PongMessage &pong_message, + std::int64_t received_ts_ns) { + ping_pong::LatencyMetrics metrics; + metrics.id = ping_message.id; + metrics.pong_sent_ts_ns = pong_message.ts_ns; + metrics.ping_received_ts_ns = received_ts_ns; + metrics.round_trip_time_ns = received_ts_ns - ping_message.ts_ns; + metrics.pong_to_ping_time_ns = received_ts_ns - pong_message.ts_ns; + metrics.ping_to_pong_and_processing_ns = + pong_message.ts_ns - ping_message.ts_ns; + metrics.estimated_one_way_latency_ns = + static_cast(metrics.round_trip_time_ns) / 2.0; + metrics.round_trip_time_ms = + static_cast(metrics.round_trip_time_ns) / 1'000'000.0; + metrics.pong_to_ping_time_ms = + static_cast(metrics.pong_to_ping_time_ns) / 1'000'000.0; + metrics.ping_to_pong_and_processing_ms = + static_cast(metrics.ping_to_pong_and_processing_ns) / + 1'000'000.0; + metrics.estimated_one_way_latency_ms = + metrics.estimated_one_way_latency_ns / 1'000'000.0; + return metrics; +} + +} // namespace + +int main(int argc, char *argv[]) { + std::string url = ping_pong::getenvOrEmpty("LIVEKIT_URL"); + std::string token = ping_pong::getenvOrEmpty("LIVEKIT_TOKEN"); + + if (argc >= 3) { + url = argv[1]; + token = argv[2]; + } + + if (url.empty() || token.empty()) { + LK_LOG_ERROR("LIVEKIT_URL and LIVEKIT_TOKEN (or ) are " + "required"); + return 1; + } + + std::signal(SIGINT, handleSignal); +#ifdef SIGTERM + std::signal(SIGTERM, handleSignal); +#endif + + livekit::initialize(livekit::LogLevel::Info, livekit::LogSink::kConsole); + + auto room = std::make_unique(); + RoomOptions options; + options.auto_subscribe = true; + options.dynacast = false; + + if (!room->Connect(url, token, options)) { + LK_LOG_ERROR("Failed to connect to room"); + livekit::shutdown(); + return 1; + } + + LocalParticipant *local_participant = room->localParticipant(); + assert(local_participant); + + LK_LOG_INFO("ping connected as identity='{}' room='{}'", + local_participant->identity(), room->room_info().name); + + auto publish_result = + local_participant->publishDataTrack(ping_pong::kPingTrackName); + if (!publish_result) { + const auto &error = publish_result.error(); + LK_LOG_ERROR("Failed to publish ping data track: code={} retryable={} " + "message={}", + static_cast(error.code), error.retryable, + error.message); + room->setDelegate(nullptr); + room.reset(); + livekit::shutdown(); + return 1; + } + + std::shared_ptr ping_track = publish_result.value(); + std::unordered_map sent_messages; + std::mutex sent_messages_mutex; + + const auto callback_id = room->addOnDataFrameCallback( + ping_pong::kPongParticipantIdentity, ping_pong::kPongTrackName, + [&sent_messages, + &sent_messages_mutex](const std::vector &payload, + std::optional /*user_timestamp*/) { + try { + if (payload.empty()) { + LK_LOG_DEBUG("Ignoring empty pong payload"); + return; + } + + const auto pong_message = + ping_pong::pongMessageFromJson(ping_pong::toString(payload)); + const auto received_ts_ns = ping_pong::timeSinceEpochNs(); + + ping_pong::PingMessage ping_message; + { + std::lock_guard lock(sent_messages_mutex); + const auto it = sent_messages.find(pong_message.rec_id); + if (it == sent_messages.end()) { + LK_LOG_WARN("Received pong for unknown id={}", + pong_message.rec_id); + return; + } + ping_message = it->second; + sent_messages.erase(it); + } + + const auto metrics = calculateLatencyMetrics( + ping_message, pong_message, received_ts_ns); + + LK_LOG_INFO( + "pong id={} rtt_ms={:.3f} " + "pong_to_ping_ms={:.3f} " + "ping_to_pong_and_processing_ms={:.3f} " + "estimated_one_way_latency_ms={:.3f}", + metrics.id, metrics.round_trip_time_ms, metrics.pong_to_ping_time_ms, + metrics.ping_to_pong_and_processing_ms, + metrics.estimated_one_way_latency_ms); + } catch (const std::exception &e) { + LK_LOG_WARN("Failed to process pong payload: {}", e.what()); + } + }); + + LK_LOG_INFO("published data track '{}' and listening for '{}' from '{}'", + ping_pong::kPingTrackName, ping_pong::kPongTrackName, + ping_pong::kPongParticipantIdentity); + + std::uint64_t next_id = 1; + auto next_deadline = std::chrono::steady_clock::now(); + + while (g_running.load()) { + ping_pong::PingMessage ping_message; + ping_message.id = next_id++; + ping_message.ts_ns = ping_pong::timeSinceEpochNs(); + + const std::string json = ping_pong::pingMessageToJson(ping_message); + auto push_result = ping_track->tryPush(ping_pong::toPayload(json)); + if (!push_result) { + const auto &error = push_result.error(); + LK_LOG_WARN("Failed to push ping data frame: code={} retryable={} " + "message={}", + static_cast(error.code), error.retryable, + error.message); + } else { + { + std::lock_guard lock(sent_messages_mutex); + sent_messages.emplace(ping_message.id, ping_message); + } + LK_LOG_INFO("sent ping id={} ts_ns={}", ping_message.id, + ping_message.ts_ns); + } + + next_deadline += ping_pong::kPingPeriod; + std::this_thread::sleep_until(next_deadline); + } + + LK_LOG_INFO("shutting down ping participant"); + room.reset(); + livekit::shutdown(); + return 0; +} diff --git a/examples/ping_pong/pong.cpp b/examples/ping_pong/pong.cpp new file mode 100644 index 00000000..03d11b84 --- /dev/null +++ b/examples/ping_pong/pong.cpp @@ -0,0 +1,151 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// Pong participant: listens on the "ping" data track and publishes responses +/// on the "pong" data track. Use a token whose identity is `pong`. + +#include "constants.h" +#include "json_converters.h" +#include "livekit/livekit.h" +#include "livekit/lk_log.h" +#include "messages.h" +#include "utils.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace livekit; + +namespace { + +std::atomic g_running{true}; + +void handleSignal(int) { g_running.store(false); } + +} // namespace + +int main(int argc, char *argv[]) { + std::string url = ping_pong::getenvOrEmpty("LIVEKIT_URL"); + std::string token = ping_pong::getenvOrEmpty("LIVEKIT_TOKEN"); + + if (argc >= 3) { + url = argv[1]; + token = argv[2]; + } + + if (url.empty() || token.empty()) { + LK_LOG_ERROR("LIVEKIT_URL and LIVEKIT_TOKEN (or ) are " + "required"); + return 1; + } + + std::signal(SIGINT, handleSignal); +#ifdef SIGTERM + std::signal(SIGTERM, handleSignal); +#endif + + livekit::initialize(livekit::LogLevel::Info, livekit::LogSink::kConsole); + + auto room = std::make_unique(); + RoomOptions options; + options.auto_subscribe = true; + options.dynacast = false; + + if (!room->Connect(url, token, options)) { + LK_LOG_ERROR("Failed to connect to room"); + livekit::shutdown(); + return 1; + } + + LocalParticipant *local_participant = room->localParticipant(); + assert(local_participant); + + LK_LOG_INFO("pong connected as identity='{}' room='{}'", + local_participant->identity(), room->room_info().name); + + auto publish_result = + local_participant->publishDataTrack(ping_pong::kPongTrackName); + if (!publish_result) { + const auto &error = publish_result.error(); + LK_LOG_ERROR("Failed to publish pong data track: code={} retryable={} " + "message={}", + static_cast(error.code), error.retryable, + error.message); + room->setDelegate(nullptr); + room.reset(); + livekit::shutdown(); + return 1; + } + + std::shared_ptr pong_track = publish_result.value(); + + const auto callback_id = room->addOnDataFrameCallback( + ping_pong::kPingParticipantIdentity, ping_pong::kPingTrackName, + [pong_track](const std::vector &payload, + std::optional /*user_timestamp*/) { + try { + if (payload.empty()) { + LK_LOG_DEBUG("Ignoring empty ping payload"); + return; + } + + const auto ping_message = + ping_pong::pingMessageFromJson(ping_pong::toString(payload)); + + ping_pong::PongMessage pong_message; + pong_message.rec_id = ping_message.id; + pong_message.ts_ns = ping_pong::timeSinceEpochNs(); + + const std::string json = ping_pong::pongMessageToJson(pong_message); + auto push_result = pong_track->tryPush(ping_pong::toPayload(json)); + if (!push_result) { + const auto &error = push_result.error(); + LK_LOG_WARN("Failed to push pong data frame: code={} retryable={} " + "message={}", + static_cast(error.code), + error.retryable, error.message); + return; + } + + LK_LOG_INFO("received ping id={} ts_ns={} and sent pong rec_id={} " + "ts_ns={}", + ping_message.id, ping_message.ts_ns, pong_message.rec_id, + pong_message.ts_ns); + } catch (const std::exception &e) { + LK_LOG_WARN("Failed to process ping payload: {}", e.what()); + } + }); + + LK_LOG_INFO("published data track '{}' and listening for '{}' from '{}'", + ping_pong::kPongTrackName, ping_pong::kPingTrackName, + ping_pong::kPingParticipantIdentity); + + while (g_running.load()) { + std::this_thread::sleep_for(ping_pong::kPollPeriod); + } + + LK_LOG_INFO("shutting down pong participant"); + room.reset(); + livekit::shutdown(); + return 0; +} diff --git a/examples/ping_pong/utils.h b/examples/ping_pong/utils.h new file mode 100644 index 00000000..56c915b9 --- /dev/null +++ b/examples/ping_pong/utils.h @@ -0,0 +1,45 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include + +namespace ping_pong { + +inline std::string getenvOrEmpty(const char *name) { + const char *value = std::getenv(name); + return value ? std::string(value) : std::string{}; +} + +inline std::int64_t timeSinceEpochNs() { + const auto now = std::chrono::system_clock::now().time_since_epoch(); + return std::chrono::duration_cast(now).count(); +} + +inline std::vector toPayload(const std::string &json) { + return std::vector(json.begin(), json.end()); +} + +inline std::string toString(const std::vector &payload) { + return std::string(payload.begin(), payload.end()); +} + +} // namespace ping_pong diff --git a/examples/simple_status/consumer.cpp b/examples/simple_status/consumer.cpp deleted file mode 100644 index 2859e8b9..00000000 --- a/examples/simple_status/consumer.cpp +++ /dev/null @@ -1,127 +0,0 @@ -/* - * Copyright 2026 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/// Consumer participant: creates 3 independent data track subscriptions to the -/// producer's "status" data track and logs each frame with the subscriber -/// index. Use a token whose identity is `consumer`. - -#include "livekit/livekit.h" -#include "livekit/lk_log.h" - -#include -#include -#include -#include -#include -#include -#include - -using namespace livekit; - -namespace { - -constexpr const char *kProducerIdentity = "producer"; -constexpr const char *kTrackName = "status"; -constexpr int kNumSubscribers = 3; - -std::atomic g_running{true}; - -void handleSignal(int) { g_running.store(false); } - -std::string getenvOrEmpty(const char *name) { - const char *v = std::getenv(name); - return v ? std::string(v) : std::string{}; -} - -} // namespace - -int main(int argc, char *argv[]) { - std::string url = getenvOrEmpty("LIVEKIT_URL"); - std::string token = getenvOrEmpty("LIVEKIT_TOKEN"); - - if (argc >= 3) { - url = argv[1]; - token = argv[2]; - } - - if (url.empty() || token.empty()) { - LK_LOG_ERROR("LIVEKIT_URL and LIVEKIT_TOKEN (or ) are " - "required"); - return 1; - } - - std::signal(SIGINT, handleSignal); -#ifdef SIGTERM - std::signal(SIGTERM, handleSignal); -#endif - - livekit::initialize(livekit::LogLevel::Info, livekit::LogSink::kConsole); - - auto room = std::make_unique(); - RoomOptions options; - options.auto_subscribe = true; - options.dynacast = false; - - if (!room->Connect(url, token, options)) { - LK_LOG_ERROR("Failed to connect to room"); - livekit::shutdown(); - return 1; - } - - LocalParticipant *lp = room->localParticipant(); - if (!lp) { - LK_LOG_ERROR("No local participant after connect"); - room->setDelegate(nullptr); - room.reset(); - livekit::shutdown(); - return 1; - } - - LK_LOG_INFO("consumer connected as identity='{}' room='{}'", lp->identity(), - room->room_info().name); - - std::vector sub_ids; - sub_ids.reserve(kNumSubscribers); - - for (int i = 0; i < kNumSubscribers; ++i) { - auto id = room->addOnDataFrameCallback( - kProducerIdentity, kTrackName, - [i](const std::vector &payload, - std::optional /*user_timestamp*/) { - std::string text(payload.begin(), payload.end()); - LK_LOG_INFO("[subscriber {}] {}", i, text); - }); - sub_ids.push_back(id); - LK_LOG_INFO("registered subscriber {} (id={})", i, id); - } - - LK_LOG_INFO("listening for data track '{}' from '{}' with {} subscribers; " - "Ctrl-C to exit", - kTrackName, kProducerIdentity, kNumSubscribers); - - while (g_running.load()) { - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - } - - LK_LOG_INFO("shutting down"); - for (auto id : sub_ids) { - room->removeOnDataFrameCallback(id); - } - room->setDelegate(nullptr); - room.reset(); - livekit::shutdown(); - return 0; -} diff --git a/examples/simple_status/producer.cpp b/examples/simple_status/producer.cpp deleted file mode 100644 index 0f06aad6..00000000 --- a/examples/simple_status/producer.cpp +++ /dev/null @@ -1,150 +0,0 @@ -/* - * Copyright 2026 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/// Producer participant: publishes a data track named "status" and pushes -/// periodic binary status frames (4 Hz). Use a token whose identity is -/// `producer`. - -#include "livekit/livekit.h" -#include "livekit/lk_log.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -using namespace livekit; - -namespace { - -constexpr const char *kTrackName = "status"; - -std::atomic g_running{true}; - -void handleSignal(int) { g_running.store(false); } - -std::string getenvOrEmpty(const char *name) { - const char *v = std::getenv(name); - return v ? std::string(v) : std::string{}; -} - -} // namespace - -int main(int argc, char *argv[]) { - std::string url = getenvOrEmpty("LIVEKIT_URL"); - std::string token = getenvOrEmpty("LIVEKIT_TOKEN"); - - if (argc >= 3) { - url = argv[1]; - token = argv[2]; - } - - if (url.empty() || token.empty()) { - LK_LOG_ERROR("LIVEKIT_URL and LIVEKIT_TOKEN (or ) are " - "required"); - return 1; - } - - std::signal(SIGINT, handleSignal); -#ifdef SIGTERM - std::signal(SIGTERM, handleSignal); -#endif - - livekit::initialize(livekit::LogLevel::Info, livekit::LogSink::kConsole); - - auto room = std::make_unique(); - RoomOptions options; - options.auto_subscribe = true; - options.dynacast = false; - - if (!room->Connect(url, token, options)) { - LK_LOG_ERROR("Failed to connect to room"); - livekit::shutdown(); - return 1; - } - - LocalParticipant *lp = room->localParticipant(); - if (!lp) { - LK_LOG_ERROR("No local participant after connect"); - room->setDelegate(nullptr); - room.reset(); - livekit::shutdown(); - return 1; - } - - LK_LOG_INFO("producer connected as identity='{}' room='{}'", lp->identity(), - room->room_info().name); - - std::shared_ptr data_track; - auto publish_result = lp->publishDataTrack(kTrackName); - if (!publish_result) { - const auto &error = publish_result.error(); - LK_LOG_ERROR("Failed to publish data track: code={} retryable={} message={}", - static_cast(error.code), error.retryable, - error.message); - room->setDelegate(nullptr); - room.reset(); - livekit::shutdown(); - return 1; - } - data_track = publish_result.value(); - - LK_LOG_INFO("published data track '{}'", kTrackName); - - using clock = std::chrono::steady_clock; - const auto start = clock::now(); - const auto period = std::chrono::milliseconds(250); - auto next_deadline = clock::now(); - std::uint64_t count = 0; - - while (g_running.load()) { - const auto now = clock::now(); - const double elapsed_sec = - std::chrono::duration(now - start).count(); - - std::ostringstream body; - body << std::fixed << std::setprecision(2) << elapsed_sec; - const std::string text = std::string("[time-since-start]: ") + body.str() + - " count: " + std::to_string(count); - - std::vector payload(text.begin(), text.end()); - auto push_result = data_track->tryPush(payload); - if (!push_result) { - const auto &error = push_result.error(); - LK_LOG_WARN("Failed to push data frame: code={} retryable={} message={}", - static_cast(error.code), error.retryable, - error.message); - } - - LK_LOG_DEBUG("sent: {}", text); - ++count; - - next_deadline += period; - std::this_thread::sleep_until(next_deadline); - } - - LK_LOG_INFO("shutting down"); - data_track->unpublishDataTrack(); - room->setDelegate(nullptr); - room.reset(); - livekit::shutdown(); - return 0; -} From 1c001f635404ea6a1ed93140dc030ea403d218ec Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Sun, 29 Mar 2026 22:37:34 -0600 Subject: [PATCH 33/34] lcaol_data_track: remove many overloaded tryPush() functions --- include/livekit/local_data_track.h | 12 ------------ src/local_data_track.cpp | 18 ------------------ 2 files changed, 30 deletions(-) diff --git a/include/livekit/local_data_track.h b/include/livekit/local_data_track.h index 044698d7..d8639009 100644 --- a/include/livekit/local_data_track.h +++ b/include/livekit/local_data_track.h @@ -80,20 +80,8 @@ class LocalDataTrack { * the frame could not be queued. */ Result - tryPush(const std::vector &payload, - std::optional user_timestamp = std::nullopt); - Result tryPush(std::vector &&payload, std::optional user_timestamp = std::nullopt); - /** - * Try to push a frame to all subscribers of this track. - * - * @return success on delivery acceptance, or a typed error describing why - * the frame could not be queued. - */ - Result - tryPush(const std::uint8_t *data, std::size_t size, - std::optional user_timestamp = std::nullopt); /// Whether the track is still published in the room. bool isPublished() const; diff --git a/src/local_data_track.cpp b/src/local_data_track.cpp index 55506104..1380d29b 100644 --- a/src/local_data_track.cpp +++ b/src/local_data_track.cpp @@ -70,15 +70,6 @@ Result LocalDataTrack::tryPush(const DataFrame &frame) { } } -Result -LocalDataTrack::tryPush(const std::vector &payload, - std::optional user_timestamp) { - DataFrame frame; - frame.payload = payload; - frame.user_timestamp = user_timestamp; - return tryPush(frame); -} - Result LocalDataTrack::tryPush(std::vector &&payload, std::optional user_timestamp) { @@ -88,15 +79,6 @@ LocalDataTrack::tryPush(std::vector &&payload, return tryPush(frame); } -Result -LocalDataTrack::tryPush(const std::uint8_t *data, std::size_t size, - std::optional user_timestamp) { - DataFrame frame; - frame.payload.assign(data, data + size); - frame.user_timestamp = user_timestamp; - return tryPush(frame); -} - bool LocalDataTrack::isPublished() const { if (!handle_.valid()) { return false; From 15bbeccd09c318a838f874c094f459f4472de97a Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Mon, 30 Mar 2026 11:10:58 -0600 Subject: [PATCH 34/34] subscribeDataTrackAsync -> subscribeDataTrack --- include/livekit/remote_data_track.h | 5 + src/ffi_client.cpp | 36 +++---- src/ffi_client.h | 5 +- src/remote_data_track.cpp | 4 +- src/tests/CMakeLists.txt | 11 +++ src/tests/integration/test_data_track.cpp | 109 +++++++++++++++++++++- 6 files changed, 145 insertions(+), 25 deletions(-) diff --git a/include/livekit/remote_data_track.h b/include/livekit/remote_data_track.h index ee9d75ba..9157c042 100644 --- a/include/livekit/remote_data_track.h +++ b/include/livekit/remote_data_track.h @@ -68,6 +68,11 @@ class RemoteDataTrack { /// Whether the track is still published by the remote participant. bool isPublished() const; +#ifdef LIVEKIT_TEST_ACCESS + /// Test-only accessor for exercising lower-level FFI subscription paths. + uintptr_t testFfiHandleId() const noexcept { return ffi_handle_id(); } +#endif + /** * Subscribe to this remote data track. * diff --git a/src/ffi_client.cpp b/src/ffi_client.cpp index 572a7f87..64ef9874 100644 --- a/src/ffi_client.cpp +++ b/src/ffi_client.cpp @@ -689,9 +689,9 @@ FfiClient::publishDataTrackAsync(std::uint64_t local_participant_handle, return fut; } -std::future> -FfiClient::subscribeDataTrackAsync(std::uint64_t track_handle, - std::optional buffer_size) { +Result +FfiClient::subscribeDataTrack(std::uint64_t track_handle, + std::optional buffer_size) { const AsyncId async_id = generateAsyncId(); auto fut = @@ -737,28 +737,32 @@ FfiClient::subscribeDataTrackAsync(std::uint64_t track_handle, proto::FfiResponse resp = sendRequest(req); if (!resp.has_subscribe_data_track()) { cancelPendingByAsyncId(async_id); - std::promise> - pr; - pr.set_value( - Result::failure( - makeDataTrackError(DataTrackErrorCode::PROTOCOL_ERROR, - "FfiResponse missing subscribe_data_track"))); - return pr.get_future(); + return Result::failure( + makeDataTrackError(DataTrackErrorCode::PROTOCOL_ERROR, + "FfiResponse missing subscribe_data_track")); + } + if (resp.subscribe_data_track().async_id() != async_id) { + cancelPendingByAsyncId(async_id); + return Result::failure( + makeDataTrackError(DataTrackErrorCode::PROTOCOL_ERROR, + "FfiResponse subscribe_data_track async_id mismatch")); } } catch (...) { cancelPendingByAsyncId(async_id); - std::promise> pr; try { throw; } catch (const std::exception &e) { - pr.set_value( - Result::failure( - makeDataTrackError(DataTrackErrorCode::INTERNAL, e.what()))); + return Result::failure( + makeDataTrackError(DataTrackErrorCode::INTERNAL, e.what())); } - return pr.get_future(); } - return fut; + try { + return fut.get(); + } catch (const std::exception &e) { + return Result::failure( + makeDataTrackError(DataTrackErrorCode::INTERNAL, e.what())); + } } std::future FfiClient::publishSipDtmfAsync( diff --git a/src/ffi_client.h b/src/ffi_client.h index 2a45abf3..cf6c8586 100644 --- a/src/ffi_client.h +++ b/src/ffi_client.h @@ -135,9 +135,8 @@ class FfiClient { publishDataTrackAsync(std::uint64_t local_participant_handle, const std::string &track_name); - // TODO(sderosa): the subscription model for data tracks has been changed to sync, need to update - std::future> - subscribeDataTrackAsync( + Result + subscribeDataTrack( std::uint64_t track_handle, std::optional buffer_size = std::nullopt); diff --git a/src/remote_data_track.cpp b/src/remote_data_track.cpp index ee2fa173..ebc0d362 100644 --- a/src/remote_data_track.cpp +++ b/src/remote_data_track.cpp @@ -56,10 +56,8 @@ RemoteDataTrack::subscribe(const DataTrackSubscription::Options &options) { false}); } - auto fut = FfiClient::instance().subscribeDataTrackAsync( + auto result = FfiClient::instance().subscribeDataTrack( static_cast(handle_.get()), options.buffer_size); - - auto result = fut.get(); if (!result) { return Result, DataTrackError>::failure(result.error()); diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index 6ff68ef9..1b9695eb 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -49,10 +49,21 @@ if(INTEGRATION_TEST_SOURCES) PRIVATE ${LIVEKIT_ROOT_DIR}/include ${LIVEKIT_ROOT_DIR}/src + ${LIVEKIT_BINARY_DIR}/generated + ${Protobuf_INCLUDE_DIRS} ) + if(TARGET absl::base) + get_target_property(_livekit_test_absl_inc absl::base INTERFACE_INCLUDE_DIRECTORIES) + if(_livekit_test_absl_inc) + target_include_directories(livekit_integration_tests PRIVATE + ${_livekit_test_absl_inc} + ) + endif() + endif() target_compile_definitions(livekit_integration_tests PRIVATE + LIVEKIT_TEST_ACCESS LIVEKIT_ROOT_DIR="${LIVEKIT_ROOT_DIR}" SPDLOG_ACTIVE_LEVEL=${_SPDLOG_ACTIVE_LEVEL} ) diff --git a/src/tests/integration/test_data_track.cpp b/src/tests/integration/test_data_track.cpp index b6c942b1..f417b960 100644 --- a/src/tests/integration/test_data_track.cpp +++ b/src/tests/integration/test_data_track.cpp @@ -23,6 +23,8 @@ #include "../common/test_common.h" +#include "ffi_client.h" + #include #include #include @@ -62,6 +64,15 @@ std::vector e2eeSharedKey() { kE2EESharedSecret, kE2EESharedSecret + sizeof(kE2EESharedSecret) - 1); } +std::size_t parseTestTrackIndex(const std::string &track_name) { + constexpr char kPrefix[] = "test_"; + if (track_name.rfind(kPrefix, 0) != 0) { + throw std::runtime_error("Unexpected test track name: " + track_name); + } + return static_cast( + std::stoul(track_name.substr(sizeof(kPrefix) - 1))); +} + E2EEOptions makeE2EEOptions() { E2EEOptions options; options.key_provider_options.shared_key = e2eeSharedKey(); @@ -146,6 +157,16 @@ class DataTrackPublishedDelegate : public RoomDelegate { return tracks_.front(); } + std::vector> + waitForTracks(std::size_t count, std::chrono::milliseconds timeout) { + std::unique_lock lock(mutex_); + if (!cv_.wait_for(lock, timeout, + [this, count] { return tracks_.size() >= count; })) { + return {}; + } + return {tracks_.begin(), tracks_.begin() + static_cast(count)}; + } + private: std::mutex mutex_; std::condition_variable cv_; @@ -228,7 +249,8 @@ TEST_P(DataTrackTransportTest, PublishesAndReceivesFramesEndToEnd) { for (size_t index = 0; index < frame_count; ++index) { std::vector payload(payload_len, static_cast(index)); - requirePushSuccess(track->tryPush(payload), "Failed to push data frame"); + requirePushSuccess(track->tryPush(std::move(payload)), + "Failed to push data frame"); next_send += frame_interval; std::this_thread::sleep_until(next_send); @@ -475,6 +497,85 @@ TEST_F(DataTrackE2ETest, CanResubscribeToRemoteDataTrack) { } } +TEST_F(DataTrackE2ETest, FfiClientSubscribeDataTrackReturnsSyncResult) { + constexpr std::size_t kTopicCount = 20; + + DataTrackPublishedDelegate subscriber_delegate; + std::vector room_configs(2); + room_configs[1].delegate = &subscriber_delegate; + + auto rooms = testRooms(room_configs); + auto &publisher_room = rooms[0]; + + std::vector> local_tracks; + local_tracks.reserve(kTopicCount); + + for (std::size_t idx = 0; idx < kTopicCount; ++idx) { + const auto track_name = "test_" + std::to_string(idx); + auto publish_result = + publisher_room->localParticipant()->publishDataTrack(track_name); + if (!publish_result) { + FAIL() << "Failed to publish " << track_name << ": " + << describeDataTrackError(publish_result.error()); + } + auto local_track = publish_result.value(); + ASSERT_TRUE(local_track->isPublished()) << track_name; + local_tracks.push_back(std::move(local_track)); + } + + auto remote_tracks = + subscriber_delegate.waitForTracks(kTopicCount, kTrackWaitTimeout); + ASSERT_EQ(remote_tracks.size(), kTopicCount) + << "Timed out waiting for all remote data tracks"; + + std::sort(remote_tracks.begin(), remote_tracks.end(), + [](const std::shared_ptr &lhs, + const std::shared_ptr &rhs) { + return parseTestTrackIndex(lhs->info().name) < + parseTestTrackIndex(rhs->info().name); + }); + + std::vector subscription_handles; + subscription_handles.reserve(kTopicCount); + + for (std::size_t idx = 0; idx < remote_tracks.size(); ++idx) { + const auto &remote_track = remote_tracks[idx]; + const auto expected_name = "test_" + std::to_string(idx); + ASSERT_NE(remote_track, nullptr); + EXPECT_TRUE(remote_track->isPublished()) << expected_name; + EXPECT_EQ(remote_track->info().name, expected_name); + + const auto subscribe_start = std::chrono::steady_clock::now(); + auto subscribe_result = FfiClient::instance().subscribeDataTrack( + static_cast(remote_track->testFfiHandleId())); + const auto subscribe_elapsed = + std::chrono::steady_clock::now() - subscribe_start; + const auto subscribe_elapsed_ns = + std::chrono::duration_cast( + subscribe_elapsed) + .count(); + + std::cout << "FfiClient::subscribeDataTrack(" << expected_name + << ") completed in " << subscribe_elapsed_ns << " ns" + << std::endl; + + if (!subscribe_result) { + FAIL() << "Failed to subscribe to " << expected_name << ": " + << describeDataTrackError(subscribe_result.error()); + } + + const auto subscription_handle_id = + static_cast(subscribe_result.value().handle().id()); + EXPECT_NE(subscription_handle_id, 0u) << expected_name; + subscription_handles.emplace_back(subscription_handle_id); + EXPECT_TRUE(subscription_handles.back().valid()) << expected_name; + } + + for (auto &local_track : local_tracks) { + local_track->unpublishDataTrack(); + } +} + TEST_F(DataTrackE2ETest, PreservesUserTimestampEndToEnd) { const auto track_name = makeTrackName("user_timestamp"); const auto sent_timestamp = getTimestampUs(); @@ -609,7 +710,7 @@ TEST_F(DataTrackE2ETest, PublishesAndReceivesEncryptedFramesEndToEnd) { for (int index = 0; index < 200; ++index) { std::vector payload(kLargeFramePayloadBytes, static_cast(index + 1)); - auto push_result = local_track->tryPush(payload); + auto push_result = local_track->tryPush(std::move(payload)); pushed = static_cast(push_result) || pushed; if (frame_future.wait_for(25ms) == std::future_status::ready) { break; @@ -696,7 +797,9 @@ TEST_F(DataTrackE2ETest, PreservesUserTimestampOnEncryptedDataTrack) { bool pushed = false; for (int attempt = 0; attempt < 200; ++attempt) { - auto push_result = local_track->tryPush(payload, sent_timestamp); + auto payload_copy = payload; + auto push_result = + local_track->tryPush(std::move(payload_copy), sent_timestamp); pushed = static_cast(push_result) || pushed; if (frame_future.wait_for(25ms) == std::future_status::ready) { break;
LiveKit Ecosystem