diff --git a/CMakeLists.txt b/CMakeLists.txt
index 86eb1e49..504e9cc1 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
)
@@ -323,7 +324,10 @@ 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
src/ffi_client.cpp
@@ -331,7 +335,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 +689,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..b318e0d5 100644
--- a/README.md
+++ b/README.md
@@ -447,6 +447,35 @@ 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/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}'
+```
+
| LiveKit Ecosystem |
diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt
index 19a37781..56cdf129 100644
--- a/examples/CMakeLists.txt
+++ b/examples/CMakeLists.txt
@@ -39,10 +39,18 @@ set(EXAMPLES_COMMON_DIR ${CMAKE_CURRENT_SOURCE_DIR}/common)
# All example executables (used for copying livekit_ffi DLL/shared lib)
set(EXAMPLES_ALL
SimpleRoom
+ DataStampingProducer
+ DataStampingConsumer
+ UserTimestampedVideoProducer
+ UserTimestampedVideoConsumer
SimpleRpc
SimpleJoystickSender
SimpleJoystickReceiver
SimpleDataStream
+ PingPongPing
+ PingPongPong
+ HelloLivekitSender
+ HelloLivekitReceiver
LoggingLevelsBasicUsage
LoggingLevelsCustomSinks
BridgeRobot
@@ -234,6 +242,56 @@ target_link_libraries(SimpleDataStream
spdlog::spdlog
)
+# --- UserTimestampedVideo example ---
+
+add_executable(DataStampingProducer
+ data_stamping/producer.cpp
+)
+
+target_include_directories(DataStampingProducer PRIVATE ${EXAMPLES_PRIVATE_INCLUDE_DIRS})
+
+target_link_libraries(DataStampingProducer
+ PRIVATE
+ livekit
+ spdlog::spdlog
+)
+
+add_executable(DataStampingConsumer
+ data_stamping/consumer.cpp
+)
+
+target_include_directories(DataStampingConsumer PRIVATE ${EXAMPLES_PRIVATE_INCLUDE_DIRS})
+
+target_link_libraries(DataStampingConsumer
+ PRIVATE
+ livekit
+ spdlog::spdlog
+)
+
+add_executable(UserTimestampedVideoProducer
+ user_timestamped_video/producer.cpp
+)
+
+target_include_directories(UserTimestampedVideoProducer PRIVATE ${EXAMPLES_PRIVATE_INCLUDE_DIRS})
+
+target_link_libraries(UserTimestampedVideoProducer
+ PRIVATE
+ livekit
+ spdlog::spdlog
+)
+
+add_executable(UserTimestampedVideoConsumer
+ user_timestamped_video/consumer.cpp
+)
+
+target_include_directories(UserTimestampedVideoConsumer PRIVATE ${EXAMPLES_PRIVATE_INCLUDE_DIRS})
+
+target_link_libraries(UserTimestampedVideoConsumer
+ PRIVATE
+ livekit
+ spdlog::spdlog
+)
+
add_custom_command(
TARGET SimpleDataStream
POST_BUILD
@@ -242,6 +300,77 @@ add_custom_command(
$/data
)
+# --- ping_pong (request/response latency measurement over data tracks) ---
+
+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(ping_pong_support PUBLIC
+ ${CMAKE_CURRENT_SOURCE_DIR}/ping_pong
+)
+
+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(PingPongPong
+ ping_pong/pong.cpp
+)
+
+target_include_directories(PingPongPong PRIVATE ${EXAMPLES_PRIVATE_INCLUDE_DIRS})
+
+target_link_libraries(PingPongPong
+ PRIVATE
+ ping_pong_support
+ livekit
+ spdlog::spdlog
+)
+
+# --- hello_livekit (minimal synthetic video + data publish / subscribe) ---
+
+add_executable(HelloLivekitSender
+ hello_livekit/sender.cpp
+)
+
+target_include_directories(HelloLivekitSender PRIVATE ${EXAMPLES_PRIVATE_INCLUDE_DIRS})
+
+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
+)
+
# --- bridge_human_robot examples (robot + human; use livekit_bridge and SDL3) ---
add_executable(BridgeRobot
@@ -398,4 +527,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/bridge_human_robot/human.cpp b/examples/bridge_human_robot/human.cpp
index 81989eb5..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", livekit::TrackSource::SOURCE_MICROPHONE,
+ "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", livekit::TrackSource::SOURCE_SCREENSHARE_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", livekit::TrackSource::SOURCE_CAMERA,
+ "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", livekit::TrackSource::SOURCE_SCREENSHARE,
+ "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/common/sdl_media_manager.cpp b/examples/common/sdl_media_manager.cpp
index f44c60ae..59b451ba 100644
--- a/examples/common/sdl_media_manager.cpp
+++ b/examples/common/sdl_media_manager.cpp
@@ -399,4 +399,4 @@ void SDLMediaManager::render() {
if (renderer_running_.load(std::memory_order_relaxed) && sdl_renderer_) {
sdl_renderer_->render();
}
-}
\ No newline at end of file
+}
diff --git a/examples/data_stamping/README.md b/examples/data_stamping/README.md
new file mode 100644
index 00000000..ed19758a
--- /dev/null
+++ b/examples/data_stamping/README.md
@@ -0,0 +1,28 @@
+# DataStamping
+
+Minimal two-process example:
+
+- `DataStampingProducer` publishes a synthetic camera track with
+ `metadata.user_timestamp_us` set on every frame.
+- `DataStampingProducer` also publishes a data track named `imu` with JSON
+ payloads shaped like:
+
+```json
+{
+ "angular": {"x": 0.0, "y": 0.0, "z": 0.0},
+ "linear": {"x": 0.0, "y": 0.0, "z": 0.0}
+}
+```
+
+The IMU values are simulated sine waves.
+
+- `DataStampingConsumer` only registers:
+ - `addOnDataFrameCallback()`
+ - `setOnVideoFrameCallback()`
+
+Run them in the same room with different participant identities:
+
+```sh
+LIVEKIT_URL=ws://localhost:7880 LIVEKIT_TOKEN= ./DataStampingProducer
+LIVEKIT_URL=ws://localhost:7880 LIVEKIT_TOKEN= ./DataStampingConsumer
+```
diff --git a/examples/data_stamping/consumer.cpp b/examples/data_stamping/consumer.cpp
new file mode 100644
index 00000000..ead84041
--- /dev/null
+++ b/examples/data_stamping/consumer.cpp
@@ -0,0 +1,134 @@
+/*
+ * 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
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "data_alignment_manager.h"
+#include "livekit/livekit.h"
+
+using namespace livekit;
+using livekit_examples::data_stamping::DataAlignmentManager;
+
+namespace {
+
+constexpr char kDataTrackName[] = "imu";
+constexpr char kProducerParticipantId[] = "producer";
+
+std::atomic g_running{true};
+
+void handleSignal(int) { g_running.store(false); }
+
+std::string getenvOrEmpty(const char *name) {
+ const char *value = std::getenv(name);
+ return value ? std::string(value) : std::string{};
+}
+
+void printUsage(const char *program) {
+ LK_LOG_INFO("Usage:\n {} \nor:\n LIVEKIT_URL=... "
+ "LIVEKIT_TOKEN=... {}",
+ program, program);
+}
+
+bool parseArgs(int argc, char *argv[], std::string &url, std::string &token) {
+ if (argc > 1) {
+ const std::string first = argv[1];
+ if (first == "-h" || first == "--help") {
+ return false;
+ }
+ }
+
+ url = getenvOrEmpty("LIVEKIT_URL");
+ token = getenvOrEmpty("LIVEKIT_TOKEN");
+
+ if (argc >= 3) {
+ url = argv[1];
+ token = argv[2];
+ }
+
+ return !(url.empty() || token.empty());
+}
+} // namespace
+
+int main(int argc, char *argv[]) {
+ std::string url;
+ std::string token;
+
+ if (!parseArgs(argc, argv, url, token)) {
+ printUsage(argv[0]);
+ return 1;
+ }
+
+ std::signal(SIGINT, handleSignal);
+#ifdef SIGTERM
+ std::signal(SIGTERM, handleSignal);
+#endif
+
+ std::shared_ptr alignment_manager =
+ std::make_shared(2'000'000);
+
+ livekit::initialize(livekit::LogLevel::Info, livekit::LogSink::kConsole);
+
+ std::unique_ptr room = std::make_unique();
+ RoomOptions options;
+ options.auto_subscribe = true;
+ options.dynacast = false;
+
+ LK_LOG_INFO("[consumer] connecting to {}", url);
+ if (!room->Connect(url, token, options)) {
+ LK_LOG_ERROR("[consumer] failed to connect");
+ return 1;
+ }
+ LK_LOG_INFO("[consumer] connected as {} to room '{}'",
+ room->localParticipant()->identity(), room->room_info().name);
+
+ room->setOnVideoFrameEventCallback(
+ kProducerParticipantId, TrackSource::SOURCE_CAMERA,
+ [alignment_manager](const VideoFrameEvent &event) {
+ if (!event.metadata || !event.metadata->user_timestamp_us.has_value()) {
+ return;
+ }
+ alignment_manager->addVideoFrame(*event.metadata->user_timestamp_us);
+ },
+ VideoStream::Options{});
+
+ room->addOnDataFrameCallback(
+ kProducerParticipantId, kDataTrackName,
+ [alignment_manager](const std::vector &,
+ std::optional user_timestamp) {
+ if (!user_timestamp.has_value()) {
+ return;
+ }
+
+ alignment_manager->addImuFrame(*user_timestamp);
+ });
+
+ while (g_running.load(std::memory_order_relaxed)) {
+ std::this_thread::sleep_for(std::chrono::milliseconds(50));
+ }
+
+ livekit::shutdown();
+ return 0;
+}
diff --git a/examples/data_stamping/data_alignment_manager.h b/examples/data_stamping/data_alignment_manager.h
new file mode 100644
index 00000000..a848c157
--- /dev/null
+++ b/examples/data_stamping/data_alignment_manager.h
@@ -0,0 +1,155 @@
+/*
+ * 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
+
+namespace livekit_examples::data_stamping {
+
+enum class FrameType {
+ Video,
+ Imu,
+};
+
+/**
+ * Aligns recent video and IMU frames by user timestamp.
+ *
+ * This demo stamps video with `VideoFrameMetadata::user_timestamp_us` and
+ * IMU data with the same microseconds-since-epoch convention. The constructor
+ * accepts an alignment window in nanoseconds for convenience, but comparisons
+ * are performed in microseconds to match the stamped values.
+ *
+ * The default window is +/- 5 ms. At 100 Hz, IMU samples arrive roughly every
+ * 10 ms, so a 5 ms half-window is a reasonable default for pairing each ~30 Hz
+ * video frame with its nearest IMU sample without being overly permissive.
+ */
+class DataAlignmentManager {
+public:
+ // Video comes in at ~30Hz, imu comes in at ~100Hz
+ static constexpr std::size_t kDefaultHistorySize = 30;
+ // We allow up to 5ms of difference between video and imu timestamps for them
+ // to be considered aligned.
+ static constexpr std::uint64_t kDefaultAlignmentWindowNs = 5'000'000;
+
+ explicit DataAlignmentManager(
+ std::uint64_t alignment_window_ns = kDefaultAlignmentWindowNs,
+ std::size_t history_size = kDefaultHistorySize)
+ : aligned_frame_count_(0), alignment_window_us_(std::max(
+ 1, (alignment_window_ns + 999) / 1000)),
+ history_size_(std::max(1, history_size)) {}
+
+ void addVideoFrame(std::uint64_t user_timestamp) {
+ addFrame(video_timestamps_, imu_timestamps_, user_timestamp,
+ FrameType::Video);
+ }
+
+ void addImuFrame(std::uint64_t user_timestamp) {
+ addFrame(imu_timestamps_, video_timestamps_, user_timestamp,
+ FrameType::Imu);
+ }
+
+ std::uint64_t numberAlignedDataFrames() const {
+ std::lock_guard lock(mutex_);
+ return aligned_frame_count_;
+ }
+
+private:
+ struct TimestampEntry {
+ std::uint64_t user_timestamp;
+ bool matched;
+ };
+
+ static std::uint64_t absoluteDifference(std::uint64_t lhs,
+ std::uint64_t rhs) {
+ return lhs >= rhs ? (lhs - rhs) : (rhs - lhs);
+ }
+
+ void addFrame(std::deque &own_timestamps,
+ std::deque &other_timestamps,
+ std::uint64_t user_timestamp, FrameType frame_type) {
+ std::lock_guard lock(mutex_);
+
+ own_timestamps.push_back(TimestampEntry{user_timestamp, false});
+ trim(own_timestamps);
+
+ const auto match_index = findBestMatch(other_timestamps, user_timestamp);
+ if (!match_index.has_value()) {
+ return;
+ }
+
+ own_timestamps.back().matched = true;
+ other_timestamps[*match_index].matched = true;
+ ++aligned_frame_count_;
+
+ LK_LOG_INFO(
+ "Aligned frames: {}={} {}={} "
+ "delta_us={}",
+ frame_type == FrameType::Video ? "Video" : "IMU", user_timestamp,
+ frame_type == FrameType::Video ? "IMU" : "Video",
+ other_timestamps[*match_index].user_timestamp,
+ absoluteDifference(user_timestamp,
+ other_timestamps[*match_index].user_timestamp));
+ }
+
+ void trim(std::deque ×tamps) const {
+ while (timestamps.size() > history_size_) {
+ timestamps.pop_front();
+ }
+ }
+
+ std::optional
+ findBestMatch(const std::deque ×tamps,
+ std::uint64_t user_timestamp) const {
+ std::optional best_index;
+ std::uint64_t best_delta = std::numeric_limits::max();
+
+ for (std::size_t i = 0; i < timestamps.size(); ++i) {
+ const auto &candidate = timestamps[i];
+ if (candidate.matched) {
+ continue;
+ }
+
+ const std::uint64_t delta =
+ absoluteDifference(candidate.user_timestamp, user_timestamp);
+ if (delta > alignment_window_us_ || delta >= best_delta) {
+ continue;
+ }
+
+ best_index = i;
+ best_delta = delta;
+ }
+
+ return best_index;
+ }
+
+ mutable std::mutex mutex_;
+ std::deque video_timestamps_;
+ std::deque imu_timestamps_;
+ std::uint64_t aligned_frame_count_;
+ std::uint64_t alignment_window_us_;
+ std::size_t history_size_;
+};
+
+} // namespace livekit_examples::data_stamping
diff --git a/examples/data_stamping/producer.cpp b/examples/data_stamping/producer.cpp
new file mode 100644
index 00000000..c0270712
--- /dev/null
+++ b/examples/data_stamping/producer.cpp
@@ -0,0 +1,266 @@
+/*
+ * 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
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "livekit/livekit.h"
+
+using namespace livekit;
+
+namespace {
+
+constexpr char kVideoTrackName[] = "stamped-camera";
+constexpr char kDataTrackName[] = "imu";
+constexpr int kFrameWidth = 640;
+constexpr int kFrameHeight = 360;
+constexpr int kVideoIntervalMs = 33; // ~30Hz
+constexpr int kDataIntervalMs = 10; // 100Hz
+constexpr double kWaveHz = 0.5;
+constexpr double kAngularAmplitude = 1.0;
+constexpr double kLinearAmplitude = 2.0;
+
+std::atomic g_running{true};
+
+void handleSignal(int) { g_running.store(false); }
+
+std::string getenvOrEmpty(const char *name) {
+ const char *value = std::getenv(name);
+ return value ? std::string(value) : std::string{};
+}
+
+std::uint64_t nowEpochUs() {
+ return static_cast(
+ std::chrono::duration_cast(
+ std::chrono::system_clock::now().time_since_epoch())
+ .count());
+}
+
+double sampleWave(double elapsed_seconds, double phase) {
+ constexpr double kTau = 6.28318530717958647692;
+ return std::sin((kTau * kWaveHz * elapsed_seconds) + phase);
+}
+
+std::string buildImuJson(double elapsed_seconds) {
+ const double angular_x = kAngularAmplitude * sampleWave(elapsed_seconds, 0.0);
+ const double angular_y =
+ kAngularAmplitude * sampleWave(elapsed_seconds, 2.09439510239);
+ const double angular_z =
+ kAngularAmplitude * sampleWave(elapsed_seconds, 4.18879020479);
+
+ const double linear_x = kLinearAmplitude * sampleWave(elapsed_seconds, 0.5);
+ const double linear_y = kLinearAmplitude * sampleWave(elapsed_seconds, 1.5);
+ const double linear_z = kLinearAmplitude * sampleWave(elapsed_seconds, 2.5);
+
+ std::ostringstream stream;
+ stream << std::fixed << std::setprecision(4) << "{"
+ << "\"angular\":{\"x\":" << angular_x << ",\"y\":" << angular_y
+ << ",\"z\":" << angular_z << "},"
+ << "\"linear\":{\"x\":" << linear_x << ",\"y\":" << linear_y
+ << ",\"z\":" << linear_z << "}"
+ << "}";
+ return stream.str();
+}
+
+void fillFrame(VideoFrame &frame, double elapsed_seconds) {
+ const double brightness = 0.5 + 0.5 * sampleWave(elapsed_seconds, 0.0);
+ const std::uint8_t blue =
+ static_cast(40 + (brightness * 120.0));
+ const std::uint8_t green =
+ static_cast(60 + (brightness * 140.0));
+ const std::uint8_t red = static_cast(80 + (brightness * 100.0));
+
+ std::uint8_t *data = frame.data();
+ for (std::size_t i = 0; i < frame.dataSize(); i += 4) {
+ data[i + 0] = blue;
+ data[i + 1] = green;
+ data[i + 2] = red;
+ data[i + 3] = 255;
+ }
+}
+
+void printUsage(const char *program) {
+ LK_LOG_INFO("Usage:\n {} \nor:\n LIVEKIT_URL=... "
+ "LIVEKIT_TOKEN=... {}",
+ program, program);
+}
+
+bool parseArgs(int argc, char *argv[], std::string &url, std::string &token) {
+ if (argc > 1) {
+ const std::string first = argv[1];
+ if (first == "-h" || first == "--help") {
+ return false;
+ }
+ }
+
+ url = getenvOrEmpty("LIVEKIT_URL");
+ token = getenvOrEmpty("LIVEKIT_TOKEN");
+
+ if (argc >= 3) {
+ url = argv[1];
+ token = argv[2];
+ }
+
+ return !(url.empty() || token.empty());
+}
+
+void runVideoPublisher(
+ const std::shared_ptr &video_source,
+ const std::chrono::steady_clock::time_point &start_time) {
+
+ uint64_t frame_count = 0;
+ VideoFrame frame =
+ VideoFrame::create(kFrameWidth, kFrameHeight, VideoBufferType::BGRA);
+ auto next_frame_at = std::chrono::steady_clock::now();
+
+ while (g_running.load(std::memory_order_relaxed)) {
+ const auto now = std::chrono::steady_clock::now();
+ const double elapsed_seconds =
+ std::chrono::duration(now - start_time).count();
+
+ fillFrame(frame, elapsed_seconds);
+
+ VideoCaptureOptions capture_options;
+ capture_options.timestamp_us = static_cast(
+ std::chrono::duration_cast(now - start_time)
+ .count());
+ capture_options.rotation = VideoRotation::VIDEO_ROTATION_0;
+ capture_options.metadata = VideoFrameMetadata{};
+ capture_options.metadata->user_timestamp_us = nowEpochUs();
+
+ video_source->captureFrame(frame, capture_options);
+ if (frame_count % 300 == 0) {
+ LK_LOG_INFO("[producer] captured frame {} timestamp={} user_timestamp={}",
+ frame_count, capture_options.timestamp_us,
+ capture_options.metadata->user_timestamp_us.value_or(0));
+ }
+ ++frame_count;
+ next_frame_at += std::chrono::milliseconds(kVideoIntervalMs);
+ std::this_thread::sleep_until(next_frame_at);
+ }
+}
+
+void runImuPublisher(const std::shared_ptr &data_track,
+ const std::chrono::steady_clock::time_point &start_time) {
+ uint64_t frame_count = 0;
+ auto next_frame_at = std::chrono::steady_clock::now();
+
+ while (g_running.load(std::memory_order_relaxed)) {
+ const auto now = std::chrono::steady_clock::now();
+ const double elapsed_seconds =
+ std::chrono::duration(now - start_time).count();
+
+ const std::string payload = buildImuJson(elapsed_seconds);
+ auto push_result = data_track->tryPush(
+ std::vector(payload.begin(), payload.end()),
+ nowEpochUs());
+ if (!push_result) {
+ const auto &error = push_result.error();
+ LK_LOG_ERROR("[producer] failed to push data frame: {}", error.message);
+ }
+
+ if (frame_count % 1000 == 0) {
+ LK_LOG_INFO("[producer] pushed IMU frame {} timestamp={}", frame_count,
+ nowEpochUs());
+ }
+ ++frame_count;
+ next_frame_at += std::chrono::milliseconds(kDataIntervalMs);
+ std::this_thread::sleep_until(next_frame_at);
+ }
+}
+
+} // namespace
+
+int main(int argc, char *argv[]) {
+ std::string url;
+ std::string token;
+
+ if (!parseArgs(argc, argv, url, token)) {
+ printUsage(argv[0]);
+ return 1;
+ }
+
+ std::signal(SIGINT, handleSignal);
+#ifdef SIGTERM
+ std::signal(SIGTERM, handleSignal);
+#endif
+
+ livekit::initialize(livekit::LogLevel::Info, livekit::LogSink::kConsole);
+
+ std::unique_ptr room = std::make_unique();
+ RoomOptions options;
+ options.auto_subscribe = true;
+ options.dynacast = false;
+
+ LK_LOG_INFO("[producer] connecting to {}", url);
+ if (!room->Connect(url, token, options)) {
+ LK_LOG_ERROR("[producer] failed to connect");
+ livekit::shutdown();
+ return 1;
+ }
+
+ auto *participant = room->localParticipant();
+ assert(participant != nullptr);
+ LK_LOG_INFO("[producer] connected as {} to room '{}'",
+ participant->identity(), room->room_info().name);
+
+ auto video_source = std::make_shared(kFrameWidth, kFrameHeight);
+ auto video_track =
+ LocalVideoTrack::createLocalVideoTrack(kVideoTrackName, video_source);
+
+ auto data_track_result = participant->publishDataTrack(kDataTrackName);
+ if (!data_track_result) {
+ const auto &error = data_track_result.error();
+ LK_LOG_ERROR("[producer] failed to publish data track: {}", error.message);
+ return 1;
+ }
+ auto data_track = data_track_result.value();
+
+ try {
+ TrackPublishOptions publish_options;
+ publish_options.source = TrackSource::SOURCE_CAMERA;
+ publish_options.packet_trailer_features.user_timestamp = true;
+ participant->publishTrack(video_track, publish_options);
+
+ LK_LOG_INFO("[producer] publishing stamped video track '{}' and data track "
+ "'{}'",
+ kVideoTrackName, kDataTrackName);
+
+ const auto steady_start = std::chrono::steady_clock::now();
+ std::thread video_thread(runVideoPublisher, video_source, steady_start);
+ std::thread imu_thread(runImuPublisher, data_track, steady_start);
+
+ video_thread.join();
+ imu_thread.join();
+ } catch (const std::exception &error) {
+ LK_LOG_ERROR("[producer] error: {}", error.what());
+ return 1;
+ }
+
+ room.reset(); // reset the room to cleanup pubs/subs/rpc/etc before shutdown
+ livekit::shutdown();
+ return 0;
+}
diff --git a/examples/hello_livekit/receiver.cpp b/examples/hello_livekit/receiver.cpp
new file mode 100644
index 00000000..bc05e5f2
--- /dev/null
+++ b/examples/hello_livekit/receiver.cpp
@@ -0,0 +1,130 @@
+/*
+ * 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";
+constexpr const char *kVideoTrackName = "camera0";
+
+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, kVideoTrackName,
+ [&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 video track '{}' + data track '{}'; "
+ "Ctrl-C to exit",
+ kVideoTrackName, 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..253091fd
--- /dev/null
+++ b/examples/hello_livekit/sender.cpp
@@ -0,0 +1,142 @@
+/*
+ * 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);
+
+ auto publish_result = lp->publishDataTrack(kDataTrackName);
+ if (!publish_result) {
+ const auto &error = publish_result.error();
+ LK_LOG_ERROR("Failed to publish data track: code={} message={}",
+ static_cast(error.code), 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;
+
+ 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();
+ 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={} message={}",
+ static_cast(error.code), error.message);
+ }
+
+ ++count;
+ std::this_thread::sleep_for(std::chrono::milliseconds(100));
+ }
+
+ LK_LOG_INFO("[sender] Disconnecting");
+ room.reset();
+
+ livekit::shutdown();
+ return 0;
+}
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..c46f941c
--- /dev/null
+++ b/examples/ping_pong/ping.cpp
@@ -0,0 +1,209 @@
+/*
+ * 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={} message={}",
+ static_cast(error.code), 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={} message={}",
+ static_cast(error.code), 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..34bdbd54
--- /dev/null
+++ b/examples/ping_pong/pong.cpp
@@ -0,0 +1,147 @@
+/*
+ * 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={} message={}",
+ static_cast(error.code), 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={} message={}",
+ static_cast(error.code), 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_joystick/receiver.cpp b/examples/simple_joystick/receiver.cpp
index aa9814f3..d62785a4 100644
--- a/examples/simple_joystick/receiver.cpp
+++ b/examples/simple_joystick/receiver.cpp
@@ -108,8 +108,9 @@ int main(int argc, char *argv[]) {
if (!g_running.load()) {
std::cout << "[Receiver] Interrupted by signal. Shutting down.\n";
} else if (!g_sender_connected.load()) {
- std::cerr << "[Receiver] Timed out after 2 minutes with no sender connection. "
- << "Exiting as failure.\n";
+ std::cerr
+ << "[Receiver] Timed out after 2 minutes with no sender connection. "
+ << "Exiting as failure.\n";
room->setDelegate(nullptr);
room.reset();
livekit::shutdown();
diff --git a/examples/simple_joystick/sender.cpp b/examples/simple_joystick/sender.cpp
index 9af5b4ee..a235c3da 100644
--- a/examples/simple_joystick/sender.cpp
+++ b/examples/simple_joystick/sender.cpp
@@ -165,10 +165,12 @@ int main(int argc, char *argv[]) {
bool receiver_present = (room->remoteParticipant("robot") != nullptr);
if (receiver_present && !receiver_connected) {
- std::cout << "[Sender] Receiver connected! Use keys to send commands.\n";
+ std::cout
+ << "[Sender] Receiver connected! Use keys to send commands.\n";
receiver_connected = true;
} else if (!receiver_present && receiver_connected) {
- std::cout << "[Sender] Receiver disconnected. Waiting for reconnect...\n";
+ std::cout
+ << "[Sender] Receiver disconnected. Waiting for reconnect...\n";
receiver_connected = false;
}
}
@@ -236,7 +238,8 @@ int main(int argc, char *argv[]) {
simple_joystick::JoystickCommand cmd{x, y, z};
std::string payload = simple_joystick::joystick_to_json(cmd);
- std::cout << "[Sender] Sending: x=" << x << " y=" << y << " z=" << z << "\n";
+ std::cout << "[Sender] Sending: x=" << x << " y=" << y << " z=" << z
+ << "\n";
try {
std::string response =
@@ -246,7 +249,8 @@ int main(int argc, char *argv[]) {
std::cerr << "[Sender] RPC error: " << e.message() << "\n";
if (static_cast(e.code()) ==
RpcError::ErrorCode::RECIPIENT_DISCONNECTED) {
- std::cout << "[Sender] Receiver disconnected. Waiting for reconnect...\n";
+ std::cout
+ << "[Sender] Receiver disconnected. Waiting for reconnect...\n";
receiver_connected = false;
}
} catch (const std::exception &e) {
diff --git a/examples/tokens/README.md b/examples/tokens/README.md
new file mode 100644
index 00000000..ebed99c1
--- /dev/null
+++ b/examples/tokens/README.md
@@ -0,0 +1,8 @@
+# Overview
+Examples of generating tokens
+
+## gen_and_set.bash
+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/gen_and_set.bash b/examples/tokens/gen_and_set.bash
new file mode 100755
index 00000000..b933a24f
--- /dev/null
+++ b/examples/tokens/gen_and_set.bash
@@ -0,0 +1,169 @@
+#!/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 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_data_track_test_tokens.bash b/examples/tokens/set_data_track_test_tokens.bash
new file mode 100755
index 00000000..1cc8bb56
--- /dev/null
+++ b/examples/tokens/set_data_track_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_data_track_test_tokens.bash
+# eval "$(bash examples/tokens/set_data_track_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_data_track_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_data_track_test_tokens.bash: for this shell run: source $0 or: eval \"\$(bash $0 ...)\"" >&2
+fi
diff --git a/examples/user_timestamped_video/README.md b/examples/user_timestamped_video/README.md
new file mode 100644
index 00000000..ca9e8814
--- /dev/null
+++ b/examples/user_timestamped_video/README.md
@@ -0,0 +1,53 @@
+# UserTimestampedVideo
+
+This example is split into two executables and can demonstrate all four
+producer/consumer combinations:
+
+- `UserTimestampedVideoProducer` publishes a synthetic camera track and stamps
+ each frame with `VideoCaptureOptions::metadata.user_timestamp_us`.
+- `UserTimestampedVideoConsumer` subscribes to remote camera frames with
+ either the rich or legacy callback path.
+
+Run them in the same room with different participant identities:
+
+```sh
+LIVEKIT_URL=ws://localhost:7880 LIVEKIT_TOKEN= ./UserTimestampedVideoProducer
+LIVEKIT_URL=ws://localhost:7880 LIVEKIT_TOKEN= ./UserTimestampedVideoConsumer
+```
+
+Flags:
+
+- Producer default: sends user timestamps
+- Producer `--without-user-timestamp`: does not send user timestamps
+- Consumer default: reads user timestamps through `setOnVideoFrameEventCallback`
+- Consumer `--ignore-user-timestamp`: ignores metadata through the legacy
+ `setOnVideoFrameCallback`
+
+Matrix:
+
+```sh
+# 1. Producer sends, consumer reads
+./UserTimestampedVideoProducer
+./UserTimestampedVideoConsumer
+
+# 2. Producer sends, consumer ignores
+./UserTimestampedVideoProducer
+./UserTimestampedVideoConsumer --ignore-user-timestamp
+
+# 3. Producer does not send, consumer ignores
+./UserTimestampedVideoProducer --without-user-timestamp
+./UserTimestampedVideoConsumer --ignore-user-timestamp
+
+# 4. Producer does not send, consumer reads
+./UserTimestampedVideoProducer --without-user-timestamp
+./UserTimestampedVideoConsumer
+```
+
+Timestamp note:
+
+- `user_ts_us` is application metadata and is the value to compare end to end.
+- `capture_ts_us` on the producer is the timestamp submitted to `captureFrame`.
+- `capture_ts_us` on the consumer is the received WebRTC frame timestamp.
+- Producer and consumer `capture_ts_us` values are not expected to match exactly,
+ because WebRTC may translate frame timestamps onto its own internal
+ capture-time timeline before delivery.
diff --git a/examples/user_timestamped_video/consumer.cpp b/examples/user_timestamped_video/consumer.cpp
new file mode 100644
index 00000000..191a1156
--- /dev/null
+++ b/examples/user_timestamped_video/consumer.cpp
@@ -0,0 +1,247 @@
+/*
+ * 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.
+ */
+
+/*
+ * UserTimestampedVideoConsumer
+ *
+ * Receives remote camera frames via Room::setOnVideoFrameEventCallback() and
+ * logs any VideoFrameMetadata::user_timestamp_us values that arrive.
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "livekit/livekit.h"
+
+using namespace livekit;
+
+namespace {
+
+std::atomic g_running{true};
+
+void handleSignal(int) { g_running.store(false); }
+
+std::string getenvOrEmpty(const char *name) {
+ const char *value = std::getenv(name);
+ return value ? std::string(value) : std::string{};
+}
+
+std::string
+formatUserTimestamp(const std::optional &metadata) {
+ if (!metadata || !metadata->user_timestamp_us.has_value()) {
+ return "n/a";
+ }
+
+ return std::to_string(*metadata->user_timestamp_us);
+}
+
+void printUsage(const char *program) {
+ std::cerr << "Usage:\n"
+ << " " << program << " [--ignore-user-timestamp]\n"
+ << "or:\n"
+ << " LIVEKIT_URL=... LIVEKIT_TOKEN=... " << program
+ << " [--ignore-user-timestamp]\n";
+}
+
+bool parseArgs(int argc, char *argv[], std::string &url, std::string &token,
+ bool &read_user_timestamp) {
+ read_user_timestamp = true;
+ std::vector positional;
+
+ for (int i = 1; i < argc; ++i) {
+ const std::string arg = argv[i];
+ if (arg == "-h" || arg == "--help") {
+ return false;
+ }
+ if (arg == "--ignore-user-timestamp") {
+ read_user_timestamp = false;
+ continue;
+ }
+ if (arg == "--read-user-timestamp") {
+ read_user_timestamp = true;
+ continue;
+ }
+
+ positional.push_back(arg);
+ }
+
+ url = getenvOrEmpty("LIVEKIT_URL");
+ token = getenvOrEmpty("LIVEKIT_TOKEN");
+
+ if (positional.size() >= 2) {
+ url = positional[0];
+ token = positional[1];
+ }
+
+ return !(url.empty() || token.empty());
+}
+
+class UserTimestampedVideoConsumerDelegate : public RoomDelegate {
+public:
+ UserTimestampedVideoConsumerDelegate(Room &room, bool read_user_timestamp)
+ : room_(room), read_user_timestamp_(read_user_timestamp) {}
+
+ void registerExistingParticipants() {
+ for (const auto &participant : room_.remoteParticipants()) {
+ if (participant) {
+ registerRemoteCameraCallback(participant->identity());
+ }
+ }
+ }
+
+ void onParticipantConnected(Room &,
+ const ParticipantConnectedEvent &event) override {
+ if (!event.participant) {
+ return;
+ }
+
+ std::cout << "[consumer] participant connected: "
+ << event.participant->identity() << "\n";
+ registerRemoteCameraCallback(event.participant->identity());
+ }
+
+ void onParticipantDisconnected(
+ Room &, const ParticipantDisconnectedEvent &event) override {
+ if (!event.participant) {
+ return;
+ }
+
+ const std::string identity = event.participant->identity();
+ room_.clearOnVideoFrameCallback(identity, TrackSource::SOURCE_CAMERA);
+
+ {
+ std::lock_guard lock(mutex_);
+ registered_identities_.erase(identity);
+ }
+
+ std::cout << "[consumer] participant disconnected: " << identity << "\n";
+ }
+
+private:
+ void registerRemoteCameraCallback(const std::string &identity) {
+ {
+ std::lock_guard lock(mutex_);
+ if (!registered_identities_.insert(identity).second) {
+ return;
+ }
+ }
+
+ VideoStream::Options stream_options;
+ stream_options.format = VideoBufferType::RGBA;
+
+ if (read_user_timestamp_) {
+ room_.setOnVideoFrameEventCallback(
+ identity, TrackSource::SOURCE_CAMERA,
+ [identity](const VideoFrameEvent &event) {
+ std::cout << "[consumer] from=" << identity
+ << " size=" << event.frame.width() << "x"
+ << event.frame.height()
+ << " capture_ts_us=" << event.timestamp_us
+ << " user_ts_us=" << formatUserTimestamp(event.metadata)
+ << " rotation=" << static_cast(event.rotation)
+ << "\n";
+ },
+ stream_options);
+ } else {
+ room_.setOnVideoFrameCallback(
+ identity, TrackSource::SOURCE_CAMERA,
+ [identity](const VideoFrame &frame, const std::int64_t timestamp_us) {
+ std::cout << "[consumer] from=" << identity
+ << " size=" << frame.width() << "x" << frame.height()
+ << " capture_ts_us=" << timestamp_us
+ << " user_ts_us=ignored\n";
+ },
+ stream_options);
+ }
+
+ std::cout << "[consumer] listening for camera frames from " << identity
+ << " with user timestamp "
+ << (read_user_timestamp_ ? "enabled" : "ignored") << "\n";
+ }
+
+ Room &room_;
+ bool read_user_timestamp_;
+ std::mutex mutex_;
+ std::unordered_set registered_identities_;
+};
+
+} // namespace
+
+int main(int argc, char *argv[]) {
+ std::string url;
+ std::string token;
+ bool read_user_timestamp = true;
+
+ if (!parseArgs(argc, argv, url, token, read_user_timestamp)) {
+ printUsage(argv[0]);
+ return 1;
+ }
+
+ std::signal(SIGINT, handleSignal);
+#ifdef SIGTERM
+ std::signal(SIGTERM, handleSignal);
+#endif
+
+ livekit::initialize(livekit::LogLevel::Info, livekit::LogSink::kConsole);
+ int exit_code = 0;
+
+ {
+ Room room;
+ RoomOptions options;
+ options.auto_subscribe = true;
+ options.dynacast = false;
+
+ UserTimestampedVideoConsumerDelegate delegate(room, read_user_timestamp);
+ room.setDelegate(&delegate);
+
+ std::cout << "[consumer] connecting to " << url << "\n";
+ if (!room.Connect(url, token, options)) {
+ std::cerr << "[consumer] failed to connect\n";
+ exit_code = 1;
+ } else {
+ std::cout << "[consumer] connected as "
+ << room.localParticipant()->identity() << " to room '"
+ << room.room_info().name << "' with user timestamp "
+ << (read_user_timestamp ? "enabled" : "ignored") << "\n";
+
+ delegate.registerExistingParticipants();
+
+ while (g_running.load(std::memory_order_relaxed)) {
+ std::this_thread::sleep_for(std::chrono::milliseconds(50));
+ }
+
+ for (const auto &participant : room.remoteParticipants()) {
+ if (participant) {
+ room.clearOnVideoFrameCallback(participant->identity(),
+ TrackSource::SOURCE_CAMERA);
+ }
+ }
+ }
+
+ room.setDelegate(nullptr);
+ }
+
+ livekit::shutdown();
+ return exit_code;
+}
diff --git a/examples/user_timestamped_video/producer.cpp b/examples/user_timestamped_video/producer.cpp
new file mode 100644
index 00000000..98473682
--- /dev/null
+++ b/examples/user_timestamped_video/producer.cpp
@@ -0,0 +1,219 @@
+/*
+ * 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.
+ */
+
+/*
+ * UserTimestampedVideoProducer
+ *
+ * Publishes a synthetic camera track and stamps each frame with
+ * VideoCaptureOptions::metadata.user_timestamp_us.
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "livekit/livekit.h"
+
+using namespace livekit;
+
+namespace {
+
+constexpr int kFrameWidth = 640;
+constexpr int kFrameHeight = 360;
+constexpr int kFrameIntervalMs = 200;
+
+std::atomic g_running{true};
+
+void handleSignal(int) { g_running.store(false); }
+
+std::string getenvOrEmpty(const char *name) {
+ const char *value = std::getenv(name);
+ return value ? std::string(value) : std::string{};
+}
+
+std::uint64_t nowEpochUs() {
+ return static_cast(
+ std::chrono::duration_cast(
+ std::chrono::system_clock::now().time_since_epoch())
+ .count());
+}
+
+void fillFrame(VideoFrame &frame, std::uint32_t frame_index) {
+ const std::uint8_t blue = static_cast((frame_index * 7) % 255);
+ const std::uint8_t green =
+ static_cast((frame_index * 13) % 255);
+ const std::uint8_t red = static_cast((frame_index * 29) % 255);
+
+ std::uint8_t *data = frame.data();
+ for (std::size_t i = 0; i < frame.dataSize(); i += 4) {
+ data[i + 0] = blue;
+ data[i + 1] = green;
+ data[i + 2] = red;
+ data[i + 3] = 255;
+ }
+}
+
+void printUsage(const char *program) {
+ std::cerr << "Usage:\n"
+ << " " << program
+ << " [--without-user-timestamp]\n"
+ << "or:\n"
+ << " LIVEKIT_URL=... LIVEKIT_TOKEN=... " << program
+ << " [--without-user-timestamp]\n";
+}
+
+bool parseArgs(int argc, char *argv[], std::string &url, std::string &token,
+ bool &send_user_timestamp) {
+ send_user_timestamp = true;
+ std::vector positional;
+
+ for (int i = 1; i < argc; ++i) {
+ const std::string arg = argv[i];
+ if (arg == "-h" || arg == "--help") {
+ return false;
+ }
+ if (arg == "--without-user-timestamp") {
+ send_user_timestamp = false;
+ continue;
+ }
+ if (arg == "--with-user-timestamp") {
+ send_user_timestamp = true;
+ continue;
+ }
+
+ positional.push_back(arg);
+ }
+
+ url = getenvOrEmpty("LIVEKIT_URL");
+ token = getenvOrEmpty("LIVEKIT_TOKEN");
+
+ if (positional.size() >= 2) {
+ url = positional[0];
+ token = positional[1];
+ }
+
+ return !(url.empty() || token.empty());
+}
+
+} // namespace
+
+int main(int argc, char *argv[]) {
+ std::string url;
+ std::string token;
+ bool send_user_timestamp = true;
+
+ if (!parseArgs(argc, argv, url, token, send_user_timestamp)) {
+ printUsage(argv[0]);
+ return 1;
+ }
+
+ std::signal(SIGINT, handleSignal);
+#ifdef SIGTERM
+ std::signal(SIGTERM, handleSignal);
+#endif
+
+ livekit::initialize(livekit::LogLevel::Info, livekit::LogSink::kConsole);
+ int exit_code = 0;
+
+ {
+ Room room;
+ RoomOptions options;
+ options.auto_subscribe = true;
+ options.dynacast = false;
+
+ std::cout << "[producer] connecting to " << url << "\n";
+ if (!room.Connect(url, token, options)) {
+ std::cerr << "[producer] failed to connect\n";
+ exit_code = 1;
+ } else {
+ std::cout << "[producer] connected as "
+ << room.localParticipant()->identity() << " to room '"
+ << room.room_info().name << "'\n";
+
+ auto source = std::make_shared(kFrameWidth, kFrameHeight);
+ auto track =
+ LocalVideoTrack::createLocalVideoTrack("timestamped-camera", source);
+
+ try {
+ TrackPublishOptions publish_options;
+ publish_options.source = TrackSource::SOURCE_CAMERA;
+ publish_options.packet_trailer_features.user_timestamp =
+ send_user_timestamp;
+
+ room.localParticipant()->publishTrack(track, publish_options);
+ std::cout << "[producer] published camera track with user timestamp "
+ << (send_user_timestamp ? "enabled" : "disabled") << "\n";
+
+ VideoFrame frame = VideoFrame::create(kFrameWidth, kFrameHeight,
+ VideoBufferType::BGRA);
+ const auto capture_start = std::chrono::steady_clock::now();
+ std::uint32_t frame_index = 0;
+ auto next_frame_at = std::chrono::steady_clock::now();
+
+ while (g_running.load(std::memory_order_relaxed)) {
+ fillFrame(frame, frame_index);
+
+ VideoCaptureOptions capture_options;
+
+ // a steady_clock to align with other data/video frames
+ capture_options.timestamp_us = static_cast(
+ std::chrono::duration_cast(
+ std::chrono::steady_clock::now() - capture_start)
+ .count());
+ capture_options.rotation = VideoRotation::VIDEO_ROTATION_0;
+ if (send_user_timestamp) {
+ capture_options.metadata = VideoFrameMetadata{};
+ capture_options.metadata->user_timestamp_us = nowEpochUs();
+ }
+
+ source->captureFrame(frame, capture_options);
+
+ if (frame_index % 5 == 0) {
+ std::cout << "[producer] frame=" << frame_index
+ << " capture_ts_us=" << capture_options.timestamp_us
+ << " user_ts_us="
+ << (send_user_timestamp
+ ? std::to_string(
+ *capture_options.metadata->user_timestamp_us)
+ : std::string("disabled"))
+ << "\n";
+ }
+
+ ++frame_index;
+ next_frame_at += std::chrono::milliseconds(kFrameIntervalMs);
+ std::this_thread::sleep_until(next_frame_at);
+ }
+ } catch (const std::exception &error) {
+ std::cerr << "[producer] error: " << error.what() << "\n";
+ exit_code = 1;
+ }
+
+ if (track->publication()) {
+ room.localParticipant()->unpublishTrack(track->publication()->sid());
+ }
+ }
+ }
+
+ livekit::shutdown();
+ return exit_code;
+}
diff --git a/include/livekit/data_frame.h b/include/livekit/data_frame.h
new file mode 100644
index 00000000..ac830fee
--- /dev/null
+++ b/include/livekit/data_frame.h
@@ -0,0 +1,60 @@
+/*
+ * 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 {
+
+namespace proto {
+class DataTrackFrame;
+} // namespace proto
+
+/**
+ * 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;
+ 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) {}
+
+ static DataFrame fromOwnedInfo(const proto::DataTrackFrame &owned);
+};
+
+} // namespace livekit
diff --git a/include/livekit/data_track_error.h b/include/livekit/data_track_error.h
new file mode 100644
index 00000000..5ec8f97f
--- /dev/null
+++ b/include/livekit/data_track_error.h
@@ -0,0 +1,88 @@
+/*
+ * 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 PublishDataTrackError;
+class LocalDataTrackTryPushError;
+class SubscribeDataTrackError;
+} // namespace proto
+
+enum class PublishDataTrackErrorCode : std::uint32_t {
+ UNKNOWN = 0,
+ INVALID_HANDLE = 1,
+ DUPLICATE_NAME = 2,
+ TIMEOUT = 3,
+ DISCONNECTED = 4,
+ NOT_ALLOWED = 5,
+ INVALID_NAME = 6,
+ LIMIT_REACHED = 7,
+ PROTOCOL_ERROR = 8,
+ INTERNAL = 9,
+};
+
+struct PublishDataTrackError {
+ PublishDataTrackErrorCode code{PublishDataTrackErrorCode::UNKNOWN};
+ std::string message;
+
+ static PublishDataTrackError
+ fromProto(const proto::PublishDataTrackError &error);
+};
+
+enum class LocalDataTrackTryPushErrorCode : std::uint32_t {
+ UNKNOWN = 0,
+ INVALID_HANDLE = 1,
+ TRACK_UNPUBLISHED = 2,
+ QUEUE_FULL = 3,
+ INTERNAL = 4,
+};
+
+struct LocalDataTrackTryPushError {
+ LocalDataTrackTryPushErrorCode code{LocalDataTrackTryPushErrorCode::UNKNOWN};
+ std::string message;
+
+ static LocalDataTrackTryPushError
+ fromProto(const proto::LocalDataTrackTryPushError &error);
+};
+
+enum class SubscribeDataTrackErrorCode : std::uint32_t {
+ UNKNOWN = 0,
+ INVALID_HANDLE = 1,
+ UNPUBLISHED = 2,
+ TIMEOUT = 3,
+ DISCONNECTED = 4,
+ PROTOCOL_ERROR = 5,
+ INTERNAL = 6,
+};
+
+struct SubscribeDataTrackError {
+ SubscribeDataTrackErrorCode code{SubscribeDataTrackErrorCode::UNKNOWN};
+ std::string message;
+
+ static SubscribeDataTrackError
+ fromProto(const proto::SubscribeDataTrackError &error);
+};
+
+} // namespace livekit
+
+#endif // LIVEKIT_DATA_TRACK_ERROR_H
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..cfac2b24
--- /dev/null
+++ b/include/livekit/data_track_subscription.h
@@ -0,0 +1,130 @@
+/*
+ * 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
+#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 stored internally.
+ *
+ * Dropping (destroying) the subscription automatically unsubscribes from the
+ * remote track by releasing the underlying FFI handle.
+ *
+ * Typical usage:
+ *
+ * auto sub_result = remoteDataTrack->subscribe();
+ * if (sub_result) {
+ * auto sub = sub_result.value();
+ * DataFrame frame;
+ * while (sub->read(frame)) {
+ * // process frame.payload
+ * }
+ * }
+ */
+class DataTrackSubscription {
+public:
+ struct Options {
+ /// Maximum frames buffered on the Rust side. Rust defaults to 16.
+ std::optional buffer_size{std::nullopt};
+ };
+
+ virtual ~DataTrackSubscription();
+
+ DataTrackSubscription(const DataTrackSubscription &) = delete;
+ DataTrackSubscription &operator=(const DataTrackSubscription &) = delete;
+ // 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
+ * 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);
+
+ /// FFI event handler, called by FfiClient.
+ void onFfiEvent(const proto::FfiEvent &event);
+
+ /// Push a received DataFrame to the internal storage.
+ 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_;
+
+ /** 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};
+
+ /** 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..bfb8e4c1
--- /dev/null
+++ b/include/livekit/local_data_track.h
@@ -0,0 +1,110 @@
+/*
+ * 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_error.h"
+#include "livekit/data_track_info.h"
+#include "livekit/ffi_handle.h"
+#include "livekit/result.h"
+
+#include
+#include
+#include
+#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 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:
+ ~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 success on delivery acceptance, or a typed error describing why
+ * the frame could not be queued.
+ */
+ Result tryPush(const DataFrame &frame);
+
+ /**
+ * 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(std::vector &&payload,
+ 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;
+
+ 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..a7b855d2 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,32 @@ 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
+ * LocalDataTrack::unpublishDataTrack() or
+ * LocalParticipant::unpublishDataTrack().
+ *
+ * @param name Unique track name visible to other participants.
+ * @return The published track on success, or a typed error describing why
+ * publication failed.
+ */
+ Result, PublishDataTrackError>
+ publishDataTrack(const std::string &name);
+
+ /**
+ * Unpublish a data track from the room.
+ *
+ * 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. Null is ignored.
+ */
+ void unpublishDataTrack(const std::shared_ptr &track);
+
/**
* Initiate an RPC call to a remote participant.
*
@@ -244,6 +277,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/remote_data_track.h b/include/livekit/remote_data_track.h
new file mode 100644
index 00000000..02a76094
--- /dev/null
+++ b/include/livekit/remote_data_track.h
@@ -0,0 +1,101 @@
+/*
+ * 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_error.h"
+#include "livekit/data_track_info.h"
+#include "livekit/data_track_subscription.h"
+#include "livekit/ffi_handle.h"
+#include "livekit/result.h"
+
+#include
+#include
+
+namespace livekit {
+
+namespace proto {
+class OwnedRemoteDataTrack;
+}
+
+/**
+ * Represents a data track published by a remote participant.
+ *
+ * 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::onDataTrackPublished callback:
+ * auto sub_result = remoteDataTrack->subscribe();
+ * if (sub_result) {
+ * auto sub = sub_result.value();
+ * 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;
+
+#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.
+ *
+ * Returns a DataTrackSubscription that delivers frames via blocking
+ * read(). Destroy the subscription to unsubscribe.
+ */
+ Result, SubscribeDataTrackError>
+ 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/result.h b/include/livekit/result.h
new file mode 100644
index 00000000..bc7f1338
--- /dev/null
+++ b/include/livekit/result.h
@@ -0,0 +1,181 @@
+/*
+ * 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