diff --git a/CMakeLists.txt b/CMakeLists.txt
index 86eb1e49..30ecbcf8 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -76,6 +76,7 @@ set(FFI_PROTO_FILES
${FFI_PROTO_DIR}/e2ee.proto
${FFI_PROTO_DIR}/stats.proto
${FFI_PROTO_DIR}/data_stream.proto
+ ${FFI_PROTO_DIR}/data_track.proto
${FFI_PROTO_DIR}/rpc.proto
${FFI_PROTO_DIR}/track_publication.proto
)
@@ -324,6 +325,7 @@ add_library(livekit SHARED
src/audio_source.cpp
src/audio_stream.cpp
src/data_stream.cpp
+ src/data_track_subscription.cpp
src/e2ee.cpp
src/ffi_handle.cpp
src/ffi_client.cpp
@@ -331,7 +333,9 @@ add_library(livekit SHARED
src/livekit.cpp
src/logging.cpp
src/local_audio_track.cpp
+ src/local_data_track.cpp
src/remote_audio_track.cpp
+ src/remote_data_track.cpp
src/room.cpp
src/room_proto_converter.cpp
src/room_proto_converter.h
@@ -683,10 +687,6 @@ install(FILES
# Build the LiveKit C++ bridge before examples (human_robot depends on it)
add_subdirectory(bridge)
-# ---- Examples ----
-# add_subdirectory(examples)
-
-
if(LIVEKIT_BUILD_EXAMPLES)
add_subdirectory(examples)
endif()
diff --git a/README.md b/README.md
index eb473f26..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..da98cb33 100644
--- a/examples/CMakeLists.txt
+++ b/examples/CMakeLists.txt
@@ -43,6 +43,10 @@ set(EXAMPLES_ALL
SimpleJoystickSender
SimpleJoystickReceiver
SimpleDataStream
+ SimpleStatusProducer
+ SimpleStatusConsumer
+ HelloLivekitSender
+ HelloLivekitReceiver
LoggingLevelsBasicUsage
LoggingLevelsCustomSinks
BridgeRobot
@@ -242,6 +246,58 @@ add_custom_command(
$/data
)
+# --- simple_status (producer + consumer text stream on producer-status) ---
+
+add_executable(SimpleStatusProducer
+ simple_status/producer.cpp
+)
+
+target_include_directories(SimpleStatusProducer PRIVATE ${EXAMPLES_PRIVATE_INCLUDE_DIRS})
+
+target_link_libraries(SimpleStatusProducer
+ PRIVATE
+ livekit
+ spdlog::spdlog
+)
+
+add_executable(SimpleStatusConsumer
+ simple_status/consumer.cpp
+)
+
+target_include_directories(SimpleStatusConsumer PRIVATE ${EXAMPLES_PRIVATE_INCLUDE_DIRS})
+
+target_link_libraries(SimpleStatusConsumer
+ PRIVATE
+ livekit
+ spdlog::spdlog
+)
+
+# --- 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
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/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..3ca70d5f
--- /dev/null
+++ b/examples/hello_livekit/sender.cpp
@@ -0,0 +1,129 @@
+/*
+ * Copyright 2026 LiveKit
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/// Publishes synthetic RGBA video and a data track. Run the receiver in another
+/// process and pass this participant's identity (printed after connect).
+///
+/// Usage:
+/// HelloLivekitSender
+///
+/// Or via environment variables:
+/// LIVEKIT_URL, LIVEKIT_SENDER_TOKEN
+
+#include "livekit/livekit.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+using namespace livekit;
+
+constexpr int kWidth = 640;
+constexpr int kHeight = 480;
+constexpr const char *kVideoTrackName = "camera0";
+constexpr const char *kDataTrackName = "app-data";
+
+std::atomic g_running{true};
+
+void handleSignal(int) { g_running.store(false); }
+
+std::string getenvOrEmpty(const char *name) {
+ const char *v = std::getenv(name);
+ return v ? std::string(v) : std::string{};
+}
+
+int main(int argc, char *argv[]) {
+ std::string url = getenvOrEmpty("LIVEKIT_URL");
+ std::string sender_token = getenvOrEmpty("LIVEKIT_SENDER_TOKEN");
+
+ if (argc >= 3) {
+ url = argv[1];
+ sender_token = argv[2];
+ }
+
+ if (url.empty() || sender_token.empty()) {
+ LK_LOG_ERROR(
+ "Usage: HelloLivekitSender \n"
+ " or set LIVEKIT_URL, LIVEKIT_SENDER_TOKEN");
+ return 1;
+ }
+
+ std::signal(SIGINT, handleSignal);
+#ifdef SIGTERM
+ std::signal(SIGTERM, handleSignal);
+#endif
+
+ livekit::initialize(livekit::LogLevel::Info, livekit::LogSink::kConsole);
+
+ auto room = std::make_unique();
+ RoomOptions options;
+ options.auto_subscribe = true;
+ options.dynacast = false;
+
+ if (!room->Connect(url, sender_token, options)) {
+ LK_LOG_ERROR("[sender] Failed to connect");
+ livekit::shutdown();
+ return 1;
+ }
+
+ LocalParticipant *lp = room->localParticipant();
+ assert(lp);
+
+ LK_LOG_INFO("[sender] Connected as identity='{}' room='{}' — pass this "
+ "identity to HelloLivekitReceiver",
+ lp->identity(), room->room_info().name);
+
+ auto video_source = std::make_shared(kWidth, kHeight);
+
+ std::shared_ptr video_track = lp->publishVideoTrack(
+ kVideoTrackName, video_source, TrackSource::SOURCE_CAMERA);
+
+ std::shared_ptr data_track =
+ lp->publishDataTrack(kDataTrackName);
+
+ const auto t0 = std::chrono::steady_clock::now();
+ std::uint64_t count = 0;
+
+ LK_LOG_INFO(
+ "[sender] Publishing synthetic video + data on '{}'; Ctrl-C to exit",
+ kDataTrackName);
+
+ while (g_running.load()) {
+ VideoFrame vf = VideoFrame::create(kWidth, kHeight, VideoBufferType::RGBA);
+ video_source->captureFrame(std::move(vf));
+
+ const auto now = std::chrono::steady_clock::now();
+ const double ms =
+ std::chrono::duration(now - t0).count();
+ std::ostringstream oss;
+ oss << std::fixed << std::setprecision(2) << ms << " ms, count: " << count;
+ const std::string msg = oss.str();
+ data_track->tryPush(std::vector(msg.begin(), msg.end()));
+
+ ++count;
+ std::this_thread::sleep_for(std::chrono::milliseconds(100));
+ }
+
+ LK_LOG_INFO("[sender] Disconnecting");
+ room.reset();
+
+ livekit::shutdown();
+ return 0;
+}
diff --git a/examples/simple_status/consumer.cpp b/examples/simple_status/consumer.cpp
new file mode 100644
index 00000000..2859e8b9
--- /dev/null
+++ b/examples/simple_status/consumer.cpp
@@ -0,0 +1,127 @@
+/*
+ * Copyright 2026 LiveKit
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/// Consumer participant: creates 3 independent data track subscriptions to the
+/// producer's "status" data track and logs each frame with the subscriber
+/// index. Use a token whose identity is `consumer`.
+
+#include "livekit/livekit.h"
+#include "livekit/lk_log.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+using namespace livekit;
+
+namespace {
+
+constexpr const char *kProducerIdentity = "producer";
+constexpr const char *kTrackName = "status";
+constexpr int kNumSubscribers = 3;
+
+std::atomic g_running{true};
+
+void handleSignal(int) { g_running.store(false); }
+
+std::string getenvOrEmpty(const char *name) {
+ const char *v = std::getenv(name);
+ return v ? std::string(v) : std::string{};
+}
+
+} // namespace
+
+int main(int argc, char *argv[]) {
+ std::string url = getenvOrEmpty("LIVEKIT_URL");
+ std::string token = getenvOrEmpty("LIVEKIT_TOKEN");
+
+ if (argc >= 3) {
+ url = argv[1];
+ token = argv[2];
+ }
+
+ if (url.empty() || token.empty()) {
+ LK_LOG_ERROR("LIVEKIT_URL and LIVEKIT_TOKEN (or ) are "
+ "required");
+ return 1;
+ }
+
+ std::signal(SIGINT, handleSignal);
+#ifdef SIGTERM
+ std::signal(SIGTERM, handleSignal);
+#endif
+
+ livekit::initialize(livekit::LogLevel::Info, livekit::LogSink::kConsole);
+
+ auto room = std::make_unique();
+ RoomOptions options;
+ options.auto_subscribe = true;
+ options.dynacast = false;
+
+ if (!room->Connect(url, token, options)) {
+ LK_LOG_ERROR("Failed to connect to room");
+ livekit::shutdown();
+ return 1;
+ }
+
+ LocalParticipant *lp = room->localParticipant();
+ if (!lp) {
+ LK_LOG_ERROR("No local participant after connect");
+ room->setDelegate(nullptr);
+ room.reset();
+ livekit::shutdown();
+ return 1;
+ }
+
+ LK_LOG_INFO("consumer connected as identity='{}' room='{}'", lp->identity(),
+ room->room_info().name);
+
+ std::vector sub_ids;
+ sub_ids.reserve(kNumSubscribers);
+
+ for (int i = 0; i < kNumSubscribers; ++i) {
+ auto id = room->addOnDataFrameCallback(
+ kProducerIdentity, kTrackName,
+ [i](const std::vector &payload,
+ std::optional /*user_timestamp*/) {
+ std::string text(payload.begin(), payload.end());
+ LK_LOG_INFO("[subscriber {}] {}", i, text);
+ });
+ sub_ids.push_back(id);
+ LK_LOG_INFO("registered subscriber {} (id={})", i, id);
+ }
+
+ LK_LOG_INFO("listening for data track '{}' from '{}' with {} subscribers; "
+ "Ctrl-C to exit",
+ kTrackName, kProducerIdentity, kNumSubscribers);
+
+ while (g_running.load()) {
+ std::this_thread::sleep_for(std::chrono::milliseconds(50));
+ }
+
+ LK_LOG_INFO("shutting down");
+ for (auto id : sub_ids) {
+ room->removeOnDataFrameCallback(id);
+ }
+ room->setDelegate(nullptr);
+ room.reset();
+ livekit::shutdown();
+ return 0;
+}
diff --git a/examples/simple_status/producer.cpp b/examples/simple_status/producer.cpp
new file mode 100644
index 00000000..fa7e882c
--- /dev/null
+++ b/examples/simple_status/producer.cpp
@@ -0,0 +1,143 @@
+/*
+ * Copyright 2026 LiveKit
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/// Producer participant: publishes a data track named "status" and pushes
+/// periodic binary status frames (4 Hz). Use a token whose identity is
+/// `producer`.
+
+#include "livekit/livekit.h"
+#include "livekit/lk_log.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+using namespace livekit;
+
+namespace {
+
+constexpr const char *kTrackName = "status";
+
+std::atomic g_running{true};
+
+void handleSignal(int) { g_running.store(false); }
+
+std::string getenvOrEmpty(const char *name) {
+ const char *v = std::getenv(name);
+ return v ? std::string(v) : std::string{};
+}
+
+} // namespace
+
+int main(int argc, char *argv[]) {
+ std::string url = getenvOrEmpty("LIVEKIT_URL");
+ std::string token = getenvOrEmpty("LIVEKIT_TOKEN");
+
+ if (argc >= 3) {
+ url = argv[1];
+ token = argv[2];
+ }
+
+ if (url.empty() || token.empty()) {
+ LK_LOG_ERROR("LIVEKIT_URL and LIVEKIT_TOKEN (or ) are "
+ "required");
+ return 1;
+ }
+
+ std::signal(SIGINT, handleSignal);
+#ifdef SIGTERM
+ std::signal(SIGTERM, handleSignal);
+#endif
+
+ livekit::initialize(livekit::LogLevel::Info, livekit::LogSink::kConsole);
+
+ auto room = std::make_unique();
+ RoomOptions options;
+ options.auto_subscribe = true;
+ options.dynacast = false;
+
+ if (!room->Connect(url, token, options)) {
+ LK_LOG_ERROR("Failed to connect to room");
+ livekit::shutdown();
+ return 1;
+ }
+
+ LocalParticipant *lp = room->localParticipant();
+ if (!lp) {
+ LK_LOG_ERROR("No local participant after connect");
+ room->setDelegate(nullptr);
+ room.reset();
+ livekit::shutdown();
+ return 1;
+ }
+
+ LK_LOG_INFO("producer connected as identity='{}' room='{}'", lp->identity(),
+ room->room_info().name);
+
+ std::shared_ptr data_track;
+ try {
+ data_track = lp->publishDataTrack(kTrackName);
+ } catch (const std::exception &e) {
+ LK_LOG_ERROR("Failed to publish data track: {}", e.what());
+ room->setDelegate(nullptr);
+ room.reset();
+ livekit::shutdown();
+ return 1;
+ }
+
+ LK_LOG_INFO("published data track '{}'", kTrackName);
+
+ using clock = std::chrono::steady_clock;
+ const auto start = clock::now();
+ const auto period = std::chrono::milliseconds(250);
+ auto next_deadline = clock::now();
+ std::uint64_t count = 0;
+
+ while (g_running.load()) {
+ const auto now = clock::now();
+ const double elapsed_sec =
+ std::chrono::duration(now - start).count();
+
+ std::ostringstream body;
+ body << std::fixed << std::setprecision(2) << elapsed_sec;
+ const std::string text = std::string("[time-since-start]: ") + body.str() +
+ " count: " + std::to_string(count);
+
+ std::vector payload(text.begin(), text.end());
+ if (!data_track->tryPush(payload)) {
+ LK_LOG_WARN("Failed to push data frame");
+ }
+
+ LK_LOG_DEBUG("sent: {}", text);
+ ++count;
+
+ next_deadline += period;
+ std::this_thread::sleep_until(next_deadline);
+ }
+
+ LK_LOG_INFO("shutting down");
+ data_track->unpublishDataTrack();
+ room->setDelegate(nullptr);
+ room.reset();
+ livekit::shutdown();
+ return 0;
+}
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/include/livekit/data_frame.h b/include/livekit/data_frame.h
new file mode 100644
index 00000000..6bfa5e0a
--- /dev/null
+++ b/include/livekit/data_frame.h
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2026 LiveKit
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#pragma once
+
+#include
+#include
+#include
+
+namespace livekit {
+
+/**
+ * A single frame of data published or received on a data track.
+ *
+ * Carries an arbitrary binary payload and an optional user-specified
+ * timestamp. The unit is application-defined; the SDK examples use
+ * microseconds since the Unix epoch (system_clock).
+ */
+struct DataFrame {
+ /** Arbitrary binary payload (the frame contents). */
+ std::vector payload;
+
+ /**
+ * Optional application-defined timestamp.
+ *
+ * The proto field is a bare uint64 with no prescribed unit.
+ * By convention the SDK examples use microseconds since the Unix epoch.
+ */
+ std::optional user_timestamp;
+};
+
+} // namespace livekit
diff --git a/include/livekit/data_track_frame.h b/include/livekit/data_track_frame.h
new file mode 100644
index 00000000..6bfa5e0a
--- /dev/null
+++ b/include/livekit/data_track_frame.h
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2026 LiveKit
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#pragma once
+
+#include
+#include
+#include
+
+namespace livekit {
+
+/**
+ * A single frame of data published or received on a data track.
+ *
+ * Carries an arbitrary binary payload and an optional user-specified
+ * timestamp. The unit is application-defined; the SDK examples use
+ * microseconds since the Unix epoch (system_clock).
+ */
+struct DataFrame {
+ /** Arbitrary binary payload (the frame contents). */
+ std::vector payload;
+
+ /**
+ * Optional application-defined timestamp.
+ *
+ * The proto field is a bare uint64 with no prescribed unit.
+ * By convention the SDK examples use microseconds since the Unix epoch.
+ */
+ std::optional user_timestamp;
+};
+
+} // namespace livekit
diff --git a/include/livekit/data_track_info.h b/include/livekit/data_track_info.h
new file mode 100644
index 00000000..45c4fc5f
--- /dev/null
+++ b/include/livekit/data_track_info.h
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2026 LiveKit
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#pragma once
+
+#include
+
+namespace livekit {
+
+/**
+ * Metadata about a published data track.
+ *
+ * Unlike audio/video tracks, data tracks are not part of the Track class
+ * hierarchy. They carry their own lightweight info struct.
+ */
+struct DataTrackInfo {
+ /** Publisher-assigned track name (unique per publisher). */
+ std::string name;
+
+ /** SFU-assigned track identifier. */
+ std::string sid;
+
+ /** Whether frames on this track use end-to-end encryption. */
+ bool uses_e2ee = false;
+};
+
+} // namespace livekit
diff --git a/include/livekit/data_track_subscription.h b/include/livekit/data_track_subscription.h
new file mode 100644
index 00000000..4f3619f3
--- /dev/null
+++ b/include/livekit/data_track_subscription.h
@@ -0,0 +1,123 @@
+/*
+ * 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 = remoteDataTrack->subscribe();
+ * 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;
+ DataTrackSubscription(DataTrackSubscription &&) noexcept;
+ DataTrackSubscription &operator=(DataTrackSubscription &&) noexcept;
+
+ /**
+ * Blocking read: waits until a DataFrame is available, or the
+ * subscription reaches EOS / is closed.
+ *
+ * @param out On success, filled with the next data frame.
+ * @return true if a frame was delivered; false if the subscription ended.
+ */
+ bool read(DataFrame &out);
+
+ /**
+ * End the subscription early.
+ *
+ * Releases the FFI handle (which unsubscribes from the remote track),
+ * unregisters the event listener, and wakes any blocking read().
+ */
+ void close();
+
+private:
+ friend class RemoteDataTrack;
+
+ DataTrackSubscription() = default;
+ /// Internal init helper, called by RemoteDataTrack.
+ void init(FfiHandle subscription_handle);
+
+ /// 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..2375d728
--- /dev/null
+++ b/include/livekit/local_data_track.h
@@ -0,0 +1,112 @@
+/*
+ * Copyright 2026 LiveKit
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#pragma once
+
+#include "livekit/data_frame.h"
+#include "livekit/data_track_info.h"
+#include "livekit/ffi_handle.h"
+
+#include
+#include
+#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 dt = lp->publishDataTrack("sensor-data");
+ * DataFrame frame;
+ * frame.payload = {0x01, 0x02, 0x03};
+ * 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 true on success, false if the push failed (e.g. back-pressure
+ * or the track has been unpublished).
+ */
+ bool tryPush(const DataFrame &frame);
+
+ /**
+ * Try to push a frame to all subscribers of this track.
+ *
+ * @return true on success, false if the push failed (e.g. back-pressure
+ * or the track has been unpublished).
+ */
+ bool tryPush(const std::vector &payload,
+ std::optional user_timestamp = std::nullopt);
+ /**
+ * Try to push a frame to all subscribers of this track.
+ *
+ * @return true on success, false if the push failed (e.g. back-pressure
+ * or the track has been unpublished).
+ */
+ bool tryPush(const std::uint8_t *data, std::size_t size,
+ std::optional user_timestamp = std::nullopt);
+
+ /// Whether the track is still published in the room.
+ bool isPublished() const;
+
+ /**
+ * Unpublish this data track from the room.
+ *
+ * After this call, tryPush() fails and the track cannot be re-published.
+ */
+ void unpublishDataTrack();
+
+private:
+ friend class LocalParticipant;
+
+ 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..fed3a190 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,31 @@ 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 Shared pointer to the published data track.
+ * @throws std::runtime_error on FFI or publish failure.
+ */
+ std::shared_ptr publishDataTrack(const std::string &name);
+
+ /**
+ * Unpublish a data track from the room.
+ *
+ * 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 +276,7 @@ class LocalParticipant : public Participant {
/// cached publication). \c mutable so \ref trackPublications() const can
/// prune expired \c weak_ptr entries.
mutable TrackMap published_tracks_by_sid_;
+
std::unordered_map rpc_handlers_;
// Shared state for RPC invocation tracking. Using shared_ptr so the state
diff --git a/include/livekit/remote_data_track.h b/include/livekit/remote_data_track.h
new file mode 100644
index 00000000..ce755a06
--- /dev/null
+++ b/include/livekit/remote_data_track.h
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2026 LiveKit
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#pragma once
+
+#include "livekit/data_track_info.h"
+#include "livekit/data_track_subscription.h"
+#include "livekit/ffi_handle.h"
+
+#include
+#include
+
+namespace livekit {
+
+namespace proto {
+class OwnedRemoteDataTrack;
+}
+
+/**
+ * Represents a data track published by a remote participant.
+ *
+ * Discovered via the 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 = remoteDataTrack->subscribe();
+ * DataFrame frame;
+ * while (sub->read(frame)) {
+ * // process frame
+ * }
+ */
+class RemoteDataTrack {
+public:
+ ~RemoteDataTrack() = default;
+
+ RemoteDataTrack(const RemoteDataTrack &) = delete;
+ RemoteDataTrack &operator=(const RemoteDataTrack &) = delete;
+
+ /// Metadata about this data track.
+ const DataTrackInfo &info() const noexcept { return info_; }
+
+ /// Identity of the remote participant who published this track.
+ const std::string &publisherIdentity() const noexcept {
+ return publisher_identity_;
+ }
+
+ /// Whether the track is still published by the remote participant.
+ bool isPublished() const;
+
+ /**
+ * Subscribe to this remote data track.
+ *
+ * Returns a DataTrackSubscription that delivers frames via blocking
+ * read(). Destroy the subscription to unsubscribe.
+ *
+ * @throws std::runtime_error if the FFI subscribe call fails.
+ */
+ std::shared_ptr
+ subscribe(const DataTrackSubscription::Options &options = {});
+
+private:
+ friend class Room;
+
+ explicit RemoteDataTrack(const proto::OwnedRemoteDataTrack &owned);
+
+ uintptr_t ffi_handle_id() const noexcept { return handle_.get(); }
+ /** RAII wrapper for the Rust-owned FFI resource. */
+ FfiHandle handle_;
+
+ /** Metadata snapshot taken at construction time. */
+ DataTrackInfo info_;
+
+ /** Identity string of the remote participant who published this track. */
+ std::string publisher_identity_;
+};
+
+} // namespace livekit
diff --git a/include/livekit/room.h b/include/livekit/room.h
index d808ecd4..c8e501d3 100644
--- a/include/livekit/room.h
+++ b/include/livekit/room.h
@@ -241,62 +241,74 @@ class Room {
// ---------------------------------------------------------------
/**
- * Set a callback for audio frames from a specific remote participant and
- * track source.
- *
- * A dedicated reader thread is spawned for each (participant, source) pair
- * when the track is subscribed. If the track is already subscribed, the
- * reader starts immediately. If not, it starts when the track arrives.
- *
- * Only one callback may exist per (participant, source) pair. Re-calling
- * with the same pair replaces the previous callback.
- *
- * @param participant_identity Identity of the remote participant.
- * @param source Track source (e.g. SOURCE_MICROPHONE).
- * @param callback Function invoked per audio frame.
- * @param opts AudioStream options (capacity, noise
- * cancellation).
+ * @brief Sets the audio frame callback via SubscriptionThreadDispatcher.
*/
void setOnAudioFrameCallback(const std::string &participant_identity,
TrackSource source, AudioFrameCallback callback,
AudioStream::Options opts = {});
/**
- * Set a callback for video frames from a specific remote participant and
- * track source.
- *
- * @see setOnAudioFrameCallback for threading and lifecycle semantics.
- *
- * @param participant_identity Identity of the remote participant.
- * @param source Track source (e.g. SOURCE_CAMERA).
- * @param callback Function invoked per video frame.
- * @param opts VideoStream options (capacity, pixel format).
+ * @brief Sets the audio frame callback via SubscriptionThreadDispatcher.
+ */
+ void setOnAudioFrameCallback(const std::string &participant_identity,
+ const std::string &track_name,
+ AudioFrameCallback callback,
+ AudioStream::Options opts = {});
+
+ /**
+ * @brief Sets the video frame callback via SubscriptionThreadDispatcher.
*/
void setOnVideoFrameCallback(const std::string &participant_identity,
TrackSource source, VideoFrameCallback callback,
VideoStream::Options opts = {});
/**
- * Clear the audio frame callback for a specific (participant, source) pair.
- * Stops and joins any active reader thread.
- * No-op if no callback is registered for this key.
- * @param participant_identity Identity of the remote participant.
- * @param source Track source (e.g. SOURCE_MICROPHONE).
+ * @brief Sets the video frame callback via SubscriptionThreadDispatcher.
+ */
+ void setOnVideoFrameCallback(const std::string &participant_identity,
+ const std::string &track_name,
+ VideoFrameCallback callback,
+ VideoStream::Options opts = {});
+
+ /**
+ * @brief Clears the audio frame callback via SubscriptionThreadDispatcher.
*/
void clearOnAudioFrameCallback(const std::string &participant_identity,
TrackSource source);
+ /**
+ * @brief Clears the audio frame callback via SubscriptionThreadDispatcher.
+ */
+ void clearOnAudioFrameCallback(const std::string &participant_identity,
+ const std::string &track_name);
/**
- * Clear the video frame callback for a specific (participant, source) pair.
- * Stops and joins any active reader thread.
- * No-op if no callback is registered for this key.
- * @param participant_identity Identity of the remote participant.
- * @param source Track source (e.g. SOURCE_CAMERA).
+ * @brief Clears the video frame callback via SubscriptionThreadDispatcher.
*/
void clearOnVideoFrameCallback(const std::string &participant_identity,
TrackSource source);
+ /**
+ * @brief Clears the video frame callback via SubscriptionThreadDispatcher.
+ */
+ void clearOnVideoFrameCallback(const std::string &participant_identity,
+ const std::string &track_name);
+
+ /**
+ * @brief Adds a data frame callback via SubscriptionThreadDispatcher.
+ */
+ DataFrameCallbackId
+ addOnDataFrameCallback(const std::string &participant_identity,
+ const std::string &track_name,
+ DataFrameCallback callback);
+
+ /**
+ * @brief Removes the data frame callback via SubscriptionThreadDispatcher.
+ */
+ void removeOnDataFrameCallback(DataFrameCallbackId id);
+
private:
+ friend class RoomCallbackTest;
+
mutable std::mutex lock_;
ConnectionState connection_state_ = ConnectionState::Disconnected;
RoomDelegate *delegate_ = nullptr; // Not owned
diff --git a/include/livekit/room_delegate.h b/include/livekit/room_delegate.h
index 04474a9f..2621c92c 100644
--- a/include/livekit/room_delegate.h
+++ b/include/livekit/room_delegate.h
@@ -287,6 +287,24 @@ class RoomDelegate {
*/
virtual void onTextStreamOpened(Room &, const TextStreamOpenedEvent &) {}
+ // ------------------------------------------------------------------
+ // Data tracks
+ // ------------------------------------------------------------------
+
+ /**
+ * Called when a remote participant publishes a data track.
+ *
+ * Data tracks are independent of the audio/video track hierarchy and
+ * require an explicit subscribe() call to start receiving frames.
+ */
+ virtual void onDataTrackPublished(Room &, const DataTrackPublishedEvent &) {}
+
+ /**
+ * Called when a remote participant unpublishes a data track.
+ */
+ virtual void onDataTrackUnpublished(Room &,
+ const DataTrackUnpublishedEvent &) {}
+
// ------------------------------------------------------------------
// Participants snapshot
// ------------------------------------------------------------------
diff --git a/include/livekit/room_event_types.h b/include/livekit/room_event_types.h
index 63c75140..553f79c8 100644
--- a/include/livekit/room_event_types.h
+++ b/include/livekit/room_event_types.h
@@ -29,6 +29,7 @@ namespace livekit {
class Track;
class Participant;
class RemoteParticipant;
+class RemoteDataTrack;
class LocalTrackPublication;
class RemoteTrackPublication;
class TrackPublication;
@@ -100,7 +101,7 @@ enum class DisconnectReason {
RoomClosed,
UserUnavailable,
UserRejected,
- SipTrunkFailure,
+ SipTrunkFailure, ///< SIP (telephony) trunk connection failed
ConnectionTimeout,
MediaFailure
};
@@ -117,10 +118,17 @@ struct UserPacketData {
};
/**
- * SIP DTMF payload carried via data packets.
+ * SIP (Session Initiation Protocol) DTMF payload carried via data packets.
+ *
+ * SIP is a signalling protocol used in VoIP telephony. LiveKit supports
+ * SIP trunking, which bridges traditional phone calls into LiveKit rooms.
+ * DTMF (Dual-Tone Multi-Frequency) tones are the signals generated when
+ * phone keypad buttons are pressed (0-9, *, #). This struct surfaces
+ * those tones so that applications handling SIP-bridged calls can react
+ * to caller input (e.g. IVR menu selection).
*/
struct SipDtmfData {
- /** DTMF code value. */
+ /** Numeric DTMF code (0-15, mapping to 0-9, *, #, A-D). */
std::uint32_t code = 0;
/** Human-readable digit representation (e.g. "1", "#"). */
@@ -719,4 +727,24 @@ struct E2eeStateChangedEvent {
EncryptionState state = EncryptionState::New;
};
+/**
+ * Fired when a participant publishes a data track.
+ *
+ * Data tracks are independent of the audio/video track hierarchy.
+ * The application must call RemoteDataTrack::subscribe() to start
+ * receiving frames.
+ */
+struct DataTrackPublishedEvent {
+ /** The newly published remote data track. */
+ std::shared_ptr track;
+};
+
+/**
+ * Fired when a remote participant unpublishes a data track.
+ */
+struct DataTrackUnpublishedEvent {
+ /** SID of the track that was unpublished. */
+ std::string sid;
+};
+
} // namespace livekit
diff --git a/include/livekit/subscription_thread_dispatcher.h b/include/livekit/subscription_thread_dispatcher.h
index 3e843541..f7795fc2 100644
--- a/include/livekit/subscription_thread_dispatcher.h
+++ b/include/livekit/subscription_thread_dispatcher.h
@@ -24,13 +24,17 @@
#include
#include
#include
+#include
#include
#include
#include
+#include
namespace livekit {
class AudioFrame;
+class DataTrackSubscription;
+class RemoteDataTrack;
class Track;
class VideoFrame;
@@ -43,6 +47,18 @@ using AudioFrameCallback = std::function;
using VideoFrameCallback =
std::function;
+/// Callback type for incoming data track frames.
+/// Invoked on a dedicated reader thread per subscription.
+/// @param payload Raw binary data received.
+/// @param user_timestamp Optional application-defined timestamp from sender.
+using DataFrameCallback =
+ std::function &payload,
+ std::optional user_timestamp)>;
+
+/// Opaque identifier returned by addOnDataFrameCallback, used to remove an
+/// individual subscription via removeOnDataFrameCallback.
+using DataFrameCallbackId = std::uint64_t;
+
/**
* Owns subscription callback registration and per-subscription reader threads.
*
@@ -90,6 +106,24 @@ class SubscriptionThreadDispatcher {
TrackSource source, AudioFrameCallback callback,
AudioStream::Options opts = {});
+ /**
+ * Register or replace an audio frame callback for a remote subscription.
+ *
+ * The callback is keyed by remote participant identity plus \p track_name.
+ * If the matching remote audio track is already subscribed, \ref Room may
+ * immediately call \ref handleTrackSubscribed to start a reader.
+ *
+ * @param participant_identity Identity of the remote participant.
+ * @param track_name Track name to match.
+ * @param callback Function invoked for each decoded audio frame.
+ * @param opts Options used when creating the backing
+ * \ref AudioStream.
+ */
+ void setOnAudioFrameCallback(const std::string &participant_identity,
+ const std::string &track_name,
+ AudioFrameCallback callback,
+ AudioStream::Options opts = {});
+
/**
* Register or replace a video frame callback for a remote subscription.
*
@@ -107,6 +141,24 @@ class SubscriptionThreadDispatcher {
TrackSource source, VideoFrameCallback callback,
VideoStream::Options opts = {});
+ /**
+ * Register or replace a video frame callback for a remote subscription.
+ *
+ * The callback is keyed by remote participant identity plus \p track_name.
+ * If the matching remote video track is already subscribed, \ref Room may
+ * immediately call \ref handleTrackSubscribed to start a reader.
+ *
+ * @param participant_identity Identity of the remote participant.
+ * @param track_name Track name to match.
+ * @param callback Function invoked for each decoded video frame.
+ * @param opts Options used when creating the backing
+ * \ref VideoStream.
+ */
+ void setOnVideoFrameCallback(const std::string &participant_identity,
+ const std::string &track_name,
+ VideoFrameCallback callback,
+ VideoStream::Options opts = {});
+
/**
* Remove an audio callback registration and stop any active reader.
*
@@ -119,6 +171,18 @@ class SubscriptionThreadDispatcher {
void clearOnAudioFrameCallback(const std::string &participant_identity,
TrackSource source);
+ /**
+ * Remove an audio callback registration and stop any active reader.
+ *
+ * If an audio reader thread is active for the given key, its stream is
+ * closed and the thread is joined before this call returns.
+ *
+ * @param participant_identity Identity of the remote participant.
+ * @param track_name Track name to clear.
+ */
+ void clearOnAudioFrameCallback(const std::string &participant_identity,
+ const std::string &track_name);
+
/**
* Remove a video callback registration and stop any active reader.
*
@@ -131,6 +195,18 @@ class SubscriptionThreadDispatcher {
void clearOnVideoFrameCallback(const std::string &participant_identity,
TrackSource source);
+ /**
+ * Remove a video callback registration and stop any active reader.
+ *
+ * If a video reader thread is active for the given key, its stream is
+ * closed and the thread is joined before this call returns.
+ *
+ * @param participant_identity Identity of the remote participant.
+ * @param track_name Track name to clear.
+ */
+ void clearOnVideoFrameCallback(const std::string &participant_identity,
+ const std::string &track_name);
+
/**
* Start or restart reader dispatch for a newly subscribed remote track.
*
@@ -146,7 +222,7 @@ class SubscriptionThreadDispatcher {
* @param track Subscribed remote track to read from.
*/
void handleTrackSubscribed(const std::string &participant_identity,
- TrackSource source,
+ TrackSource source, const std::string &track_name,
const std::shared_ptr