diff --git a/CMakeLists.txt b/CMakeLists.txt index 7ba5e70..60b59c2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,21 +1,72 @@ +# Copyright 2026 LiveKit, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + cmake_minimum_required(VERSION 3.20) project(livekit_cpp_example_collection LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_POSITION_INDEPENDENT_CODE ON) # Make "include(LiveKitSDK)" search in ./cmake list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") set(LIVEKIT_SDK_VERSION "latest" CACHE STRING "LiveKit C++ SDK version (e.g. 0.2.0 or latest)") +set(LIVEKIT_LOCAL_SDK_DIR "" CACHE PATH "Path to a local LiveKit SDK install prefix (skips download)") -list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") -include(LiveKitSDK) +if(LIVEKIT_LOCAL_SDK_DIR) + message(STATUS "Using local LiveKit SDK: ${LIVEKIT_LOCAL_SDK_DIR}") + list(PREPEND CMAKE_PREFIX_PATH "${LIVEKIT_LOCAL_SDK_DIR}") +else() + include(LiveKitSDK) + livekit_sdk_setup( + VERSION "${LIVEKIT_SDK_VERSION}" + SDK_DIR "${CMAKE_BINARY_DIR}/_deps/livekit-sdk" + GITHUB_TOKEN "$ENV{GITHUB_TOKEN}" + ) +endif() +find_package(LiveKit CONFIG REQUIRED) -livekit_sdk_setup( - VERSION "${LIVEKIT_SDK_VERSION}" - SDK_DIR "${CMAKE_BINARY_DIR}/_deps/livekit-sdk" - GITHUB_TOKEN "$ENV{GITHUB_TOKEN}" -) +if(TARGET LiveKit::livekit) + set(LIVEKIT_CORE_TARGET LiveKit::livekit) +elseif(TARGET livekit) + set(LIVEKIT_CORE_TARGET livekit) +else() + message(FATAL_ERROR "Could not find a LiveKit core target (expected LiveKit::livekit or livekit).") +endif() + +if(DEFINED _SPDLOG_ACTIVE_LEVEL AND NOT "${_SPDLOG_ACTIVE_LEVEL}" STREQUAL "") + add_compile_definitions(SPDLOG_ACTIVE_LEVEL=${_SPDLOG_ACTIVE_LEVEL}) +else() + add_compile_definitions(SPDLOG_ACTIVE_LEVEL=SPDLOG_LEVEL_INFO) +endif() + +set(LIVEKIT_DATA_DIR "") +if(DEFINED LIVEKIT_ROOT_DIR AND EXISTS "${LIVEKIT_ROOT_DIR}/data") + set(LIVEKIT_DATA_DIR "${LIVEKIT_ROOT_DIR}/data") +endif() + +include(ExampleDeps) -find_package(LiveKit CONFIG REQUIRED) add_subdirectory(basic_room) +add_subdirectory(simple_room) +add_subdirectory(simple_rpc) +add_subdirectory(simple_data_stream) +add_subdirectory(logging_levels/basic_usage) +add_subdirectory(logging_levels/custom_sinks) +add_subdirectory(hello_livekit/sender) +add_subdirectory(hello_livekit/receiver) +add_subdirectory(simple_joystick/sender) +add_subdirectory(simple_joystick/receiver) +add_subdirectory(ping_pong/ping) +add_subdirectory(ping_pong/pong) diff --git a/README.md b/README.md index 4ddb858..8b1fb58 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,10 @@ The goal of these examples is to demonstrate common usage patterns of the LiveKit C++ SDK (connecting to a room, publishing tracks, RPC, data streams, etc.) without requiring users to build the SDK from source. +Related examples are grouped under shared folders. For example, the ping-pong +examples now live under `ping_pong/`, and paired sender/receiver examples are +grouped together similarly. + ## How the SDK is provided @@ -35,6 +39,16 @@ rm -rf build cmake -S . -B build -DLIVEKIT_SDK_VERSION=0.3.1 ``` +Build against a local SDK: +```bash +rm -rf build +# install the SDK into $HOME/livekit-sdk-install (or any other directory) +cmake --install --prefix $HOME/livekit-sdk-install + +# build the examples against the local SDK +cmake -S . -B build -DLIVEKIT_LOCAL_SDK_DIR=$HOME/livekit-sdk-install +``` + ### Building the examples #### macOS / Linux @@ -53,9 +67,10 @@ The Livekit Release SDK is downloaded into **build/_deps/livekit-sdk/** ### Running the examples -After building, example binaries are located under: +After building, example binaries are located under their corresponding build +subdirectories: ```bash -build// +build// ``` For example: @@ -63,6 +78,11 @@ For example: ./build/basic_room/basic_room --url --token ``` +Grouped examples follow the same pattern, for example: +```bash +./build/ping_pong/ping/PingPongPing --url --token +``` + ### Supported platforms Prebuilt SDKs are downloaded automatically for: @@ -70,4 +90,4 @@ Prebuilt SDKs are downloaded automatically for: * macOS: x64, arm64 (Apple Silicon) * Linux: x64 -If no matching SDK is available for your platform, CMake configuration will fail with a clear error. \ No newline at end of file +If no matching SDK is available for your platform, CMake configuration will fail with a clear error. diff --git a/basic_room/CMakeLists.txt b/basic_room/CMakeLists.txt index 59f24f6..0b5ca12 100644 --- a/basic_room/CMakeLists.txt +++ b/basic_room/CMakeLists.txt @@ -1,3 +1,17 @@ +# 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. + add_executable(basic_room main.cpp capture_utils.cpp @@ -13,7 +27,6 @@ get_filename_component(_lk_lib_dir "${_lk_cmake_dir}" DIRECTORY) # .../lib target_link_directories(basic_room PRIVATE "${_lk_lib_dir}") - # Nice-to-have: copy runtime DLLs next to the exe on Windows for "run from build dir". # Only do this if your exported package provides these targets. if(WIN32) diff --git a/basic_room/main.cpp b/basic_room/main.cpp index 0fc8854..bb5d221 100644 --- a/basic_room/main.cpp +++ b/basic_room/main.cpp @@ -41,7 +41,8 @@ void printUsage(const char *prog) { << " LIVEKIT_URL, LIVEKIT_TOKEN\n"; } -bool parseArgs(int argc, char *argv[], std::string &url, std::string &token, bool &self_test) { +bool parseArgs(int argc, char *argv[], std::string &url, std::string &token, + bool &self_test) { for (int i = 1; i < argc; ++i) { const std::string a = argv[i]; if (a == "-h" || a == "--help") @@ -85,14 +86,9 @@ bool parseArgs(int argc, char *argv[], std::string &url, std::string &token, boo } void print_livekit_version() { - std::cout - << "LiveKit version: " - << LIVEKIT_BUILD_VERSION_FULL - << " (" << LIVEKIT_BUILD_FLAVOR - << ", commit " << LIVEKIT_BUILD_COMMIT - << ", built " << LIVEKIT_BUILD_DATE - << ")" - << std::endl; + std::cout << "LiveKit version: " << LIVEKIT_BUILD_VERSION_FULL << " (" + << LIVEKIT_BUILD_FLAVOR << ", commit " << LIVEKIT_BUILD_COMMIT + << ", built " << LIVEKIT_BUILD_DATE << ")" << std::endl; } } // namespace @@ -106,7 +102,7 @@ int main(int argc, char *argv[]) { return 1; } if (self_test) { - livekit::initialize(livekit::LogSink::kConsole); + livekit::initialize(livekit::LogLevel::Info, livekit::LogSink::kConsole); livekit::shutdown(); std::cout << "self-test ok" << std::endl; return 0; @@ -115,7 +111,7 @@ int main(int argc, char *argv[]) { std::signal(SIGINT, handleSignal); // Init LiveKit - livekit::initialize(livekit::LogSink::kConsole); + livekit::initialize(livekit::LogLevel::Info, livekit::LogSink::kConsole); auto room = std::make_unique(); @@ -145,7 +141,8 @@ int main(int argc, char *argv[]) { std::shared_ptr audioPub; try { - audioPub = room->localParticipant()->publishTrack(audioTrack, audioOpts); + room->localParticipant()->publishTrack(audioTrack, audioOpts); + audioPub = audioTrack->publication(); std::cout << "Published audio: sid=" << audioPub->sid() << "\n"; } catch (const std::exception &e) { std::cerr << "Failed to publish audio: " << e.what() << "\n"; @@ -163,7 +160,8 @@ int main(int argc, char *argv[]) { std::shared_ptr videoPub; try { - videoPub = room->localParticipant()->publishTrack(videoTrack, videoOpts); + room->localParticipant()->publishTrack(videoTrack, videoOpts); + videoPub = videoTrack->publication(); std::cout << "Published video: sid=" << videoPub->sid() << "\n"; } catch (const std::exception &e) { std::cerr << "Failed to publish video: " << e.what() << "\n"; diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..453776c --- /dev/null +++ b/build.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# 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. + +set -euo pipefail + +usage() { + cat < +EOF +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BUILD_DIR="${SCRIPT_DIR}/build" + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + usage + exit 0 +fi + +if [[ "${1:-}" == "clean" ]]; then + echo "Removing ${BUILD_DIR} ..." + rm -rf "${BUILD_DIR}" + shift +fi + +mkdir -p "${BUILD_DIR}" +cd "${BUILD_DIR}" + +cmake "${SCRIPT_DIR}" "$@" +cmake --build . --parallel diff --git a/cmake/ExampleDeps.cmake b/cmake/ExampleDeps.cmake new file mode 100644 index 0000000..907fe1b --- /dev/null +++ b/cmake/ExampleDeps.cmake @@ -0,0 +1,30 @@ +include(FetchContent) + +find_package(nlohmann_json CONFIG QUIET) +if(NOT TARGET nlohmann_json::nlohmann_json) + FetchContent_Declare( + nlohmann_json + URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz + ) + FetchContent_MakeAvailable(nlohmann_json) +endif() + +find_package(SDL3 CONFIG QUIET) +set(_need_sdl3_fetch TRUE) +if(TARGET SDL3::SDL3) + get_target_property(_sdl3_include_dirs SDL3::SDL3 INTERFACE_INCLUDE_DIRECTORIES) + if(_sdl3_include_dirs) + set(_need_sdl3_fetch FALSE) + endif() +endif() + +if(_need_sdl3_fetch) + FetchContent_Declare( + SDL3 + URL https://github.com/libsdl-org/SDL/releases/download/release-3.2.26/SDL3-3.2.26.tar.gz + ) + FetchContent_MakeAvailable(SDL3) +endif() + +unset(_need_sdl3_fetch) +unset(_sdl3_include_dirs) diff --git a/hello_livekit/receiver/CMakeLists.txt b/hello_livekit/receiver/CMakeLists.txt new file mode 100644 index 0000000..06bb66f --- /dev/null +++ b/hello_livekit/receiver/CMakeLists.txt @@ -0,0 +1,20 @@ +# 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. + +add_executable(HelloLivekitReceiver + main.cpp +) + +target_include_directories(HelloLivekitReceiver PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(HelloLivekitReceiver PRIVATE ${LIVEKIT_CORE_TARGET}) diff --git a/hello_livekit/receiver/main.cpp b/hello_livekit/receiver/main.cpp new file mode 100644 index 0000000..bc05e5f --- /dev/null +++ b/hello_livekit/receiver/main.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/hello_livekit/sender/CMakeLists.txt b/hello_livekit/sender/CMakeLists.txt new file mode 100644 index 0000000..b55c001 --- /dev/null +++ b/hello_livekit/sender/CMakeLists.txt @@ -0,0 +1,20 @@ +# 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. + +add_executable(HelloLivekitSender + main.cpp +) + +target_include_directories(HelloLivekitSender PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(HelloLivekitSender PRIVATE ${LIVEKIT_CORE_TARGET}) diff --git a/hello_livekit/sender/main.cpp b/hello_livekit/sender/main.cpp new file mode 100644 index 0000000..253091f --- /dev/null +++ b/hello_livekit/sender/main.cpp @@ -0,0 +1,142 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// Publishes synthetic RGBA video and a data track. Run the receiver in another +/// process and pass this participant's identity (printed after connect). +/// +/// Usage: +/// HelloLivekitSender +/// +/// Or via environment variables: +/// LIVEKIT_URL, LIVEKIT_SENDER_TOKEN + +#include "livekit/livekit.h" + +#include +#include +#include +#include +#include +#include +#include + +using namespace livekit; + +constexpr int kWidth = 640; +constexpr int kHeight = 480; +constexpr const char *kVideoTrackName = "camera0"; +constexpr const char *kDataTrackName = "app-data"; + +std::atomic g_running{true}; + +void handleSignal(int) { g_running.store(false); } + +std::string getenvOrEmpty(const char *name) { + const char *v = std::getenv(name); + return v ? std::string(v) : std::string{}; +} + +int main(int argc, char *argv[]) { + std::string url = getenvOrEmpty("LIVEKIT_URL"); + std::string sender_token = getenvOrEmpty("LIVEKIT_SENDER_TOKEN"); + + if (argc >= 3) { + url = argv[1]; + sender_token = argv[2]; + } + + if (url.empty() || sender_token.empty()) { + LK_LOG_ERROR("Usage: HelloLivekitSender \n" + " or set LIVEKIT_URL, LIVEKIT_SENDER_TOKEN"); + return 1; + } + + std::signal(SIGINT, handleSignal); +#ifdef SIGTERM + std::signal(SIGTERM, handleSignal); +#endif + + livekit::initialize(livekit::LogLevel::Info, livekit::LogSink::kConsole); + + auto room = std::make_unique(); + RoomOptions options; + options.auto_subscribe = true; + options.dynacast = false; + + if (!room->Connect(url, sender_token, options)) { + LK_LOG_ERROR("[sender] Failed to connect"); + livekit::shutdown(); + return 1; + } + + LocalParticipant *lp = room->localParticipant(); + assert(lp); + + LK_LOG_INFO("[sender] Connected as identity='{}' room='{}' — pass this " + "identity to HelloLivekitReceiver", + lp->identity(), room->room_info().name); + + auto video_source = std::make_shared(kWidth, kHeight); + + std::shared_ptr video_track = lp->publishVideoTrack( + kVideoTrackName, video_source, TrackSource::SOURCE_CAMERA); + + auto publish_result = lp->publishDataTrack(kDataTrackName); + if (!publish_result) { + const auto &error = publish_result.error(); + LK_LOG_ERROR("Failed to publish data track: code={} message={}", + static_cast(error.code), error.message); + room.reset(); + livekit::shutdown(); + return 1; + } + std::shared_ptr data_track = publish_result.value(); + + const auto t0 = std::chrono::steady_clock::now(); + std::uint64_t count = 0; + + LK_LOG_INFO( + "[sender] Publishing synthetic video + data on '{}'; Ctrl-C to exit", + kDataTrackName); + + while (g_running.load()) { + VideoFrame vf = VideoFrame::create(kWidth, kHeight, VideoBufferType::RGBA); + video_source->captureFrame(std::move(vf)); + + const auto now = std::chrono::steady_clock::now(); + const double ms = + std::chrono::duration(now - t0).count(); + std::ostringstream oss; + oss << std::fixed << std::setprecision(2) << ms << " ms, count: " << count; + const std::string msg = oss.str(); + auto push_result = + data_track->tryPush(std::vector(msg.begin(), msg.end())); + if (!push_result) { + const auto &error = push_result.error(); + LK_LOG_WARN("Failed to push data frame: code={} message={}", + static_cast(error.code), error.message); + } + + ++count; + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + + LK_LOG_INFO("[sender] Disconnecting"); + room.reset(); + + livekit::shutdown(); + return 0; +} diff --git a/logging_levels/basic_usage/CMakeLists.txt b/logging_levels/basic_usage/CMakeLists.txt new file mode 100644 index 0000000..b305fb1 --- /dev/null +++ b/logging_levels/basic_usage/CMakeLists.txt @@ -0,0 +1,20 @@ +# 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. + +add_executable(LoggingLevelsBasicUsage + main.cpp +) + +target_include_directories(LoggingLevelsBasicUsage PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(LoggingLevelsBasicUsage PRIVATE ${LIVEKIT_CORE_TARGET}) diff --git a/logging_levels/basic_usage/main.cpp b/logging_levels/basic_usage/main.cpp new file mode 100644 index 0000000..8c0b031 --- /dev/null +++ b/logging_levels/basic_usage/main.cpp @@ -0,0 +1,163 @@ +/* + * Copyright 2023 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// @file main.cpp +/// @brief Demonstrates LiveKit SDK log-level control and custom log callbacks. +/// +/// Logging has two tiers of filtering: +/// +/// 1. **Compile-time** (LIVEKIT_LOG_LEVEL, set via CMake): +/// Calls below this level are stripped from the binary entirely. +/// Default is TRACE (nothing stripped). For a lean release build: +/// cmake -DLIVEKIT_LOG_LEVEL=WARN ... +/// +/// 2. **Runtime** (setLogLevel()): +/// Among the levels that survived compilation, setLogLevel() controls +/// which ones actually produce output. This is what this example demos. +/// +/// Usage: +/// LoggingLevels [trace|debug|info|warn|error|critical|off] +/// +/// If no argument is given, the example cycles through every level so you can +/// see which messages are filtered at each setting. + +#include "livekit/livekit.h" +#include "livekit/lk_log.h" + +#include +#include +#include +#include + +namespace { + +const char *levelName(livekit::LogLevel level) { + switch (level) { + case livekit::LogLevel::Trace: + return "TRACE"; + case livekit::LogLevel::Debug: + return "DEBUG"; + case livekit::LogLevel::Info: + return "INFO"; + case livekit::LogLevel::Warn: + return "WARN"; + case livekit::LogLevel::Error: + return "ERROR"; + case livekit::LogLevel::Critical: + return "CRITICAL"; + case livekit::LogLevel::Off: + return "OFF"; + } + return "UNKNOWN"; +} + +livekit::LogLevel parseLevel(const char *arg) { + if (std::strcmp(arg, "trace") == 0) + return livekit::LogLevel::Trace; + if (std::strcmp(arg, "debug") == 0) + return livekit::LogLevel::Debug; + if (std::strcmp(arg, "info") == 0) + return livekit::LogLevel::Info; + if (std::strcmp(arg, "warn") == 0) + return livekit::LogLevel::Warn; + if (std::strcmp(arg, "error") == 0) + return livekit::LogLevel::Error; + if (std::strcmp(arg, "critical") == 0) + return livekit::LogLevel::Critical; + if (std::strcmp(arg, "off") == 0) + return livekit::LogLevel::Off; + std::cerr << "Unknown level '" << arg << "', defaulting to Info.\n" + << "Valid: trace, debug, info, warn, error, critical, off\n"; + return livekit::LogLevel::Info; +} + +/// Emit one message at every severity level using the LK_LOG_* macros. +void emitAllLevels() { + LK_LOG_TRACE("This is a TRACE message (very verbose internals)"); + LK_LOG_DEBUG("This is a DEBUG message (diagnostic detail)"); + LK_LOG_INFO("This is an INFO message (normal operation)"); + LK_LOG_WARN("This is a WARN message (something unexpected)"); + LK_LOG_ERROR("This is an ERROR message (something failed)"); + LK_LOG_CRITICAL("This is a CRITICAL message (unrecoverable)"); +} + +/// Demonstrate cycling through every log level. +void runLevelCycleDemo() { + const livekit::LogLevel levels[] = { + livekit::LogLevel::Trace, livekit::LogLevel::Debug, + livekit::LogLevel::Info, livekit::LogLevel::Warn, + livekit::LogLevel::Error, livekit::LogLevel::Critical, + livekit::LogLevel::Off, + }; + + for (auto level : levels) { + std::cout << "\n========================================\n" + << " Setting log level to: " << levelName(level) << "\n" + << "========================================\n"; + livekit::setLogLevel(level); + emitAllLevels(); + } +} + +/// Demonstrate a custom log callback (e.g. for ROS2 integration). +void runCallbackDemo() { + std::cout << "\n========================================\n" + << " Custom LogCallback demo\n" + << "========================================\n"; + + livekit::setLogLevel(livekit::LogLevel::Trace); + + // Install a user-defined callback that captures all log output. + // In a real ROS2 node you would replace this with RCLCPP_* macros. + livekit::setLogCallback([](livekit::LogLevel level, + const std::string &logger_name, + const std::string &message) { + std::cout << "[CALLBACK] [" << levelName(level) << "] [" << logger_name + << "] " << message << "\n"; + }); + + LK_LOG_INFO("This message is routed through the custom callback"); + LK_LOG_WARN("Warnings also go through the callback"); + LK_LOG_ERROR("Errors too -- the callback sees everything >= the level"); + + // Restore default stderr sink by passing an empty callback. + livekit::setLogCallback(nullptr); + + std::cout << "\n(Restored default stderr sink)\n"; + LK_LOG_INFO("This message goes to stderr again (default sink)"); +} + +} // namespace + +int main(int argc, char *argv[]) { + // Initialize the LiveKit SDK (creates the spdlog logger). + livekit::initialize(); + + if (argc > 1) { + // Single-level mode: set the requested level and emit all messages. + livekit::LogLevel level = parseLevel(argv[1]); + std::cout << "Setting log level to: " << levelName(level) << "\n\n"; + livekit::setLogLevel(level); + emitAllLevels(); + } else { + // Full demo: cycle through levels, then show the callback API. + runLevelCycleDemo(); + runCallbackDemo(); + } + + livekit::shutdown(); + return 0; +} diff --git a/logging_levels/custom_sinks/CMakeLists.txt b/logging_levels/custom_sinks/CMakeLists.txt new file mode 100644 index 0000000..b12f515 --- /dev/null +++ b/logging_levels/custom_sinks/CMakeLists.txt @@ -0,0 +1,20 @@ +# 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. + +add_executable(LoggingLevelsCustomSinks + main.cpp +) + +target_include_directories(LoggingLevelsCustomSinks PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(LoggingLevelsCustomSinks PRIVATE ${LIVEKIT_CORE_TARGET}) diff --git a/logging_levels/custom_sinks/main.cpp b/logging_levels/custom_sinks/main.cpp new file mode 100644 index 0000000..40ddcb4 --- /dev/null +++ b/logging_levels/custom_sinks/main.cpp @@ -0,0 +1,292 @@ +/* + * Copyright 2023 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// @file custom_sinks.cpp +/// @brief Shows how SDK consumers supply their own log backend via +/// livekit::setLogCallback(). +/// +/// This example uses ONLY the public SDK API (). +/// No internal headers or spdlog dependency required. +/// +/// Three patterns are demonstrated: +/// +/// 1. **File logger** -- write SDK logs to a file on disk. +/// 2. **JSON logger** -- emit structured JSON lines (for log aggregation). +/// 3. **ROS2 bridge** -- skeleton showing how to route SDK logs into +/// RCLCPP_* macros (the rclcpp headers are stubbed +/// so this compiles without a ROS2 install). +/// +/// Usage: +/// CustomSinks [file|json|ros2] +/// +/// If no argument is given, all three sinks are demonstrated in sequence. + +#include "livekit/livekit.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +// --------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------- + +const char *levelTag(livekit::LogLevel level) { + switch (level) { + case livekit::LogLevel::Trace: + return "TRACE"; + case livekit::LogLevel::Debug: + return "DEBUG"; + case livekit::LogLevel::Info: + return "INFO"; + case livekit::LogLevel::Warn: + return "WARN"; + case livekit::LogLevel::Error: + return "ERROR"; + case livekit::LogLevel::Critical: + return "CRITICAL"; + case livekit::LogLevel::Off: + return "OFF"; + } + return "?"; +} + +std::string nowISO8601() { + auto now = std::chrono::system_clock::now(); + auto tt = std::chrono::system_clock::to_time_t(now); + auto ms = std::chrono::duration_cast( + now.time_since_epoch()) % + 1000; + std::ostringstream ss; + ss << std::put_time(std::gmtime(&tt), "%FT%T") << '.' << std::setfill('0') + << std::setw(3) << ms.count() << 'Z'; + return ss.str(); +} + +struct SampleLog { + livekit::LogLevel level; + const char *message; +}; + +// Representative messages that the SDK would emit during normal operation. +// We drive the installed callback directly so this example has zero internal +// dependencies -- only the public API. +const SampleLog kSampleLogs[] = { + {livekit::LogLevel::Trace, "per-frame data: pts=12345 bytes=921600"}, + {livekit::LogLevel::Debug, "negotiating codec: VP8 -> H264 fallback"}, + {livekit::LogLevel::Info, "track published: sid=TR_abc123 kind=video"}, + {livekit::LogLevel::Warn, "ICE candidate pair changed unexpectedly"}, + {livekit::LogLevel::Error, "DTLS handshake failed: timeout after 10s"}, + {livekit::LogLevel::Critical, "out of memory allocating decode buffer"}, +}; + +void driveCallback(const livekit::LogCallback &cb) { + for (const auto &entry : kSampleLogs) { + cb(entry.level, "livekit", entry.message); + } +} + +// --------------------------------------------------------------- +// 1. File logger +// --------------------------------------------------------------- + +void runFileSinkDemo() { + const char *path = "livekit.log"; + std::cout << "\n=== File sink: writing SDK logs to '" << path << "' ===\n"; + + auto file = std::make_shared(path, std::ios::trunc); + if (!file->is_open()) { + std::cerr << "Could not open " << path << " for writing\n"; + return; + } + + // The shared_ptr keeps the stream alive inside the lambda even if + // the local variable goes out of scope before the callback fires. + livekit::LogCallback fileSink = [file](livekit::LogLevel level, + const std::string &logger_name, + const std::string &message) { + *file << nowISO8601() << " [" << levelTag(level) << "] [" << logger_name + << "] " << message << "\n"; + file->flush(); + }; + + // In a real app you would call: + // livekit::setLogCallback(fileSink); + // and then SDK operations (room.connect, publishTrack, ...) would route + // their internal log output through your callback automatically. + // + // Here we drive the callback directly with sample data so the example + // is self-contained and doesn't require a LiveKit server. + livekit::setLogCallback(fileSink); + driveCallback(fileSink); + livekit::setLogCallback(nullptr); + + std::cout << "Wrote " << path << " -- contents:\n\n"; + std::ifstream in(path); + std::cout << in.rdbuf() << "\n"; +} + +// --------------------------------------------------------------- +// 2. JSON structured logger +// --------------------------------------------------------------- + +std::string escapeJson(const std::string &s) { + std::string out; + out.reserve(s.size() + 8); + for (char c : s) { + switch (c) { + case '"': + out += "\\\""; + break; + case '\\': + out += "\\\\"; + break; + case '\n': + out += "\\n"; + break; + case '\r': + out += "\\r"; + break; + case '\t': + out += "\\t"; + break; + default: + out += c; + } + } + return out; +} + +void runJsonSinkDemo() { + std::cout << "\n=== JSON sink: structured log lines to stdout ===\n\n"; + + livekit::LogCallback jsonSink = [](livekit::LogLevel level, + const std::string &logger_name, + const std::string &message) { + std::cout << R"({"ts":")" << nowISO8601() << R"(","level":")" + << levelTag(level) << R"(","logger":")" << escapeJson(logger_name) + << R"(","msg":")" << escapeJson(message) << "\"}\n"; + }; + + livekit::setLogCallback(jsonSink); + driveCallback(jsonSink); + livekit::setLogCallback(nullptr); +} + +// --------------------------------------------------------------- +// 3. ROS2 bridge (stubbed -- compiles without rclcpp) +// --------------------------------------------------------------- +// +// In a real ROS2 node the lambda body would be: +// +// switch (level) { +// case livekit::LogLevel::Trace: +// case livekit::LogLevel::Debug: +// RCLCPP_DEBUG(node_->get_logger(), "[%s] %s", +// logger_name.c_str(), message.c_str()); +// break; +// case livekit::LogLevel::Info: +// RCLCPP_INFO(node_->get_logger(), "[%s] %s", +// logger_name.c_str(), message.c_str()); +// break; +// case livekit::LogLevel::Warn: +// RCLCPP_WARN(node_->get_logger(), "[%s] %s", +// logger_name.c_str(), message.c_str()); +// break; +// case livekit::LogLevel::Error: +// case livekit::LogLevel::Critical: +// RCLCPP_ERROR(node_->get_logger(), "[%s] %s", +// logger_name.c_str(), message.c_str()); +// break; +// default: +// break; +// } +// +// Here we stub it with console output that mimics ROS2 formatting. + +void runRos2SinkDemo() { + std::cout << "\n=== ROS2 bridge sink (stubbed) ===\n\n"; + + const std::string node_name = "livekit_bridge_node"; + + livekit::LogCallback ros2Sink = [&node_name](livekit::LogLevel level, + const std::string &logger_name, + const std::string &message) { + const char *ros_level; + switch (level) { + case livekit::LogLevel::Trace: + case livekit::LogLevel::Debug: + ros_level = "DEBUG"; + break; + case livekit::LogLevel::Info: + ros_level = "INFO"; + break; + case livekit::LogLevel::Warn: + ros_level = "WARN"; + break; + case livekit::LogLevel::Error: + case livekit::LogLevel::Critical: + ros_level = "ERROR"; + break; + default: + ros_level = "INFO"; + break; + } + + // Mimic: [INFO] [1719500000.123] [livekit_bridge_node]: [livekit] msg + auto epoch_s = std::chrono::duration( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + std::cout << "[" << ros_level << "] [" << std::fixed << std::setprecision(3) + << epoch_s << "] [" << node_name << "]: [" << logger_name << "] " + << message << "\n"; + }; + + livekit::setLogCallback(ros2Sink); + driveCallback(ros2Sink); + livekit::setLogCallback(nullptr); +} + +} // namespace + +int main(int argc, char *argv[]) { + livekit::initialize(); + + if (argc > 1) { + if (std::strcmp(argv[1], "file") == 0) { + runFileSinkDemo(); + } else if (std::strcmp(argv[1], "json") == 0) { + runJsonSinkDemo(); + } else if (std::strcmp(argv[1], "ros2") == 0) { + runRos2SinkDemo(); + } else { + std::cerr << "Unknown sink '" << argv[1] << "'.\n" + << "Usage: CustomSinks [file|json|ros2]\n"; + } + } else { + runFileSinkDemo(); + runJsonSinkDemo(); + runRos2SinkDemo(); + } + + livekit::shutdown(); + return 0; +} diff --git a/ping_pong/ping/CMakeLists.txt b/ping_pong/ping/CMakeLists.txt new file mode 100644 index 0000000..665a834 --- /dev/null +++ b/ping_pong/ping/CMakeLists.txt @@ -0,0 +1,31 @@ +# 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. + +add_library(ping_pong_ping_support STATIC + json_converters.cpp + json_converters.h + constants.h + messages.h + utils.h +) + +target_include_directories(ping_pong_ping_support PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(ping_pong_ping_support PRIVATE nlohmann_json::nlohmann_json) + +add_executable(PingPongPing + main.cpp +) + +target_include_directories(PingPongPing PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(PingPongPing PRIVATE ping_pong_ping_support ${LIVEKIT_CORE_TARGET}) diff --git a/ping_pong/ping/constants.h b/ping_pong/ping/constants.h new file mode 100644 index 0000000..da3c9b5 --- /dev/null +++ b/ping_pong/ping/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/ping_pong/ping/json_converters.cpp b/ping_pong/ping/json_converters.cpp new file mode 100644 index 0000000..24f89b1 --- /dev/null +++ b/ping_pong/ping/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/ping_pong/ping/json_converters.h b/ping_pong/ping/json_converters.h new file mode 100644 index 0000000..3491ef6 --- /dev/null +++ b/ping_pong/ping/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/ping_pong/ping/main.cpp b/ping_pong/ping/main.cpp new file mode 100644 index 0000000..c46f941 --- /dev/null +++ b/ping_pong/ping/main.cpp @@ -0,0 +1,209 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// Ping participant: publishes on the "ping" data track, listens on "pong", +/// and logs latency metrics for each matched response. Use a token whose +/// identity is `ping`. + +#include "constants.h" +#include "json_converters.h" +#include "livekit/livekit.h" +#include "livekit/lk_log.h" +#include "messages.h" +#include "utils.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace livekit; + +namespace { + +std::atomic g_running{true}; + +void handleSignal(int) { g_running.store(false); } + +ping_pong::LatencyMetrics +calculateLatencyMetrics(const ping_pong::PingMessage &ping_message, + const ping_pong::PongMessage &pong_message, + std::int64_t received_ts_ns) { + ping_pong::LatencyMetrics metrics; + metrics.id = ping_message.id; + metrics.pong_sent_ts_ns = pong_message.ts_ns; + metrics.ping_received_ts_ns = received_ts_ns; + metrics.round_trip_time_ns = received_ts_ns - ping_message.ts_ns; + metrics.pong_to_ping_time_ns = received_ts_ns - pong_message.ts_ns; + metrics.ping_to_pong_and_processing_ns = + pong_message.ts_ns - ping_message.ts_ns; + metrics.estimated_one_way_latency_ns = + static_cast(metrics.round_trip_time_ns) / 2.0; + metrics.round_trip_time_ms = + static_cast(metrics.round_trip_time_ns) / 1'000'000.0; + metrics.pong_to_ping_time_ms = + static_cast(metrics.pong_to_ping_time_ns) / 1'000'000.0; + metrics.ping_to_pong_and_processing_ms = + static_cast(metrics.ping_to_pong_and_processing_ns) / 1'000'000.0; + metrics.estimated_one_way_latency_ms = + metrics.estimated_one_way_latency_ns / 1'000'000.0; + return metrics; +} + +} // namespace + +int main(int argc, char *argv[]) { + std::string url = ping_pong::getenvOrEmpty("LIVEKIT_URL"); + std::string token = ping_pong::getenvOrEmpty("LIVEKIT_TOKEN"); + + if (argc >= 3) { + url = argv[1]; + token = argv[2]; + } + + if (url.empty() || token.empty()) { + LK_LOG_ERROR("LIVEKIT_URL and LIVEKIT_TOKEN (or ) are " + "required"); + return 1; + } + + std::signal(SIGINT, handleSignal); +#ifdef SIGTERM + std::signal(SIGTERM, handleSignal); +#endif + + livekit::initialize(livekit::LogLevel::Info, livekit::LogSink::kConsole); + + auto room = std::make_unique(); + RoomOptions options; + options.auto_subscribe = true; + options.dynacast = false; + + if (!room->Connect(url, token, options)) { + LK_LOG_ERROR("Failed to connect to room"); + livekit::shutdown(); + return 1; + } + + LocalParticipant *local_participant = room->localParticipant(); + assert(local_participant); + + LK_LOG_INFO("ping connected as identity='{}' room='{}'", + local_participant->identity(), room->room_info().name); + + auto publish_result = + local_participant->publishDataTrack(ping_pong::kPingTrackName); + if (!publish_result) { + const auto &error = publish_result.error(); + LK_LOG_ERROR("Failed to publish ping data track: code={} message={}", + static_cast(error.code), error.message); + room->setDelegate(nullptr); + room.reset(); + livekit::shutdown(); + return 1; + } + + std::shared_ptr ping_track = publish_result.value(); + std::unordered_map sent_messages; + std::mutex sent_messages_mutex; + + const auto callback_id = room->addOnDataFrameCallback( + ping_pong::kPongParticipantIdentity, ping_pong::kPongTrackName, + [&sent_messages, + &sent_messages_mutex](const std::vector &payload, + std::optional /*user_timestamp*/) { + try { + if (payload.empty()) { + LK_LOG_DEBUG("Ignoring empty pong payload"); + return; + } + + const auto pong_message = + ping_pong::pongMessageFromJson(ping_pong::toString(payload)); + const auto received_ts_ns = ping_pong::timeSinceEpochNs(); + + ping_pong::PingMessage ping_message; + { + std::lock_guard lock(sent_messages_mutex); + const auto it = sent_messages.find(pong_message.rec_id); + if (it == sent_messages.end()) { + LK_LOG_WARN("Received pong for unknown id={}", + pong_message.rec_id); + return; + } + ping_message = it->second; + sent_messages.erase(it); + } + + const auto metrics = calculateLatencyMetrics( + ping_message, pong_message, received_ts_ns); + + LK_LOG_INFO("pong id={} rtt_ms={:.3f} " + "pong_to_ping_ms={:.3f} " + "ping_to_pong_and_processing_ms={:.3f} " + "estimated_one_way_latency_ms={:.3f}", + metrics.id, metrics.round_trip_time_ms, + metrics.pong_to_ping_time_ms, + metrics.ping_to_pong_and_processing_ms, + metrics.estimated_one_way_latency_ms); + } catch (const std::exception &e) { + LK_LOG_WARN("Failed to process pong payload: {}", e.what()); + } + }); + + LK_LOG_INFO("published data track '{}' and listening for '{}' from '{}'", + ping_pong::kPingTrackName, ping_pong::kPongTrackName, + ping_pong::kPongParticipantIdentity); + + std::uint64_t next_id = 1; + auto next_deadline = std::chrono::steady_clock::now(); + + while (g_running.load()) { + ping_pong::PingMessage ping_message; + ping_message.id = next_id++; + ping_message.ts_ns = ping_pong::timeSinceEpochNs(); + + const std::string json = ping_pong::pingMessageToJson(ping_message); + auto push_result = ping_track->tryPush(ping_pong::toPayload(json)); + if (!push_result) { + const auto &error = push_result.error(); + LK_LOG_WARN("Failed to push ping data frame: code={} message={}", + static_cast(error.code), error.message); + } else { + { + std::lock_guard lock(sent_messages_mutex); + sent_messages.emplace(ping_message.id, ping_message); + } + LK_LOG_INFO("sent ping id={} ts_ns={}", ping_message.id, + ping_message.ts_ns); + } + + next_deadline += ping_pong::kPingPeriod; + std::this_thread::sleep_until(next_deadline); + } + + LK_LOG_INFO("shutting down ping participant"); + room.reset(); + livekit::shutdown(); + return 0; +} diff --git a/ping_pong/ping/messages.h b/ping_pong/ping/messages.h new file mode 100644 index 0000000..d4212ed --- /dev/null +++ b/ping_pong/ping/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/ping_pong/ping/utils.h b/ping_pong/ping/utils.h new file mode 100644 index 0000000..56c915b --- /dev/null +++ b/ping_pong/ping/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/ping_pong/pong/CMakeLists.txt b/ping_pong/pong/CMakeLists.txt new file mode 100644 index 0000000..14f139c --- /dev/null +++ b/ping_pong/pong/CMakeLists.txt @@ -0,0 +1,31 @@ +# 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. + +add_library(ping_pong_pong_support STATIC + json_converters.cpp + json_converters.h + constants.h + messages.h + utils.h +) + +target_include_directories(ping_pong_pong_support PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(ping_pong_pong_support PRIVATE nlohmann_json::nlohmann_json) + +add_executable(PingPongPong + main.cpp +) + +target_include_directories(PingPongPong PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(PingPongPong PRIVATE ping_pong_pong_support ${LIVEKIT_CORE_TARGET}) diff --git a/ping_pong/pong/constants.h b/ping_pong/pong/constants.h new file mode 100644 index 0000000..da3c9b5 --- /dev/null +++ b/ping_pong/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/ping_pong/pong/json_converters.cpp b/ping_pong/pong/json_converters.cpp new file mode 100644 index 0000000..24f89b1 --- /dev/null +++ b/ping_pong/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/ping_pong/pong/json_converters.h b/ping_pong/pong/json_converters.h new file mode 100644 index 0000000..3491ef6 --- /dev/null +++ b/ping_pong/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/ping_pong/pong/main.cpp b/ping_pong/pong/main.cpp new file mode 100644 index 0000000..34bdbd5 --- /dev/null +++ b/ping_pong/pong/main.cpp @@ -0,0 +1,147 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// Pong participant: listens on the "ping" data track and publishes responses +/// on the "pong" data track. Use a token whose identity is `pong`. + +#include "constants.h" +#include "json_converters.h" +#include "livekit/livekit.h" +#include "livekit/lk_log.h" +#include "messages.h" +#include "utils.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace livekit; + +namespace { + +std::atomic g_running{true}; + +void handleSignal(int) { g_running.store(false); } + +} // namespace + +int main(int argc, char *argv[]) { + std::string url = ping_pong::getenvOrEmpty("LIVEKIT_URL"); + std::string token = ping_pong::getenvOrEmpty("LIVEKIT_TOKEN"); + + if (argc >= 3) { + url = argv[1]; + token = argv[2]; + } + + if (url.empty() || token.empty()) { + LK_LOG_ERROR("LIVEKIT_URL and LIVEKIT_TOKEN (or ) are " + "required"); + return 1; + } + + std::signal(SIGINT, handleSignal); +#ifdef SIGTERM + std::signal(SIGTERM, handleSignal); +#endif + + livekit::initialize(livekit::LogLevel::Info, livekit::LogSink::kConsole); + + auto room = std::make_unique(); + RoomOptions options; + options.auto_subscribe = true; + options.dynacast = false; + + if (!room->Connect(url, token, options)) { + LK_LOG_ERROR("Failed to connect to room"); + livekit::shutdown(); + return 1; + } + + LocalParticipant *local_participant = room->localParticipant(); + assert(local_participant); + + LK_LOG_INFO("pong connected as identity='{}' room='{}'", + local_participant->identity(), room->room_info().name); + + auto publish_result = + local_participant->publishDataTrack(ping_pong::kPongTrackName); + if (!publish_result) { + const auto &error = publish_result.error(); + LK_LOG_ERROR("Failed to publish pong data track: code={} message={}", + static_cast(error.code), error.message); + room->setDelegate(nullptr); + room.reset(); + livekit::shutdown(); + return 1; + } + + std::shared_ptr pong_track = publish_result.value(); + + const auto callback_id = room->addOnDataFrameCallback( + ping_pong::kPingParticipantIdentity, ping_pong::kPingTrackName, + [pong_track](const std::vector &payload, + std::optional /*user_timestamp*/) { + try { + if (payload.empty()) { + LK_LOG_DEBUG("Ignoring empty ping payload"); + return; + } + + const auto ping_message = + ping_pong::pingMessageFromJson(ping_pong::toString(payload)); + + ping_pong::PongMessage pong_message; + pong_message.rec_id = ping_message.id; + pong_message.ts_ns = ping_pong::timeSinceEpochNs(); + + const std::string json = ping_pong::pongMessageToJson(pong_message); + auto push_result = pong_track->tryPush(ping_pong::toPayload(json)); + if (!push_result) { + const auto &error = push_result.error(); + LK_LOG_WARN("Failed to push pong data frame: code={} message={}", + static_cast(error.code), error.message); + return; + } + + LK_LOG_INFO("received ping id={} ts_ns={} and sent pong rec_id={} " + "ts_ns={}", + ping_message.id, ping_message.ts_ns, pong_message.rec_id, + pong_message.ts_ns); + } catch (const std::exception &e) { + LK_LOG_WARN("Failed to process ping payload: {}", e.what()); + } + }); + + LK_LOG_INFO("published data track '{}' and listening for '{}' from '{}'", + ping_pong::kPongTrackName, ping_pong::kPingTrackName, + ping_pong::kPingParticipantIdentity); + + while (g_running.load()) { + std::this_thread::sleep_for(ping_pong::kPollPeriod); + } + + LK_LOG_INFO("shutting down pong participant"); + room.reset(); + livekit::shutdown(); + return 0; +} diff --git a/ping_pong/pong/messages.h b/ping_pong/pong/messages.h new file mode 100644 index 0000000..d4212ed --- /dev/null +++ b/ping_pong/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/ping_pong/pong/utils.h b/ping_pong/pong/utils.h new file mode 100644 index 0000000..56c915b --- /dev/null +++ b/ping_pong/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/simple_data_stream/CMakeLists.txt b/simple_data_stream/CMakeLists.txt new file mode 100644 index 0000000..de67f3c --- /dev/null +++ b/simple_data_stream/CMakeLists.txt @@ -0,0 +1,28 @@ +# 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. + +add_executable(SimpleDataStream + main.cpp +) + +target_include_directories(SimpleDataStream PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(SimpleDataStream PRIVATE ${LIVEKIT_CORE_TARGET}) + +if(LIVEKIT_DATA_DIR) + add_custom_command(TARGET SimpleDataStream POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + ${LIVEKIT_DATA_DIR} + $/data + ) +endif() diff --git a/simple_data_stream/main.cpp b/simple_data_stream/main.cpp new file mode 100644 index 0000000..f8144b7 --- /dev/null +++ b/simple_data_stream/main.cpp @@ -0,0 +1,284 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "livekit/livekit.h" + +using namespace livekit; + +namespace { + +std::atomic g_running{true}; + +void handleSignal(int) { g_running.store(false); } + +// Helper: get env var or empty string +std::string getenvOrEmpty(const char *name) { + const char *v = std::getenv(name); + return v ? std::string(v) : std::string{}; +} + +std::int64_t nowEpochMs() { + using namespace std::chrono; + return duration_cast(system_clock::now().time_since_epoch()) + .count(); +} + +std::string randomHexId(std::size_t nbytes = 16) { + static thread_local std::mt19937_64 rng{std::random_device{}()}; + std::ostringstream oss; + for (std::size_t i = 0; i < nbytes; ++i) { + std::uint8_t b = static_cast(rng() & 0xFF); + const char *hex = "0123456789abcdef"; + oss << hex[(b >> 4) & 0xF] << hex[b & 0xF]; + } + return oss.str(); +} + +// Greeting: send text + image +void greetParticipant(Room *room, const std::string &identity) { + std::cout << "[DataStream] Greeting participant: " << identity << "\n"; + + LocalParticipant *lp = room->localParticipant(); + if (!lp) { + std::cerr << "[DataStream] No local participant, cannot greet.\n"; + return; + } + + try { + const std::int64_t sent_ms = nowEpochMs(); + const std::string sender_id = + !lp->identity().empty() ? lp->identity() : std::string("cpp_sender"); + const std::vector dest{identity}; + + // Send text stream ("chat") + const std::string chat_stream_id = randomHexId(); + const std::string reply_to_id = ""; + std::map chat_attrs; + chat_attrs["sent_ms"] = std::to_string(sent_ms); + chat_attrs["kind"] = "chat"; + chat_attrs["test_flag"] = "1"; + chat_attrs["seq"] = "1"; + + // Put timestamp in payload too (so you can compute latency even if + // attributes aren’t plumbed through your reader info yet). + const std::string body = "Hi! Just a friendly message"; + const std::string payload = "sent_ms=" + std::to_string(sent_ms) + "\n" + + "stream_id=" + chat_stream_id + "\n" + body; + TextStreamWriter text_writer(*lp, "chat", chat_attrs, chat_stream_id, + payload.size(), reply_to_id, dest, sender_id); + + const std::string message = "Hi! Just a friendly message"; + text_writer.write(message); // will be chunked internally if needed + text_writer.close(); // optional reason/attributes omitted + + // Send image as byte stream + const std::string file_path = "data/green.avif"; + std::ifstream in(file_path, std::ios::binary); + if (!in) { + std::cerr << "[DataStream] Failed to open file: " << file_path << "\n"; + return; + } + + std::vector data((std::istreambuf_iterator(in)), + std::istreambuf_iterator()); + + const std::string file_stream_id = randomHexId(); + std::map file_attrs; + file_attrs["sent_ms"] = std::to_string(sent_ms); + file_attrs["kind"] = "file"; + file_attrs["test_flag"] = "1"; + file_attrs["orig_path"] = file_path; + const std::string name = + std::filesystem::path(file_path).filename().string(); + const std::string mime = "image/avif"; + ByteStreamWriter byte_writer(*lp, name, "files", file_attrs, file_stream_id, + data.size(), mime, dest, sender_id); + byte_writer.write(data); + byte_writer.close(); + + std::cout << "[DataStream] Greeting sent to " << identity + << " (sent_ms=" << sent_ms << ")\n"; + } catch (const std::exception &e) { + std::cerr << "[DataStream] Error greeting participant " << identity << ": " + << e.what() << "\n"; + } +} + +// Handlers for incoming streams +void handleChatMessage(std::shared_ptr reader, + const std::string &participant_identity) { + try { + const auto info = reader->info(); // copy (safe even if reader goes away) + const std::int64_t recv_ms = nowEpochMs(); + const std::int64_t sent_ms = info.timestamp; + const auto latency = (sent_ms > 0) ? (recv_ms - sent_ms) : -1; + std::string full_text = reader->readAll(); + std::cout << "[DataStream] Received chat from " << participant_identity + << " topic=" << info.topic << " stream_id=" << info.stream_id + << " latency_ms=" << latency << " text='" << full_text << "'\n"; + } catch (const std::exception &e) { + std::cerr << "[DataStream] Error reading chat stream from " + << participant_identity << ": " << e.what() << "\n"; + } +} + +void handleWelcomeImage(std::shared_ptr reader, + const std::string &participant_identity) { + try { + const auto info = reader->info(); + const std::string stream_id = + info.stream_id.empty() ? "unknown" : info.stream_id; + const std::string original_name = + info.name.empty() ? "received_image.bin" : info.name; + // Latency: prefer header timestamp + std::int64_t sent_ms = info.timestamp; + // Optional: override with explicit attribute if you set it + auto it = info.attributes.find("sent_ms"); + if (it != info.attributes.end()) { + try { + sent_ms = std::stoll(it->second); + } catch (...) { + } + } + const std::int64_t recv_ms = nowEpochMs(); + const std::int64_t latency_ms = (sent_ms > 0) ? (recv_ms - sent_ms) : -1; + const std::string out_file = "received_" + original_name; + std::cout << "[DataStream] Receiving image from " << participant_identity + << " stream_id=" << stream_id << " name='" << original_name << "'" + << " mime='" << info.mime_type << "'" + << " size=" + << (info.size ? std::to_string(*info.size) : "unknown") + << " latency_ms=" << latency_ms << " -> '" << out_file << "'\n"; + std::ofstream out(out_file, std::ios::binary); + if (!out) { + std::cerr << "[DataStream] Failed to open output file: " << out_file + << "\n"; + return; + } + std::vector chunk; + std::uint64_t total_written = 0; + while (reader->readNext(chunk)) { + if (!chunk.empty()) { + out.write(reinterpret_cast(chunk.data()), + static_cast(chunk.size())); + total_written += chunk.size(); + } + } + std::cout << "[DataStream] Saved image from " << participant_identity + << " stream_id=" << stream_id << " bytes=" << total_written + << " to '" << out_file << std::endl; + } catch (const std::exception &e) { + std::cerr << "[DataStream] Error reading image stream from " + << participant_identity << ": " << e.what() << "\n"; + } +} + +} // namespace + +int main(int argc, char *argv[]) { + // Get URL and token from env. + std::string url = getenvOrEmpty("LIVEKIT_URL"); + std::string token = getenvOrEmpty("LIVEKIT_TOKEN"); + + if (argc >= 3) { + // Allow overriding via CLI: ./SimpleDataStream + url = argv[1]; + token = argv[2]; + } + + if (url.empty() || token.empty()) { + std::cerr << "LIVEKIT_URL and LIVEKIT_TOKEN (or CLI args) are required\n"; + return 1; + } + + std::cout << "[DataStream] Connecting to: " << url << "\n"; + + std::signal(SIGINT, handleSignal); +#ifdef SIGTERM + std::signal(SIGTERM, handleSignal); +#endif + + // Initialize the livekit with logging to console. + livekit::initialize(livekit::LogLevel::Info, livekit::LogSink::kConsole); + auto room = std::make_unique(); + RoomOptions options; + options.auto_subscribe = true; + options.dynacast = false; + + bool ok = room->Connect(url, token, options); + std::cout << "[DataStream] Connect result: " << std::boolalpha << ok << "\n"; + if (!ok) { + std::cerr << "[DataStream] Failed to connect to room\n"; + livekit::shutdown(); + return 1; + } + + auto info = room->room_info(); + std::cout << "[DataStream] Connected to room '" << info.name + << "', participants: " << info.num_participants << "\n"; + + // Register stream handlers + room->registerTextStreamHandler( + "chat", [](std::shared_ptr reader, + const std::string &participant_identity) { + std::thread t(handleChatMessage, std::move(reader), + participant_identity); + t.detach(); + }); + + room->registerByteStreamHandler( + "files", [](std::shared_ptr reader, + const std::string &participant_identity) { + std::thread t(handleWelcomeImage, std::move(reader), + participant_identity); + t.detach(); + }); + + // Greet existing participants + { + auto remotes = room->remoteParticipants(); + for (const auto &rp : remotes) { + if (!rp) + continue; + std::cout << "Remote: " << rp->identity() << "\n"; + greetParticipant(room.get(), rp->identity()); + } + } + + // Optionally: greet on join + // + // If Room API exposes a participant-connected callback, you could do: + // + // room->onParticipantConnected( + // [&](RemoteParticipant& participant) { + // std::cout << "[DataStream] participant connected: " + // << participant.sid() << " " << participant.identity() + // << "\n"; + // greetParticipant(room.get(), participant.identity()); + // }); + // + // Adjust to your actual event API. + std::cout << "[DataStream] Ready. Waiting for streams (Ctrl-C to exit)...\n"; + // Keep process alive until signal + while (g_running.load()) { + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + + std::cout << "[DataStream] Shutting down...\n"; + // It is important to clean up the delegate and room in order. + room->setDelegate(nullptr); + room.reset(); + livekit::shutdown(); + return 0; +} diff --git a/simple_joystick/receiver/CMakeLists.txt b/simple_joystick/receiver/CMakeLists.txt new file mode 100644 index 0000000..ada010c --- /dev/null +++ b/simple_joystick/receiver/CMakeLists.txt @@ -0,0 +1,30 @@ +# 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. + +add_library(simple_joystick_receiver_support STATIC + json_utils.cpp + json_utils.h + utils.cpp + utils.h +) + +target_include_directories(simple_joystick_receiver_support PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(simple_joystick_receiver_support PUBLIC nlohmann_json::nlohmann_json) + +add_executable(SimpleJoystickReceiver + main.cpp +) + +target_include_directories(SimpleJoystickReceiver PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(SimpleJoystickReceiver PRIVATE simple_joystick_receiver_support ${LIVEKIT_CORE_TARGET}) diff --git a/simple_joystick/receiver/json_utils.cpp b/simple_joystick/receiver/json_utils.cpp new file mode 100644 index 0000000..d634aaa --- /dev/null +++ b/simple_joystick/receiver/json_utils.cpp @@ -0,0 +1,46 @@ +/* + * Copyright 2025 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "json_utils.h" + +#include +#include + +namespace simple_joystick { + +std::string joystick_to_json(const JoystickCommand &cmd) { + nlohmann::json j; + j["x"] = cmd.x; + j["y"] = cmd.y; + j["z"] = cmd.z; + return j.dump(); +} + +JoystickCommand json_to_joystick(const std::string &json) { + try { + auto j = nlohmann::json::parse(json); + JoystickCommand cmd; + cmd.x = j.at("x").get(); + cmd.y = j.at("y").get(); + cmd.z = j.at("z").get(); + return cmd; + } catch (const nlohmann::json::exception &e) { + throw std::runtime_error(std::string("Failed to parse joystick JSON: ") + + e.what()); + } +} + +} // namespace simple_joystick diff --git a/simple_joystick/receiver/json_utils.h b/simple_joystick/receiver/json_utils.h new file mode 100644 index 0000000..66ba16a --- /dev/null +++ b/simple_joystick/receiver/json_utils.h @@ -0,0 +1,38 @@ +/* + * Copyright 2025 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +namespace simple_joystick { + +/// Represents a joystick command with three axes. +struct JoystickCommand { + double x = 0.0; + double y = 0.0; + double z = 0.0; +}; + +/// Serialize a JoystickCommand to a JSON string. +/// Example output: {"x":1.0,"y":2.0,"z":3.0} +std::string joystick_to_json(const JoystickCommand &cmd); + +/// Deserialize a JSON string into a JoystickCommand. +/// Throws std::runtime_error if the JSON is invalid or missing fields. +JoystickCommand json_to_joystick(const std::string &json); + +} // namespace simple_joystick diff --git a/simple_joystick/receiver/main.cpp b/simple_joystick/receiver/main.cpp new file mode 100644 index 0000000..d62785a --- /dev/null +++ b/simple_joystick/receiver/main.cpp @@ -0,0 +1,126 @@ +/* + * Copyright 2025 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include +#include +#include +#include + +#include "json_utils.h" +#include "livekit/livekit.h" +#include "utils.h" + +using namespace livekit; +using namespace std::chrono_literals; + +namespace { + +std::atomic g_running{true}; +std::atomic g_sender_connected{false}; + +void handleSignal(int) { g_running.store(false); } + +void printUsage(const char *prog) { + std::cerr << "Usage:\n" + << " " << prog << " \n" + << "or:\n" + << " " << prog << " --url= --token=\n\n" + << "Env fallbacks:\n" + << " LIVEKIT_URL, LIVEKIT_TOKEN\n\n" + << "This is the receiver. It waits for a sender peer to\n" + << "connect and send joystick commands via RPC.\n" + << "Exits after 2 minutes if no commands are received.\n"; +} + +} // namespace + +int main(int argc, char *argv[]) { + std::string url, token; + if (!simple_joystick::parseArgs(argc, argv, url, token)) { + printUsage(argv[0]); + return 1; + } + + std::cout << "[Receiver] Connecting to: " << url << "\n"; + std::signal(SIGINT, handleSignal); + + livekit::initialize(livekit::LogLevel::Info, livekit::LogSink::kConsole); + auto room = std::make_unique(); + RoomOptions options; + options.auto_subscribe = true; + options.dynacast = false; + + bool res = room->Connect(url, token, options); + std::cout << "[Receiver] Connect result: " << std::boolalpha << res << "\n"; + if (!res) { + std::cerr << "[Receiver] Failed to connect to room\n"; + livekit::shutdown(); + return 1; + } + + auto info = room->room_info(); + std::cout << "[Receiver] Connected to room: " << info.name << "\n"; + std::cout << "[Receiver] Waiting for sender peer (up to 2 minutes)...\n"; + + // Register RPC handler for joystick commands + LocalParticipant *lp = room->localParticipant(); + lp->registerRpcMethod( + "joystick_command", + [](const RpcInvocationData &data) -> std::optional { + try { + auto cmd = simple_joystick::json_to_joystick(data.payload); + g_sender_connected.store(true); + std::cout << "[Receiver] Joystick from '" << data.caller_identity + << "': x=" << cmd.x << " y=" << cmd.y << " z=" << cmd.z + << "\n"; + return std::optional{"ok"}; + } catch (const std::exception &e) { + std::cerr << "[Receiver] Bad joystick payload: " << e.what() << "\n"; + throw; + } + }); + + std::cout << "[Receiver] RPC handler 'joystick_command' registered. " + << "Listening for commands...\n"; + + // Wait up to 2 minutes for activity, then exit as failure + auto deadline = std::chrono::steady_clock::now() + 2min; + + while (g_running.load() && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(100ms); + } + + if (!g_running.load()) { + std::cout << "[Receiver] Interrupted by signal. Shutting down.\n"; + } else if (!g_sender_connected.load()) { + std::cerr + << "[Receiver] Timed out after 2 minutes with no sender connection. " + << "Exiting as failure.\n"; + room->setDelegate(nullptr); + room.reset(); + livekit::shutdown(); + return 1; + } else { + std::cout << "[Receiver] Session complete.\n"; + } + + room->setDelegate(nullptr); + room.reset(); + livekit::shutdown(); + return 0; +} diff --git a/simple_joystick/receiver/utils.cpp b/simple_joystick/receiver/utils.cpp new file mode 100644 index 0000000..cc0ef96 --- /dev/null +++ b/simple_joystick/receiver/utils.cpp @@ -0,0 +1,87 @@ +/* + * Copyright 2025 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "utils.h" + +#include +#include +#include + +namespace simple_joystick { + +bool parseArgs(int argc, char *argv[], std::string &url, std::string &token) { + for (int i = 1; i < argc; ++i) { + std::string a = argv[i]; + if (a == "-h" || a == "--help") { + return false; + } + } + + auto get_flag_value = [&](const std::string &name, int &i) -> std::string { + std::string arg = argv[i]; + const std::string eq = name + "="; + if (arg.rfind(name, 0) == 0) { + if (arg.size() > name.size() && arg[name.size()] == '=') { + return arg.substr(eq.size()); + } else if (i + 1 < argc) { + return std::string(argv[++i]); + } + } + return {}; + }; + + for (int i = 1; i < argc; ++i) { + const std::string a = argv[i]; + if (a.rfind("--url", 0) == 0) { + auto v = get_flag_value("--url", i); + if (!v.empty()) + url = v; + } else if (a.rfind("--token", 0) == 0) { + auto v = get_flag_value("--token", i); + if (!v.empty()) + token = v; + } + } + + // Positional args: + std::vector pos; + for (int i = 1; i < argc; ++i) { + std::string a = argv[i]; + if (a.rfind("--", 0) == 0) + continue; + pos.push_back(std::move(a)); + } + if (url.empty() && pos.size() >= 1) + url = pos[0]; + if (token.empty() && pos.size() >= 2) + token = pos[1]; + + // Environment variable fallbacks + if (url.empty()) { + const char *e = std::getenv("LIVEKIT_URL"); + if (e) + url = e; + } + if (token.empty()) { + const char *e = std::getenv("LIVEKIT_TOKEN"); + if (e) + token = e; + } + + return !(url.empty() || token.empty()); +} + +} // namespace simple_joystick diff --git a/simple_joystick/receiver/utils.h b/simple_joystick/receiver/utils.h new file mode 100644 index 0000000..7fe94ee --- /dev/null +++ b/simple_joystick/receiver/utils.h @@ -0,0 +1,31 @@ +/* + * Copyright 2025 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +namespace simple_joystick { + +/// Parse command-line arguments for --url and --token. +/// Supports: +/// - Positional: +/// - Flags: --url= / --url , --token= / --token +/// - Env vars: LIVEKIT_URL, LIVEKIT_TOKEN +/// Returns true if both url and token were resolved, false otherwise. +bool parseArgs(int argc, char *argv[], std::string &url, std::string &token); + +} // namespace simple_joystick diff --git a/simple_joystick/sender/CMakeLists.txt b/simple_joystick/sender/CMakeLists.txt new file mode 100644 index 0000000..be0347a --- /dev/null +++ b/simple_joystick/sender/CMakeLists.txt @@ -0,0 +1,30 @@ +# 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. + +add_library(simple_joystick_sender_support STATIC + json_utils.cpp + json_utils.h + utils.cpp + utils.h +) + +target_include_directories(simple_joystick_sender_support PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(simple_joystick_sender_support PUBLIC nlohmann_json::nlohmann_json) + +add_executable(SimpleJoystickSender + main.cpp +) + +target_include_directories(SimpleJoystickSender PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(SimpleJoystickSender PRIVATE simple_joystick_sender_support ${LIVEKIT_CORE_TARGET}) diff --git a/simple_joystick/sender/json_utils.cpp b/simple_joystick/sender/json_utils.cpp new file mode 100644 index 0000000..d634aaa --- /dev/null +++ b/simple_joystick/sender/json_utils.cpp @@ -0,0 +1,46 @@ +/* + * Copyright 2025 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "json_utils.h" + +#include +#include + +namespace simple_joystick { + +std::string joystick_to_json(const JoystickCommand &cmd) { + nlohmann::json j; + j["x"] = cmd.x; + j["y"] = cmd.y; + j["z"] = cmd.z; + return j.dump(); +} + +JoystickCommand json_to_joystick(const std::string &json) { + try { + auto j = nlohmann::json::parse(json); + JoystickCommand cmd; + cmd.x = j.at("x").get(); + cmd.y = j.at("y").get(); + cmd.z = j.at("z").get(); + return cmd; + } catch (const nlohmann::json::exception &e) { + throw std::runtime_error(std::string("Failed to parse joystick JSON: ") + + e.what()); + } +} + +} // namespace simple_joystick diff --git a/simple_joystick/sender/json_utils.h b/simple_joystick/sender/json_utils.h new file mode 100644 index 0000000..66ba16a --- /dev/null +++ b/simple_joystick/sender/json_utils.h @@ -0,0 +1,38 @@ +/* + * Copyright 2025 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +namespace simple_joystick { + +/// Represents a joystick command with three axes. +struct JoystickCommand { + double x = 0.0; + double y = 0.0; + double z = 0.0; +}; + +/// Serialize a JoystickCommand to a JSON string. +/// Example output: {"x":1.0,"y":2.0,"z":3.0} +std::string joystick_to_json(const JoystickCommand &cmd); + +/// Deserialize a JSON string into a JoystickCommand. +/// Throws std::runtime_error if the JSON is invalid or missing fields. +JoystickCommand json_to_joystick(const std::string &json); + +} // namespace simple_joystick diff --git a/simple_joystick/sender/main.cpp b/simple_joystick/sender/main.cpp new file mode 100644 index 0000000..a235c3d --- /dev/null +++ b/simple_joystick/sender/main.cpp @@ -0,0 +1,268 @@ +/* + * Copyright 2025 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#else +#include +#include +#include +#endif + +#include "json_utils.h" +#include "livekit/livekit.h" +#include "utils.h" + +using namespace livekit; +using namespace std::chrono_literals; + +namespace { + +std::atomic g_running{true}; + +void handleSignal(int) { g_running.store(false); } + +// --- Raw terminal input helpers --- + +#ifndef _WIN32 +struct termios g_orig_termios; +bool g_raw_mode_enabled = false; + +void disableRawMode() { + if (g_raw_mode_enabled) { + tcsetattr(STDIN_FILENO, TCSAFLUSH, &g_orig_termios); + g_raw_mode_enabled = false; + } +} + +void enableRawMode() { + tcgetattr(STDIN_FILENO, &g_orig_termios); + g_raw_mode_enabled = true; + std::atexit(disableRawMode); + + struct termios raw = g_orig_termios; + raw.c_lflag &= ~(ECHO | ICANON); // disable echo and canonical mode + raw.c_cc[VMIN] = 0; // non-blocking read + raw.c_cc[VTIME] = 0; + tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw); +} + +// Returns -1 if no key is available, otherwise the character code. +int readKeyNonBlocking() { + fd_set fds; + FD_ZERO(&fds); + FD_SET(STDIN_FILENO, &fds); + struct timeval tv = {0, 0}; // immediate return + if (select(STDIN_FILENO + 1, &fds, nullptr, nullptr, &tv) > 0) { + unsigned char ch; + if (read(STDIN_FILENO, &ch, 1) == 1) + return ch; + } + return -1; +} +#else +void enableRawMode() { /* Windows _getch() is already unbuffered */ } +void disableRawMode() {} + +int readKeyNonBlocking() { + if (_kbhit()) + return _getch(); + return -1; +} +#endif + +void printUsage(const char *prog) { + std::cerr << "Usage:\n" + << " " << prog << " \n" + << "or:\n" + << " " << prog << " --url= --token=\n\n" + << "Env fallbacks:\n" + << " LIVEKIT_URL, LIVEKIT_TOKEN\n\n" + << "This is the sender. It connects to the room and\n" + << "continuously checks for a receiver peer every 2 seconds.\n" + << "Once connected, use keyboard to send joystick commands:\n" + << " w / s = +x / -x\n" + << " d / a = +y / -y\n" + << " z / c = +z / -z\n" + << " q = quit\n" + << "Automatically reconnects if receiver leaves.\n"; +} + +void printControls() { + std::cout << "\n" + << " Controls:\n" + << " w / s = +x / -x\n" + << " d / a = +y / -y\n" + << " z / c = +z / -z\n" + << " q = quit\n\n"; +} + +} // namespace + +int main(int argc, char *argv[]) { + std::string url, token; + if (!simple_joystick::parseArgs(argc, argv, url, token)) { + printUsage(argv[0]); + return 1; + } + + std::cout << "[Sender] Connecting to: " << url << "\n"; + std::signal(SIGINT, handleSignal); + + livekit::initialize(livekit::LogLevel::Info, livekit::LogSink::kConsole); + auto room = std::make_unique(); + RoomOptions options; + options.auto_subscribe = true; + options.dynacast = false; + + bool res = room->Connect(url, token, options); + std::cout << "[Sender] Connect result: " << std::boolalpha << res << "\n"; + if (!res) { + std::cerr << "[Sender] Failed to connect to room\n"; + livekit::shutdown(); + return 1; + } + + auto info = room->room_info(); + std::cout << "[Sender] Connected to room: " << info.name << "\n"; + + // Enable raw terminal mode for immediate keypress detection + enableRawMode(); + + std::cout << "[Sender] Waiting for 'robot' to join (checking every 2s)...\n"; + printControls(); + + LocalParticipant *lp = room->localParticipant(); + double x = 0.0, y = 0.0, z = 0.0; + bool receiver_connected = false; + auto last_receiver_check = std::chrono::steady_clock::now(); + + while (g_running.load()) { + // Periodically check receiver presence every 2 seconds + auto now = std::chrono::steady_clock::now(); + if (now - last_receiver_check >= 2s) { + last_receiver_check = now; + bool receiver_present = (room->remoteParticipant("robot") != nullptr); + + if (receiver_present && !receiver_connected) { + std::cout + << "[Sender] Receiver connected! Use keys to send commands.\n"; + receiver_connected = true; + } else if (!receiver_present && receiver_connected) { + std::cout + << "[Sender] Receiver disconnected. Waiting for reconnect...\n"; + receiver_connected = false; + } + } + + // Poll for keypress (non-blocking) + int key = readKeyNonBlocking(); + if (key == -1) { + std::this_thread::sleep_for(20ms); // avoid busy-wait + continue; + } + + // Handle quit + if (key == 'q' || key == 'Q') { + std::cout << "\n[Sender] Quit requested.\n"; + break; + } + + // Map key to axis change + bool changed = false; + switch (key) { + case 'w': + case 'W': + x += 1.0; + changed = true; + break; + case 's': + case 'S': + x -= 1.0; + changed = true; + break; + case 'd': + case 'D': + y += 1.0; + changed = true; + break; + case 'a': + case 'A': + y -= 1.0; + changed = true; + break; + case 'z': + case 'Z': + z += 1.0; + changed = true; + break; + case 'c': + case 'C': + z -= 1.0; + changed = true; + break; + default: + break; + } + + if (!changed) + continue; + + if (!receiver_connected) { + std::cout << "[Sender] (no receiver connected) x=" << x << " y=" << y + << " z=" << z << "\n"; + continue; + } + + // Send joystick command via RPC + simple_joystick::JoystickCommand cmd{x, y, z}; + std::string payload = simple_joystick::joystick_to_json(cmd); + + std::cout << "[Sender] Sending: x=" << x << " y=" << y << " z=" << z + << "\n"; + + try { + std::string response = + lp->performRpc("robot", "joystick_command", payload, 5.0); + std::cout << "[Sender] Receiver acknowledged: " << response << "\n"; + } catch (const RpcError &e) { + std::cerr << "[Sender] RPC error: " << e.message() << "\n"; + if (static_cast(e.code()) == + RpcError::ErrorCode::RECIPIENT_DISCONNECTED) { + std::cout + << "[Sender] Receiver disconnected. Waiting for reconnect...\n"; + receiver_connected = false; + } + } catch (const std::exception &e) { + std::cerr << "[Sender] Error sending command: " << e.what() << "\n"; + } + } + + disableRawMode(); + + std::cout << "[Sender] Done. Shutting down.\n"; + room->setDelegate(nullptr); + room.reset(); + livekit::shutdown(); + return 0; +} diff --git a/simple_joystick/sender/utils.cpp b/simple_joystick/sender/utils.cpp new file mode 100644 index 0000000..cc0ef96 --- /dev/null +++ b/simple_joystick/sender/utils.cpp @@ -0,0 +1,87 @@ +/* + * Copyright 2025 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "utils.h" + +#include +#include +#include + +namespace simple_joystick { + +bool parseArgs(int argc, char *argv[], std::string &url, std::string &token) { + for (int i = 1; i < argc; ++i) { + std::string a = argv[i]; + if (a == "-h" || a == "--help") { + return false; + } + } + + auto get_flag_value = [&](const std::string &name, int &i) -> std::string { + std::string arg = argv[i]; + const std::string eq = name + "="; + if (arg.rfind(name, 0) == 0) { + if (arg.size() > name.size() && arg[name.size()] == '=') { + return arg.substr(eq.size()); + } else if (i + 1 < argc) { + return std::string(argv[++i]); + } + } + return {}; + }; + + for (int i = 1; i < argc; ++i) { + const std::string a = argv[i]; + if (a.rfind("--url", 0) == 0) { + auto v = get_flag_value("--url", i); + if (!v.empty()) + url = v; + } else if (a.rfind("--token", 0) == 0) { + auto v = get_flag_value("--token", i); + if (!v.empty()) + token = v; + } + } + + // Positional args: + std::vector pos; + for (int i = 1; i < argc; ++i) { + std::string a = argv[i]; + if (a.rfind("--", 0) == 0) + continue; + pos.push_back(std::move(a)); + } + if (url.empty() && pos.size() >= 1) + url = pos[0]; + if (token.empty() && pos.size() >= 2) + token = pos[1]; + + // Environment variable fallbacks + if (url.empty()) { + const char *e = std::getenv("LIVEKIT_URL"); + if (e) + url = e; + } + if (token.empty()) { + const char *e = std::getenv("LIVEKIT_TOKEN"); + if (e) + token = e; + } + + return !(url.empty() || token.empty()); +} + +} // namespace simple_joystick diff --git a/simple_joystick/sender/utils.h b/simple_joystick/sender/utils.h new file mode 100644 index 0000000..7fe94ee --- /dev/null +++ b/simple_joystick/sender/utils.h @@ -0,0 +1,31 @@ +/* + * Copyright 2025 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +namespace simple_joystick { + +/// Parse command-line arguments for --url and --token. +/// Supports: +/// - Positional: +/// - Flags: --url= / --url , --token= / --token +/// - Env vars: LIVEKIT_URL, LIVEKIT_TOKEN +/// Returns true if both url and token were resolved, false otherwise. +bool parseArgs(int argc, char *argv[], std::string &url, std::string &token); + +} // namespace simple_joystick diff --git a/simple_room/CMakeLists.txt b/simple_room/CMakeLists.txt new file mode 100644 index 0000000..59a3d97 --- /dev/null +++ b/simple_room/CMakeLists.txt @@ -0,0 +1,24 @@ +add_executable(SimpleRoom + main.cpp + fallback_capture.cpp + fallback_capture.h + sdl_media.cpp + sdl_media.h + sdl_media_manager.cpp + sdl_media_manager.h + sdl_video_renderer.cpp + sdl_video_renderer.h + wav_audio_source.cpp + wav_audio_source.h +) + +target_include_directories(SimpleRoom PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(SimpleRoom PRIVATE ${LIVEKIT_CORE_TARGET} SDL3::SDL3) + +if(LIVEKIT_DATA_DIR) + add_custom_command(TARGET SimpleRoom POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + ${LIVEKIT_DATA_DIR} + $/data + ) +endif() diff --git a/simple_room/fallback_capture.cpp b/simple_room/fallback_capture.cpp new file mode 100644 index 0000000..2626988 --- /dev/null +++ b/simple_room/fallback_capture.cpp @@ -0,0 +1,119 @@ +/* + * Copyright 2025 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "fallback_capture.h" + +#include +#include +#include +#include +#include +#include + +#include "livekit/livekit.h" +#include "wav_audio_source.h" + +using namespace livekit; + +// Test utils to run a capture loop to publish noisy audio frames to the room +void runNoiseCaptureLoop(const std::shared_ptr &source, + std::atomic &running_flag) { + const int sample_rate = source->sample_rate(); + const int num_channels = source->num_channels(); + const int frame_ms = 10; + const int samples_per_channel = sample_rate * frame_ms / 1000; + + // FIX: variable name should not shadow the type + WavAudioSource wavSource("data/welcome.wav", 48000, 1, false); + + using Clock = std::chrono::steady_clock; + auto next_deadline = Clock::now(); + while (running_flag.load(std::memory_order_relaxed)) { + AudioFrame frame = + AudioFrame::create(sample_rate, num_channels, samples_per_channel); + wavSource.fillFrame(frame); + try { + source->captureFrame(frame); + } catch (const std::exception &e) { + LK_LOG_ERROR("Error in captureFrame (noise): {}", e.what()); + break; + } + + // Pace the loop to roughly real-time + next_deadline += std::chrono::milliseconds(frame_ms); + std::this_thread::sleep_until(next_deadline); + } + + try { + source->clearQueue(); + } catch (...) { + LK_LOG_WARN("Error in clearQueue (noise)"); + } +} + +// Fake video source: solid color cycling +void runFakeVideoCaptureLoop(const std::shared_ptr &source, + std::atomic &running_flag) { + auto frame = VideoFrame::create(1280, 720, VideoBufferType::BGRA); + const double framerate = 1.0 / 30.0; + + while (running_flag.load(std::memory_order_relaxed)) { + static auto start = std::chrono::high_resolution_clock::now(); + float t = std::chrono::duration( + std::chrono::high_resolution_clock::now() - start) + .count(); + // Cycle every 4 seconds: 0=red, 1=green, 2=blue, 3=black + int stage = static_cast(t) % 4; + + std::array rgb{}; + switch (stage) { + case 0: // red + rgb = {255, 0, 0, 0}; + break; + case 1: // green + rgb = {0, 255, 0, 0}; + break; + case 2: // blue + rgb = {0, 0, 255, 0}; + break; + case 3: // black + default: + rgb = {0, 0, 0, 0}; + break; + } + + // ARGB + uint8_t *data = frame.data(); + const size_t size = frame.dataSize(); + for (size_t i = 0; i < size; i += 4) { + data[i + 0] = 255; // A + data[i + 1] = rgb[0]; // R + data[i + 2] = rgb[1]; // G + data[i + 3] = rgb[2]; // B + } + + try { + // If VideoSource is ARGB-capable, pass frame. + // If it expects I420, pass i420 instead. + source->captureFrame(frame, 0, VideoRotation::VIDEO_ROTATION_0); + } catch (const std::exception &e) { + LK_LOG_ERROR("Error in captureFrame (fake video): {}", e.what()); + break; + } + + std::this_thread::sleep_for(std::chrono::duration(framerate)); + } +} diff --git a/simple_room/fallback_capture.h b/simple_room/fallback_capture.h new file mode 100644 index 0000000..a7d8536 --- /dev/null +++ b/simple_room/fallback_capture.h @@ -0,0 +1,35 @@ +/* + * Copyright 2025 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +// Assuming you already have this somewhere: +extern std::atomic g_running; + +namespace livekit { +class AudioSource; +class VideoSource; +} // namespace livekit + +void runNoiseCaptureLoop(const std::shared_ptr &source, + std::atomic &running_flag); + +void runFakeVideoCaptureLoop( + const std::shared_ptr &source, + std::atomic &running_flag); diff --git a/simple_room/main.cpp b/simple_room/main.cpp new file mode 100644 index 0000000..bd7c6ab --- /dev/null +++ b/simple_room/main.cpp @@ -0,0 +1,416 @@ +/* + * Copyright 2025 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "livekit/livekit.h" +#include "sdl_media_manager.h" +#include "wav_audio_source.h" + +using namespace livekit; + +namespace { + +std::atomic g_running{true}; + +void printUsage(const char *prog) { + std::cerr + << "Usage:\n" + << " " << prog + << " [--enable_e2ee] [--e2ee_key ]\n" + << "or:\n" + << " " << prog + << " --url= --token= [--enable_e2ee] [--e2ee_key=]\n" + << " " << prog + << " --url --token [--enable_e2ee] [--e2ee_key " + "]\n\n" + << "E2EE:\n" + << " --enable_e2ee Enable end-to-end encryption (E2EE)\n" + << " --e2ee_key Optional shared key (UTF-8). If omitted, " + "E2EE is enabled\n" + << " but no shared key is set (advanced " + "usage).\n\n" + << "Env fallbacks:\n" + << " LIVEKIT_URL, LIVEKIT_TOKEN, LIVEKIT_E2EE_KEY\n"; +} + +void handleSignal(int) { g_running.store(false); } + +bool parseArgs(int argc, char *argv[], std::string &url, std::string &token, + bool &enable_e2ee, std::string &e2ee_key) { + enable_e2ee = false; + // --help + for (int i = 1; i < argc; ++i) { + std::string a = argv[i]; + if (a == "-h" || a == "--help") { + return false; + } + } + + // flags: --url= / --token= or split form + auto get_flag_value = [&](const std::string &name, int &i) -> std::string { + std::string arg = argv[i]; + const std::string eq = name + "="; + if (arg.rfind(name, 0) == 0) { // starts with name + if (arg.size() > name.size() && arg[name.size()] == '=') { + return arg.substr(eq.size()); + } else if (i + 1 < argc) { + return std::string(argv[++i]); + } + } + return {}; + }; + + for (int i = 1; i < argc; ++i) { + const std::string a = argv[i]; + if (a == "--enable_e2ee") { + enable_e2ee = true; + } else if (a.rfind("--url", 0) == 0) { + auto v = get_flag_value("--url", i); + if (!v.empty()) + url = v; + } else if (a.rfind("--token", 0) == 0) { + auto v = get_flag_value("--token", i); + if (!v.empty()) + token = v; + } else if (a.rfind("--e2ee_key", 0) == 0) { + auto v = get_flag_value("--e2ee_key", i); + if (!v.empty()) + e2ee_key = v; + } + } + + // positional if still empty + if (url.empty() || token.empty()) { + std::vector pos; + for (int i = 1; i < argc; ++i) { + std::string a = argv[i]; + if (a.rfind("--", 0) == 0) + continue; // skip flags we already parsed + pos.push_back(std::move(a)); + } + if (pos.size() >= 2) { + if (url.empty()) + url = pos[0]; + if (token.empty()) + token = pos[1]; + } + } + + // 4) env fallbacks + if (url.empty()) { + const char *e = std::getenv("LIVEKIT_URL"); + if (e) + url = e; + } + if (token.empty()) { + const char *e = std::getenv("LIVEKIT_TOKEN"); + if (e) + token = e; + } + if (e2ee_key.empty()) { + const char *e = std::getenv("LIVEKIT_E2EE_KEY"); + if (e) + e2ee_key = e; + } + + return !(url.empty() || token.empty()); +} + +class MainThreadDispatcher { +public: + static void dispatch(std::function fn) { + std::lock_guard lock(mutex_); + queue_.push(std::move(fn)); + } + + static void update() { + std::queue> local; + + { + std::lock_guard lock(mutex_); + std::swap(local, queue_); + } + + // Run everything on main thread + while (!local.empty()) { + local.front()(); + local.pop(); + } + } + +private: + static inline std::mutex mutex_; + static inline std::queue> queue_; +}; + +class SimpleRoomDelegate : public livekit::RoomDelegate { +public: + explicit SimpleRoomDelegate(SDLMediaManager &media) : media_(media) {} + + void onParticipantConnected( + livekit::Room & /*room*/, + const livekit::ParticipantConnectedEvent &ev) override { + std::cout << "[Room] participant connected: identity=" + << ev.participant->identity() + << " name=" << ev.participant->name() << "\n"; + } + + void onTrackSubscribed(livekit::Room & /*room*/, + const livekit::TrackSubscribedEvent &ev) override { + const char *participant_identity = + ev.participant ? ev.participant->identity().c_str() : ""; + const std::string track_sid = + ev.publication ? ev.publication->sid() : ""; + const std::string track_name = + ev.publication ? ev.publication->name() : ""; + std::cout << "[Room] track subscribed: participant_identity=" + << participant_identity << " track_sid=" << track_sid + << " name=" << track_name; + if (ev.track) { + std::cout << " kind=" << static_cast(ev.track->kind()); + } + if (ev.publication) { + std::cout << " source=" << static_cast(ev.publication->source()); + } + std::cout << std::endl; + + // If this is a VIDEO track, create a VideoStream and attach to renderer + if (ev.track && ev.track->kind() == TrackKind::KIND_VIDEO) { + VideoStream::Options opts; + opts.format = livekit::VideoBufferType::RGBA; + auto video_stream = VideoStream::fromTrack(ev.track, opts); + if (!video_stream) { + LK_LOG_ERROR("Failed to create VideoStream for track {}", track_sid); + return; + } + + MainThreadDispatcher::dispatch([this, video_stream] { + if (!media_.initRenderer(video_stream)) { + LK_LOG_ERROR("SDLMediaManager::startRenderer failed for track"); + } + }); + } else if (ev.track && ev.track->kind() == TrackKind::KIND_AUDIO) { + AudioStream::Options opts; + auto audio_stream = AudioStream::fromTrack(ev.track, opts); + MainThreadDispatcher::dispatch([this, audio_stream] { + if (!media_.startSpeaker(audio_stream)) { + LK_LOG_ERROR("SDLMediaManager::startSpeaker failed for track"); + } + }); + } + } + +private: + SDLMediaManager &media_; +}; + +static std::vector toBytes(const std::string &s) { + return std::vector(s.begin(), s.end()); +} + +void print_livekit_version() { + std::cout << "LiveKit version: " << LIVEKIT_BUILD_VERSION_FULL << " (" + << LIVEKIT_BUILD_FLAVOR << ", commit " << LIVEKIT_BUILD_COMMIT + << ", built " << LIVEKIT_BUILD_DATE << ")" << std::endl; +} + +} // namespace + +int main(int argc, char *argv[]) { + print_livekit_version(); + std::string url, token; + bool enable_e2ee = false; + std::string e2ee_key; + if (!parseArgs(argc, argv, url, token, enable_e2ee, e2ee_key)) { + printUsage(argv[0]); + return 1; + } + + // Exit if token and url are not set + if (url.empty() || token.empty()) { + std::cerr << "LIVEKIT_URL and LIVEKIT_TOKEN (or CLI args) are required\n"; + return 1; + } + + if (!SDL_Init(SDL_INIT_VIDEO)) { + LK_LOG_ERROR("SDL_Init(SDL_INIT_VIDEO) failed: {}", SDL_GetError()); + // You can choose to exit, or run in "headless" mode without renderer. + // return 1; + } + + // Setup media; + SDLMediaManager media; + + std::cout << "Connecting to: " << url << std::endl; + + // Handle Ctrl-C to exit the idle loop + std::signal(SIGINT, handleSignal); + + livekit::initialize(); + auto room = std::make_unique(); + SimpleRoomDelegate delegate(media); + room->setDelegate(&delegate); + + RoomOptions options; + options.auto_subscribe = true; + options.dynacast = false; + + if (enable_e2ee) { + livekit::E2EEOptions encryption; + encryption.encryption_type = livekit::EncryptionType::GCM; + // Optional shared key: if empty, we enable E2EE without setting a shared + // key. (Advanced use: keys can be set/ratcheted later via + // E2EEManager/KeyProvider.) + if (!e2ee_key.empty()) { + encryption.key_provider_options.shared_key = toBytes(e2ee_key); + } + options.encryption = encryption; + if (!e2ee_key.empty()) { + std::cout << "[E2EE] enabled : (shared key length=" << e2ee_key.size() + << ")\n"; + } else { + std::cout << "[E2EE] enabled: (no shared key set)\n"; + } + } + + bool res = room->Connect(url, token, options); + std::cout << "Connect result is " << std::boolalpha << res << std::endl; + if (!res) { + LK_LOG_ERROR("Failed to connect to room"); + livekit::shutdown(); + return 1; + } + + auto info = room->room_info(); + std::cout << "Connected to room:\n" + << " SID: " << (info.sid ? *info.sid : "(none)") << "\n" + << " Name: " << info.name << "\n" + << " Metadata: " << info.metadata << "\n" + << " Max participants: " << info.max_participants << "\n" + << " Num participants: " << info.num_participants << "\n" + << " Num publishers: " << info.num_publishers << "\n" + << " Active recording: " << (info.active_recording ? "yes" : "no") + << "\n" + << " Empty timeout (s): " << info.empty_timeout << "\n" + << " Departure timeout (s): " << info.departure_timeout << "\n" + << " Lossy DC low threshold: " + << info.lossy_dc_buffered_amount_low_threshold << "\n" + << " Reliable DC low threshold: " + << info.reliable_dc_buffered_amount_low_threshold << "\n" + << " Creation time (ms): " << info.creation_time << "\n"; + + // Setup Audio Source / Track + auto audioSource = std::make_shared(44100, 1, 0); + auto audioTrack = + LocalAudioTrack::createLocalAudioTrack("micTrack", audioSource); + + TrackPublishOptions audioOpts; + audioOpts.source = TrackSource::SOURCE_MICROPHONE; + audioOpts.dtx = false; + audioOpts.simulcast = false; + try { + room->localParticipant()->publishTrack(audioTrack, audioOpts); + const auto audioPub = audioTrack->publication(); + + std::cout << "Published track:\n" + << " SID: " << audioPub->sid() << "\n" + << " Name: " << audioPub->name() << "\n" + << " Kind: " << static_cast(audioPub->kind()) << "\n" + << " Source: " << static_cast(audioPub->source()) << "\n" + << " Simulcasted: " << std::boolalpha << audioPub->simulcasted() + << "\n" + << " Muted: " << std::boolalpha << audioPub->muted() << "\n"; + } catch (const std::exception &e) { + LK_LOG_ERROR("Failed to publish track: {}", e.what()); + } + + media.startMic(audioSource); + + // Setup Video Source / Track + auto videoSource = std::make_shared(1280, 720); + auto videoTrack = LocalVideoTrack::createLocalVideoTrack("cam", videoSource); + + TrackPublishOptions videoOpts; + videoOpts.source = TrackSource::SOURCE_CAMERA; + videoOpts.dtx = false; + videoOpts.simulcast = true; + try { + // publishTrack takes std::shared_ptr, LocalAudioTrack derives from + // Track + room->localParticipant()->publishTrack(videoTrack, videoOpts); + + const auto videoPub = videoTrack->publication(); + + std::cout << "Published track:\n" + << " SID: " << videoPub->sid() << "\n" + << " Name: " << videoPub->name() << "\n" + << " Kind: " << static_cast(videoPub->kind()) << "\n" + << " Source: " << static_cast(videoPub->source()) << "\n" + << " Simulcasted: " << std::boolalpha << videoPub->simulcasted() + << "\n" + << " Muted: " << std::boolalpha << videoPub->muted() << "\n"; + } catch (const std::exception &e) { + LK_LOG_ERROR("Failed to publish track: {}", e.what()); + } + media.startCamera(videoSource); + + // Keep the app alive until Ctrl-C so we continue receiving events, + // similar to asyncio.run(main()) keeping the loop running. + while (g_running.load()) { + MainThreadDispatcher::update(); + media.render(); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + // Shutdown the audio / video capture threads. + media.stopMic(); + media.stopCamera(); + media.stopSpeaker(); + media.shutdownRenderer(); + + // Drain any queued tasks that might still try to update the renderer / + // speaker + MainThreadDispatcher::update(); + + // Must be cleaned up before FfiClient::instance().shutdown(); + room->setDelegate(nullptr); + + if (audioTrack->publication()) { + room->localParticipant()->unpublishTrack(audioTrack->publication()->sid()); + } + if (videoTrack->publication()) { + room->localParticipant()->unpublishTrack(videoTrack->publication()->sid()); + } + audioTrack.reset(); + videoTrack.reset(); + + room.reset(); + + livekit::shutdown(); + std::cout << "Exiting.\n"; + return 0; +} diff --git a/simple_room/sdl_media.cpp b/simple_room/sdl_media.cpp new file mode 100644 index 0000000..d4a44e6 --- /dev/null +++ b/simple_room/sdl_media.cpp @@ -0,0 +1,226 @@ +/* + * Copyright 2025 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "sdl_media.h" + +#include "livekit/lk_log.h" + +// ---------------------- SDLMicSource ----------------------------- + +SDLMicSource::SDLMicSource(int sample_rate, int channels, int frame_samples, + AudioCallback cb) + : sample_rate_(sample_rate), channels_(channels), + frame_samples_(frame_samples), callback_(std::move(cb)) {} + +SDLMicSource::~SDLMicSource() { + if (stream_) { + SDL_DestroyAudioStream(stream_); + stream_ = nullptr; + } +} + +bool SDLMicSource::init() { + // desired output (what SDL will give us when we call SDL_GetAudioStreamData) + SDL_zero(spec_); + spec_.format = SDL_AUDIO_S16; // 16-bit signed + spec_.channels = static_cast(channels_); + spec_.freq = sample_rate_; + + // Open default recording device as an audio stream + // This works for both playback and recording, depending on the device id. + stream_ = SDL_OpenAudioDeviceStream( + SDL_AUDIO_DEVICE_DEFAULT_RECORDING, // recording device + &spec_, + nullptr, // no callback, we'll poll + nullptr); + + if (!stream_) { + LK_LOG_ERROR("Failed to open recording stream: {}", SDL_GetError()); + return false; + } + + if (!SDL_ResumeAudioStreamDevice(stream_)) { // unpause device + LK_LOG_ERROR("Failed to resume recording device: {}", SDL_GetError()); + return false; + } + + return true; +} + +void SDLMicSource::pump() { + if (!stream_ || !callback_) + return; + + const int samples_per_frame_total = frame_samples_ * channels_; + const int bytes_per_frame = samples_per_frame_total * sizeof(int16_t); + + // Only pull if at least one "frame" worth of audio is available + const int available = SDL_GetAudioStreamAvailable(stream_); // bytes + if (available < bytes_per_frame) { + return; + } + + std::vector buffer(samples_per_frame_total); + + const int got_bytes = SDL_GetAudioStreamData(stream_, buffer.data(), + bytes_per_frame); // + + if (got_bytes <= 0) { + return; // nothing or error (log if you like) + } + + const int got_samples_total = got_bytes / sizeof(int16_t); + const int got_samples_per_channel = got_samples_total / channels_; + + callback_(buffer.data(), got_samples_per_channel, sample_rate_, channels_); +} + +void SDLMicSource::pause() { + if (stream_) { + SDL_PauseAudioStreamDevice(stream_); // + } +} + +void SDLMicSource::resume() { + if (stream_) { + SDL_ResumeAudioStreamDevice(stream_); // + } +} + +// ---------------------- DDLSpeakerSink ----------------------------- + +DDLSpeakerSink::DDLSpeakerSink(int sample_rate, int channels) + : sample_rate_(sample_rate), channels_(channels) {} + +DDLSpeakerSink::~DDLSpeakerSink() { + if (stream_) { + SDL_DestroyAudioStream(stream_); // also closes device + stream_ = nullptr; + } +} + +bool DDLSpeakerSink::init() { + SDL_zero(spec_); + spec_.format = SDL_AUDIO_S16; // expect S16 input for playback + spec_.channels = static_cast(channels_); + spec_.freq = sample_rate_; + + // Open default playback device as a stream. + stream_ = SDL_OpenAudioDeviceStream(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &spec_, + nullptr, // no callback; we'll push data + nullptr); + + if (!stream_) { + LK_LOG_ERROR("Failed to open playback stream: {}", SDL_GetError()); + return false; + } + + if (!SDL_ResumeAudioStreamDevice(stream_)) { + LK_LOG_ERROR("Failed to resume playback device: {}", SDL_GetError()); + return false; + } + + return true; +} + +void DDLSpeakerSink::enqueue(const int16_t *samples, + int num_samples_per_channel) { + if (!stream_ || !samples) + return; + + const int totalSamples = num_samples_per_channel * channels_; + const int bytes = totalSamples * static_cast(sizeof(int16_t)); + + // SDL will resample / convert as needed on SDL_GetAudioStreamData() side. + if (!SDL_PutAudioStreamData(stream_, samples, bytes)) { + LK_LOG_ERROR("SDL_PutAudioStreamData failed: {}", SDL_GetError()); + } +} + +void DDLSpeakerSink::pause() { + if (stream_) { + SDL_PauseAudioStreamDevice(stream_); + } +} + +void DDLSpeakerSink::resume() { + if (stream_) { + SDL_ResumeAudioStreamDevice(stream_); + } +} + +// ---------------------- SDLCamSource ----------------------------- + +SDLCamSource::SDLCamSource(int desired_width, int desired_height, + int desired_fps, SDL_PixelFormat pixel_format, + VideoCallback cb) + : width_(desired_width), height_(desired_height), fps_(desired_fps), + format_(pixel_format), callback_(std::move(cb)) {} + +SDLCamSource::~SDLCamSource() { + if (camera_) { + SDL_CloseCamera(camera_); // + camera_ = nullptr; + } +} + +bool SDLCamSource::init() { + int count = 0; + SDL_CameraID *cams = SDL_GetCameras(&count); // + if (!cams || count == 0) { + LK_LOG_ERROR("No cameras available: {}", SDL_GetError()); + if (cams) + SDL_free(cams); + return false; + } + + SDL_CameraID camId = cams[0]; // first camera for now + SDL_free(cams); + + SDL_zero(spec_); + spec_.format = format_; + spec_.colorspace = SDL_COLORSPACE_SRGB; + spec_.width = width_; + spec_.height = height_; + spec_.framerate_numerator = fps_; + spec_.framerate_denominator = 1; + + camera_ = SDL_OpenCamera(camId, &spec_); + if (!camera_) { + LK_LOG_ERROR("Failed to open camera: {}", SDL_GetError()); + return false; + } + + // On many platforms you must wait for SDL_EVENT_CAMERA_DEVICE_APPROVED; + // here we assume the app’s main loop is already handling that. + return true; +} + +void SDLCamSource::pump() { + if (!camera_ || !callback_) + return; + + Uint64 tsNS = 0; + SDL_Surface *surf = SDL_AcquireCameraFrame(camera_, &tsNS); // non-blocking + if (!surf) { + return; + } + + callback_(static_cast(surf->pixels), surf->pitch, surf->w, surf->h, + surf->format, tsNS); + + SDL_ReleaseCameraFrame(camera_, surf); // +} diff --git a/simple_room/sdl_media.h b/simple_room/sdl_media.h new file mode 100644 index 0000000..a60bca6 --- /dev/null +++ b/simple_room/sdl_media.h @@ -0,0 +1,128 @@ +/* + * Copyright 2025 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +// ------------------------- +// SDLMicSource +// ------------------------- +// Periodically call pump() from your main loop or a capture thread. +// It will pull 10ms frames from the mic (by default) and pass them to the +// AudioCallback. +class SDLMicSource { +public: + using AudioCallback = std::function; + + SDLMicSource(int sample_rate = 48000, int channels = 1, + int frame_samples = 480, AudioCallback cb = nullptr); + + ~SDLMicSource(); + + // Initialize SDL audio stream for recording + bool init(); + + // Call regularly to pull mic data and send to callback. + void pump(); + + void pause(); + void resume(); + + bool isValid() const { return stream_ != nullptr; } + +private: + SDL_AudioStream *stream_ = nullptr; + SDL_AudioSpec spec_{}; + int sample_rate_; + int channels_; + int frame_samples_; + AudioCallback callback_; +}; + +// ------------------------- +// DDLSpeakerSink +// ------------------------- +// For remote audio: when you get a decoded PCM frame, +// call enqueue() with interleaved S16 samples. +class DDLSpeakerSink { +public: + DDLSpeakerSink(int sample_rate = 48000, int channels = 1); + + ~DDLSpeakerSink(); + + bool init(); + + // Enqueue interleaved S16 samples for playback. + void enqueue(const int16_t *samples, int num_samples_per_channel); + + void pause(); + void resume(); + + bool isValid() const { return stream_ != nullptr; } + +private: + SDL_AudioStream *stream_ = nullptr; + SDL_AudioSpec spec_{}; + int sample_rate_; + int channels_; +}; + +// ------------------------- +// SDLCamSource +// ------------------------- +// Periodically call pump(); each time a new frame is available +// it will invoke the VideoCallback with the raw pixels. +// +// NOTE: pixels are in the SDL_Surface format returned by the camera +// (often SDL_PIXELFORMAT_ARGB8888). You can either: +// - convert to whatever your LiveKit video source expects, or +// - tell LiveKit that this is ARGB with the given stride. +class SDLCamSource { +public: + using VideoCallback = std::function; + + SDLCamSource(int desired_width = 1280, int desired_height = 720, + int desired_fps = 30, + SDL_PixelFormat pixelFormat = SDL_PIXELFORMAT_RGBA8888, + VideoCallback cb = nullptr); + + ~SDLCamSource(); + + bool init(); // open first available camera with (approximately) given spec + + // Call regularly; will call VideoCallback when a frame is available. + void pump(); + + bool isValid() const { return camera_ != nullptr; } + +private: + SDL_Camera *camera_ = nullptr; + SDL_CameraSpec spec_{}; + int width_; + int height_; + int fps_; + SDL_PixelFormat format_; + VideoCallback callback_; +}; diff --git a/simple_room/sdl_media_manager.cpp b/simple_room/sdl_media_manager.cpp new file mode 100644 index 0000000..f44c60a --- /dev/null +++ b/simple_room/sdl_media_manager.cpp @@ -0,0 +1,402 @@ +/* + * Copyright 2025 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "sdl_media_manager.h" + +#include "fallback_capture.h" +#include "livekit/livekit.h" +#include "livekit/lk_log.h" +#include "sdl_media.h" +#include "sdl_video_renderer.h" +#include +#include +using namespace livekit; + +SDLMediaManager::SDLMediaManager() = default; + +SDLMediaManager::~SDLMediaManager() { + stopMic(); + stopCamera(); + stopSpeaker(); + shutdownRenderer(); +} + +bool SDLMediaManager::ensureSDLInit(Uint32 flags) { + if ((SDL_WasInit(flags) & flags) == flags) { + return true; // already init + } + if (!SDL_InitSubSystem(flags)) { + LK_LOG_ERROR("SDL_InitSubSystem failed (flags={}): {}", flags, + SDL_GetError()); + return false; + } + return true; +} + +// ---------- Mic control ---------- + +bool SDLMediaManager::startMic( + const std::shared_ptr &audio_source) { + stopMic(); + + if (!audio_source) { + LK_LOG_ERROR("startMic: audioSource is null"); + return false; + } + + mic_source_ = audio_source; + mic_running_.store(true, std::memory_order_relaxed); + + // Try SDL path + if (!ensureSDLInit(SDL_INIT_AUDIO)) { + LK_LOG_WARN("No SDL audio, falling back to noise loop."); + mic_using_sdl_ = false; + mic_thread_ = + std::thread(runNoiseCaptureLoop, mic_source_, std::ref(mic_running_)); + return true; + } + + int recCount = 0; + SDL_AudioDeviceID *recDevs = SDL_GetAudioRecordingDevices(&recCount); + if (!recDevs || recCount == 0) { + LK_LOG_WARN("No microphone devices found, falling back to noise loop."); + if (recDevs) + SDL_free(recDevs); + mic_using_sdl_ = false; + mic_thread_ = + std::thread(runNoiseCaptureLoop, mic_source_, std::ref(mic_running_)); + return true; + } + SDL_free(recDevs); + + // We have at least one mic; use SDL + mic_using_sdl_ = true; + + mic_sdl_ = std::make_unique( + mic_source_->sample_rate(), mic_source_->num_channels(), + mic_source_->sample_rate() / 100, // ~10ms + [src = mic_source_](const int16_t *samples, int num_samples_per_channel, + int sample_rate, int num_channels) { + AudioFrame frame = AudioFrame::create(sample_rate, num_channels, + num_samples_per_channel); + std::memcpy(frame.data().data(), samples, + num_samples_per_channel * num_channels * sizeof(int16_t)); + try { + src->captureFrame(frame); + } catch (const std::exception &e) { + LK_LOG_ERROR("Error in captureFrame (SDL mic): {}", e.what()); + } + }); + + if (!mic_sdl_->init()) { + LK_LOG_WARN("Failed to init SDL mic, falling back to noise loop."); + mic_using_sdl_ = false; + mic_sdl_.reset(); + mic_thread_ = + std::thread(runNoiseCaptureLoop, mic_source_, std::ref(mic_running_)); + return true; + } + + mic_thread_ = std::thread(&SDLMediaManager::micLoopSDL, this); + return true; +} + +void SDLMediaManager::micLoopSDL() { + while (mic_running_.load(std::memory_order_relaxed)) { + mic_sdl_->pump(); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } +} + +void SDLMediaManager::stopMic() { + mic_running_.store(false, std::memory_order_relaxed); + if (mic_thread_.joinable()) { + mic_thread_.join(); + } + mic_sdl_.reset(); + mic_source_.reset(); +} + +// ---------- Camera control ---------- + +bool SDLMediaManager::startCamera( + const std::shared_ptr &video_source) { + stopCamera(); + + if (!video_source) { + LK_LOG_ERROR("startCamera: videoSource is null"); + return false; + } + + cam_source_ = video_source; + cam_running_.store(true, std::memory_order_relaxed); + + // Try SDL + if (!ensureSDLInit(SDL_INIT_CAMERA)) { + LK_LOG_WARN("No SDL camera subsystem, using fake video loop."); + cam_using_sdl_ = false; + cam_thread_ = std::thread(runFakeVideoCaptureLoop, cam_source_, + std::ref(cam_running_)); + return true; + } + + int camCount = 0; + SDL_CameraID *cams = SDL_GetCameras(&camCount); + if (!cams || camCount == 0) { + LK_LOG_WARN("No camera devices found, using fake video loop."); + if (cams) + SDL_free(cams); + cam_using_sdl_ = false; + cam_thread_ = std::thread(runFakeVideoCaptureLoop, cam_source_, + std::ref(cam_running_)); + return true; + } + SDL_free(cams); + + cam_using_sdl_ = true; + can_sdl_ = std::make_unique( + 1280, 720, 30, + SDL_PIXELFORMAT_RGBA32, // Note SDL_PIXELFORMAT_RGBA8888 is not compatable + // with Livekit RGBA format. + [src = cam_source_](const uint8_t *pixels, int pitch, int width, + int height, SDL_PixelFormat /*fmt*/, + Uint64 timestampNS) { + auto frame = VideoFrame::create(width, height, VideoBufferType::RGBA); + uint8_t *dst = frame.data(); + const int dstPitch = width * 4; + + for (int y = 0; y < height; ++y) { + std::memcpy(dst + y * dstPitch, pixels + y * pitch, dstPitch); + } + + try { + src->captureFrame(frame, timestampNS / 1000, + VideoRotation::VIDEO_ROTATION_0); + } catch (const std::exception &e) { + LK_LOG_ERROR("Error in captureFrame (SDL cam): {}", e.what()); + } + }); + + if (!can_sdl_->init()) { + LK_LOG_WARN("Failed to init SDL camera, using fake video loop."); + cam_using_sdl_ = false; + can_sdl_.reset(); + cam_thread_ = std::thread(runFakeVideoCaptureLoop, cam_source_, + std::ref(cam_running_)); + return true; + } + + cam_thread_ = std::thread(&SDLMediaManager::cameraLoopSDL, this); + return true; +} + +void SDLMediaManager::cameraLoopSDL() { + while (cam_running_.load(std::memory_order_relaxed)) { + can_sdl_->pump(); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } +} + +void SDLMediaManager::stopCamera() { + cam_running_.store(false, std::memory_order_relaxed); + if (cam_thread_.joinable()) { + cam_thread_.join(); + } + can_sdl_.reset(); + cam_source_.reset(); +} + +// ---------- Speaker control (placeholder) ---------- + +bool SDLMediaManager::startSpeaker( + const std::shared_ptr &audio_stream) { + stopSpeaker(); + + if (!audio_stream) { + LK_LOG_ERROR("startSpeaker: audioStream is null"); + return false; + } + + if (!ensureSDLInit(SDL_INIT_AUDIO)) { + LK_LOG_ERROR("startSpeaker: SDL_INIT_AUDIO failed"); + return false; + } + + speaker_stream_ = audio_stream; + speaker_running_.store(true, std::memory_order_relaxed); + + // Note, we don't open the speaker since the format is unknown yet. + // Instead, open the speaker in the speakerLoopSDL thread with the native + // format. + try { + speaker_thread_ = std::thread(&SDLMediaManager::speakerLoopSDL, this); + } catch (const std::exception &e) { + LK_LOG_ERROR("startSpeaker: failed to start speaker thread: {}", e.what()); + speaker_running_.store(false, std::memory_order_relaxed); + speaker_stream_.reset(); + return false; + } + + return true; +} + +void SDLMediaManager::speakerLoopSDL() { + SDL_AudioStream *localStream = nullptr; + SDL_AudioDeviceID dev = 0; + + while (speaker_running_.load(std::memory_order_relaxed)) { + if (!speaker_stream_) { + break; + } + + livekit::AudioFrameEvent ev; + if (!speaker_stream_->read(ev)) { + // EOS or closed + break; + } + + const livekit::AudioFrame &frame = ev.frame; + const auto &data = frame.data(); + if (data.empty()) { + continue; + } + + // Lazily open SDL audio stream based on the first frame's format, so no + // resampler is needed. + if (!localStream) { + SDL_AudioSpec want{}; + want.format = SDL_AUDIO_S16; + want.channels = static_cast(frame.num_channels()); + want.freq = frame.sample_rate(); + + localStream = + SDL_OpenAudioDeviceStream(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &want, + /*callback=*/nullptr, + /*userdata=*/nullptr); + + if (!localStream) { + LK_LOG_ERROR("speakerLoopSDL: SDL_OpenAudioDeviceStream failed: {}", + SDL_GetError()); + break; + } + + sdl_audio_stream_ = localStream; // store if you want to inspect later + + dev = SDL_GetAudioStreamDevice(localStream); + if (dev == 0) { + LK_LOG_ERROR("speakerLoopSDL: SDL_GetAudioStreamDevice failed: {}", + SDL_GetError()); + break; + } + + if (!SDL_ResumeAudioDevice(dev)) { + LK_LOG_ERROR("speakerLoopSDL: SDL_ResumeAudioDevice failed: {}", + SDL_GetError()); + break; + } + } + + // Push PCM to SDL. We assume frames are already S16, interleaved, matching + // sample_rate / channels we used above. + const int numBytes = static_cast(data.size() * sizeof(std::int16_t)); + + if (!SDL_PutAudioStreamData(localStream, data.data(), numBytes)) { + LK_LOG_ERROR("speakerLoopSDL: SDL_PutAudioStreamData failed: {}", + SDL_GetError()); + break; + } + + // Tiny sleep to avoid busy loop; SDL buffers internally. + SDL_Delay(2); + } + + if (localStream) { + SDL_DestroyAudioStream(localStream); + localStream = nullptr; + sdl_audio_stream_ = nullptr; + } + + speaker_running_.store(false, std::memory_order_relaxed); +} + +void SDLMediaManager::stopSpeaker() { + speaker_running_.store(false, std::memory_order_relaxed); + if (speaker_thread_.joinable()) { + speaker_thread_.join(); + } + if (sdl_audio_stream_) { + SDL_DestroyAudioStream(sdl_audio_stream_); + sdl_audio_stream_ = nullptr; + } + speaker_stream_.reset(); +} + +// ---------- Renderer control (placeholder) ---------- + +bool SDLMediaManager::initRenderer( + const std::shared_ptr &video_stream) { + if (!video_stream) { + LK_LOG_ERROR("startRenderer: videoStream is null"); + return false; + } + // Ensure SDL video subsystem is initialized + if (!ensureSDLInit(SDL_INIT_VIDEO)) { + LK_LOG_ERROR("startRenderer: SDL_INIT_VIDEO failed"); + return false; + } + renderer_stream_ = video_stream; + renderer_running_.store(true, std::memory_order_relaxed); + + // Lazily create the SDLVideoRenderer + if (!sdl_renderer_) { + sdl_renderer_ = std::make_unique(); + // You can tune these dimensions or even make them options + if (!sdl_renderer_->init("LiveKit Remote Video", 1280, 720)) { + LK_LOG_ERROR("startRenderer: SDLVideoRenderer::init failed"); + sdl_renderer_.reset(); + renderer_stream_.reset(); + renderer_running_.store(false, std::memory_order_relaxed); + return false; + } + } + + // Start the SDL renderer's own render thread + sdl_renderer_->setStream(renderer_stream_); + + return true; +} + +void SDLMediaManager::shutdownRenderer() { + renderer_running_.store(false, std::memory_order_relaxed); + + // Shut down SDL renderer thread if it exists + if (sdl_renderer_) { + sdl_renderer_->shutdown(); + } + + // Old renderer_thread_ is no longer used, but if you still have it: + if (renderer_thread_.joinable()) { + renderer_thread_.join(); + } + + renderer_stream_.reset(); +} + +void SDLMediaManager::render() { + if (renderer_running_.load(std::memory_order_relaxed) && sdl_renderer_) { + sdl_renderer_->render(); + } +} \ No newline at end of file diff --git a/simple_room/sdl_media_manager.h b/simple_room/sdl_media_manager.h new file mode 100644 index 0000000..cd9ba46 --- /dev/null +++ b/simple_room/sdl_media_manager.h @@ -0,0 +1,109 @@ +/* + * Copyright 2025 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +#include +#include +#include + +#include "wav_audio_source.h" + +namespace livekit { +class AudioSource; +class VideoSource; +class AudioStream; +class VideoStream; +} // namespace livekit + +// Forward-declared SDL helpers (you can also keep these separate if you like) +class SDLMicSource; +class SDLCamSource; +class SDLVideoRenderer; + +// SDLMediaManager gives you dedicated control over: +// - mic capture -> AudioSource +// - camera capture -> VideoSource +// - speaker playback -> AudioStream (TODO: integrate your API) +// - renderer -> VideoStream (TODO: integrate your API) +class SDLMediaManager { +public: + SDLMediaManager(); + ~SDLMediaManager(); + + // Mic (local capture -> AudioSource) + bool startMic(const std::shared_ptr &audio_source); + void stopMic(); + + // Camera (local capture -> VideoSource) + bool startCamera(const std::shared_ptr &video_source); + void stopCamera(); + + // Speaker (remote audio playback) + bool startSpeaker(const std::shared_ptr &audio_stream); + void stopSpeaker(); + + // Renderer (remote video rendering) + // Following APIs must be called on main thread + bool initRenderer(const std::shared_ptr &video_stream); + void shutdownRenderer(); + void render(); + +private: + // ---- SDL bootstrap helpers ---- + bool ensureSDLInit(Uint32 flags); + + // ---- Mic helpers ---- + void micLoopSDL(); + void micLoopNoise(); + + // ---- Camera helpers ---- + void cameraLoopSDL(); + void cameraLoopFake(); + + // ---- Speaker helpers (TODO: wire AudioStream -> SDL audio) ---- + void speakerLoopSDL(); + + // Mic + std::shared_ptr mic_source_; + std::unique_ptr mic_sdl_; + std::thread mic_thread_; + std::atomic mic_running_{false}; + bool mic_using_sdl_ = false; + + // Camera + std::shared_ptr cam_source_; + std::unique_ptr can_sdl_; + std::thread cam_thread_; + std::atomic cam_running_{false}; + bool cam_using_sdl_ = false; + + // Speaker (remote audio) – left mostly as a placeholder + std::shared_ptr speaker_stream_; + std::thread speaker_thread_; + std::atomic speaker_running_{false}; + SDL_AudioStream *sdl_audio_stream_ = nullptr; + + // Renderer (remote video) – left mostly as a placeholder + std::unique_ptr sdl_renderer_; + std::shared_ptr renderer_stream_; + std::thread renderer_thread_; + std::atomic renderer_running_{false}; +}; diff --git a/simple_room/sdl_video_renderer.cpp b/simple_room/sdl_video_renderer.cpp new file mode 100644 index 0000000..5ba2cd7 --- /dev/null +++ b/simple_room/sdl_video_renderer.cpp @@ -0,0 +1,176 @@ +/* + * Copyright 2025 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an “AS IS” BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "sdl_video_renderer.h" + +#include "livekit/livekit.h" +#include "livekit/lk_log.h" +#include + +using namespace livekit; + +constexpr int kMaxFPS = 60; + +SDLVideoRenderer::SDLVideoRenderer() = default; + +SDLVideoRenderer::~SDLVideoRenderer() { shutdown(); } + +bool SDLVideoRenderer::init(const char *title, int width, int height) { + width_ = width; + height_ = height; + + // Assume SDL_Init(SDL_INIT_VIDEO) already called in main() + window_ = SDL_CreateWindow(title, width_, height_, 0); + if (!window_) { + LK_LOG_ERROR("SDL_CreateWindow failed: {}", SDL_GetError()); + return false; + } + + renderer_ = SDL_CreateRenderer(window_, nullptr); + if (!renderer_) { + LK_LOG_ERROR("SDL_CreateRenderer failed: {}", SDL_GetError()); + return false; + } + + // Note, web will send out BGRA as default, and we can't use ARGB since ffi + // does not support converting from BGRA to ARGB. + texture_ = SDL_CreateTexture(renderer_, SDL_PIXELFORMAT_RGBA8888, + SDL_TEXTUREACCESS_STREAMING, width_, height_); + if (!texture_) { + LK_LOG_ERROR("SDL_CreateTexture failed: {}", SDL_GetError()); + return false; + } + + return true; +} + +void SDLVideoRenderer::shutdown() { + if (texture_) { + SDL_DestroyTexture(texture_); + texture_ = nullptr; + } + if (renderer_) { + SDL_DestroyRenderer(renderer_); + renderer_ = nullptr; + } + if (window_) { + SDL_DestroyWindow(window_); + window_ = nullptr; + } + + stream_.reset(); +} + +void SDLVideoRenderer::setStream(std::shared_ptr stream) { + stream_ = std::move(stream); +} + +void SDLVideoRenderer::render() { + // 0) Basic sanity + if (!window_ || !renderer_) { + return; + } + + // 1) Pump SDL events on the main thread + SDL_Event e; + while (SDL_PollEvent(&e)) { + if (e.type == SDL_EVENT_QUIT) { + // TODO: set some global or member flag if you want to quit the app + } + } + + // 2) If no stream, nothing to render + if (!stream_) { + return; + } + + // Throttle rendering to kMaxFPS + const auto now = std::chrono::steady_clock::now(); + if (last_render_time_.time_since_epoch().count() != 0) { + const auto min_interval = std::chrono::microseconds(1'000'000 / kMaxFPS); + if (now - last_render_time_ < min_interval) { + return; + } + } + last_render_time_ = now; + + // 3) Read a frame from VideoStream (blocking until one is available) + livekit::VideoFrameEvent vfe; + bool gotFrame = stream_->read(vfe); + if (!gotFrame) { + // EOS / closed – nothing more to render + return; + } + + livekit::VideoFrame &frame = vfe.frame; + + // 4) Ensure the frame is RGBA. + // Ideally you requested RGBA from VideoStream::Options so this is a no-op. + if (frame.type() != livekit::VideoBufferType::RGBA) { + try { + frame = frame.convert(livekit::VideoBufferType::RGBA, false); + } catch (const std::exception &ex) { + LK_LOG_ERROR("SDLVideoRenderer: convert to RGBA failed: {}", ex.what()); + return; + } + } + + // Handle size change: recreate texture if needed + if (frame.width() != width_ || frame.height() != height_) { + width_ = frame.width(); + height_ = frame.height(); + + if (texture_) { + SDL_DestroyTexture(texture_); + texture_ = nullptr; + } + texture_ = SDL_CreateTexture( + renderer_, + SDL_PIXELFORMAT_RGBA32, // Note, SDL_PIXELFORMAT_RGBA8888 is not + // compatible with Livekit RGBA format. + SDL_TEXTUREACCESS_STREAMING, width_, height_); + if (!texture_) { + LK_LOG_ERROR("SDLVideoRenderer: SDL_CreateTexture failed: {}", + SDL_GetError()); + return; + } + } + + // 6) Upload RGBA data to SDL texture + void *pixels = nullptr; + int pitch = 0; + if (!SDL_LockTexture(texture_, nullptr, &pixels, &pitch)) { + LK_LOG_ERROR("SDLVideoRenderer: SDL_LockTexture failed: {}", + SDL_GetError()); + return; + } + + const std::uint8_t *src = frame.data(); + const int srcPitch = frame.width() * 4; // RGBA: 4 bytes per pixel + + for (int y = 0; y < frame.height(); ++y) { + std::memcpy(static_cast(pixels) + y * pitch, + src + y * srcPitch, srcPitch); + } + + SDL_UnlockTexture(texture_); + + // 7) Present + SDL_SetRenderDrawColor(renderer_, 0, 0, 0, 255); + SDL_RenderClear(renderer_); + SDL_RenderTexture(renderer_, texture_, nullptr, nullptr); + SDL_RenderPresent(renderer_); +} diff --git a/simple_room/sdl_video_renderer.h b/simple_room/sdl_video_renderer.h new file mode 100644 index 0000000..fb0d41e --- /dev/null +++ b/simple_room/sdl_video_renderer.h @@ -0,0 +1,53 @@ +/* + * Copyright 2025 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an “AS IS” BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +namespace livekit { +class VideoStream; +} + +class SDLVideoRenderer { +public: + SDLVideoRenderer(); + ~SDLVideoRenderer(); + + // Must be called on main thread, after SDL_Init(SDL_INIT_VIDEO). + bool init(const char *title, int width, int height); + + // Set/replace the stream to render. Safe to call from main thread. + void setStream(std::shared_ptr stream); + + // Called on main thread each tick to pump events and draw latest frame. + void render(); + + void shutdown(); // destroy window/renderer/texture + +private: + SDL_Window *window_ = nullptr; + SDL_Renderer *renderer_ = nullptr; + SDL_Texture *texture_ = nullptr; + + std::shared_ptr stream_; + int width_ = 0; + int height_ = 0; + std::chrono::steady_clock::time_point last_render_time_{}; +}; diff --git a/simple_room/wav_audio_source.cpp b/simple_room/wav_audio_source.cpp new file mode 100644 index 0000000..b519b81 --- /dev/null +++ b/simple_room/wav_audio_source.cpp @@ -0,0 +1,162 @@ +/* + * Copyright 2025 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an “AS IS” BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "wav_audio_source.h" + +#include +#include +#include + +// -------------------------------------------------- +// Minimal WAV loader (16-bit PCM only) +// -------------------------------------------------- +WavData load_wav16(const std::string &path) { + std::ifstream file(path, std::ios::binary); + if (!file) { + throw std::runtime_error("Failed to open WAV file: " + path + + " (If this file exists in the repo, ensure Git " + "LFS is installed and run `git lfs pull`)"); + } + + auto read_u32 = [&](uint32_t &out_value) { + file.read(reinterpret_cast(&out_value), 4); + }; + auto read_u16 = [&](uint16_t &out_value) { + file.read(reinterpret_cast(&out_value), 2); + }; + + char riff[4]; + file.read(riff, 4); + if (std::strncmp(riff, "RIFF", 4) != 0) { + throw std::runtime_error("Not a RIFF file"); + } + + uint32_t chunk_size = 0; + read_u32(chunk_size); + + char wave[4]; + file.read(wave, 4); + if (std::strncmp(wave, "WAVE", 4) != 0) { + throw std::runtime_error("Not a WAVE file"); + } + + uint16_t audio_format = 0; + uint16_t num_channels = 0; + uint32_t sample_rate = 0; + uint16_t bits_per_sample = 0; + + bool have_fmt = false; + bool have_data = false; + std::vector samples; + + while (!have_data && file) { + char sub_id[4]; + file.read(sub_id, 4); + + uint32_t sub_size = 0; + read_u32(sub_size); + + if (std::strncmp(sub_id, "fmt ", 4) == 0) { + have_fmt = true; + + read_u16(audio_format); + read_u16(num_channels); + read_u32(sample_rate); + + uint32_t byte_rate = 0; + uint16_t block_align = 0; + read_u32(byte_rate); + read_u16(block_align); + read_u16(bits_per_sample); + + if (sub_size > 16) { + file.seekg(sub_size - 16, std::ios::cur); + } + + if (audio_format != 1) { + throw std::runtime_error("Only PCM WAV supported"); + } + if (bits_per_sample != 16) { + throw std::runtime_error("Only 16-bit WAV supported"); + } + + } else if (std::strncmp(sub_id, "data", 4) == 0) { + if (!have_fmt) { + throw std::runtime_error("data chunk appeared before fmt chunk"); + } + + have_data = true; + const std::size_t count = sub_size / sizeof(int16_t); + samples.resize(count); + file.read(reinterpret_cast(samples.data()), sub_size); + + } else { + // Unknown chunk: skip it + file.seekg(sub_size, std::ios::cur); + } + } + + if (!have_data) { + throw std::runtime_error("No data chunk in WAV file"); + } + + WavData out; + out.sample_rate = static_cast(sample_rate); + out.num_channels = static_cast(num_channels); + out.samples = std::move(samples); + return out; +} + +WavAudioSource::WavAudioSource(const std::string &path, + int expected_sample_rate, int expected_channels, + bool loop_enabled) + : loop_enabled_(loop_enabled) { + wav_ = load_wav16(path); + + if (wav_.sample_rate != expected_sample_rate) { + throw std::runtime_error("WAV sample rate mismatch"); + } + if (wav_.num_channels != expected_channels) { + throw std::runtime_error("WAV channel count mismatch"); + } + + sample_rate_ = wav_.sample_rate; + num_channels_ = wav_.num_channels; + + playhead_ = 0; +} + +void WavAudioSource::fillFrame(AudioFrame &frame) { + const std::size_t frame_samples = + static_cast(frame.num_channels()) * + static_cast(frame.samples_per_channel()); + + int16_t *dst = frame.data().data(); + const std::size_t total_wav_samples = wav_.samples.size(); + + for (std::size_t i = 0; i < frame_samples; ++i) { + if (playhead_ < total_wav_samples) { + dst[i] = wav_.samples[playhead_]; + ++playhead_; + } else if (loop_enabled_ && total_wav_samples > 0) { + playhead_ = 0; + dst[i] = wav_.samples[playhead_]; + ++playhead_; + } else { + dst[i] = 0; + } + } +} diff --git a/simple_room/wav_audio_source.h b/simple_room/wav_audio_source.h new file mode 100644 index 0000000..51a101c --- /dev/null +++ b/simple_room/wav_audio_source.h @@ -0,0 +1,56 @@ + +/* + * Copyright 2025 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an “AS IS” BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "livekit/livekit.h" +#include +#include +#include +#include + +// Simple WAV container for 16-bit PCM files +struct WavData { + int sample_rate = 0; + int num_channels = 0; + std::vector samples; +}; + +// Helper that loads 16-bit PCM WAV (16-bit, PCM only) +WavData loadWav16(const std::string &path); + +using namespace livekit; + +class WavAudioSource { +public: + // loop_enabled: whether to loop when reaching the end + WavAudioSource(const std::string &path, int expected_sample_rate, + int expected_channels, bool loop_enabled = true); + + // Fill a frame with the next chunk of audio. + void fillFrame(AudioFrame &frame); + +private: + void initLoopDelayCounter(); + + WavData wav_; + std::size_t playhead_ = 0; + + const bool loop_enabled_; + int sample_rate_; + int num_channels_; +}; diff --git a/simple_rpc/CMakeLists.txt b/simple_rpc/CMakeLists.txt new file mode 100644 index 0000000..62a4879 --- /dev/null +++ b/simple_rpc/CMakeLists.txt @@ -0,0 +1,20 @@ +# 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. + +add_executable(SimpleRpc + main.cpp +) + +target_include_directories(SimpleRpc PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(SimpleRpc PRIVATE nlohmann_json::nlohmann_json ${LIVEKIT_CORE_TARGET}) diff --git a/simple_rpc/README.md b/simple_rpc/README.md new file mode 100644 index 0000000..2ded78c --- /dev/null +++ b/simple_rpc/README.md @@ -0,0 +1,157 @@ +# 📘 SimpleRpc Example — Technical Overview + +This README provides deeper technical details about the RPC (Remote Procedure Call) support demonstrated in the SimpleRpc example. +It complements the example instructions found in the root README.md. + +If you're looking for how to run the example, see the root [README](https://github.com/livekit/client-sdk-cpp). + +This document explains: +- How LiveKit RPC works in the C++ SDK +- Where the APIs are defined +- How senders call RPC methods +- How receivers register handlers +- What happens if the receiver is absent +- How long-running operations behave +- Timeouts, disconnects, and unsupported methods +- RPC lifecycle events and error propagation + +## 🔧 Overview: How RPC Works +LiveKit RPC allows one participant (the caller) to invoke a method on another participant (the receiver) using the data channel transport. +It is: +- Peer-to-peer within the room (not server-executed RPC) +- Request/response (caller waits for a reply or an error) +- Asynchronous under the hood, synchronous or blocking from the caller’s perspective +- Delivery-guaranteed when using the reliable data channel + +Each RPC call includes: +| Field | Meaning | +|--------------------------|-----------------------------------------------------| +| **destination_identity** | Identity of the target participant | +| **method** | Method name string (e.g., "square-root") | +| **payload** | Arbitrary UTF-8 text | +| **response_timeout** | Optional timeout (seconds) | +| **invocation_id** | Server-generated ID used internally for correlation | + +## 📍 Location of APIs in C++ +All public-facing RPC APIs live in: +[include/livekit/local_participant.h](https://github.com/livekit/client-sdk-cpp/blob/main/include/livekit/local_participant.h#L160) + +### Key methods: + +#### Sender-side APIs +```bash +std::string performRpc( + const std::string& destination_identity, + const std::string& method, + const std::string& payload, + std::optional response_timeout_sec = std::nullopt +); + +Receiver-side APIs +void registerRpcMethod( + const std::string& method_name, + RpcHandler handler +); + +void unregisterRpcMethod(const std::string& method_name); + +Handler signature +using RpcHandler = + std::function(const RpcInvocationData&)>; +``` + +Handlers can: +- Return a string (the RPC response payload) +- Return std::nullopt (meaning “no return payload”) +- Throw exceptions (mapped to APPLICATION_ERROR) +- Throw a RpcError (mapped to specific error codes) + +#### 🛰 Sender Behavior (performRpc) + +When the caller invokes: +```bash +auto reply = lp->performRpc("math-genius", "square-root", "{\"number\":16}"); +``` + +The following occurs: + +A PerformRpcRequest is sent through FFI to the SDK core. + +The SDK transmits the invocation to the target participant (if present). + +The caller begins waiting for a matching RpcMethodInvocationResponse. + +One of the following happens: +| Outcome | Meaning | +|--------------------------|------------------------------------------| +| **Success** | Receiver returned a payload | +| **UNSUPPORTED_METHOD** | Receiver did not register the method | +| **RECIPIENT_NOT_FOUND** | Target identity not present in room | +| **RECIPIENT_DISCONNECTED** | Target left before replying | +| **RESPONSE_TIMEOUT** | Receiver took too long | +| **APPLICATION_ERROR** | Handler threw an exception | + +#### 🔄 Round-trip time (RTT) + +The caller can measure RTT externally (as SimpleRpc does), but the SDK does not measure RTT internally. + +#### 📡 Receiver Behavior (registerRpcMethod) + +A receiver must explicitly register handlers: +```bash +local_participant->registerRpcMethod("square-root", + [](const RpcInvocationData& data) { + double number = parse(data.payload); + return make_json("result", std::sqrt(number)); + }); +``` + +When an invocation arrives: +- Room receives a RpcMethodInvocationEvent +- Room forwards it to the corresponding LocalParticipant +- LocalParticipant::handleRpcMethodInvocation(): +- Calls the handler +- Converts any exceptions into RpcError +- Sends back RpcMethodInvocationResponse + +⚠ If no handler exists: + +Receiver returns: UNSUPPORTED_METHOD + + +#### 🚨 What Happens if Receiver Is Absent? +| Case | Behavior | +|-----------------------------------------------------|---------------------------------------------------| +| Receiver identity is not in the room | Caller immediately receives `RECIPIENT_NOT_FOUND` | +| Receiver is present but disconnects before replying | Caller receives `RECIPIENT_DISCONNECTED` | +| Receiver joins later | Caller must retry manually (no automatic waiting) | + +**Important**: +LiveKit does not queue RPC calls for offline participants. + +#### ⏳ Timeout Behavior + +If the caller specifies: + +performRpc(..., /*response_timeout=*/10.0); + +Then: +- Receiver is given 10 seconds to respond. +- If the receiver handler takes longer (e.g., sleep 30s), caller receives: +RESPONSE_TIMEOUT + +**If no response_timeout is provided explicitly, the default timeout is 15 seconds.** + + +This is by design and demonstrated in the example. + +#### 🧨 Errors & Failure Modes +| Error Code | Cause | +|------------------------|---------------------------------------------| +| **APPLICATION_ERROR** | Handler threw a C++ exception | +| **UNSUPPORTED_METHOD** | No handler registered for the method | +| **RECIPIENT_NOT_FOUND** | Destination identity not in room | +| **RECIPIENT_DISCONNECTED** | Participant left mid-flight | +| **RESPONSE_TIMEOUT** | Handler exceeded allowed response time | +| **CONNECTION_TIMEOUT** | Transport-level issue | +| **SEND_FAILED** | SDK failed to send invocation | diff --git a/simple_rpc/main.cpp b/simple_rpc/main.cpp new file mode 100644 index 0000000..b171f9b --- /dev/null +++ b/simple_rpc/main.cpp @@ -0,0 +1,547 @@ +/* + * Copyright 2025 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an “AS IS” BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "livekit/livekit.h" + +using namespace livekit; +using namespace std::chrono_literals; + +namespace { + +std::atomic g_running{true}; + +void handleSignal(int) { g_running.store(false); } + +void printUsage(const char *prog) { + std::cerr << "Usage:\n" + << " " << prog << " [role]\n" + << "or:\n" + << " " << prog + << " --url= --token= [--role=]\n" + << " " << prog + << " --url --token [--role ]\n\n" + << "Env fallbacks:\n" + << " LIVEKIT_URL, LIVEKIT_TOKEN\n" + << "Role (participant behavior):\n" + << " SIMPLE_RPC_ROLE or --role=\n" + << " default: caller\n"; +} + +inline double nowMs() { + return std::chrono::duration( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); +} + +// Poll the room until a remote participant with the given identity appears, +// or until 'timeout' elapses. Returns true if found, false on timeout. +bool waitForParticipant(Room *room, const std::string &identity, + std::chrono::milliseconds timeout) { + auto start = std::chrono::steady_clock::now(); + + while (std::chrono::steady_clock::now() - start < timeout) { + if (room->remoteParticipant(identity) != nullptr) { + return true; + } + std::this_thread::sleep_for(100ms); + } + return false; +} + +// For the caller: wait for a specific peer, and if they don't show up, +// explain why and how to start them in another terminal. +bool ensurePeerPresent(Room *room, const std::string &identity, + const std::string &friendly_role, const std::string &url, + std::chrono::seconds timeout) { + std::cout << "[Caller] Waiting up to " << timeout.count() << "s for " + << friendly_role << " (identity=\"" << identity + << "\") to join...\n"; + bool present = waitForParticipant( + room, identity, + std::chrono::duration_cast(timeout)); + if (present) { + std::cout << "[Caller] " << friendly_role << " is present.\n"; + return true; + } + // Timed out + auto info = room->room_info(); + const std::string room_name = info.name; + std::cout << "[Caller] Timed out after " << timeout.count() + << "s waiting for " << friendly_role << " (identity=\"" << identity + << "\").\n"; + std::cout << "[Caller] No participant with identity \"" << identity + << "\" appears to be connected to room \"" << room_name + << "\".\n\n"; + std::cout << "To start a " << friendly_role + << " in another terminal, run:\n\n" + << " lk token create -r test -i " << identity + << " --join --valid-for 99999h --dev --room=" << room_name << "\n" + << " ./build/examples/SimpleRpc " << url + << " $token --role=" << friendly_role << "\n\n"; + return false; +} + +bool parseArgs(int argc, char *argv[], std::string &url, std::string &token, + std::string &role) { + // --help + for (int i = 1; i < argc; ++i) { + std::string a = argv[i]; + if (a == "-h" || a == "--help") { + return false; + } + } + + // helper for flags + auto get_flag_value = [&](const std::string &name, int &i) -> std::string { + std::string arg = argv[i]; + const std::string eq = name + "="; + if (arg.rfind(name, 0) == 0) { // starts with name + if (arg.size() > name.size() && arg[name.size()] == '=') { + return arg.substr(eq.size()); + } else if (i + 1 < argc) { + return std::string(argv[++i]); + } + } + return {}; + }; + + // flags: --url / --token / --role (with = or split) + for (int i = 1; i < argc; ++i) { + const std::string a = argv[i]; + if (a.rfind("--url", 0) == 0) { + auto v = get_flag_value("--url", i); + if (!v.empty()) + url = v; + } else if (a.rfind("--token", 0) == 0) { + auto v = get_flag_value("--token", i); + if (!v.empty()) + token = v; + } else if (a.rfind("--role", 0) == 0) { + auto v = get_flag_value("--role", i); + if (!v.empty()) + role = v; + } + } + + std::vector pos; + for (int i = 1; i < argc; ++i) { + std::string a = argv[i]; + if (a.rfind("--", 0) == 0) + continue; + pos.push_back(std::move(a)); + } + if (!pos.empty()) { + if (url.empty() && pos.size() >= 1) { + url = pos[0]; + } + if (token.empty() && pos.size() >= 2) { + token = pos[1]; + } + if (role.empty() && pos.size() >= 3) { + role = pos[2]; + } + } + + if (url.empty()) { + const char *e = std::getenv("LIVEKIT_URL"); + if (e) + url = e; + } + if (token.empty()) { + const char *e = std::getenv("LIVEKIT_TOKEN"); + if (e) + token = e; + } + if (role.empty()) { + const char *e = std::getenv("SIMPLE_RPC_ROLE"); + if (e) + role = e; + } + if (role.empty()) { + role = "caller"; + } + + return !(url.empty() || token.empty()); +} + +std::string makeNumberJson(const std::string &key, double value) { + std::ostringstream oss; + oss << "{\"" << key << "\":" << value << "}"; + return oss.str(); +} + +std::string makeStringJson(const std::string &key, const std::string &value) { + std::ostringstream oss; + oss << "{\"" << key << "\":\"" << value << "\"}"; + return oss.str(); +} + +double parseNumberFromJson(const std::string &json) { + auto colon = json.find(':'); + if (colon == std::string::npos) + throw std::runtime_error("invalid json: " + json); + auto start = colon + 1; + auto end = json.find_first_of(",}", start); + std::string num_str = json.substr(start, end - start); + return std::stod(num_str); +} + +std::string parseStringFromJson(const std::string &json) { + auto colon = json.find(':'); + if (colon == std::string::npos) + throw std::runtime_error("invalid json: " + json); + auto first_quote = json.find('"', colon + 1); + if (first_quote == std::string::npos) + throw std::runtime_error("invalid json: " + json); + auto second_quote = json.find('"', first_quote + 1); + if (second_quote == std::string::npos) + throw std::runtime_error("invalid json: " + json); + return json.substr(first_quote + 1, second_quote - first_quote - 1); +} + +// RPC handler registration +void registerReceiverMethods(Room *greeters_room, Room *math_genius_room) { + LocalParticipant *greeter_lp = greeters_room->localParticipant(); + LocalParticipant *math_genius_lp = math_genius_room->localParticipant(); + + // arrival + greeter_lp->registerRpcMethod( + "arrival", + [](const RpcInvocationData &data) -> std::optional { + std::cout << "[Greeter] Oh " << data.caller_identity + << " arrived and said \"" << data.payload << "\"\n"; + std::this_thread::sleep_for(2s); + return std::optional{"Welcome and have a wonderful day!"}; + }); + + // square-root + math_genius_lp->registerRpcMethod( + "square-root", + [](const RpcInvocationData &data) -> std::optional { + double number = parseNumberFromJson(data.payload); + std::cout << "[Math Genius] I guess " << data.caller_identity + << " wants the square root of " << number + << ". I've only got " << data.response_timeout_sec + << " seconds to respond but I think I can pull it off.\n"; + std::cout << "[Math Genius] *doing math*…\n"; + std::this_thread::sleep_for(2s); + double result = std::sqrt(number); + std::cout << "[Math Genius] Aha! It's " << result << "\n"; + return makeNumberJson("result", result); + }); + + // divide + math_genius_lp->registerRpcMethod( + "divide", + [](const RpcInvocationData &data) -> std::optional { + // expect {"dividend":X,"divisor":Y} – we'll parse very lazily + auto div_pos = data.payload.find("dividend"); + auto dvr_pos = data.payload.find("divisor"); + if (div_pos == std::string::npos || dvr_pos == std::string::npos) { + throw std::runtime_error("invalid divide payload"); + } + + double dividend = parseNumberFromJson( + data.payload.substr(div_pos, dvr_pos - div_pos - 1)); // rough slice + double divisor = parseNumberFromJson(data.payload.substr(dvr_pos)); + + std::cout << "[Math Genius] " << data.caller_identity + << " wants to divide " << dividend << " by " << divisor + << ".\n"; + + if (divisor == 0.0) { + // will be translated to APPLICATION_ERROR by your RpcError logic + throw std::runtime_error("division by zero"); + } + + double result = dividend / divisor; + return makeNumberJson("result", result); + }); + + // long-calculation + math_genius_lp->registerRpcMethod( + "long-calculation", + [](const RpcInvocationData &data) -> std::optional { + std::cout << "[Math Genius] Starting a very long calculation for " + << data.caller_identity << "\n"; + std::cout << "[Math Genius] This will take 30 seconds even though " + "you're only giving me " + << data.response_timeout_sec << " seconds\n"; + // Sleep for 30 seconds to mimic a long running task. + std::this_thread::sleep_for(30s); + return makeStringJson("result", "Calculation complete!"); + }); + + // Note: we do NOT register "quantum-hypergeometric-series" here, + // so the caller sees UNSUPPORTED_METHOD +} + +void performGreeting(Room *room) { + std::cout << "[Caller] Letting the greeter know that I've arrived\n"; + double t0 = nowMs(); + try { + std::string response = room->localParticipant()->performRpc( + "greeter", "arrival", "Hello", std::nullopt); + double t1 = nowMs(); + std::cout << "[Caller] RTT: " << (t1 - t0) << " ms\n"; + std::cout << "[Caller] That's nice, the greeter said: \"" << response + << "\"\n"; + } catch (const std::exception &error) { + double t1 = nowMs(); + std::cout << "[Caller] (FAILED) RTT: " << (t1 - t0) << " ms\n"; + std::cout << "[Caller] RPC call failed: " << error.what() << "\n"; + throw; + } +} + +void performSquareRoot(Room *room) { + std::cout << "[Caller] What's the square root of 16?\n"; + double t0 = nowMs(); + try { + std::string payload = makeNumberJson("number", 16.0); + std::string response = room->localParticipant()->performRpc( + "math-genius", "square-root", payload, std::nullopt); + double t1 = nowMs(); + std::cout << "[Caller] RTT: " << (t1 - t0) << " ms\n"; + double result = parseNumberFromJson(response); + std::cout << "[Caller] Nice, the answer was " << result << "\n"; + } catch (const std::exception &error) { + double t1 = nowMs(); + std::cout << "[Caller] (FAILED) RTT: " << (t1 - t0) << " ms\n"; + std::cout << "[Caller] RPC call failed: " << error.what() << "\n"; + throw; + } +} + +void performQuantumHyperGeometricSeries(Room *room) { + std::cout << "\n=== Unsupported Method Example ===\n"; + std::cout + << "[Caller] Asking math-genius for 'quantum-hypergeometric-series'. " + "This should FAIL because the handler is NOT registered.\n"; + double t0 = nowMs(); + try { + std::string payload = makeNumberJson("number", 42.0); + std::string response = room->localParticipant()->performRpc( + "math-genius", "quantum-hypergeometric-series", payload, std::nullopt); + double t1 = nowMs(); + std::cout << "[Caller] (Unexpected success) RTT=" << (t1 - t0) << " ms\n"; + std::cout << "[Caller] Result: " << response << "\n"; + } catch (const RpcError &error) { + double t1 = nowMs(); + std::cout << "[Caller] RpcError RTT=" << (t1 - t0) << " ms\n"; + auto code = static_cast(error.code()); + if (code == RpcError::ErrorCode::UNSUPPORTED_METHOD) { + std::cout << "[Caller] ✓ Expected: math-genius does NOT implement this " + "method.\n"; + std::cout << "[Caller] Server returned UNSUPPORTED_METHOD.\n"; + } else { + std::cout << "[Caller] ✗ Unexpected error type: " << error.message() + << "\n"; + } + } +} + +void performDivide(Room *room) { + std::cout << "\n=== Divide Example ===\n"; + std::cout << "[Caller] Asking math-genius to divide 10 by 0. " + "This is EXPECTED to FAIL with an APPLICATION_ERROR.\n"; + double t0 = nowMs(); + try { + std::string payload = "{\"dividend\":10,\"divisor\":0}"; + std::string response = room->localParticipant()->performRpc( + "math-genius", "divide", payload, std::nullopt); + double t1 = nowMs(); + std::cout << "[Caller] (Unexpected success) RTT=" << (t1 - t0) << " ms\n"; + std::cout << "[Caller] Result = " << response << "\n"; + } catch (const RpcError &error) { + double t1 = nowMs(); + std::cout << "[Caller] RpcError RTT=" << (t1 - t0) << " ms\n"; + auto code = static_cast(error.code()); + if (code == RpcError::ErrorCode::APPLICATION_ERROR) { + std::cout << "[Caller] ✓ Expected: divide-by-zero triggers " + "APPLICATION_ERROR.\n"; + std::cout << "[Caller] Math-genius threw an exception: " + << error.message() << "\n"; + } else { + std::cout << "[Caller] ✗ Unexpected RpcError type: " << error.message() + << "\n"; + } + } +} + +void performLongCalculation(Room *room) { + std::cout << "\n=== Long Calculation Example ===\n"; + std::cout + << "[Caller] Asking math-genius for a calculation that takes 30s.\n"; + std::cout + << "[Caller] Giving only 10s to respond. EXPECTED RESULT: TIMEOUT.\n"; + double t0 = nowMs(); + try { + std::string response = room->localParticipant()->performRpc( + "math-genius", "long-calculation", "{}", 10.0); + double t1 = nowMs(); + std::cout << "[Caller] (Unexpected success) RTT=" << (t1 - t0) << " ms\n"; + std::cout << "[Caller] Result: " << response << "\n"; + } catch (const RpcError &error) { + double t1 = nowMs(); + std::cout << "[Caller] RpcError RTT=" << (t1 - t0) << " ms\n"; + auto code = static_cast(error.code()); + if (code == RpcError::ErrorCode::RESPONSE_TIMEOUT) { + std::cout + << "[Caller] ✓ Expected: handler sleeps 30s but timeout is 10s.\n"; + std::cout << "[Caller] Server correctly returned RESPONSE_TIMEOUT.\n"; + } else if (code == RpcError::ErrorCode::RECIPIENT_DISCONNECTED) { + std::cout << "[Caller] ✓ Expected if math-genius disconnects during the " + "test.\n"; + } else { + std::cout << "[Caller] ✗ Unexpected RPC error: " << error.message() + << "\n"; + } + } +} + +} // namespace + +int main(int argc, char *argv[]) { + std::string url, token, role; + if (!parseArgs(argc, argv, url, token, role)) { + printUsage(argv[0]); + return 1; + } + + if (url.empty() || token.empty()) { + std::cerr << "LIVEKIT_URL and LIVEKIT_TOKEN (or CLI args) are required\n"; + return 1; + } + + std::cout << "Connecting to: " << url << "\n"; + std::cout << "Role: " << role << "\n"; + + // Ctrl-C to quit the program + std::signal(SIGINT, handleSignal); + + // Initialize the livekit with logging to console. + livekit::initialize(livekit::LogLevel::Info, livekit::LogSink::kConsole); + auto room = std::make_unique(); + RoomOptions options; + options.auto_subscribe = true; + options.dynacast = false; + + bool res = room->Connect(url, token, options); + std::cout << "Connect result is " << std::boolalpha << res << "\n"; + if (!res) { + std::cerr << "Failed to connect to room\n"; + livekit::shutdown(); + return 1; + } + + auto info = room->room_info(); + std::cout << "Connected to room:\n" + << " Name: " << info.name << "\n" + << " Metadata: " << info.metadata << "\n" + << " Num participants: " << info.num_participants << "\n"; + + try { + if (role == "caller") { + // Check that both peers are present (or explain how to start them). + bool has_greeter = + ensurePeerPresent(room.get(), "greeter", "greeter", url, 8s); + bool has_math_genius = + ensurePeerPresent(room.get(), "math-genius", "math-genius", url, 8s); + if (!has_greeter || !has_math_genius) { + std::cout << "\n[Caller] One or more RPC peers are missing. " + << "Some examples may be skipped.\n"; + } + if (has_greeter) { + std::cout << "\n\nRunning greeting example...\n"; + performGreeting(room.get()); + } else { + std::cout << "[Caller] Skipping greeting example because greeter is " + "not present.\n"; + } + if (has_math_genius) { + std::cout << "\n\nRunning error handling example...\n"; + performDivide(room.get()); + + std::cout << "\n\nRunning math example...\n"; + performSquareRoot(room.get()); + std::this_thread::sleep_for(2s); + performQuantumHyperGeometricSeries(room.get()); + + std::cout << "\n\nRunning long calculation with timeout...\n"; + performLongCalculation(room.get()); + } else { + std::cout << "[Caller] Skipping math examples because math-genius is " + "not present.\n"; + } + + std::cout << "\n\nCaller done. Exiting.\n"; + } else if (role == "greeter" || role == "math-genius") { + // For these roles we expect multiple processes: + // - One process with role=caller + // - One with role=greeter + // - One with role=math-genius + // + // Each process gets its own token (with that identity) via LIVEKIT_TOKEN. + // Here we only register handlers for the appropriate role, and then + // stay alive until Ctrl-C so we can receive RPCs. + + if (role == "greeter") { + // Use the same room object for both arguments; only "arrival" is used. + registerReceiverMethods(room.get(), room.get()); + } else { // math-genius + // We only need math handlers; greeter handlers won't be used. + registerReceiverMethods(room.get(), room.get()); + } + + std::cout << "RPC handlers registered for role=" << role + << ". Waiting for RPC calls (Ctrl-C to exit)...\n"; + + while (g_running.load()) { + std::this_thread::sleep_for(50ms); + } + std::cout << "Exiting receiver role.\n"; + } else { + std::cerr << "Unknown role: " << role << "\n"; + } + } catch (const std::exception &e) { + std::cerr << "Unexpected error in main: " << e.what() << "\n"; + } + + // It is important to clean up the delegate and room in order. + room->setDelegate(nullptr); + room.reset(); + livekit::shutdown(); + return 0; +}