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..6d7ab0a5 100644
--- a/examples/CMakeLists.txt
+++ b/examples/CMakeLists.txt
@@ -43,6 +43,10 @@ set(EXAMPLES_ALL
SimpleJoystickSender
SimpleJoystickReceiver
SimpleDataStream
+ PingPongPing
+ PingPongPong
+ HelloLivekitSender
+ HelloLivekitReceiver
LoggingLevelsBasicUsage
LoggingLevelsCustomSinks
BridgeRobot
@@ -242,6 +246,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 +473,4 @@ if(UNIX)
foreach(EXAMPLE ${EXAMPLES_BRIDGE})
add_dependencies(${EXAMPLE} copy_bridge_to_bin)
endforeach()
-endif()
\ No newline at end of file
+endif()
diff --git a/examples/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..189e59be
--- /dev/null
+++ b/examples/hello_livekit/sender.cpp
@@ -0,0 +1,145 @@
+/*
+ * 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={} retryable={} message={}",
+ static_cast(error.code), error.retryable,
+ error.message);
+ room.reset();
+ livekit::shutdown();
+ return 1;
+ }
+ std::shared_ptr data_track = publish_result.value();
+
+ const auto t0 = std::chrono::steady_clock::now();
+ std::uint64_t count = 0;
+
+ 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={} retryable={} message={}",
+ static_cast(error.code), error.retryable,
+ 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..2071e025
--- /dev/null
+++ b/examples/ping_pong/ping.cpp
@@ -0,0 +1,214 @@
+/*
+ * Copyright 2026 LiveKit
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/// Ping participant: publishes on the "ping" data track, listens on "pong",
+/// and logs latency metrics for each matched response. Use a token whose
+/// identity is `ping`.
+
+#include "constants.h"
+#include "json_converters.h"
+#include "livekit/livekit.h"
+#include "livekit/lk_log.h"
+#include "messages.h"
+#include "utils.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+using namespace livekit;
+
+namespace {
+
+std::atomic g_running{true};
+
+void handleSignal(int) { g_running.store(false); }
+
+ping_pong::LatencyMetrics
+calculateLatencyMetrics(const ping_pong::PingMessage &ping_message,
+ const ping_pong::PongMessage &pong_message,
+ std::int64_t received_ts_ns) {
+ ping_pong::LatencyMetrics metrics;
+ metrics.id = ping_message.id;
+ metrics.pong_sent_ts_ns = pong_message.ts_ns;
+ metrics.ping_received_ts_ns = received_ts_ns;
+ metrics.round_trip_time_ns = received_ts_ns - ping_message.ts_ns;
+ metrics.pong_to_ping_time_ns = received_ts_ns - pong_message.ts_ns;
+ metrics.ping_to_pong_and_processing_ns =
+ pong_message.ts_ns - ping_message.ts_ns;
+ metrics.estimated_one_way_latency_ns =
+ static_cast(metrics.round_trip_time_ns) / 2.0;
+ metrics.round_trip_time_ms =
+ static_cast(metrics.round_trip_time_ns) / 1'000'000.0;
+ metrics.pong_to_ping_time_ms =
+ static_cast(metrics.pong_to_ping_time_ns) / 1'000'000.0;
+ metrics.ping_to_pong_and_processing_ms =
+ static_cast(metrics.ping_to_pong_and_processing_ns) /
+ 1'000'000.0;
+ metrics.estimated_one_way_latency_ms =
+ metrics.estimated_one_way_latency_ns / 1'000'000.0;
+ return metrics;
+}
+
+} // namespace
+
+int main(int argc, char *argv[]) {
+ std::string url = ping_pong::getenvOrEmpty("LIVEKIT_URL");
+ std::string token = ping_pong::getenvOrEmpty("LIVEKIT_TOKEN");
+
+ if (argc >= 3) {
+ url = argv[1];
+ token = argv[2];
+ }
+
+ if (url.empty() || token.empty()) {
+ LK_LOG_ERROR("LIVEKIT_URL and LIVEKIT_TOKEN (or ) are "
+ "required");
+ return 1;
+ }
+
+ std::signal(SIGINT, handleSignal);
+#ifdef SIGTERM
+ std::signal(SIGTERM, handleSignal);
+#endif
+
+ livekit::initialize(livekit::LogLevel::Info, livekit::LogSink::kConsole);
+
+ auto room = std::make_unique();
+ RoomOptions options;
+ options.auto_subscribe = true;
+ options.dynacast = false;
+
+ if (!room->Connect(url, token, options)) {
+ LK_LOG_ERROR("Failed to connect to room");
+ livekit::shutdown();
+ return 1;
+ }
+
+ LocalParticipant *local_participant = room->localParticipant();
+ assert(local_participant);
+
+ LK_LOG_INFO("ping connected as identity='{}' room='{}'",
+ local_participant->identity(), room->room_info().name);
+
+ auto publish_result =
+ local_participant->publishDataTrack(ping_pong::kPingTrackName);
+ if (!publish_result) {
+ const auto &error = publish_result.error();
+ LK_LOG_ERROR("Failed to publish ping data track: code={} retryable={} "
+ "message={}",
+ static_cast(error.code), error.retryable,
+ error.message);
+ room->setDelegate(nullptr);
+ room.reset();
+ livekit::shutdown();
+ return 1;
+ }
+
+ std::shared_ptr ping_track = publish_result.value();
+ std::unordered_map sent_messages;
+ std::mutex sent_messages_mutex;
+
+ const auto callback_id = room->addOnDataFrameCallback(
+ ping_pong::kPongParticipantIdentity, ping_pong::kPongTrackName,
+ [&sent_messages,
+ &sent_messages_mutex](const std::vector &payload,
+ std::optional /*user_timestamp*/) {
+ try {
+ if (payload.empty()) {
+ LK_LOG_DEBUG("Ignoring empty pong payload");
+ return;
+ }
+
+ const auto pong_message =
+ ping_pong::pongMessageFromJson(ping_pong::toString(payload));
+ const auto received_ts_ns = ping_pong::timeSinceEpochNs();
+
+ ping_pong::PingMessage ping_message;
+ {
+ std::lock_guard lock(sent_messages_mutex);
+ const auto it = sent_messages.find(pong_message.rec_id);
+ if (it == sent_messages.end()) {
+ LK_LOG_WARN("Received pong for unknown id={}",
+ pong_message.rec_id);
+ return;
+ }
+ ping_message = it->second;
+ sent_messages.erase(it);
+ }
+
+ const auto metrics = calculateLatencyMetrics(
+ ping_message, pong_message, received_ts_ns);
+
+ LK_LOG_INFO(
+ "pong id={} rtt_ms={:.3f} "
+ "pong_to_ping_ms={:.3f} "
+ "ping_to_pong_and_processing_ms={:.3f} "
+ "estimated_one_way_latency_ms={:.3f}",
+ metrics.id, metrics.round_trip_time_ms, metrics.pong_to_ping_time_ms,
+ metrics.ping_to_pong_and_processing_ms,
+ metrics.estimated_one_way_latency_ms);
+ } catch (const std::exception &e) {
+ LK_LOG_WARN("Failed to process pong payload: {}", e.what());
+ }
+ });
+
+ LK_LOG_INFO("published data track '{}' and listening for '{}' from '{}'",
+ ping_pong::kPingTrackName, ping_pong::kPongTrackName,
+ ping_pong::kPongParticipantIdentity);
+
+ std::uint64_t next_id = 1;
+ auto next_deadline = std::chrono::steady_clock::now();
+
+ while (g_running.load()) {
+ ping_pong::PingMessage ping_message;
+ ping_message.id = next_id++;
+ ping_message.ts_ns = ping_pong::timeSinceEpochNs();
+
+ const std::string json = ping_pong::pingMessageToJson(ping_message);
+ auto push_result = ping_track->tryPush(ping_pong::toPayload(json));
+ if (!push_result) {
+ const auto &error = push_result.error();
+ LK_LOG_WARN("Failed to push ping data frame: code={} retryable={} "
+ "message={}",
+ static_cast(error.code), error.retryable,
+ error.message);
+ } else {
+ {
+ std::lock_guard lock(sent_messages_mutex);
+ sent_messages.emplace(ping_message.id, ping_message);
+ }
+ LK_LOG_INFO("sent ping id={} ts_ns={}", ping_message.id,
+ ping_message.ts_ns);
+ }
+
+ next_deadline += ping_pong::kPingPeriod;
+ std::this_thread::sleep_until(next_deadline);
+ }
+
+ LK_LOG_INFO("shutting down ping participant");
+ room.reset();
+ livekit::shutdown();
+ return 0;
+}
diff --git a/examples/ping_pong/pong.cpp b/examples/ping_pong/pong.cpp
new file mode 100644
index 00000000..03d11b84
--- /dev/null
+++ b/examples/ping_pong/pong.cpp
@@ -0,0 +1,151 @@
+/*
+ * Copyright 2026 LiveKit
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/// Pong participant: listens on the "ping" data track and publishes responses
+/// on the "pong" data track. Use a token whose identity is `pong`.
+
+#include "constants.h"
+#include "json_converters.h"
+#include "livekit/livekit.h"
+#include "livekit/lk_log.h"
+#include "messages.h"
+#include "utils.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+using namespace livekit;
+
+namespace {
+
+std::atomic g_running{true};
+
+void handleSignal(int) { g_running.store(false); }
+
+} // namespace
+
+int main(int argc, char *argv[]) {
+ std::string url = ping_pong::getenvOrEmpty("LIVEKIT_URL");
+ std::string token = ping_pong::getenvOrEmpty("LIVEKIT_TOKEN");
+
+ if (argc >= 3) {
+ url = argv[1];
+ token = argv[2];
+ }
+
+ if (url.empty() || token.empty()) {
+ LK_LOG_ERROR("LIVEKIT_URL and LIVEKIT_TOKEN (or ) are "
+ "required");
+ return 1;
+ }
+
+ std::signal(SIGINT, handleSignal);
+#ifdef SIGTERM
+ std::signal(SIGTERM, handleSignal);
+#endif
+
+ livekit::initialize(livekit::LogLevel::Info, livekit::LogSink::kConsole);
+
+ auto room = std::make_unique();
+ RoomOptions options;
+ options.auto_subscribe = true;
+ options.dynacast = false;
+
+ if (!room->Connect(url, token, options)) {
+ LK_LOG_ERROR("Failed to connect to room");
+ livekit::shutdown();
+ return 1;
+ }
+
+ LocalParticipant *local_participant = room->localParticipant();
+ assert(local_participant);
+
+ LK_LOG_INFO("pong connected as identity='{}' room='{}'",
+ local_participant->identity(), room->room_info().name);
+
+ auto publish_result =
+ local_participant->publishDataTrack(ping_pong::kPongTrackName);
+ if (!publish_result) {
+ const auto &error = publish_result.error();
+ LK_LOG_ERROR("Failed to publish pong data track: code={} retryable={} "
+ "message={}",
+ static_cast(error.code), error.retryable,
+ error.message);
+ room->setDelegate(nullptr);
+ room.reset();
+ livekit::shutdown();
+ return 1;
+ }
+
+ std::shared_ptr pong_track = publish_result.value();
+
+ const auto callback_id = room->addOnDataFrameCallback(
+ ping_pong::kPingParticipantIdentity, ping_pong::kPingTrackName,
+ [pong_track](const std::vector &payload,
+ std::optional /*user_timestamp*/) {
+ try {
+ if (payload.empty()) {
+ LK_LOG_DEBUG("Ignoring empty ping payload");
+ return;
+ }
+
+ const auto ping_message =
+ ping_pong::pingMessageFromJson(ping_pong::toString(payload));
+
+ ping_pong::PongMessage pong_message;
+ pong_message.rec_id = ping_message.id;
+ pong_message.ts_ns = ping_pong::timeSinceEpochNs();
+
+ const std::string json = ping_pong::pongMessageToJson(pong_message);
+ auto push_result = pong_track->tryPush(ping_pong::toPayload(json));
+ if (!push_result) {
+ const auto &error = push_result.error();
+ LK_LOG_WARN("Failed to push pong data frame: code={} retryable={} "
+ "message={}",
+ static_cast(error.code),
+ error.retryable, error.message);
+ return;
+ }
+
+ LK_LOG_INFO("received ping id={} ts_ns={} and sent pong rec_id={} "
+ "ts_ns={}",
+ ping_message.id, ping_message.ts_ns, pong_message.rec_id,
+ pong_message.ts_ns);
+ } catch (const std::exception &e) {
+ LK_LOG_WARN("Failed to process ping payload: {}", e.what());
+ }
+ });
+
+ LK_LOG_INFO("published data track '{}' and listening for '{}' from '{}'",
+ ping_pong::kPongTrackName, ping_pong::kPingTrackName,
+ ping_pong::kPingParticipantIdentity);
+
+ while (g_running.load()) {
+ std::this_thread::sleep_for(ping_pong::kPollPeriod);
+ }
+
+ LK_LOG_INFO("shutting down pong participant");
+ room.reset();
+ livekit::shutdown();
+ return 0;
+}
diff --git a/examples/ping_pong/utils.h b/examples/ping_pong/utils.h
new file mode 100644
index 00000000..56c915b9
--- /dev/null
+++ b/examples/ping_pong/utils.h
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2026 LiveKit
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+
+namespace ping_pong {
+
+inline std::string getenvOrEmpty(const char *name) {
+ const char *value = std::getenv(name);
+ return value ? std::string(value) : std::string{};
+}
+
+inline std::int64_t timeSinceEpochNs() {
+ const auto now = std::chrono::system_clock::now().time_since_epoch();
+ return std::chrono::duration_cast(now).count();
+}
+
+inline std::vector toPayload(const std::string &json) {
+ return std::vector(json.begin(), json.end());
+}
+
+inline std::string toString(const std::vector &payload) {
+ return std::string(payload.begin(), payload.end());
+}
+
+} // namespace ping_pong
diff --git a/examples/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..2f80dbf4
--- /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..fe8fe401
--- /dev/null
+++ b/include/livekit/data_track_error.h
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2026 LiveKit
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef LIVEKIT_DATA_TRACK_ERROR_H
+#define LIVEKIT_DATA_TRACK_ERROR_H
+
+#include
+#include
+
+namespace livekit {
+
+namespace proto {
+class DataTrackError;
+}
+
+/// Stable error codes for data-track operations.
+enum class DataTrackErrorCode : std::uint32_t {
+ UNKNOWN = 0,
+ INVALID_HANDLE = 1,
+ DUPLICATE_TRACK_NAME = 2,
+ TRACK_UNPUBLISHED = 3,
+ BUFFER_FULL = 4,
+ SUBSCRIPTION_CLOSED = 5,
+ CANCELLED = 6,
+ PROTOCOL_ERROR = 7,
+ INTERNAL = 8,
+};
+
+/// Structured failure returned by non-throwing data-track APIs.
+struct DataTrackError {
+ /// Machine-readable error code.
+ DataTrackErrorCode code{DataTrackErrorCode::UNKNOWN};
+ /// Human-readable description from the backend or SDK.
+ std::string message;
+ /// Whether retrying the operation may succeed.
+ bool retryable{false};
+
+ /// Convert the FFI proto representation into the public SDK type.
+ static DataTrackError fromProto(const proto::DataTrackError &error);
+};
+
+} // namespace livekit
+
+#endif // LIVEKIT_DATA_TRACK_ERROR_H
\ No newline at end of file
diff --git a/include/livekit/data_track_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..d8639009
--- /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..da67557a 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, DataTrackError>
+ 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..9157c042
--- /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_info.h"
+#include "livekit/data_track_subscription.h"
+#include "livekit/data_track_error.h"
+#include "livekit/ffi_handle.h"
+#include "livekit/result.h"
+
+#include
+#include
+
+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, DataTrackError>
+ 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..a0028496
--- /dev/null
+++ b/include/livekit/result.h
@@ -0,0 +1,138 @@
+/*
+ * Copyright 2026 LiveKit
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef LIVEKIT_RESULT_H
+#define LIVEKIT_RESULT_H
+
+#include
+#include
+#include
+#include
+
+namespace livekit {
+
+/**
+ * Lightweight success-or-error return type for non-exceptional API failures.
+ *
+ * This is intended for SDK operations where callers are expected to branch on
+ * success vs. failure, such as back-pressure or an unpublished track.
+ *
+ * `Result` stores either:
+ * - a success value of type `T`, or
+ * - an error value of type `E`
+ *
+ * Accessors are intentionally non-throwing. Calling `value()` on an error
+ * result, or `error()` on a success result, is a programmer error and will
+ * trip the debug assertion.
+ */
+template class Result {
+public:
+ /// Construct a successful result containing a value.
+ static Result success(T value) {
+ return Result(std::variant(std::in_place_index<0>,
+ std::move(value)));
+ }
+
+ /// Construct a failed result containing an error.
+ static Result failure(E error) {
+ return Result(
+ std::variant(std::in_place_index<1>, std::move(error)));
+ }
+
+ /// True when the result contains a success value.
+ bool ok() const noexcept { return storage_.index() == 0; }
+ /// True when the result contains an error.
+ bool has_error() const noexcept { return !ok(); }
+ /// Allows `if (result)` style success checks.
+ explicit operator bool() const noexcept { return ok(); }
+
+ /// Access the success value. Requires `ok() == true`.
+ T &value() noexcept {
+ assert(ok());
+ return std::get<0>(storage_);
+ }
+
+ /// Access the success value. Requires `ok() == true`.
+ const T &value() const noexcept {
+ assert(ok());
+ return std::get<0>(storage_);
+ }
+
+ /// Access the error value. Requires `has_error() == true`.
+ E &error() noexcept {
+ assert(has_error());
+ return std::get<1>(storage_);
+ }
+
+ /// Access the error value. Requires `has_error() == true`.
+ const E &error() const noexcept {
+ assert(has_error());
+ return std::get<1>(storage_);
+ }
+
+private:
+ explicit Result(std::variant storage) : storage_(std::move(storage)) {}
+
+ std::variant storage_;
+};
+
+/**
+ * `void` specialization for operations that only report success or failure.
+ *
+ * This keeps the same calling style as `Result` without forcing callers
+ * to invent a dummy success payload.
+ */
+template class Result {
+public:
+ /// Construct a successful result with no payload.
+ static Result success() { return Result(std::nullopt); }
+
+ /// Construct a failed result containing an error.
+ static Result failure(E error) {
+ return Result(std::optional(std::move(error)));
+ }
+
+ /// True when the operation succeeded.
+ bool ok() const noexcept { return !error_.has_value(); }
+ /// True when the operation failed.
+ bool has_error() const noexcept { return error_.has_value(); }
+ /// Allows `if (result)` style success checks.
+ explicit operator bool() const noexcept { return ok(); }
+
+ /// Validates success in debug builds. Mirrors the `value()` API shape.
+ void value() const noexcept { assert(ok()); }
+
+ /// Access the error value. Requires `has_error() == true`.
+ E &error() noexcept {
+ assert(has_error());
+ return *error_;
+ }
+
+ /// Access the error value. Requires `has_error() == true`.
+ const E &error() const noexcept {
+ assert(has_error());
+ return *error_;
+ }
+
+private:
+ explicit Result(std::optional error) : error_(std::move(error)) {}
+
+ std::optional error_;
+};
+
+} // namespace livekit
+
+#endif // LIVEKIT_RESULT_H
diff --git a/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