From ec4d4d4ca5897e370d7adc453294db47f0d49cd0 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Thu, 12 Feb 2026 15:41:20 -0700 Subject: [PATCH] livekit_bridge: Ergonomic library on top of the Client C++ SDK --- .gitignore | 2 + CMakeLists.txt | 4 + README.md | 5 +- bridge/CMakeLists.txt | 124 +++++ bridge/README.md | 238 ++++++++++ bridge/examples/human.cpp | 272 +++++++++++ bridge/examples/human_stub.cpp | 133 ++++++ bridge/examples/robot.cpp | 253 ++++++++++ bridge/examples/robot_stub.cpp | 138 ++++++ .../livekit_bridge/bridge_audio_track.h | 124 +++++ .../livekit_bridge/bridge_video_track.h | 122 +++++ .../include/livekit_bridge/livekit_bridge.h | 279 +++++++++++ bridge/src/bridge_audio_track.cpp | 103 +++++ bridge/src/bridge_room_delegate.cpp | 50 ++ bridge/src/bridge_room_delegate.h | 46 ++ bridge/src/bridge_video_track.cpp | 100 ++++ bridge/src/livekit_bridge.cpp | 434 ++++++++++++++++++ bridge/tests/CMakeLists.txt | 94 ++++ bridge/tests/test_bridge_audio_track.cpp | 116 +++++ bridge/tests/test_bridge_video_track.cpp | 112 +++++ bridge/tests/test_callback_key.cpp | 121 +++++ bridge/tests/test_livekit_bridge.cpp | 177 +++++++ examples/CMakeLists.txt | 48 +- examples/simple_robot/human.cpp | 267 +++++++++++ examples/simple_robot/json_utils.cpp | 46 ++ examples/simple_robot/json_utils.h | 38 ++ examples/simple_robot/robot.cpp | 125 +++++ examples/simple_robot/utils.cpp | 87 ++++ examples/simple_robot/utils.h | 31 ++ 29 files changed, 3686 insertions(+), 3 deletions(-) create mode 100644 bridge/CMakeLists.txt create mode 100644 bridge/README.md create mode 100644 bridge/examples/human.cpp create mode 100644 bridge/examples/human_stub.cpp create mode 100644 bridge/examples/robot.cpp create mode 100644 bridge/examples/robot_stub.cpp create mode 100644 bridge/include/livekit_bridge/bridge_audio_track.h create mode 100644 bridge/include/livekit_bridge/bridge_video_track.h create mode 100644 bridge/include/livekit_bridge/livekit_bridge.h create mode 100644 bridge/src/bridge_audio_track.cpp create mode 100644 bridge/src/bridge_room_delegate.cpp create mode 100644 bridge/src/bridge_room_delegate.h create mode 100644 bridge/src/bridge_video_track.cpp create mode 100644 bridge/src/livekit_bridge.cpp create mode 100644 bridge/tests/CMakeLists.txt create mode 100644 bridge/tests/test_bridge_audio_track.cpp create mode 100644 bridge/tests/test_bridge_video_track.cpp create mode 100644 bridge/tests/test_callback_key.cpp create mode 100644 bridge/tests/test_livekit_bridge.cpp create mode 100644 examples/simple_robot/human.cpp create mode 100644 examples/simple_robot/json_utils.cpp create mode 100644 examples/simple_robot/json_utils.h create mode 100644 examples/simple_robot/robot.cpp create mode 100644 examples/simple_robot/utils.cpp create mode 100644 examples/simple_robot/utils.h diff --git a/.gitignore b/.gitignore index cc4bdfc..4ebc57a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ out build/ build-debug/ build-release/ +release/ vcpkg_installed/ # Generated header include/livekit/build.h @@ -17,6 +18,7 @@ docs/html/ docs/latex/ .vs/ .vscode/ +.cursor/ # Compiled output bin/ lib/ diff --git a/CMakeLists.txt b/CMakeLists.txt index bc344ff..c1561be 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,6 +12,7 @@ list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") option(LIVEKIT_BUILD_EXAMPLES "Build LiveKit examples" OFF) option(LIVEKIT_BUILD_TESTS "Build LiveKit tests" OFF) +option(LIVEKIT_BUILD_BRIDGE "Build LiveKit Bridge (simplified high-level API)" OFF) # vcpkg is only used on Windows; Linux/macOS use system package managers if(WIN32) @@ -639,6 +640,9 @@ if(LIVEKIT_BUILD_TESTS) add_subdirectory(src/tests) endif() +# Build the LiveKit C++ bridge +add_subdirectory(bridge) + add_custom_target(clean_generated COMMAND ${CMAKE_COMMAND} -E rm -rf "${PROTO_BINARY_DIR}" COMMENT "Clean generated protobuf files" diff --git a/README.md b/README.md index dee88f2..09af65a 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,9 @@ This page covers how to build and install the LiveKit C++ Client SDK for real-ti > **Note**: If the SDK was built with Protobuf 6.0+, you also need `libabsl-dev` (Linux) or `abseil` (macOS). +## Prerequisites +- install livekit-cli by following the (official livekit docs)[https://docs.livekit.io/intro/basics/cli/start/] + ## 🧩 Clone the Repository Make sure to initialize the Rust submodule (`client-sdk-rust`): @@ -328,4 +331,4 @@ In some cases, you may need to perform a full clean that deletes all build artif CPP SDK is using clang C++ format ```bash brew install clang-format -``` \ No newline at end of file +``` diff --git a/bridge/CMakeLists.txt b/bridge/CMakeLists.txt new file mode 100644 index 0000000..b9823e3 --- /dev/null +++ b/bridge/CMakeLists.txt @@ -0,0 +1,124 @@ +cmake_minimum_required(VERSION 3.20) + +project(livekit_bridge LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +add_library(livekit_bridge SHARED + src/livekit_bridge.cpp + src/bridge_audio_track.cpp + src/bridge_video_track.cpp + src/bridge_room_delegate.cpp + src/bridge_room_delegate.h +) + +if(WIN32) + set_target_properties(livekit_bridge PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS ON) +endif() + +target_include_directories(livekit_bridge + PUBLIC + $ + $ + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src +) + +# Link against the main livekit SDK library (which transitively provides +# include paths for livekit/*.h and links livekit_ffi). +target_link_libraries(livekit_bridge + PUBLIC + livekit +) + +if(MSVC) + target_compile_options(livekit_bridge PRIVATE /permissive- /Zc:__cplusplus /W4) +else() + target_compile_options(livekit_bridge PRIVATE -Wall -Wextra -Wpedantic) +endif() + +# --- Tests --- +# Bridge tests default to OFF. They are automatically enabled when the parent +# SDK tests are enabled (LIVEKIT_BUILD_TESTS=ON), e.g. via ./build.sh debug-tests. +option(LIVEKIT_BRIDGE_BUILD_TESTS "Build bridge unit tests" OFF) + +if(LIVEKIT_BRIDGE_BUILD_TESTS OR LIVEKIT_BUILD_TESTS) + add_subdirectory(tests) +endif() + +# --- Examples --- +option(LIVEKIT_BRIDGE_BUILD_EXAMPLES "Build the bridge examples" ON) + +if(LIVEKIT_BRIDGE_BUILD_EXAMPLES) + # ---- Set RPATH so examples find shared libs in the executable directory ---- + if(UNIX) + if(APPLE) + set(CMAKE_BUILD_RPATH "@loader_path;@loader_path/../lib") + set(CMAKE_INSTALL_RPATH "@loader_path;@loader_path/../lib") + else() + set(CMAKE_BUILD_RPATH "$ORIGIN:$ORIGIN/../lib") + set(CMAKE_INSTALL_RPATH "$ORIGIN:$ORIGIN/../lib") + endif() + set(CMAKE_BUILD_RPATH_USE_ORIGIN TRUE) + set(CMAKE_SKIP_BUILD_RPATH FALSE) + set(CMAKE_BUILD_WITH_INSTALL_RPATH TRUE) + endif() + + # ---- SDL3 (shared by robot and human targets) ---- + list(APPEND CMAKE_MODULE_PATH "${LIVEKIT_ROOT_DIR}/examples/cmake") + include(sdl3) + + # Path to the SDL media helpers in examples/simple_room/ + set(SDL_MEDIA_DIR "${LIVEKIT_ROOT_DIR}/examples/simple_room") + + # ---- Stub examples (no SDL, synthetic data) ---- + add_executable(robot_stub examples/robot_stub.cpp) + target_link_libraries(robot_stub PRIVATE livekit_bridge) + + add_executable(human_stub examples/human_stub.cpp) + target_link_libraries(human_stub PRIVATE livekit_bridge) + + # ---- Robot: Real webcam + mic via SDL3 ---- + add_executable(robot + examples/robot.cpp + ${SDL_MEDIA_DIR}/sdl_media.cpp + ${SDL_MEDIA_DIR}/sdl_media.h + ) + target_include_directories(robot PRIVATE ${SDL_MEDIA_DIR}) + target_link_libraries(robot PRIVATE livekit_bridge SDL3::SDL3) + + # ---- Human: SDL3 video display + speaker playback ---- + add_executable(human + examples/human.cpp + ${SDL_MEDIA_DIR}/sdl_media.cpp + ${SDL_MEDIA_DIR}/sdl_media.h + ) + target_include_directories(human PRIVATE ${SDL_MEDIA_DIR}) + target_link_libraries(human PRIVATE livekit_bridge SDL3::SDL3) + + # ---- Copy SDL3 shared library to robot/human output directories, avoids duplicate code ---- + set(_SDL_TARGETS robot human) + foreach(_target ${_SDL_TARGETS}) + if(UNIX AND NOT APPLE) + add_custom_command(TARGET ${_target} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$" + "$" + COMMAND ${CMAKE_COMMAND} -E create_symlink + "$" + "$/$" + COMMENT "Copying SDL3 shared library and SONAME symlink to ${_target} output directory" + VERBATIM + ) + else() + add_custom_command(TARGET ${_target} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$" + "$" + COMMENT "Copying SDL3 shared library to ${_target} output directory" + VERBATIM + ) + endif() + endforeach() +endif() diff --git a/bridge/README.md b/bridge/README.md new file mode 100644 index 0000000..6086f02 --- /dev/null +++ b/bridge/README.md @@ -0,0 +1,238 @@ +# LiveKit Bridge + +A simplified, high-level C++ wrapper around the [LiveKit C++ SDK](../README.md). The bridge abstracts away room lifecycle management, track creation, publishing, and subscription boilerplate so that external codebases can interface with LiveKit in just a few lines. It is intended that this library will be used to bridge the LiveKit C++ SDK into other SDKs such as, but not limited to, Foxglove, ROS, and Rerun. + +It is intended that this library closely matches the style of the core LiveKit C++ SDK. + +# Prerequisites +Since this is an extention of the LiveKit C++ SDK, go through the LiveKit C++ SDK installation instructions first: +*__[LiveKit C++ SDK](../README.md)__* + +## Usage Overview + +```cpp +#include "livekit_bridge/livekit_bridge.h" +#include "livekit/audio_frame.h" +#include "livekit/video_frame.h" +#include "livekit/track.h" + +// 1. Connect +livekit_bridge::LiveKitBridge bridge; +bridge.connect("wss://my-server.livekit.cloud", token); + +// 2. Create outgoing tracks (RAII-managed) +auto mic = bridge.createAudioTrack("mic", 48000, 2); // name, sample_rate, channels +auto cam = bridge.createVideoTrack("cam", 1280, 720); // name, width, height + +// 3. Push frames to remote participants +mic->pushFrame(pcm_data, samples_per_channel); +cam->pushFrame(rgba_data, timestamp_us); + +// 4. Receive frames from a remote participant +bridge.registerOnAudioFrame("remote-peer", livekit::TrackSource::SOURCE_MICROPHONE, + [](const livekit::AudioFrame& frame) { + // Called on a background reader thread + }); + +bridge.registerOnVideoFrame("remote-peer", livekit::TrackSource::SOURCE_CAMERA, + [](const livekit::VideoFrame& frame, int64_t timestamp_us) { + // Called on a background reader thread + }); + +// 5. Cleanup is automatic (RAII), or explicit: +mic.reset(); // unpublishes the audio track +cam.reset(); // unpublishes the video track +bridge.disconnect(); +``` + +## Building + +The bridge is a component of the `client-sdk-cpp` build. See the "⚙️ BUILD" section of the [LiveKit C++ SDK README](../README.md) for instructions on how to build the bridge. + +This produces `liblivekit_bridge` (shared library) and optional `robot_stub`, `human_stub`, `robot`, and `human` executables. + +### Using the bridge in your own CMake project +TODO(sderosa): add instructions on how to use the bridge in your own CMake project. + +## Architecture + +### Data Flow Overview + +``` +Your Application + | | + | pushFrame() -----> BridgeAudioTrack | (sending to remote participants) + | pushFrame() -----> BridgeVideoTrack | + | | + | callback() <------ Reader Thread | (receiving from remote participants) + | | + +------- LiveKitBridge -----------------+ + | + LiveKit Room + | + LiveKit Server +``` + +### Core Components + +**`LiveKitBridge`** -- The main entry point. Owns the full room lifecycle: SDK initialization, room connection, track publishing, and frame callback management. + +**`BridgeAudioTrack` / `BridgeVideoTrack`** -- RAII handles for published local tracks. Created via `createAudioTrack()` / `createVideoTrack()`. When the `shared_ptr` is dropped, the track is automatically unpublished and all underlying SDK resources are freed. Call `pushFrame()` to send audio/video data to remote participants. + +**`BridgeRoomDelegate`** -- Internal (not part of the public API; lives in `src/`). Listens for `onTrackSubscribed` / `onTrackUnsubscribed` events from the LiveKit SDK and wires up reader threads automatically. + +### What is a Reader? + +A **reader** is a background thread that receives decoded media frames from a remote participant. + +When a remote participant publishes an audio or video track and the bridge subscribes to it (auto-subscribe is enabled by default), the bridge creates an `AudioStream` or `VideoStream` from that track and spins up a dedicated thread. This thread loops on `stream->read()`, which blocks until a new frame arrives. Each received frame is forwarded to the user's registered callback. + +In short: + +- **Sending** (you -> remote): `BridgeAudioTrack::pushFrame()` / `BridgeVideoTrack::pushFrame()` +- **Receiving** (remote -> you): reader threads invoke your registered callbacks + +Reader threads are managed entirely by the bridge. They are created when a matching remote track is subscribed, and torn down (stream closed, thread joined) when the track is unsubscribed, the callback is unregistered, or `disconnect()` is called. + +### Callback Registration Timing + +Callbacks are keyed by `(participant_identity, track_source)`. You can register them **before** the remote participant has joined the room. The bridge stores the callback and automatically wires it up when the matching track is subscribed. This means the typical pattern is: + +```cpp +// Register first, connect second -- or register after connect but before +// the remote participant joins. +bridge.registerOnAudioFrame("robot-1", livekit::TrackSource::SOURCE_MICROPHONE, my_callback); +bridge.connect(url, token); +// When robot-1 joins and publishes a mic track, my_callback starts firing. +``` + +### Thread Safety + +- `LiveKitBridge` uses a mutex to protect the callback map and active reader state. +- Frame callbacks fire on background reader threads. If your callback accesses shared application state, you are responsible for synchronization. +- `disconnect()` closes all streams and joins all reader threads before returning -- it is safe to destroy the bridge immediately after. + +## API Reference + +### `LiveKitBridge` + +| Method | Description | +|---|---| +| `connect(url, token)` | Connect to a LiveKit room. Initializes the SDK, creates a Room, and connects with auto-subscribe enabled. | +| `disconnect()` | Disconnect and release all resources. Joins all reader threads. Safe to call multiple times. | +| `isConnected()` | Returns whether the bridge is currently connected. | +| `createAudioTrack(name, sample_rate, num_channels)` | Create and publish a local audio track. Returns an RAII `shared_ptr`. | +| `createVideoTrack(name, width, height)` | Create and publish a local video track. Returns an RAII `shared_ptr`. | +| `registerOnAudioFrame(identity, source, callback)` | Register a callback for audio frames from a specific remote participant + track source. | +| `registerOnVideoFrame(identity, source, callback)` | Register a callback for video frames from a specific remote participant + track source. | +| `unregisterOnAudioFrame(identity, source)` | Unregister an audio callback. Stops and joins the reader thread if active. | +| `unregisterOnVideoFrame(identity, source)` | Unregister a video callback. Stops and joins the reader thread if active. | + +### `BridgeAudioTrack` + +| Method | Description | +|---|---| +| `pushFrame(data, samples_per_channel, timeout_ms)` | Push interleaved int16 PCM samples. Accepts `std::vector` or raw pointer. | +| `mute()` / `unmute()` | Mute/unmute the track (stops/resumes sending audio). | +| `release()` | Explicitly unpublish and free resources. Called automatically by the destructor. | +| `name()` / `sampleRate()` / `numChannels()` | Accessors for track configuration. | + +### `BridgeVideoTrack` + +| Method | Description | +|---|---| +| `pushFrame(data, timestamp_us)` | Push RGBA pixel data. Accepts `std::vector` or raw pointer + size. | +| `mute()` / `unmute()` | Mute/unmute the track (stops/resumes sending video). | +| `release()` | Explicitly unpublish and free resources. Called automatically by the destructor. | +| `name()` / `width()` / `height()` | Accessors for track configuration. | + +## Examples +- robot.cpp: publishes video and audio from a webcam and microphone. This requires a webcam and microphone to be available. +- robot_stub.cpp: publishes stubbed audio and video. This exists to exemplify simplicity. +- human.cpp: receives and renders video to the screen, receives and plays audio through the speaker. +- human_stub.cpp: receives video and audio and prints that it was received. This exists to exemplify simplicity. + +### Running the examples: +Note: the following workflow works for both `human`/`robot` and `robot_stub`/`human_stub`. + +1. create a `robo_room` +``` +lk token create \ + --join --room robo_room --identity test_user \ + --valid-for 24h +``` + +2. generate tokens for the robot and human +``` +lk token create --api-key --api-secret \ + --join --room robo_room --identity robot --valid-for 24h + +lk token create --api-key --api-secret \ + --join --room robo_room --identity human --valid-for 24h +``` + +save these tokens as you will need them to run the examples. + +3. kick off the robot: +``` +export LIVEKIT_URL="wss://your-server.livekit.cloud" +export LIVEKIT_TOKEN= +./build-release/bin/robot_stub +``` + +4. kick off the human (in a new terminal): +``` +export LIVEKIT_URL="wss://your-server.livekit.cloud" +export LIVEKIT_TOKEN= +./build-release/bin/human +``` + +The human will print periodic summaries like: + +``` +[human] Audio frame #1: 480 samples/ch, 48000 Hz, 1 ch, duration=0.010s +[human] Video frame #1: 640x480, 1228800 bytes, ts=0 us +[human] Status: 500 audio frames, 150 video frames received so far. +``` + +## Testing + +The bridge includes a unit test suite built with [Google Test](https://github.com/google/googletest). Tests cover +1. `CallbackKey` hashing/equality, +2. `BridgeAudioTrack`/`BridgeVideoTrack` state management, and +3. `LiveKitBridge` pre-connection behaviour (callback registration, error handling). + +### Building and running tests + +Bridge tests are automatically included when you build with the `debug-tests` or `release-tests` command: + +```bash +./build.sh debug-tests +``` + +Then run them directly: + +```bash +./build-debug/bin/livekit_bridge_tests +``` + +### Standalone bridge tests only + +If you want to build bridge tests independently (without the parent SDK tests), set `LIVEKIT_BRIDGE_BUILD_TESTS=ON`: + +```bash +cmake --preset macos-debug -DLIVEKIT_BRIDGE_BUILD_TESTS=ON +cmake --build build-debug --target livekit_bridge_tests +``` + +## Limitations + +The bridge is designed for simplicity and currently only supports limited audio and video features. It does not expose: + +- E2EE configuration +- RPC / data channels / data tracks +- Simulcast tuning +- Video format selection (RGBA is the default; no format option yet) +- Custom `RoomOptions` or `TrackPublishOptions` + +For advanced use cases, use the full `client-sdk-cpp` API directly, or expand the bridge to support your use case. diff --git a/bridge/examples/human.cpp b/bridge/examples/human.cpp new file mode 100644 index 0000000..0623ac1 --- /dev/null +++ b/bridge/examples/human.cpp @@ -0,0 +1,272 @@ +/* + * Human example -- receives audio and video frames from a robot in a + * LiveKit room and renders them using SDL3. + * + * Video: displayed in an SDL window via a streaming texture. + * Audio: played through the default speaker using DDLSpeakerSink. + * + * Usage: + * human + * LIVEKIT_URL=... LIVEKIT_TOKEN=... human + * + * The token must grant identity "human". Generate one with: + * lk token create --api-key --api-secret \ + * --join --room my-room --identity human \ + * --valid-for 24h + * + * Run alongside the "robot" example (which publishes with identity "robot"). + */ + +#include "livekit/audio_frame.h" +#include "livekit/track.h" +#include "livekit/video_frame.h" +#include "livekit_bridge/livekit_bridge.h" +#include "sdl_media.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +static std::atomic g_running{true}; +static void handleSignal(int) { g_running.store(false); } + +// ---- Thread-safe video frame slot ---- +// The bridge callback writes the latest frame here; the main loop reads it. +struct LatestVideoFrame { + std::mutex mutex; + std::vector data; + int width = 0; + int height = 0; + bool dirty = false; // true when a new frame has been written +}; + +static LatestVideoFrame g_latest_video; + +// ---- Counters for periodic status ---- +static std::atomic g_audio_frames{0}; +static std::atomic g_video_frames{0}; + +int main(int argc, char *argv[]) { + // ----- Parse args / env ----- + std::string url, token; + if (argc >= 3) { + url = argv[1]; + token = argv[2]; + } else { + const char *e = std::getenv("LIVEKIT_URL"); + if (e) + url = e; + e = std::getenv("LIVEKIT_TOKEN"); + if (e) + token = e; + } + if (url.empty() || token.empty()) { + std::cerr << "Usage: human \n" + << " or: LIVEKIT_URL=... LIVEKIT_TOKEN=... human\n"; + return 1; + } + + std::signal(SIGINT, handleSignal); + + // ----- Initialize SDL3 ----- + if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO)) { + std::cerr << "[human] SDL_Init failed: " << SDL_GetError() << "\n"; + return 1; + } + + // ----- Create SDL window + renderer ----- + constexpr int kWindowWidth = 1280; + constexpr int kWindowHeight = 720; + + SDL_Window *window = SDL_CreateWindow("Human - Robot Camera Feed", + kWindowWidth, kWindowHeight, 0); + if (!window) { + std::cerr << "[human] SDL_CreateWindow failed: " << SDL_GetError() << "\n"; + SDL_Quit(); + return 1; + } + + SDL_Renderer *renderer = SDL_CreateRenderer(window, nullptr); + if (!renderer) { + std::cerr << "[human] SDL_CreateRenderer failed: " << SDL_GetError() + << "\n"; + SDL_DestroyWindow(window); + SDL_Quit(); + return 1; + } + + // Texture for displaying video frames (lazily recreated on size change) + SDL_Texture *texture = nullptr; + int tex_width = 0; + int tex_height = 0; + + // ----- SDL speaker for audio playback ----- + // We lazily initialize the DDLSpeakerSink on the first audio frame, + // so we know the sample rate and channel count. + std::unique_ptr speaker; + std::mutex speaker_mutex; + + // ----- Connect to LiveKit ----- + livekit_bridge::LiveKitBridge bridge; + std::cout << "[human] Connecting to " << url << " ...\n"; + if (!bridge.connect(url, token)) { + std::cerr << "[human] Failed to connect.\n"; + SDL_DestroyRenderer(renderer); + SDL_DestroyWindow(window); + SDL_Quit(); + return 1; + } + std::cout << "[human] Connected. Waiting for robot...\n"; + + // ----- Register audio callback ----- + bridge.registerOnAudioFrame( + "robot", livekit::TrackSource::SOURCE_MICROPHONE, + [&speaker, &speaker_mutex](const livekit::AudioFrame &frame) { + g_audio_frames.fetch_add(1, std::memory_order_relaxed); + + const auto &samples = frame.data(); + if (samples.empty()) + return; + + std::lock_guard lock(speaker_mutex); + + // Lazily initialize speaker on first frame + if (!speaker) { + speaker = std::make_unique(frame.sample_rate(), + frame.num_channels()); + if (!speaker->init()) { + std::cerr << "[human] Failed to init SDL speaker.\n"; + speaker.reset(); + return; + } + std::cout << "[human] Speaker opened: " << frame.sample_rate() + << " Hz, " << frame.num_channels() << " ch.\n"; + } + + speaker->enqueue(samples.data(), frame.samples_per_channel()); + }); + + // ----- Register video callback ----- + bridge.registerOnVideoFrame( + "robot", livekit::TrackSource::SOURCE_CAMERA, + [](const livekit::VideoFrame &frame, std::int64_t /*timestamp_us*/) { + g_video_frames.fetch_add(1, std::memory_order_relaxed); + + // Store the latest frame for the main loop to render. + // The frame arrives as RGBA by default. + const std::uint8_t *src = frame.data(); + const std::size_t size = frame.dataSize(); + if (!src || size == 0) + return; + + std::lock_guard lock(g_latest_video.mutex); + g_latest_video.data.assign(src, src + size); + g_latest_video.width = frame.width(); + g_latest_video.height = frame.height(); + g_latest_video.dirty = true; + }); + + // ----- Main loop ----- + std::cout << "[human] Rendering robot feed... press Ctrl-C or close window " + "to stop.\n"; + + auto last_report = std::chrono::steady_clock::now(); + + while (g_running.load()) { + // Pump SDL events + SDL_Event ev; + while (SDL_PollEvent(&ev)) { + if (ev.type == SDL_EVENT_QUIT) { + g_running.store(false); + } + } + + // Check for a new video frame + { + std::lock_guard lock(g_latest_video.mutex); + if (g_latest_video.dirty && g_latest_video.width > 0 && + g_latest_video.height > 0) { + int fw = g_latest_video.width; + int fh = g_latest_video.height; + + // Recreate texture if size changed + if (fw != tex_width || fh != tex_height) { + if (texture) + SDL_DestroyTexture(texture); + texture = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_RGBA32, + SDL_TEXTUREACCESS_STREAMING, fw, fh); + if (!texture) { + std::cerr << "[human] SDL_CreateTexture failed: " << SDL_GetError() + << "\n"; + } + tex_width = fw; + tex_height = fh; + } + + // Upload pixels to texture + if (texture) { + void *pixels = nullptr; + int pitch = 0; + if (SDL_LockTexture(texture, nullptr, &pixels, &pitch)) { + const int srcPitch = fw * 4; + for (int y = 0; y < fh; ++y) { + std::memcpy(static_cast(pixels) + y * pitch, + g_latest_video.data.data() + y * srcPitch, srcPitch); + } + SDL_UnlockTexture(texture); + } + } + + g_latest_video.dirty = false; + } + } + + // Render + SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255); + SDL_RenderClear(renderer); + if (texture) { + SDL_RenderTexture(renderer, texture, nullptr, nullptr); + } + SDL_RenderPresent(renderer); + + // Periodic status + auto now = std::chrono::steady_clock::now(); + if (now - last_report >= std::chrono::seconds(5)) { + last_report = now; + std::cout << "[human] Status: " << g_audio_frames.load() + << " audio frames, " << g_video_frames.load() + << " video frames received.\n"; + } + + // ~60fps render loop + SDL_Delay(16); + } + + // ----- Cleanup ----- + std::cout << "[human] Shutting down...\n"; + std::cout << "[human] Total received: " << g_audio_frames.load() + << " audio frames, " << g_video_frames.load() << " video frames.\n"; + + bridge.disconnect(); + + { + std::lock_guard lock(speaker_mutex); + speaker.reset(); + } + + if (texture) + SDL_DestroyTexture(texture); + SDL_DestroyRenderer(renderer); + SDL_DestroyWindow(window); + SDL_Quit(); + + std::cout << "[human] Done.\n"; + return 0; +} diff --git a/bridge/examples/human_stub.cpp b/bridge/examples/human_stub.cpp new file mode 100644 index 0000000..a7a96ea --- /dev/null +++ b/bridge/examples/human_stub.cpp @@ -0,0 +1,133 @@ +/* + * Human example -- receives audio and video frames from a robot in a + * LiveKit room and prints a summary each time a frame arrives. + * + * This participant does not publish any tracks of its own; it only + * subscribes to the robot's camera and microphone streams via + * registerOnAudioFrame / registerOnVideoFrame. + * + * Usage: + * human + * LIVEKIT_URL=... LIVEKIT_TOKEN=... human + * + * The token must grant identity "human". Generate one with: + * lk token create --api-key --api-secret \ + * --join --room my-room --identity human \ + * --valid-for 24h + * + * Run alongside the "robot" example (which publishes with identity "robot"). + */ + +#include "livekit/audio_frame.h" +#include "livekit/track.h" +#include "livekit/video_frame.h" +#include "livekit_bridge/livekit_bridge.h" + +#include +#include +#include +#include +#include +#include +#include + +static std::atomic g_running{true}; +static void handleSignal(int) { g_running.store(false); } + +// Simple counters for periodic status reporting. +static std::atomic g_audio_frames{0}; +static std::atomic g_video_frames{0}; + +int main(int argc, char *argv[]) { + // ----- Parse args / env ----- + std::string url, token; + if (argc >= 3) { + url = argv[1]; + token = argv[2]; + } else { + const char *e = std::getenv("LIVEKIT_URL"); + if (e) + url = e; + e = std::getenv("LIVEKIT_TOKEN"); + if (e) + token = e; + } + if (url.empty() || token.empty()) { + std::cerr << "Usage: human \n" + << " or: LIVEKIT_URL=... LIVEKIT_TOKEN=... human\n"; + return 1; + } + + std::signal(SIGINT, handleSignal); + + // ----- Connect ----- + livekit_bridge::LiveKitBridge bridge; + std::cout << "[human] Connecting to " << url << " ...\n"; + if (!bridge.connect(url, token)) { + std::cerr << "[human] Failed to connect.\n"; + return 1; + } + std::cout << "[human] Connected. Waiting for robot...\n"; + + // ----- Register callbacks for the "robot" participant ----- + // These are registered BEFORE the robot joins, so the bridge will + // automatically wire them up when the robot's tracks are subscribed. + + bridge.registerOnAudioFrame( + "robot", livekit::TrackSource::SOURCE_MICROPHONE, + [](const livekit::AudioFrame &frame) { + uint64_t count = g_audio_frames.fetch_add(1) + 1; + + // Print every 100th frame (~1 per second at 10ms frames) + // to avoid flooding the console. + if (count % 100 == 1) { + std::cout << "[human] Audio frame #" << count << ": " + << frame.samples_per_channel() << " samples/ch, " + << frame.sample_rate() << " Hz, " << frame.num_channels() + << " ch, duration=" << std::fixed << std::setprecision(3) + << frame.duration() << "s\n"; + } + }); + + bridge.registerOnVideoFrame( + "robot", livekit::TrackSource::SOURCE_CAMERA, + [](const livekit::VideoFrame &frame, std::int64_t timestamp_us) { + uint64_t count = g_video_frames.fetch_add(1) + 1; + + // Print every 30th frame (~1 per second at 30 fps). + if (count % 30 == 1) { + std::cout << "[human] Video frame #" << count << ": " << frame.width() + << "x" << frame.height() << ", " << frame.dataSize() + << " bytes, ts=" << timestamp_us << " us\n"; + } + }); + + // ----- Idle loop ----- + // The human has no tracks to publish. Just keep the process alive + // while the reader threads (created by the bridge on subscription) + // deliver frames to our callbacks above. + std::cout << "[human] Listening for robot frames... press Ctrl-C to stop.\n"; + + auto last_report = std::chrono::steady_clock::now(); + + while (g_running.load()) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + // Periodic summary every 5 seconds + auto now = std::chrono::steady_clock::now(); + if (now - last_report >= std::chrono::seconds(5)) { + last_report = now; + std::cout << "[human] Status: " << g_audio_frames.load() + << " audio frames, " << g_video_frames.load() + << " video frames received so far.\n"; + } + } + + // ----- Cleanup ----- + std::cout << "[human] Shutting down...\n"; + std::cout << "[human] Total received: " << g_audio_frames.load() + << " audio frames, " << g_video_frames.load() << " video frames.\n"; + bridge.disconnect(); + std::cout << "[human] Done.\n"; + return 0; +} diff --git a/bridge/examples/robot.cpp b/bridge/examples/robot.cpp new file mode 100644 index 0000000..242602b --- /dev/null +++ b/bridge/examples/robot.cpp @@ -0,0 +1,253 @@ +/* + * Robot example -- streams real webcam video and microphone audio to a + * LiveKit room using SDL3 for hardware capture. + * + * Usage: + * robot + * LIVEKIT_URL=... LIVEKIT_TOKEN=... robot + * + * The token must grant identity "robot". Generate one with: + * lk token create --api-key --api-secret \ + * --join --room my-room --identity robot \ + * --valid-for 24h + * + * Run alongside the "human" example (which displays the robot's feed). + */ + +#include "livekit/audio_frame.h" +#include "livekit/track.h" +#include "livekit/video_frame.h" +#include "livekit_bridge/livekit_bridge.h" +#include "sdl_media.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static std::atomic g_running{true}; +static void handleSignal(int) { g_running.store(false); } + +int main(int argc, char *argv[]) { + // ----- Parse args / env ----- + std::string url, token; + if (argc >= 3) { + url = argv[1]; + token = argv[2]; + } else { + const char *e = std::getenv("LIVEKIT_URL"); + if (e) + url = e; + e = std::getenv("LIVEKIT_TOKEN"); + if (e) + token = e; + } + if (url.empty() || token.empty()) { + std::cerr << "Usage: robot \n" + << " or: LIVEKIT_URL=... LIVEKIT_TOKEN=... robot\n"; + return 1; + } + + std::signal(SIGINT, handleSignal); + + // ----- Initialize SDL3 ----- + if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_CAMERA)) { + std::cerr << "[robot] SDL_Init failed: " << SDL_GetError() << "\n"; + return 1; + } + + // ----- Connect to LiveKit ----- + livekit_bridge::LiveKitBridge bridge; + std::cout << "[robot] Connecting to " << url << " ...\n"; + if (!bridge.connect(url, token)) { + std::cerr << "[robot] Failed to connect.\n"; + SDL_Quit(); + return 1; + } + std::cout << "[robot] Connected.\n"; + + // ----- Create outgoing tracks ----- + constexpr int kSampleRate = 48000; + constexpr int kChannels = 1; + constexpr int kWidth = 1280; + constexpr int kHeight = 720; + + auto mic = bridge.createAudioTrack("robot-mic", kSampleRate, kChannels); + auto cam = bridge.createVideoTrack("robot-cam", kWidth, kHeight); + std::cout << "[robot] Publishing audio (" << kSampleRate << " Hz, " + << kChannels << " ch) and video (" << kWidth << "x" << kHeight + << ").\n"; + + // ----- SDL Mic capture ----- + // SDLMicSource pulls 10ms frames from the default recording device and + // invokes our callback with interleaved int16 samples. + bool mic_using_sdl = false; + std::unique_ptr sdl_mic; + std::atomic mic_running{true}; + std::thread mic_thread; + + { + int recCount = 0; + SDL_AudioDeviceID *recDevs = SDL_GetAudioRecordingDevices(&recCount); + bool has_mic = recDevs && recCount > 0; + if (recDevs) + SDL_free(recDevs); + + if (has_mic) { + sdl_mic = std::make_unique( + kSampleRate, kChannels, kSampleRate / 100, // 10ms frames + [&mic](const int16_t *samples, int num_samples_per_channel, + int /*sample_rate*/, int /*num_channels*/) { + try { + mic->pushFrame(samples, num_samples_per_channel); + } catch (const std::exception &e) { + std::cerr << "[robot] Mic push error: " << e.what() << "\n"; + } + }); + + if (sdl_mic->init()) { + mic_using_sdl = true; + std::cout << "[robot] Using SDL microphone.\n"; + mic_thread = std::thread([&]() { + while (mic_running.load()) { + sdl_mic->pump(); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + }); + } else { + std::cerr << "[robot] SDL mic init failed.\n"; + sdl_mic.reset(); + } + } + + if (!mic_using_sdl) { + std::cout << "[robot] No microphone found; sending silence.\n"; + mic_thread = std::thread([&]() { + constexpr int kSamplesPerFrame = kSampleRate / 100; + std::vector silence(kSamplesPerFrame * kChannels, 0); + auto next = std::chrono::steady_clock::now(); + while (mic_running.load()) { + try { + mic->pushFrame(silence, kSamplesPerFrame); + } catch (...) { + } + next += std::chrono::milliseconds(10); + std::this_thread::sleep_until(next); + } + }); + } + } + + // ----- SDL Camera capture ----- + // SDLCamSource grabs webcam frames and invokes our callback with raw pixels. + bool cam_using_sdl = false; + std::unique_ptr sdl_cam; + std::atomic cam_running{true}; + std::thread cam_thread; + + { + int camCount = 0; + SDL_CameraID *cams = SDL_GetCameras(&camCount); + bool has_cam = cams && camCount > 0; + if (cams) + SDL_free(cams); + + if (has_cam) { + sdl_cam = std::make_unique( + kWidth, kHeight, 30, SDL_PIXELFORMAT_RGBA32, + [&cam](const uint8_t *pixels, int pitch, int width, int height, + SDL_PixelFormat /*fmt*/, Uint64 timestampNS) { + // Copy row-by-row (pitch may differ from width*4) + const int dstPitch = width * 4; + std::vector buf(dstPitch * height); + for (int y = 0; y < height; ++y) { + std::memcpy(buf.data() + y * dstPitch, pixels + y * pitch, + dstPitch); + } + try { + cam->pushFrame(buf.data(), buf.size(), + static_cast(timestampNS / 1000)); + } catch (const std::exception &e) { + std::cerr << "[robot] Cam push error: " << e.what() << "\n"; + } + }); + + if (sdl_cam->init()) { + cam_using_sdl = true; + std::cout << "[robot] Using SDL camera.\n"; + cam_thread = std::thread([&]() { + while (cam_running.load()) { + sdl_cam->pump(); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + }); + } else { + std::cerr << "[robot] SDL camera init failed.\n"; + sdl_cam.reset(); + } + } + + if (!cam_using_sdl) { + std::cout << "[robot] No camera found; sending solid green frames.\n"; + cam_thread = std::thread([&]() { + std::vector green(kWidth * kHeight * 4); + for (int i = 0; i < kWidth * kHeight; ++i) { + green[i * 4 + 0] = 0; + green[i * 4 + 1] = 180; + green[i * 4 + 2] = 0; + green[i * 4 + 3] = 255; + } + std::int64_t ts = 0; + while (cam_running.load()) { + try { + cam->pushFrame(green, ts); + ts += 33333; + } catch (...) { + } + std::this_thread::sleep_for(std::chrono::milliseconds(33)); + } + }); + } + } + + // ----- Main loop: keep alive + pump SDL events ----- + std::cout << "[robot] Streaming... press Ctrl-C to stop.\n"; + + while (g_running.load()) { + SDL_Event e; + while (SDL_PollEvent(&e)) { + if (e.type == SDL_EVENT_QUIT) { + g_running.store(false); + } + } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + // ----- Cleanup ----- + std::cout << "[robot] Shutting down...\n"; + + mic_running.store(false); + cam_running.store(false); + if (mic_thread.joinable()) + mic_thread.join(); + if (cam_thread.joinable()) + cam_thread.join(); + sdl_mic.reset(); + sdl_cam.reset(); + + mic.reset(); + cam.reset(); + bridge.disconnect(); + + SDL_Quit(); + std::cout << "[robot] Done.\n"; + return 0; +} diff --git a/bridge/examples/robot_stub.cpp b/bridge/examples/robot_stub.cpp new file mode 100644 index 0000000..8e14bc1 --- /dev/null +++ b/bridge/examples/robot_stub.cpp @@ -0,0 +1,138 @@ +/* + * Robot example -- publishes audio and video frames to a LiveKit room. + * + * The robot acts as a sensor platform: it streams a camera feed (simulated + * as a solid-color frame) and microphone audio (simulated as a sine tone) + * into the room. A "human" participant can subscribe and receive these + * frames via their own bridge instance. + * + * Usage: + * robot + * LIVEKIT_URL=... LIVEKIT_TOKEN=... robot + * + * The token must grant identity "robot". Generate one with: + * lk token create --api-key --api-secret \ + * --join --room my-room --identity robot \ + * --valid-for 24h + */ + +#include "livekit/audio_frame.h" +#include "livekit/track.h" +#include "livekit/video_frame.h" +#include "livekit_bridge/livekit_bridge.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +static std::atomic g_running{true}; +static void handleSignal(int) { g_running.store(false); } + +int main(int argc, char *argv[]) { + // ----- Parse args / env ----- + std::string url, token; + if (argc >= 3) { + url = argv[1]; + token = argv[2]; + } else { + const char *e = std::getenv("LIVEKIT_URL"); + if (e) + url = e; + e = std::getenv("LIVEKIT_TOKEN"); + if (e) + token = e; + } + if (url.empty() || token.empty()) { + std::cerr << "Usage: robot \n" + << " or: LIVEKIT_URL=... LIVEKIT_TOKEN=... robot\n"; + return 1; + } + + std::signal(SIGINT, handleSignal); + + // ----- Connect ----- + livekit_bridge::LiveKitBridge bridge; + std::cout << "[robot] Connecting to " << url << " ...\n"; + if (!bridge.connect(url, token)) { + std::cerr << "[robot] Failed to connect.\n"; + return 1; + } + std::cout << "[robot] Connected.\n"; + + // ----- Create outgoing tracks ----- + constexpr int kSampleRate = 48000; + constexpr int kChannels = 1; + constexpr int kWidth = 640; + constexpr int kHeight = 480; + + auto mic = bridge.createAudioTrack("robot-mic", kSampleRate, kChannels); + auto cam = bridge.createVideoTrack("robot-cam", kWidth, kHeight); + std::cout << "[robot] Publishing audio (" << kSampleRate << " Hz, " + << kChannels << " ch) and video (" << kWidth << "x" << kHeight + << ").\n"; + + // ----- Prepare frame data ----- + + // Audio: 10ms frames of a 440 Hz sine tone so the human can verify + // it is receiving real (non-silent) data. + constexpr int kSamplesPerFrame = kSampleRate / 100; // 480 samples = 10ms + constexpr double kToneHz = 440.0; + constexpr double kAmplitude = 3000.0; // ~10% of int16 max + std::vector audio_buf(kSamplesPerFrame * kChannels); + int audio_sample_index = 0; + + // Video: solid green RGBA frame (simulating a "robot camera" view). + std::vector video_buf(kWidth * kHeight * 4); + for (int i = 0; i < kWidth * kHeight; ++i) { + video_buf[i * 4 + 0] = 0; // R + video_buf[i * 4 + 1] = 180; // G + video_buf[i * 4 + 2] = 0; // B + video_buf[i * 4 + 3] = 255; // A + } + + // ----- Stream loop ----- + std::cout << "[robot] Streaming... press Ctrl-C to stop.\n"; + + std::int64_t video_ts = 0; + int loop_count = 0; + + while (g_running.load()) { + // Generate 10ms of sine tone + for (int i = 0; i < kSamplesPerFrame; ++i) { + double t = static_cast(audio_sample_index++) / kSampleRate; + audio_buf[i] = static_cast( + kAmplitude * std::sin(2.0 * M_PI * kToneHz * t)); + } + + try { + mic->pushFrame(audio_buf, kSamplesPerFrame); + } catch (const std::exception &e) { + std::cerr << "[robot] Audio push error: " << e.what() << "\n"; + } + + // Push video at ~30 fps (every 3rd loop iteration, since loop is 10ms) + if (++loop_count % 3 == 0) { + try { + cam->pushFrame(video_buf, video_ts); + video_ts += 33333; // ~30 fps in microseconds + } catch (const std::exception &e) { + std::cerr << "[robot] Video push error: " << e.what() << "\n"; + } + } + + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + // ----- Cleanup ----- + std::cout << "[robot] Shutting down...\n"; + mic.reset(); + cam.reset(); + bridge.disconnect(); + std::cout << "[robot] Done.\n"; + return 0; +} diff --git a/bridge/include/livekit_bridge/bridge_audio_track.h b/bridge/include/livekit_bridge/bridge_audio_track.h new file mode 100644 index 0000000..8e8f544 --- /dev/null +++ b/bridge/include/livekit_bridge/bridge_audio_track.h @@ -0,0 +1,124 @@ +/* + * Copyright 2025 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 AudioSource; +class LocalAudioTrack; +class LocalTrackPublication; +class LocalParticipant; +} // namespace livekit + +namespace livekit_bridge { + +namespace test { +class BridgeAudioTrackTest; +} // namespace test + +/** + * RAII wrapper around a published local audio track. + * + * Created via LiveKitBridge::createAudioTrack(). When the last shared_ptr + * reference is released (or release() is called explicitly), the track is + * unpublished and all underlying SDK resources are freed. + * + * Usage: + * auto mic = bridge.createAudioTrack("mic", 48000, 2); + * mic->pushFrame(pcm_data, samples_per_channel); + * mic->mute(); + * mic.reset(); // unpublishes + */ +class BridgeAudioTrack { +public: + ~BridgeAudioTrack(); + + // Non-copyable + BridgeAudioTrack(const BridgeAudioTrack &) = delete; + BridgeAudioTrack &operator=(const BridgeAudioTrack &) = delete; + + /** + * Push a PCM audio frame to the track. + * + * @param data Interleaved int16 PCM samples. + * Must contain exactly + * (samples_per_channel * num_channels) elements. + * @param samples_per_channel Number of samples per channel in this frame. + * @param timeout_ms Max time to wait for FFI confirmation. + * 0 = wait indefinitely (default). + */ + void pushFrame(const std::vector &data, + int samples_per_channel, int timeout_ms = 0); + + /** + * Push a PCM audio frame from a raw pointer. + * + * @param data Pointer to interleaved int16 PCM samples. + * @param samples_per_channel Number of samples per channel. + * @param timeout_ms Max time to wait for FFI confirmation. + */ + void pushFrame(const std::int16_t *data, int samples_per_channel, + int timeout_ms = 0); + + /// Mute the audio track (stops sending audio to the room). + void mute(); + + /// Unmute the audio track (resumes sending audio to the room). + void unmute(); + + /// Explicitly unpublish and release all resources. + /// Called automatically by the destructor. + void release(); + + /// Track name as provided at creation. + const std::string &name() const noexcept { return name_; } + + /// Sample rate in Hz. + int sampleRate() const noexcept { return sample_rate_; } + + /// Number of audio channels. + int numChannels() const noexcept { return num_channels_; } + + /// Whether this track has been released / unpublished. + bool isReleased() const noexcept { return released_; } + +private: + friend class LiveKitBridge; + friend class test::BridgeAudioTrackTest; + + BridgeAudioTrack(std::string name, int sample_rate, int num_channels, + std::shared_ptr source, + std::shared_ptr track, + std::shared_ptr publication, + livekit::LocalParticipant *participant); + + std::string name_; + int sample_rate_; + int num_channels_; + bool released_ = false; + + std::shared_ptr source_; + std::shared_ptr track_; + std::shared_ptr publication_; + livekit::LocalParticipant *participant_ = nullptr; // not owned +}; + +} // namespace livekit_bridge diff --git a/bridge/include/livekit_bridge/bridge_video_track.h b/bridge/include/livekit_bridge/bridge_video_track.h new file mode 100644 index 0000000..ba3c8e3 --- /dev/null +++ b/bridge/include/livekit_bridge/bridge_video_track.h @@ -0,0 +1,122 @@ +/* + * 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 VideoSource; +class LocalVideoTrack; +class LocalTrackPublication; +class LocalParticipant; +} // namespace livekit + +namespace livekit_bridge { + +namespace test { +class BridgeVideoTrackTest; +} // namespace test + +/** + * RAII wrapper around a published local video track. + * + * Created via LiveKitBridge::createVideoTrack(). When the last shared_ptr + * reference is released (or release() is called explicitly), the track is + * unpublished and all underlying SDK resources are freed. + * + * Usage: + * auto cam = bridge.createVideoTrack("cam", 1280, 720); + * cam->pushFrame(rgba_data, timestamp_us); + * cam->mute(); + * cam.reset(); // unpublishes + */ +class BridgeVideoTrack { +public: + ~BridgeVideoTrack(); + + // Non-copyable + BridgeVideoTrack(const BridgeVideoTrack &) = delete; + BridgeVideoTrack &operator=(const BridgeVideoTrack &) = delete; + + /** + * Push an RGBA video frame to the track. + * + * @param data Raw RGBA pixel data. Must contain exactly + * (width * height * 4) bytes. + * @param timestamp_us Presentation timestamp in microseconds. + * Pass 0 to let the SDK assign one. + */ + void pushFrame(const std::vector &data, + std::int64_t timestamp_us = 0); + + /** + * Push an RGBA video frame from a raw pointer. + * + * @param data Pointer to RGBA pixel data. + * @param data_size Size of the data buffer in bytes. + * @param timestamp_us Presentation timestamp in microseconds. + */ + void pushFrame(const std::uint8_t *data, std::size_t data_size, + std::int64_t timestamp_us = 0); + + /// Mute the video track (stops sending video to the room). + void mute(); + + /// Unmute the video track (resumes sending video to the room). + void unmute(); + + /// Explicitly unpublish and release all resources. + /// Called automatically by the destructor. + void release(); + + /// Track name as provided at creation. + const std::string &name() const noexcept { return name_; } + + /// Video width in pixels. + int width() const noexcept { return width_; } + + /// Video height in pixels. + int height() const noexcept { return height_; } + + /// Whether this track has been released / unpublished. + bool isReleased() const noexcept { return released_; } + +private: + friend class LiveKitBridge; + friend class test::BridgeVideoTrackTest; + + BridgeVideoTrack(std::string name, int width, int height, + std::shared_ptr source, + std::shared_ptr track, + std::shared_ptr publication, + livekit::LocalParticipant *participant); + + std::string name_; + int width_; + int height_; + bool released_ = false; + + std::shared_ptr source_; + std::shared_ptr track_; + std::shared_ptr publication_; + livekit::LocalParticipant *participant_ = nullptr; // not owned +}; + +} // namespace livekit_bridge diff --git a/bridge/include/livekit_bridge/livekit_bridge.h b/bridge/include/livekit_bridge/livekit_bridge.h new file mode 100644 index 0000000..1b61494 --- /dev/null +++ b/bridge/include/livekit_bridge/livekit_bridge.h @@ -0,0 +1,279 @@ +/* + * 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_bridge/bridge_audio_track.h" +#include "livekit_bridge/bridge_video_track.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace livekit { +class Room; +class AudioFrame; +class VideoFrame; +class AudioStream; +class VideoStream; +class Track; +enum class TrackSource; +} // namespace livekit + +namespace livekit_bridge { + +class BridgeRoomDelegate; + +namespace test { +class CallbackKeyTest; +class LiveKitBridgeTest; +} // namespace test + +/// Callback type for incoming audio frames. +/// Called on a background reader thread. +using AudioFrameCallback = std::function; + +/// Callback type for incoming video frames. +/// Called on a background reader thread. +/// @param frame The decoded video frame (RGBA by default). +/// @param timestamp_us Presentation timestamp in microseconds. +using VideoFrameCallback = + std::function; + +/** + * High-level bridge to the LiveKit C++ SDK. + * + * Owns the full room lifecycle: initialize SDK, create Room, connect, + * publish tracks, and manage incoming frame callbacks. + * + * Example: + * + * LiveKitBridge bridge; + * bridge.connect("wss://my-server.livekit.cloud", my_token); + * + * auto mic = bridge.createAudioTrack("mic", 48000, 2); + * auto cam = bridge.createVideoTrack("cam", 1280, 720); + * + * mic->pushFrame(pcm_data, samples_per_channel); + * cam->pushFrame(rgba_data, timestamp_us); + * + * bridge.registerOnAudioFrame("remote-participant", + * livekit::TrackSource::SOURCE_MICROPHONE, + * [](const livekit::AudioFrame& f) { process(f); }); + * + * bridge.registerOnVideoFrame("remote-participant", + * livekit::TrackSource::SOURCE_CAMERA, + * [](const livekit::VideoFrame& f, int64_t ts) { render(f); }); + * + * // Cleanup is automatic via RAII, or explicit: + * mic.reset(); + * bridge.disconnect(); + */ +class LiveKitBridge { +public: + LiveKitBridge(); + ~LiveKitBridge(); + + // Non-copyable, non-movable (owns threads, callbacks, room) + LiveKitBridge(const LiveKitBridge &) = delete; + LiveKitBridge &operator=(const LiveKitBridge &) = delete; + LiveKitBridge(LiveKitBridge &&) = delete; + LiveKitBridge &operator=(LiveKitBridge &&) = delete; + + // --------------------------------------------------------------- + // Connection + // --------------------------------------------------------------- + + /** + * Connect to a LiveKit room. + * + * Initializes the SDK (if not already), creates a Room, and connects. + * auto_subscribe is enabled so that remote tracks are subscribed + * automatically. + * + * @param url WebSocket URL of the LiveKit server. + * @param token Access token for authentication. + * @return true if connection succeeded. + */ + bool connect(const std::string &url, const std::string &token); + + /** + * Disconnect from the room and release all resources. + * + * All published tracks are unpublished, all reader threads are joined, + * and the SDK is shut down. Safe to call multiple times. + */ + void disconnect(); + + /// Whether the bridge is currently connected to a room. + bool isConnected() const; + + // --------------------------------------------------------------- + // Track creation (publishing) + // --------------------------------------------------------------- + + /** + * Create and publish a local audio track. + * + * The returned handle is RAII-managed: dropping the shared_ptr + * automatically unpublishes the track. + * + * @param name Human-readable track name. + * @param sample_rate Sample rate in Hz (e.g. 48000). + * @param num_channels Number of audio channels (1 = mono, 2 = stereo). + * @return Shared pointer to the published audio track handle. + * @throws std::runtime_error on failure. + */ + std::shared_ptr + createAudioTrack(const std::string &name, int sample_rate, int num_channels); + + /** + * Create and publish a local video track. + * + * The returned handle is RAII-managed: dropping the shared_ptr + * automatically unpublishes the track. + * + * @param name Human-readable track name. + * @param width Video width in pixels. + * @param height Video height in pixels. + * @return Shared pointer to the published video track handle. + * @throws std::runtime_error on failure. + */ + std::shared_ptr + createVideoTrack(const std::string &name, int width, int height); + + // --------------------------------------------------------------- + // Incoming frame callbacks + // --------------------------------------------------------------- + + /** + * Register a callback for audio frames from a specific remote participant + * and track source. + * + * The callback fires on a background thread whenever a new audio frame + * is received. If the remote participant has not yet connected, the + * callback is stored and auto-wired when the participant's track is + * subscribed. + * + * @param participant_identity Identity of the remote participant. + * @param source Track source (e.g. SOURCE_MICROPHONE). + * @param callback Function to invoke per audio frame. + */ + void registerOnAudioFrame(const std::string &participant_identity, + livekit::TrackSource source, + AudioFrameCallback callback); + + /** + * Register a callback for video frames from a specific remote participant + * and track source. + * + * @param participant_identity Identity of the remote participant. + * @param source Track source (e.g. SOURCE_CAMERA). + * @param callback Function to invoke per video frame. + */ + void registerOnVideoFrame(const std::string &participant_identity, + livekit::TrackSource source, + VideoFrameCallback callback); + + /** + * Unregister a previously registered audio frame callback. + * + * If a reader thread is active for this (identity, source), it is + * stopped and joined. + */ + void unregisterOnAudioFrame(const std::string &participant_identity, + livekit::TrackSource source); + + /** + * Unregister a previously registered video frame callback. + */ + void unregisterOnVideoFrame(const std::string &participant_identity, + livekit::TrackSource source); + +private: + friend class BridgeRoomDelegate; + friend class test::CallbackKeyTest; + friend class test::LiveKitBridgeTest; + + // Composite key for the callback map: (participant_identity, source) + struct CallbackKey { + std::string identity; + livekit::TrackSource source; + + bool operator==(const CallbackKey &o) const; + }; + + struct CallbackKeyHash { + std::size_t operator()(const CallbackKey &k) const; + }; + + // Active reader thread + stream for an incoming track + struct ActiveReader { + std::shared_ptr audio_stream; + std::shared_ptr video_stream; + std::thread thread; + bool is_audio = false; + }; + + // Called by BridgeRoomDelegate when a remote track is subscribed + void onTrackSubscribed(const std::string &participant_identity, + livekit::TrackSource source, + const std::shared_ptr &track); + + // Called by BridgeRoomDelegate when a remote track is unsubscribed + void onTrackUnsubscribed(const std::string &participant_identity, + livekit::TrackSource source); + + // Close the stream and extract the thread for the caller to join + // (caller must hold mutex_) + std::thread extractReaderThread(const CallbackKey &key); + + // Close the stream and detach the thread (caller must hold mutex_) + void stopReader(const CallbackKey &key); + + // Start a reader thread for a subscribed track + void startAudioReader(const CallbackKey &key, + const std::shared_ptr &track, + AudioFrameCallback cb); + void startVideoReader(const CallbackKey &key, + const std::shared_ptr &track, + VideoFrameCallback cb); + + mutable std::mutex mutex_; + bool connected_ = false; + bool sdk_initialized_ = false; + + std::unique_ptr room_; + std::unique_ptr delegate_; + + // Registered callbacks (may be registered before tracks are subscribed) + std::unordered_map + audio_callbacks_; + std::unordered_map + video_callbacks_; + + // Active reader threads for subscribed tracks + std::unordered_map + active_readers_; +}; + +} // namespace livekit_bridge diff --git a/bridge/src/bridge_audio_track.cpp b/bridge/src/bridge_audio_track.cpp new file mode 100644 index 0000000..42ef445 --- /dev/null +++ b/bridge/src/bridge_audio_track.cpp @@ -0,0 +1,103 @@ +/* + * 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 "livekit_bridge/bridge_audio_track.h" + +#include "livekit/audio_frame.h" +#include "livekit/audio_source.h" +#include "livekit/local_audio_track.h" +#include "livekit/local_participant.h" +#include "livekit/local_track_publication.h" + +#include + +namespace livekit_bridge { + +BridgeAudioTrack::BridgeAudioTrack( + std::string name, int sample_rate, int num_channels, + std::shared_ptr source, + std::shared_ptr track, + std::shared_ptr publication, + livekit::LocalParticipant *participant) + : name_(std::move(name)), sample_rate_(sample_rate), + num_channels_(num_channels), source_(std::move(source)), + track_(std::move(track)), publication_(std::move(publication)), + participant_(participant) {} + +BridgeAudioTrack::~BridgeAudioTrack() { release(); } + +void BridgeAudioTrack::pushFrame(const std::vector &data, + int samples_per_channel, int timeout_ms) { + if (released_) { + throw std::runtime_error( + "BridgeAudioTrack::pushFrame: track has been released"); + } + + livekit::AudioFrame frame( + std::vector(data.begin(), data.end()), sample_rate_, + num_channels_, samples_per_channel); + source_->captureFrame(frame, timeout_ms); +} + +void BridgeAudioTrack::pushFrame(const std::int16_t *data, + int samples_per_channel, int timeout_ms) { + if (released_) { + throw std::runtime_error( + "BridgeAudioTrack::pushFrame: track has been released"); + } + + const int total_samples = samples_per_channel * num_channels_; + livekit::AudioFrame frame( + std::vector(data, data + total_samples), sample_rate_, + num_channels_, samples_per_channel); + source_->captureFrame(frame, timeout_ms); +} + +void BridgeAudioTrack::mute() { + if (!released_ && track_) { + track_->mute(); + } +} + +void BridgeAudioTrack::unmute() { + if (!released_ && track_) { + track_->unmute(); + } +} + +void BridgeAudioTrack::release() { + if (released_) { + return; + } + released_ = true; + + // Unpublish the track from the room + if (participant_ && publication_) { + try { + participant_->unpublishTrack(publication_->sid()); + } catch (...) { + // Best-effort cleanup; ignore errors during teardown + } + } + + // Release SDK objects in reverse order + publication_.reset(); + track_.reset(); + source_.reset(); + participant_ = nullptr; +} + +} // namespace livekit_bridge diff --git a/bridge/src/bridge_room_delegate.cpp b/bridge/src/bridge_room_delegate.cpp new file mode 100644 index 0000000..ac9ddea --- /dev/null +++ b/bridge/src/bridge_room_delegate.cpp @@ -0,0 +1,50 @@ +/* + * 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 "bridge_room_delegate.h" + +#include "livekit/remote_participant.h" +#include "livekit/remote_track_publication.h" +#include "livekit/track.h" +#include "livekit_bridge/livekit_bridge.h" + +namespace livekit_bridge { + +void BridgeRoomDelegate::onTrackSubscribed( + livekit::Room & /*room*/, const livekit::TrackSubscribedEvent &ev) { + if (!ev.track || !ev.participant || !ev.publication) { + return; + } + + const std::string identity = ev.participant->identity(); + const livekit::TrackSource source = ev.publication->source(); + + bridge_.onTrackSubscribed(identity, source, ev.track); +} + +void BridgeRoomDelegate::onTrackUnsubscribed( + livekit::Room & /*room*/, const livekit::TrackUnsubscribedEvent &ev) { + if (!ev.participant || !ev.publication) { + return; + } + + const std::string identity = ev.participant->identity(); + const livekit::TrackSource source = ev.publication->source(); + + bridge_.onTrackUnsubscribed(identity, source); +} + +} // namespace livekit_bridge diff --git a/bridge/src/bridge_room_delegate.h b/bridge/src/bridge_room_delegate.h new file mode 100644 index 0000000..79a5193 --- /dev/null +++ b/bridge/src/bridge_room_delegate.h @@ -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. + */ + +#pragma once + +#include "livekit/room_delegate.h" + +namespace livekit_bridge { + +class LiveKitBridge; + +/** + * Internal RoomDelegate that automatically wires up AudioStream/VideoStream + * reader threads when remote tracks are subscribed, and tears them down + * on unsubscribe. + * + * Not part of the public API, so its in src/ instead of include/. + */ +class BridgeRoomDelegate : public livekit::RoomDelegate { +public: + explicit BridgeRoomDelegate(LiveKitBridge &bridge) : bridge_(bridge) {} + + void onTrackSubscribed(livekit::Room &room, + const livekit::TrackSubscribedEvent &ev) override; + + void onTrackUnsubscribed(livekit::Room &room, + const livekit::TrackUnsubscribedEvent &ev) override; + +private: + LiveKitBridge &bridge_; +}; + +} // namespace livekit_bridge diff --git a/bridge/src/bridge_video_track.cpp b/bridge/src/bridge_video_track.cpp new file mode 100644 index 0000000..cb3d75a --- /dev/null +++ b/bridge/src/bridge_video_track.cpp @@ -0,0 +1,100 @@ +/* + * 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 "livekit_bridge/bridge_video_track.h" + +#include "livekit/local_participant.h" +#include "livekit/local_track_publication.h" +#include "livekit/local_video_track.h" +#include "livekit/video_frame.h" +#include "livekit/video_source.h" + +#include + +namespace livekit_bridge { + +BridgeVideoTrack::BridgeVideoTrack( + std::string name, int width, int height, + std::shared_ptr source, + std::shared_ptr track, + std::shared_ptr publication, + livekit::LocalParticipant *participant) + : name_(std::move(name)), width_(width), height_(height), + source_(std::move(source)), track_(std::move(track)), + publication_(std::move(publication)), participant_(participant) {} + +BridgeVideoTrack::~BridgeVideoTrack() { release(); } + +void BridgeVideoTrack::pushFrame(const std::vector &data, + std::int64_t timestamp_us) { + if (released_) { + throw std::runtime_error( + "BridgeVideoTrack::pushFrame: track has been released"); + } + + livekit::VideoFrame frame(width_, height_, livekit::VideoBufferType::RGBA, + std::vector(data.begin(), data.end())); + source_->captureFrame(frame, timestamp_us); +} + +void BridgeVideoTrack::pushFrame(const std::uint8_t *data, + std::size_t data_size, + std::int64_t timestamp_us) { + if (released_) { + throw std::runtime_error( + "BridgeVideoTrack::pushFrame: track has been released"); + } + + livekit::VideoFrame frame(width_, height_, livekit::VideoBufferType::RGBA, + std::vector(data, data + data_size)); + source_->captureFrame(frame, timestamp_us); +} + +void BridgeVideoTrack::mute() { + if (!released_ && track_) { + track_->mute(); + } +} + +void BridgeVideoTrack::unmute() { + if (!released_ && track_) { + track_->unmute(); + } +} + +void BridgeVideoTrack::release() { + if (released_) { + return; + } + released_ = true; + + // Unpublish the track from the room + if (participant_ && publication_) { + try { + participant_->unpublishTrack(publication_->sid()); + } catch (...) { + // Best-effort cleanup; ignore errors during teardown + } + } + + // Release SDK objects in reverse order + publication_.reset(); + track_.reset(); + source_.reset(); + participant_ = nullptr; +} + +} // namespace livekit_bridge diff --git a/bridge/src/livekit_bridge.cpp b/bridge/src/livekit_bridge.cpp new file mode 100644 index 0000000..bc0a859 --- /dev/null +++ b/bridge/src/livekit_bridge.cpp @@ -0,0 +1,434 @@ +/* + * 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 "livekit_bridge/livekit_bridge.h" +#include "bridge_room_delegate.h" + +#include "livekit/audio_frame.h" +#include "livekit/audio_source.h" +#include "livekit/audio_stream.h" +#include "livekit/livekit.h" +#include "livekit/local_audio_track.h" +#include "livekit/local_participant.h" +#include "livekit/local_track_publication.h" +#include "livekit/local_video_track.h" +#include "livekit/room.h" +#include "livekit/track.h" +#include "livekit/video_frame.h" +#include "livekit/video_source.h" +#include "livekit/video_stream.h" + +#include +#include + +namespace livekit_bridge { + +// --------------------------------------------------------------- +// CallbackKey +// --------------------------------------------------------------- + +bool LiveKitBridge::CallbackKey::operator==(const CallbackKey &o) const { + return identity == o.identity && source == o.source; +} + +std::size_t +LiveKitBridge::CallbackKeyHash::operator()(const CallbackKey &k) const { + std::size_t h1 = std::hash{}(k.identity); + std::size_t h2 = std::hash{}(static_cast(k.source)); + return h1 ^ (h2 << 1); +} + +// --------------------------------------------------------------- +// Construction / Destruction +// --------------------------------------------------------------- + +LiveKitBridge::LiveKitBridge() = default; + +LiveKitBridge::~LiveKitBridge() { disconnect(); } + +// --------------------------------------------------------------- +// Connection +// --------------------------------------------------------------- + +bool LiveKitBridge::connect(const std::string &url, const std::string &token) { + std::lock_guard lock(mutex_); + + if (connected_) { + return true; // already connected + } + + // Initialize the LiveKit SDK (idempotent) + if (!sdk_initialized_) { + livekit::initialize(livekit::LogSink::kConsole); + sdk_initialized_ = true; + } + + // Create room and delegate + room_ = std::make_unique(); + delegate_ = std::make_unique(*this); + room_->setDelegate(delegate_.get()); + + // Connect with auto_subscribe enabled + livekit::RoomOptions options; + options.auto_subscribe = true; + options.dynacast = false; + + bool result = room_->Connect(url, token, options); + if (!result) { + room_->setDelegate(nullptr); + delegate_.reset(); + room_.reset(); + return false; + } + + connected_ = true; + return true; +} + +void LiveKitBridge::disconnect() { + // Collect threads to join outside the lock to avoid deadlock. + std::vector threads_to_join; + bool should_shutdown_sdk = false; + + { + std::lock_guard lock(mutex_); + + if (!connected_) { + return; + } + + connected_ = false; + + // Close all streams (unblocks read loops) and collect threads + for (auto &[key, reader] : active_readers_) { + if (reader.audio_stream) { + reader.audio_stream->close(); + } + if (reader.video_stream) { + reader.video_stream->close(); + } + if (reader.thread.joinable()) { + threads_to_join.push_back(std::move(reader.thread)); + } + } + active_readers_.clear(); + + // Clear callback registrations + audio_callbacks_.clear(); + video_callbacks_.clear(); + + // Tear down the room + if (room_) { + room_->setDelegate(nullptr); + } + delegate_.reset(); + room_.reset(); + + if (sdk_initialized_) { + sdk_initialized_ = false; + should_shutdown_sdk = true; + } + } + + // Join threads outside the lock + for (auto &t : threads_to_join) { + if (t.joinable()) { + t.join(); + } + } + + // Shut down the SDK outside the lock (may block) + if (should_shutdown_sdk) { + livekit::shutdown(); + } +} + +bool LiveKitBridge::isConnected() const { + std::lock_guard lock(mutex_); + return connected_; +} + +// --------------------------------------------------------------- +// Track creation (publishing) +// --------------------------------------------------------------- + +std::shared_ptr +LiveKitBridge::createAudioTrack(const std::string &name, int sample_rate, + int num_channels) { + std::lock_guard lock(mutex_); + + if (!connected_ || !room_) { + throw std::runtime_error( + "LiveKitBridge::createAudioTrack: not connected to a room"); + } + + // 1. Create audio source (real-time mode, queue_size_ms=0) + auto source = std::make_shared(sample_rate, + num_channels, 0); + + // 2. Create local audio track + auto track = livekit::LocalAudioTrack::createLocalAudioTrack(name, source); + + // 3. Publish with sensible defaults + livekit::TrackPublishOptions opts; + opts.source = livekit::TrackSource::SOURCE_MICROPHONE; + + auto publication = + room_->localParticipant()->publishTrack(track, opts); + + // 4. Wrap in RAII handle + return std::shared_ptr( + new BridgeAudioTrack(name, sample_rate, num_channels, std::move(source), + std::move(track), std::move(publication), + room_->localParticipant())); +} + +std::shared_ptr +LiveKitBridge::createVideoTrack(const std::string &name, int width, + int height) { + std::lock_guard lock(mutex_); + + if (!connected_ || !room_) { + throw std::runtime_error( + "LiveKitBridge::createVideoTrack: not connected to a room"); + } + + // 1. Create video source + auto source = std::make_shared(width, height); + + // 2. Create local video track + auto track = livekit::LocalVideoTrack::createLocalVideoTrack(name, source); + + // 3. Publish with sensible defaults + livekit::TrackPublishOptions opts; + opts.source = livekit::TrackSource::SOURCE_CAMERA; + + auto publication = + room_->localParticipant()->publishTrack(track, opts); + + // 4. Wrap in RAII handle + return std::shared_ptr( + new BridgeVideoTrack(name, width, height, std::move(source), + std::move(track), std::move(publication), + room_->localParticipant())); +} + +// --------------------------------------------------------------- +// Incoming frame callbacks +// --------------------------------------------------------------- + +void LiveKitBridge::registerOnAudioFrame( + const std::string &participant_identity, livekit::TrackSource source, + AudioFrameCallback callback) { + std::lock_guard lock(mutex_); + + CallbackKey key{participant_identity, source}; + audio_callbacks_[key] = std::move(callback); + + // If there is already an active reader for this key (e.g., track was + // subscribed before the callback was registered), we don't need to do + // anything special -- the next time onTrackSubscribed fires it will + // pick up the callback. However, since auto_subscribe is on, the track + // may have already been subscribed. We don't have a way to retroactively + // query subscribed tracks here, so the user should register callbacks + // before connecting or before the remote participant joins. In practice, + // the delegate fires onTrackSubscribed when the track arrives, so if we + // register the callback first (before the participant joins), it will + // be picked up. +} + +void LiveKitBridge::registerOnVideoFrame( + const std::string &participant_identity, livekit::TrackSource source, + VideoFrameCallback callback) { + std::lock_guard lock(mutex_); + + CallbackKey key{participant_identity, source}; + video_callbacks_[key] = std::move(callback); +} + +void LiveKitBridge::unregisterOnAudioFrame( + const std::string &participant_identity, livekit::TrackSource source) { + std::thread thread_to_join; + { + std::lock_guard lock(mutex_); + CallbackKey key{participant_identity, source}; + audio_callbacks_.erase(key); + thread_to_join = extractReaderThread(key); + } + if (thread_to_join.joinable()) { + thread_to_join.join(); + } +} + +void LiveKitBridge::unregisterOnVideoFrame( + const std::string &participant_identity, livekit::TrackSource source) { + std::thread thread_to_join; + { + std::lock_guard lock(mutex_); + CallbackKey key{participant_identity, source}; + video_callbacks_.erase(key); + thread_to_join = extractReaderThread(key); + } + if (thread_to_join.joinable()) { + thread_to_join.join(); + } +} + +// --------------------------------------------------------------- +// Internal: track subscribe / unsubscribe from delegate +// --------------------------------------------------------------- + +void LiveKitBridge::onTrackSubscribed( + const std::string &participant_identity, livekit::TrackSource source, + const std::shared_ptr &track) { + std::lock_guard lock(mutex_); + + CallbackKey key{participant_identity, source}; + + if (track->kind() == livekit::TrackKind::KIND_AUDIO) { + auto it = audio_callbacks_.find(key); + if (it != audio_callbacks_.end()) { + startAudioReader(key, track, it->second); + } + } else if (track->kind() == livekit::TrackKind::KIND_VIDEO) { + auto it = video_callbacks_.find(key); + if (it != video_callbacks_.end()) { + startVideoReader(key, track, it->second); + } + } +} + +void LiveKitBridge::onTrackUnsubscribed( + const std::string &participant_identity, livekit::TrackSource source) { + std::thread thread_to_join; + { + std::lock_guard lock(mutex_); + CallbackKey key{participant_identity, source}; + thread_to_join = extractReaderThread(key); + } + if (thread_to_join.joinable()) { + thread_to_join.join(); + } +} + +// --------------------------------------------------------------- +// Internal: reader thread management +// --------------------------------------------------------------- + +std::thread LiveKitBridge::extractReaderThread(const CallbackKey &key) { + // Caller must hold mutex_. + // Closes the stream and extracts the thread for the caller to join. + auto it = active_readers_.find(key); + if (it == active_readers_.end()) { + return {}; + } + + auto &reader = it->second; + + // Close the stream to unblock the read() loop + if (reader.audio_stream) { + reader.audio_stream->close(); + } + if (reader.video_stream) { + reader.video_stream->close(); + } + + auto thread = std::move(reader.thread); + active_readers_.erase(it); + return thread; +} + +void LiveKitBridge::stopReader(const CallbackKey &key) { + // Caller must hold mutex_. + // Closes the stream and detaches the thread. + // Used internally when replacing readers (e.g. in startAudioReader). + auto thread = extractReaderThread(key); + if (thread.joinable()) { + thread.detach(); + } +} + +void LiveKitBridge::startAudioReader( + const CallbackKey &key, const std::shared_ptr &track, + AudioFrameCallback cb) { + // Caller must hold mutex_ + // Stop any existing reader for this key + stopReader(key); + + livekit::AudioStream::Options opts; + auto stream = livekit::AudioStream::fromTrack(track, opts); + if (!stream) { + std::cerr << "[LiveKitBridge] Failed to create AudioStream for " + << key.identity << "\n"; + return; + } + + auto stream_copy = stream; // captured by the thread + + ActiveReader reader; + reader.audio_stream = std::move(stream); + reader.is_audio = true; + reader.thread = std::thread([stream_copy, cb]() { + livekit::AudioFrameEvent ev; + while (stream_copy->read(ev)) { + try { + cb(ev.frame); + } catch (const std::exception &e) { + std::cerr << "[LiveKitBridge] Audio callback exception: " << e.what() + << "\n"; + } + } + }); + + active_readers_[key] = std::move(reader); +} + +void LiveKitBridge::startVideoReader( + const CallbackKey &key, const std::shared_ptr &track, + VideoFrameCallback cb) { + // Caller must hold mutex_ + stopReader(key); + + livekit::VideoStream::Options opts; + opts.format = livekit::VideoBufferType::RGBA; + auto stream = livekit::VideoStream::fromTrack(track, opts); + if (!stream) { + std::cerr << "[LiveKitBridge] Failed to create VideoStream for " + << key.identity << "\n"; + return; + } + + auto stream_copy = stream; + + ActiveReader reader; + reader.video_stream = std::move(stream); + reader.is_audio = false; + reader.thread = std::thread([stream_copy, cb]() { + livekit::VideoFrameEvent ev; + while (stream_copy->read(ev)) { + try { + cb(ev.frame, ev.timestamp_us); + } catch (const std::exception &e) { + std::cerr << "[LiveKitBridge] Video callback exception: " << e.what() + << "\n"; + } + } + }); + + active_readers_[key] = std::move(reader); +} + +} // namespace livekit_bridge diff --git a/bridge/tests/CMakeLists.txt b/bridge/tests/CMakeLists.txt new file mode 100644 index 0000000..258681e --- /dev/null +++ b/bridge/tests/CMakeLists.txt @@ -0,0 +1,94 @@ +cmake_minimum_required(VERSION 3.20) + +# ============================================================================ +# Google Test Setup via FetchContent +# ============================================================================ + +include(FetchContent) + +FetchContent_Declare( + googletest + GIT_REPOSITORY https://github.com/google/googletest.git + GIT_TAG v1.14.0 +) + +# Prevent overriding the parent project's compiler/linker settings on Windows +set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) + +# Don't install gtest when installing this project +set(INSTALL_GTEST OFF CACHE BOOL "" FORCE) + +FetchContent_MakeAvailable(googletest) + +# Enable CTest +enable_testing() +include(GoogleTest) + +# ============================================================================ +# Bridge Unit Tests +# ============================================================================ + +file(GLOB BRIDGE_TEST_SOURCES + "${CMAKE_CURRENT_SOURCE_DIR}/*.cpp" +) + +if(BRIDGE_TEST_SOURCES) + add_executable(livekit_bridge_tests + ${BRIDGE_TEST_SOURCES} + ) + + target_link_libraries(livekit_bridge_tests + PRIVATE + livekit_bridge + GTest::gtest_main + ) + + # Copy shared libraries to test executable directory + if(WIN32) + add_custom_command(TARGET livekit_bridge_tests POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$/livekit_ffi.dll" + $ + COMMENT "Copying DLLs to bridge test directory" + ) + elseif(APPLE) + add_custom_command(TARGET livekit_bridge_tests POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$/liblivekit_ffi.dylib" + $ + COMMENT "Copying dylibs to bridge test directory" + ) + else() + add_custom_command(TARGET livekit_bridge_tests POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$/liblivekit_ffi.so" + $ + COMMENT "Copying shared libraries to bridge test directory" + ) + endif() + + # Register tests with CTest + gtest_discover_tests(livekit_bridge_tests + WORKING_DIRECTORY ${CMAKE_RUNTIME_OUTPUT_DIRECTORY} + PROPERTIES + LABELS "bridge_unit" + ) +endif() diff --git a/bridge/tests/test_bridge_audio_track.cpp b/bridge/tests/test_bridge_audio_track.cpp new file mode 100644 index 0000000..df6a3cc --- /dev/null +++ b/bridge/tests/test_bridge_audio_track.cpp @@ -0,0 +1,116 @@ +/* + * 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 + +namespace livekit_bridge { +namespace test { + +class BridgeAudioTrackTest : public ::testing::Test { +protected: + /// Create a BridgeAudioTrack with null SDK objects for pure-logic testing. + /// The track is usable for accessor and state management tests but will + /// crash if pushFrame / mute / unmute try to dereference SDK pointers + /// on a non-released track. + static BridgeAudioTrack createNullTrack(const std::string &name = "mic", + int sample_rate = 48000, + int num_channels = 2) { + return BridgeAudioTrack(name, sample_rate, num_channels, + nullptr, // source + nullptr, // track + nullptr, // publication + nullptr // participant + ); + } +}; + +TEST_F(BridgeAudioTrackTest, AccessorsReturnConstructionValues) { + auto track = createNullTrack("test-mic", 16000, 1); + + EXPECT_EQ(track.name(), "test-mic") << "Name should match construction value"; + EXPECT_EQ(track.sampleRate(), 16000) << "Sample rate should match"; + EXPECT_EQ(track.numChannels(), 1) << "Channel count should match"; +} + +TEST_F(BridgeAudioTrackTest, InitiallyNotReleased) { + auto track = createNullTrack(); + + EXPECT_FALSE(track.isReleased()) + << "Track should not be released immediately after construction"; +} + +TEST_F(BridgeAudioTrackTest, ReleaseMarksTrackAsReleased) { + auto track = createNullTrack(); + + track.release(); + + EXPECT_TRUE(track.isReleased()) + << "Track should be released after calling release()"; +} + +TEST_F(BridgeAudioTrackTest, DoubleReleaseIsIdempotent) { + auto track = createNullTrack(); + + track.release(); + EXPECT_NO_THROW(track.release()) + << "Calling release() a second time should be a no-op"; + EXPECT_TRUE(track.isReleased()); +} + +TEST_F(BridgeAudioTrackTest, PushFrameAfterReleaseThrows) { + auto track = createNullTrack(); + track.release(); + + std::vector data(960, 0); + + EXPECT_THROW(track.pushFrame(data, 480), std::runtime_error) + << "pushFrame (vector) on a released track should throw"; +} + +TEST_F(BridgeAudioTrackTest, PushFrameRawPointerAfterReleaseThrows) { + auto track = createNullTrack(); + track.release(); + + std::vector data(960, 0); + + EXPECT_THROW(track.pushFrame(data.data(), 480), std::runtime_error) + << "pushFrame (raw pointer) on a released track should throw"; +} + +TEST_F(BridgeAudioTrackTest, MuteOnReleasedTrackDoesNotCrash) { + auto track = createNullTrack(); + track.release(); + + EXPECT_NO_THROW(track.mute()) + << "mute() on a released track should be a no-op"; +} + +TEST_F(BridgeAudioTrackTest, UnmuteOnReleasedTrackDoesNotCrash) { + auto track = createNullTrack(); + track.release(); + + EXPECT_NO_THROW(track.unmute()) + << "unmute() on a released track should be a no-op"; +} + +} // namespace test +} // namespace livekit_bridge diff --git a/bridge/tests/test_bridge_video_track.cpp b/bridge/tests/test_bridge_video_track.cpp new file mode 100644 index 0000000..d039838 --- /dev/null +++ b/bridge/tests/test_bridge_video_track.cpp @@ -0,0 +1,112 @@ +/* + * 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 + +namespace livekit_bridge { +namespace test { + +class BridgeVideoTrackTest : public ::testing::Test { +protected: + /// Create a BridgeVideoTrack with null SDK objects for pure-logic testing. + static BridgeVideoTrack createNullTrack(const std::string &name = "cam", + int width = 1280, int height = 720) { + return BridgeVideoTrack(name, width, height, + nullptr, // source + nullptr, // track + nullptr, // publication + nullptr // participant + ); + } +}; + +TEST_F(BridgeVideoTrackTest, AccessorsReturnConstructionValues) { + auto track = createNullTrack("test-cam", 640, 480); + + EXPECT_EQ(track.name(), "test-cam") << "Name should match construction value"; + EXPECT_EQ(track.width(), 640) << "Width should match"; + EXPECT_EQ(track.height(), 480) << "Height should match"; +} + +TEST_F(BridgeVideoTrackTest, InitiallyNotReleased) { + auto track = createNullTrack(); + + EXPECT_FALSE(track.isReleased()) + << "Track should not be released immediately after construction"; +} + +TEST_F(BridgeVideoTrackTest, ReleaseMarksTrackAsReleased) { + auto track = createNullTrack(); + + track.release(); + + EXPECT_TRUE(track.isReleased()) + << "Track should be released after calling release()"; +} + +TEST_F(BridgeVideoTrackTest, DoubleReleaseIsIdempotent) { + auto track = createNullTrack(); + + track.release(); + EXPECT_NO_THROW(track.release()) + << "Calling release() a second time should be a no-op"; + EXPECT_TRUE(track.isReleased()); +} + +TEST_F(BridgeVideoTrackTest, PushFrameAfterReleaseThrows) { + auto track = createNullTrack(); + track.release(); + + std::vector data(1280 * 720 * 4, 0); + + EXPECT_THROW(track.pushFrame(data), std::runtime_error) + << "pushFrame (vector) on a released track should throw"; +} + +TEST_F(BridgeVideoTrackTest, PushFrameRawPointerAfterReleaseThrows) { + auto track = createNullTrack(); + track.release(); + + std::vector data(1280 * 720 * 4, 0); + + EXPECT_THROW(track.pushFrame(data.data(), data.size()), std::runtime_error) + << "pushFrame (raw pointer) on a released track should throw"; +} + +TEST_F(BridgeVideoTrackTest, MuteOnReleasedTrackDoesNotCrash) { + auto track = createNullTrack(); + track.release(); + + EXPECT_NO_THROW(track.mute()) + << "mute() on a released track should be a no-op"; +} + +TEST_F(BridgeVideoTrackTest, UnmuteOnReleasedTrackDoesNotCrash) { + auto track = createNullTrack(); + track.release(); + + EXPECT_NO_THROW(track.unmute()) + << "unmute() on a released track should be a no-op"; +} + +} // namespace test +} // namespace livekit_bridge diff --git a/bridge/tests/test_callback_key.cpp b/bridge/tests/test_callback_key.cpp new file mode 100644 index 0000000..170ea1a --- /dev/null +++ b/bridge/tests/test_callback_key.cpp @@ -0,0 +1,121 @@ +/* + * 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 + +namespace livekit_bridge { +namespace test { + +class CallbackKeyTest : public ::testing::Test { +protected: + // Type aliases for convenience -- these are private types in LiveKitBridge, + // accessible via the friend declaration. + using CallbackKey = LiveKitBridge::CallbackKey; + using CallbackKeyHash = LiveKitBridge::CallbackKeyHash; +}; + +TEST_F(CallbackKeyTest, EqualKeysCompareEqual) { + CallbackKey a{"alice", livekit::TrackSource::SOURCE_MICROPHONE}; + CallbackKey b{"alice", livekit::TrackSource::SOURCE_MICROPHONE}; + + EXPECT_TRUE(a == b) << "Identical keys should compare equal"; +} + +TEST_F(CallbackKeyTest, DifferentIdentityComparesUnequal) { + CallbackKey a{"alice", livekit::TrackSource::SOURCE_MICROPHONE}; + CallbackKey b{"bob", livekit::TrackSource::SOURCE_MICROPHONE}; + + EXPECT_FALSE(a == b) << "Keys with different identities should not be equal"; +} + +TEST_F(CallbackKeyTest, DifferentSourceComparesUnequal) { + CallbackKey a{"alice", livekit::TrackSource::SOURCE_MICROPHONE}; + CallbackKey b{"alice", livekit::TrackSource::SOURCE_CAMERA}; + + EXPECT_FALSE(a == b) << "Keys with different sources should not be equal"; +} + +TEST_F(CallbackKeyTest, EqualKeysProduceSameHash) { + CallbackKey a{"alice", livekit::TrackSource::SOURCE_MICROPHONE}; + CallbackKey b{"alice", livekit::TrackSource::SOURCE_MICROPHONE}; + CallbackKeyHash hasher; + + EXPECT_EQ(hasher(a), hasher(b)) + << "Equal keys must produce the same hash value"; +} + +TEST_F(CallbackKeyTest, DifferentKeysProduceDifferentHashes) { + CallbackKeyHash hasher; + + CallbackKey mic{"alice", livekit::TrackSource::SOURCE_MICROPHONE}; + CallbackKey cam{"alice", livekit::TrackSource::SOURCE_CAMERA}; + CallbackKey bob{"bob", livekit::TrackSource::SOURCE_MICROPHONE}; + + // While hash collisions are technically allowed, these simple cases + // should not collide with a reasonable hash function. + EXPECT_NE(hasher(mic), hasher(cam)) + << "Different sources should (likely) produce different hashes"; + EXPECT_NE(hasher(mic), hasher(bob)) + << "Different identities should (likely) produce different hashes"; +} + +TEST_F(CallbackKeyTest, WorksAsUnorderedMapKey) { + std::unordered_map map; + + CallbackKey key1{"alice", livekit::TrackSource::SOURCE_MICROPHONE}; + CallbackKey key2{"bob", livekit::TrackSource::SOURCE_CAMERA}; + CallbackKey key3{"alice", livekit::TrackSource::SOURCE_CAMERA}; + + // Insert + map[key1] = 1; + map[key2] = 2; + map[key3] = 3; + + EXPECT_EQ(map.size(), 3u) + << "Three distinct keys should produce three entries"; + + // Find + EXPECT_EQ(map[key1], 1); + EXPECT_EQ(map[key2], 2); + EXPECT_EQ(map[key3], 3); + + // Overwrite + map[key1] = 42; + EXPECT_EQ(map[key1], 42) << "Inserting with same key should overwrite"; + EXPECT_EQ(map.size(), 3u) << "Size should not change after overwrite"; + + // Erase + map.erase(key2); + EXPECT_EQ(map.size(), 2u); + EXPECT_EQ(map.count(key2), 0u) << "Erased key should not be found"; +} + +TEST_F(CallbackKeyTest, EmptyIdentityWorks) { + CallbackKey empty{"", livekit::TrackSource::SOURCE_UNKNOWN}; + CallbackKey also_empty{"", livekit::TrackSource::SOURCE_UNKNOWN}; + CallbackKeyHash hasher; + + EXPECT_TRUE(empty == also_empty); + EXPECT_EQ(hasher(empty), hasher(also_empty)); +} + +} // namespace test +} // namespace livekit_bridge diff --git a/bridge/tests/test_livekit_bridge.cpp b/bridge/tests/test_livekit_bridge.cpp new file mode 100644 index 0000000..78b2b85 --- /dev/null +++ b/bridge/tests/test_livekit_bridge.cpp @@ -0,0 +1,177 @@ +/* + * 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 + +namespace livekit_bridge { +namespace test { + +class LiveKitBridgeTest : public ::testing::Test { +protected: + // No SetUp/TearDown needed -- we test the bridge without initializing + // the LiveKit SDK, since we only exercise pre-connection behaviour. +}; + +// ============================================================================ +// Initial state +// ============================================================================ + +TEST_F(LiveKitBridgeTest, InitiallyNotConnected) { + LiveKitBridge bridge; + + EXPECT_FALSE(bridge.isConnected()) + << "Bridge should not be connected immediately after construction"; +} + +TEST_F(LiveKitBridgeTest, DisconnectBeforeConnectIsNoOp) { + LiveKitBridge bridge; + + EXPECT_NO_THROW(bridge.disconnect()) + << "disconnect() on an unconnected bridge should be a safe no-op"; + + EXPECT_FALSE(bridge.isConnected()); +} + +TEST_F(LiveKitBridgeTest, MultipleDisconnectsAreIdempotent) { + LiveKitBridge bridge; + + EXPECT_NO_THROW({ + bridge.disconnect(); + bridge.disconnect(); + bridge.disconnect(); + }) << "Multiple disconnect() calls should be safe"; +} + +TEST_F(LiveKitBridgeTest, DestructorOnUnconnectedBridgeIsSafe) { + // Just verify no crash when the bridge is destroyed without connecting. + EXPECT_NO_THROW({ + LiveKitBridge bridge; + // bridge goes out of scope here + }); +} + +// ============================================================================ +// Track creation before connection +// ============================================================================ + +TEST_F(LiveKitBridgeTest, CreateAudioTrackBeforeConnectThrows) { + LiveKitBridge bridge; + + EXPECT_THROW(bridge.createAudioTrack("mic", 48000, 2), std::runtime_error) + << "createAudioTrack should throw when not connected"; +} + +TEST_F(LiveKitBridgeTest, CreateVideoTrackBeforeConnectThrows) { + LiveKitBridge bridge; + + EXPECT_THROW(bridge.createVideoTrack("cam", 1280, 720), std::runtime_error) + << "createVideoTrack should throw when not connected"; +} + +// ============================================================================ +// Callback registration (pre-connection, pure map operations) +// ============================================================================ + +TEST_F(LiveKitBridgeTest, RegisterAndUnregisterAudioCallbackDoesNotCrash) { + LiveKitBridge bridge; + + EXPECT_NO_THROW({ + bridge.registerOnAudioFrame("remote-participant", + livekit::TrackSource::SOURCE_MICROPHONE, + [](const livekit::AudioFrame &) {}); + + bridge.unregisterOnAudioFrame("remote-participant", + livekit::TrackSource::SOURCE_MICROPHONE); + }) << "Registering and unregistering an audio callback should be safe " + "even without a connection"; +} + +TEST_F(LiveKitBridgeTest, RegisterAndUnregisterVideoCallbackDoesNotCrash) { + LiveKitBridge bridge; + + EXPECT_NO_THROW({ + bridge.registerOnVideoFrame( + "remote-participant", livekit::TrackSource::SOURCE_CAMERA, + [](const livekit::VideoFrame &, std::int64_t) {}); + + bridge.unregisterOnVideoFrame("remote-participant", + livekit::TrackSource::SOURCE_CAMERA); + }) << "Registering and unregistering a video callback should be safe " + "even without a connection"; +} + +TEST_F(LiveKitBridgeTest, UnregisterNonExistentCallbackIsNoOp) { + LiveKitBridge bridge; + + EXPECT_NO_THROW({ + bridge.unregisterOnAudioFrame("nonexistent", + livekit::TrackSource::SOURCE_MICROPHONE); + bridge.unregisterOnVideoFrame("nonexistent", + livekit::TrackSource::SOURCE_CAMERA); + }) << "Unregistering a callback that was never registered should be a no-op"; +} + +TEST_F(LiveKitBridgeTest, MultipleRegistrationsSameKeyOverwrites) { + LiveKitBridge bridge; + + int call_count = 0; + + // Register a first callback + bridge.registerOnAudioFrame("alice", livekit::TrackSource::SOURCE_MICROPHONE, + [](const livekit::AudioFrame &) {}); + + // Register a second callback for the same key -- should overwrite + bridge.registerOnAudioFrame( + "alice", livekit::TrackSource::SOURCE_MICROPHONE, + [&call_count](const livekit::AudioFrame &) { call_count++; }); + + // Unregister once should be enough (only one entry per key) + EXPECT_NO_THROW(bridge.unregisterOnAudioFrame( + "alice", livekit::TrackSource::SOURCE_MICROPHONE)); +} + +TEST_F(LiveKitBridgeTest, RegisterCallbacksForMultipleParticipants) { + LiveKitBridge bridge; + + EXPECT_NO_THROW({ + bridge.registerOnAudioFrame("alice", + livekit::TrackSource::SOURCE_MICROPHONE, + [](const livekit::AudioFrame &) {}); + + bridge.registerOnVideoFrame( + "bob", livekit::TrackSource::SOURCE_CAMERA, + [](const livekit::VideoFrame &, std::int64_t) {}); + + bridge.registerOnAudioFrame("charlie", + livekit::TrackSource::SOURCE_SCREENSHARE_AUDIO, + [](const livekit::AudioFrame &) {}); + }) << "Should be able to register callbacks for multiple participants"; + + // Cleanup + bridge.unregisterOnAudioFrame("alice", + livekit::TrackSource::SOURCE_MICROPHONE); + bridge.unregisterOnVideoFrame("bob", livekit::TrackSource::SOURCE_CAMERA); + bridge.unregisterOnAudioFrame("charlie", + livekit::TrackSource::SOURCE_SCREENSHARE_AUDIO); +} + +} // namespace test +} // namespace livekit_bridge diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 3d36664..73b94c4 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -105,6 +105,50 @@ target_link_libraries(SimpleRpc livekit ) +# --- SimpleRobot example (robot + human executables with shared json_utils) --- + +add_library(simple_robot_json_utils STATIC + simple_robot/json_utils.cpp + simple_robot/json_utils.h + simple_robot/utils.cpp + simple_robot/utils.h +) + +target_include_directories(simple_robot_json_utils PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/simple_robot +) + +target_link_libraries(simple_robot_json_utils + PUBLIC + nlohmann_json::nlohmann_json +) + +add_executable(SimpleRobot + simple_robot/robot.cpp +) + +target_include_directories(SimpleRobot PRIVATE ${EXAMPLES_PRIVATE_INCLUDE_DIRS}) + +target_link_libraries(SimpleRobot + PRIVATE + simple_robot_json_utils + livekit +) + +add_executable(SimpleHuman + simple_robot/human.cpp +) + +target_include_directories(SimpleHuman PRIVATE ${EXAMPLES_PRIVATE_INCLUDE_DIRS}) + +target_link_libraries(SimpleHuman + PRIVATE + simple_robot_json_utils + livekit +) + +# --- SimpleDataStream example --- + add_executable(SimpleDataStream simple_data_stream/main.cpp ) @@ -135,7 +179,7 @@ if(WIN32) ) # Copy DLLs to each example's output directory - foreach(EXAMPLE SimpleRoom SimpleRpc SimpleDataStream) + foreach(EXAMPLE SimpleRoom SimpleRpc SimpleRobot SimpleHuman SimpleDataStream) foreach(DLL ${REQUIRED_DLLS}) add_custom_command(TARGET ${EXAMPLE} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different @@ -159,7 +203,7 @@ if(UNIX) endif() # Copy shared library to each example's output directory - foreach(EXAMPLE SimpleRoom SimpleRpc SimpleDataStream) + foreach(EXAMPLE SimpleRoom SimpleRpc SimpleRobot SimpleHuman SimpleDataStream) add_custom_command(TARGET ${EXAMPLE} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different "${LIVEKIT_LIB_DIR}/${FFI_SHARED_LIB}" diff --git a/examples/simple_robot/human.cpp b/examples/simple_robot/human.cpp new file mode 100644 index 0000000..30b12c4 --- /dev/null +++ b/examples/simple_robot/human.cpp @@ -0,0 +1,267 @@ +/* + * 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 "utils.h" +#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); } + +// --- 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 'human' role. It connects to the room and\n" + << "continuously checks for a 'robot' 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 robot 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_robot::parseArgs(argc, argv, url, token)) { + printUsage(argv[0]); + return 1; + } + + std::cout << "[Human] Connecting to: " << url << "\n"; + std::signal(SIGINT, handleSignal); + + livekit::initialize(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 << "[Human] Connect result: " << std::boolalpha << res << "\n"; + if (!res) { + std::cerr << "[Human] Failed to connect to room\n"; + livekit::shutdown(); + return 1; + } + + auto info = room->room_info(); + std::cout << "[Human] Connected to room: " << info.name << "\n"; + + // Enable raw terminal mode for immediate keypress detection + enableRawMode(); + + std::cout << "[Human] 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 robot_connected = false; + auto last_robot_check = std::chrono::steady_clock::now(); + + while (g_running.load()) { + // Periodically check robot presence every 2 seconds + auto now = std::chrono::steady_clock::now(); + if (now - last_robot_check >= 2s) { + last_robot_check = now; + bool robot_present = (room->remoteParticipant("robot") != nullptr); + + if (robot_present && !robot_connected) { + std::cout << "[Human] 'robot' connected! Use keys to send commands.\n"; + robot_connected = true; + } else if (!robot_present && robot_connected) { + std::cout + << "[Human] 'robot' disconnected. Waiting for reconnect...\n"; + robot_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[Human] 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 (!robot_connected) { + std::cout << "[Human] (no robot connected) x=" << x << " y=" << y + << " z=" << z << "\n"; + continue; + } + + // Send joystick command via RPC + simple_robot::JoystickCommand cmd{x, y, z}; + std::string payload = simple_robot::joystick_to_json(cmd); + + std::cout << "[Human] Sending: x=" << x << " y=" << y << " z=" << z + << "\n"; + + try { + std::string response = + lp->performRpc("robot", "joystick_command", payload, 5.0); + std::cout << "[Human] Robot acknowledged: " << response << "\n"; + } catch (const RpcError &e) { + std::cerr << "[Human] RPC error: " << e.message() << "\n"; + if (static_cast(e.code()) == + RpcError::ErrorCode::RECIPIENT_DISCONNECTED) { + std::cout + << "[Human] Robot disconnected. Waiting for reconnect...\n"; + robot_connected = false; + } + } catch (const std::exception &e) { + std::cerr << "[Human] Error sending command: " << e.what() << "\n"; + } + } + + disableRawMode(); + + std::cout << "[Human] Done. Shutting down.\n"; + room->setDelegate(nullptr); + room.reset(); + livekit::shutdown(); + return 0; +} diff --git a/examples/simple_robot/json_utils.cpp b/examples/simple_robot/json_utils.cpp new file mode 100644 index 0000000..78a0020 --- /dev/null +++ b/examples/simple_robot/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_robot { + +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_robot diff --git a/examples/simple_robot/json_utils.h b/examples/simple_robot/json_utils.h new file mode 100644 index 0000000..3e8a859 --- /dev/null +++ b/examples/simple_robot/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_robot { + +/// 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_robot diff --git a/examples/simple_robot/robot.cpp b/examples/simple_robot/robot.cpp new file mode 100644 index 0000000..d61cc10 --- /dev/null +++ b/examples/simple_robot/robot.cpp @@ -0,0 +1,125 @@ +/* + * 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 "utils.h" +#include "livekit/livekit.h" + +using namespace livekit; +using namespace std::chrono_literals; + +namespace { + +std::atomic g_running{true}; +std::atomic g_human_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 'robot' role. It waits for a 'human' 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_robot::parseArgs(argc, argv, url, token)) { + printUsage(argv[0]); + return 1; + } + + std::cout << "[Robot] Connecting to: " << url << "\n"; + std::signal(SIGINT, handleSignal); + + livekit::initialize(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 << "[Robot] Connect result: " << std::boolalpha << res << "\n"; + if (!res) { + std::cerr << "[Robot] Failed to connect to room\n"; + livekit::shutdown(); + return 1; + } + + auto info = room->room_info(); + std::cout << "[Robot] Connected to room: " << info.name << "\n"; + std::cout << "[Robot] Waiting for 'human' 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_robot::json_to_joystick(data.payload); + g_human_connected.store(true); + std::cout << "[Robot] 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 << "[Robot] Bad joystick payload: " << e.what() << "\n"; + throw; + } + }); + + std::cout << "[Robot] 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 << "[Robot] Interrupted by signal. Shutting down.\n"; + } else if (!g_human_connected.load()) { + std::cerr << "[Robot] Timed out after 2 minutes with no human connection. " + << "Exiting as failure.\n"; + room->setDelegate(nullptr); + room.reset(); + livekit::shutdown(); + return 1; + } else { + std::cout << "[Robot] Session complete.\n"; + } + + room->setDelegate(nullptr); + room.reset(); + livekit::shutdown(); + return 0; +} diff --git a/examples/simple_robot/utils.cpp b/examples/simple_robot/utils.cpp new file mode 100644 index 0000000..40d02c2 --- /dev/null +++ b/examples/simple_robot/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_robot { + +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_robot diff --git a/examples/simple_robot/utils.h b/examples/simple_robot/utils.h new file mode 100644 index 0000000..1d1067f --- /dev/null +++ b/examples/simple_robot/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_robot { + +/// 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_robot