From 79e12de3c8b33f448c191d8a1fa0a455dc601e4f 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 SDL and WAV examples/common dir. move bridge/examples to examples/ --- .gitignore | 2 + CMakeLists.txt | 4 + README.md | 4 +- bridge/CMakeLists.txt | 51 ++ bridge/README.md | 249 +++++++ .../livekit_bridge/bridge_audio_track.h | 147 ++++ .../livekit_bridge/bridge_video_track.h | 145 ++++ .../include/livekit_bridge/livekit_bridge.h | 347 ++++++++++ bridge/src/bridge_audio_track.cpp | 130 ++++ bridge/src/bridge_room_delegate.cpp | 53 ++ bridge/src/bridge_room_delegate.h | 50 ++ bridge/src/bridge_video_track.cpp | 129 ++++ bridge/src/livekit_bridge.cpp | 490 ++++++++++++++ bridge/tests/CMakeLists.txt | 94 +++ bridge/tests/test_bridge_audio_track.cpp | 118 ++++ bridge/tests/test_bridge_video_track.cpp | 114 ++++ bridge/tests/test_callback_key.cpp | 124 ++++ bridge/tests/test_livekit_bridge.cpp | 185 ++++++ examples/CMakeLists.txt | 145 +++- examples/bridge_human_robot/human.cpp | 407 ++++++++++++ examples/bridge_human_robot/robot.cpp | 625 ++++++++++++++++++ .../{simple_room => common}/sdl_media.cpp | 0 examples/{simple_room => common}/sdl_media.h | 0 .../sdl_media_manager.cpp | 0 .../sdl_media_manager.h | 0 .../sdl_video_renderer.cpp | 0 .../sdl_video_renderer.h | 0 .../wav_audio_source.cpp | 0 .../wav_audio_source.h | 0 examples/simple_robot/human.cpp | 264 ++++++++ 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 + examples/simple_room/fallback_capture.cpp | 1 - include/livekit/room.h | 2 +- 37 files changed, 4193 insertions(+), 14 deletions(-) create mode 100644 bridge/CMakeLists.txt create mode 100644 bridge/README.md 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/bridge_human_robot/human.cpp create mode 100644 examples/bridge_human_robot/robot.cpp rename examples/{simple_room => common}/sdl_media.cpp (100%) rename examples/{simple_room => common}/sdl_media.h (100%) rename examples/{simple_room => common}/sdl_media_manager.cpp (100%) rename examples/{simple_room => common}/sdl_media_manager.h (100%) rename examples/{simple_room => common}/sdl_video_renderer.cpp (100%) rename examples/{simple_room => common}/sdl_video_renderer.h (100%) rename examples/{simple_room => common}/wav_audio_source.cpp (100%) rename examples/{simple_room => common}/wav_audio_source.h (100%) 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 cc4bdfc2..4ebc57a1 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 bc344ff8..1c7e1205 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) @@ -627,6 +628,9 @@ install(FILES # ------------------------------------------------------------------------ +# Build the LiveKit C++ bridge before examples (human_robot depends on it) +add_subdirectory(bridge) + # ---- Examples ---- # add_subdirectory(examples) diff --git a/README.md b/README.md index 12662ce4..f039d418 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ This page covers how to build and install the LiveKit C++ Client SDK for real-ti - **Git LFS** (required for examples) Some example data files (e.g., audio assets) are stored using Git LFS. You must install Git LFS before cloning or pulling the repo if you want to run the examples. +- **livekit-cli** install livekit-cli by following the (official livekit docs)[https://docs.livekit.io/intro/basics/cli/start/] +- **livekit-server** install livekit-server by following the (official livekit docs)[https://docs.livekit.io/transport/self-hosting/local/] **Platform-Specific Requirements:** @@ -340,4 +342,4 @@ 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 00000000..393c5612 --- /dev/null +++ b/bridge/CMakeLists.txt @@ -0,0 +1,51 @@ +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() + +# Bridge examples (robot + human) are built from examples/CMakeLists.txt +# when LIVEKIT_BUILD_EXAMPLES is ON; see examples/bridge_human_robot/. diff --git a/bridge/README.md b/bridge/README.md new file mode 100644 index 00000000..76b4f2b6 --- /dev/null +++ b/bridge/README.md @@ -0,0 +1,249 @@ +# 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; +livekit::RoomOptions options; +options.auto_subscribe = true; // automatically subscribe to all remote tracks +options.dynacast = false; +bridge.connect("wss://my-server.livekit.cloud", token, options); + +// 2. Create outgoing tracks (RAII-managed) +auto mic = bridge.createAudioTrack("mic", 48000, 2, + livekit::TrackSource::SOURCE_MICROPHONE); // name, sample_rate, channels, source +auto cam = bridge.createVideoTrack("cam", 1280, 720, + livekit::TrackSource::SOURCE_CAMERA); // name, width, height, source + +// 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.setOnAudioFrameCallback("remote-peer", livekit::TrackSource::SOURCE_MICROPHONE, + [](const livekit::AudioFrame& frame) { + // Called on a background reader thread + }); + +bridge.setOnVideoFrameCallback("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. + +> **Note:** Only one callback may be set per `(participant_identity, track_source)` pair. Calling `setOnAudioFrameCallback` or `setOnVideoFrameCallback` again with the same identity and source will silently replace the previous callback. If you need to fan-out a single stream to multiple consumers, do so inside your callback. + +This means the typical pattern is: + +```cpp +// Register first, connect second -- or register after connect but before +// the remote participant joins. +bridge.setOnAudioFrameCallback("robot-1", livekit::TrackSource::SOURCE_MICROPHONE, my_callback); +livekit::RoomOptions options; +options.auto_subscribe = true; +bridge.connect(url, token, options); +// 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, options)` | 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, source)` | Create and publish a local audio track with the given `TrackSource` (e.g. `SOURCE_MICROPHONE`, `SOURCE_SCREENSHARE_AUDIO`). Returns an RAII `shared_ptr`. | +| `createVideoTrack(name, width, height, source)` | Create and publish a local video track with the given `TrackSource` (e.g. `SOURCE_CAMERA`, `SOURCE_SCREENSHARE`). Returns an RAII `shared_ptr`. | +| `setOnAudioFrameCallback(identity, source, callback)` | Register a callback for audio frames from a specific remote participant + track source. | +| `setOnVideoFrameCallback(identity, source, callback)` | Register a callback for video frames from a specific remote participant + track source. | +| `clearOnAudioFrameCallback(identity, source)` | Clear the audio callback for a specific remote participant + track source. Stops and joins the reader thread if active. | +| `clearOnVideoFrameCallback(identity, source)` | Clear the video callback for a specific remote participant + track source. 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 +- examples/robot.cpp: publishes video and audio from a webcam and microphone. This requires a webcam and microphone to be available. +- examples/human.cpp: receives and renders video to the screen, receives and plays audio through the speaker. + +### Running the examples: +Note: the following workflow works for both `human` and `robot`. + +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: + +- We dont support all events defined in the RoomDelegate interface. +- E2EE configuration +- RPC / data channels / data tracks +- Simulcast tuning +- Video format selection (RGBA is the default; no format option yet) +- Custom `RoomOptions` or `TrackPublishOptions` +- **One callback per (participant, source):** Only a single callback can be registered for each `(participant_identity, track_source)` pair. Re-registering with the same key silently replaces the previous callback. To fan-out a stream to multiple consumers, dispatch from within your single callback. + +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/include/livekit_bridge/bridge_audio_track.h b/bridge/include/livekit_bridge/bridge_audio_track.h new file mode 100644 index 00000000..5683e069 --- /dev/null +++ b/bridge/include/livekit_bridge/bridge_audio_track.h @@ -0,0 +1,147 @@ +/* + * 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. + */ + +/// @file bridge_audio_track.h +/// @brief Handle for a published local audio track. + +#pragma once + +#include +#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 + +/** + * Handle to a published local audio track. + * + * Created via LiveKitBridge::createAudioTrack(). The bridge retains a + * reference to every track it creates and will automatically release all + * tracks when disconnect() is called. To unpublish a track mid-session, + * call release() explicitly; dropping the shared_ptr alone is not + * sufficient because the bridge still holds a reference. + * + * After release() (whether called explicitly or by the bridge on + * disconnect), pushFrame() returns false and mute()/unmute() become + * no-ops. The track object remains valid but inert. + * + * All public methods are thread-safe: it is safe to call pushFrame() from + * one thread while another calls mute()/unmute()/release(), or to call + * pushFrame() concurrently from multiple threads. + * + * Usage: + * auto mic = bridge.createAudioTrack("mic", 48000, 2, + * livekit::TrackSource::SOURCE_MICROPHONE); + * mic->pushFrame(pcm_data, samples_per_channel); + * mic->mute(); + * mic->release(); // unpublishes the track mid-session + */ +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). + * @return true if the frame was pushed, false if the track has been released. + */ + bool 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. + * @return true if the frame was pushed, false if the track has been released. + */ + bool 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(); + + /// 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; + + /** + * Explicitly unpublish the track and release all underlying SDK resources. + * + * After this call, pushFrame() returns false and mute()/unmute() are + * no-ops. Called automatically by the destructor and by + * LiveKitBridge::disconnect(). Safe to call multiple times (idempotent). + */ + void release(); + +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); + + mutable std::mutex mutex_; + 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 00000000..8057b7a9 --- /dev/null +++ b/bridge/include/livekit_bridge/bridge_video_track.h @@ -0,0 +1,145 @@ +/* + * 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. + */ + +/// @file bridge_video_track.h +/// @brief Handle for a published local video track. + +#pragma once + +#include +#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 + +/** + * Handle to a published local video track. + * + * Created via LiveKitBridge::createVideoTrack(). The bridge retains a + * reference to every track it creates and will automatically release all + * tracks when disconnect() is called. To unpublish a track mid-session, + * call release() explicitly; dropping the shared_ptr alone is not + * sufficient because the bridge still holds a reference. + * + * After release() (whether called explicitly or by the bridge on + * disconnect), pushFrame() returns false and mute()/unmute() become + * no-ops. The track object remains valid but inert. + * + * All public methods are thread-safe: it is safe to call pushFrame() from + * one thread while another calls mute()/unmute()/release(), or to call + * pushFrame() concurrently from multiple threads. + * + * Usage: + * auto cam = bridge.createVideoTrack("cam", 1280, 720, + * livekit::TrackSource::SOURCE_CAMERA); + * cam->pushFrame(rgba_data, timestamp_us); + * cam->mute(); + * cam->release(); // unpublishes the track mid-session + */ +class BridgeVideoTrack { +public: + ~BridgeVideoTrack(); + + // Non-copyable + BridgeVideoTrack(const BridgeVideoTrack &) = delete; + BridgeVideoTrack &operator=(const BridgeVideoTrack &) = delete; + + /** + * Push an RGBA video frame to the track. + * + * @param rgba 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. + * @return true if the frame was pushed, false if the track has been released. + */ + bool pushFrame(const std::vector &rgba, + std::int64_t timestamp_us = 0); + + /** + * Push an RGBA video frame from a raw pointer. + * + * @param rgba Pointer to RGBA pixel data. + * @param rgba_size Size of the data buffer in bytes. + * @param timestamp_us Presentation timestamp in microseconds. + * @return true if the frame was pushed, false if the track has been released. + */ + bool pushFrame(const std::uint8_t *rgba, std::size_t rgba_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(); + + /// 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; + + /** + * Explicitly unpublish the track and release all underlying SDK resources. + * + * After this call, pushFrame() returns false and mute()/unmute() are + * no-ops. Called automatically by the destructor and by + * LiveKitBridge::disconnect(). Safe to call multiple times (idempotent). + */ + void release(); + +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); + + mutable std::mutex mutex_; + 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 00000000..df5f3e34 --- /dev/null +++ b/bridge/include/livekit_bridge/livekit_bridge.h @@ -0,0 +1,347 @@ +/* + * 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. + */ + +/// @file livekit_bridge.h +/// @brief High-level bridge API for the LiveKit C++ SDK. + +#pragma once + +#include "livekit_bridge/bridge_audio_track.h" +#include "livekit_bridge/bridge_video_track.h" + +#include "livekit/room.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. + * + * The bridge retains a shared_ptr to every track it creates. On + * disconnect(), all tracks are released (unpublished) before the room + * is torn down, guaranteeing safe teardown order. To unpublish a track + * mid-session, call release() on the track explicitly; dropping the + * application's shared_ptr alone is not sufficient. + * + * Example: + * + * LiveKitBridge bridge; + * livekit::RoomOptions options; + * options.auto_subscribe = true; + * bridge.connect("wss://my-server.livekit.cloud", my_token, options); + * + * auto mic = bridge.createAudioTrack("mic", 48000, 2, + * livekit::TrackSource::SOURCE_MICROPHONE); + * auto cam = bridge.createVideoTrack("cam", 1280, 720, + * livekit::TrackSource::SOURCE_CAMERA); + * + * mic->pushFrame(pcm_data, samples_per_channel); + * cam->pushFrame(rgba_data, timestamp_us); + * + * bridge.setOnAudioFrameCallback("remote-participant", + * livekit::TrackSource::SOURCE_MICROPHONE, + * [](const livekit::AudioFrame& f) { process(f); }); + * + * bridge.setOnVideoFrameCallback("remote-participant", + * livekit::TrackSource::SOURCE_CAMERA, + * [](const livekit::VideoFrame& f, int64_t ts) { render(f); }); + * + * // Unpublish a single track mid-session: + * mic->release(); + * + * // Disconnect releases all remaining tracks and tears down the room: + * 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 performs + * the WebSocket handshake. This call **blocks** until the connection + * succeeds or fails. auto_subscribe is enabled so that remote tracks + * are subscribed automatically. + * + * If the bridge is already connected, returns true immediately. + * If another thread is already in the process of connecting, returns + * false without blocking. + * + * @param url WebSocket URL of the LiveKit server. + * @param token Access token for authentication. + * @param options Room options. + + * @return true if connection succeeded (or was already connected). + */ + bool connect(const std::string &url, const std::string &token, + const livekit::RoomOptions &options); + + /** + * 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 bridge retains a reference to the track internally. To unpublish + * mid-session, call release() on the returned track. All surviving + * tracks are automatically released on disconnect(). + * + * @pre The bridge must be connected (via connect()). Calling this on a + * disconnected bridge is a programming error. + * + * @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). + * @param source Track source type (e.g. SOURCE_MICROPHONE). Use a + * different source (e.g. SOURCE_SCREENSHARE_AUDIO) to + * publish multiple audio tracks from the same + * participant that can be independently subscribed to. + * @return Shared pointer to the published audio track handle (never null). + * @throws std::runtime_error if the bridge is not connected. + */ + std::shared_ptr + createAudioTrack(const std::string &name, int sample_rate, int num_channels, + livekit::TrackSource source); + + /** + * Create and publish a local video track. + * + * The bridge retains a reference to the track internally. To unpublish + * mid-session, call release() on the returned track. All surviving + * tracks are automatically released on disconnect(). + * + * @pre The bridge must be connected (via connect()). Calling this on a + * disconnected bridge is a programming error. + * + * @param name Human-readable track name. + * @param width Video width in pixels. + * @param height Video height in pixels. + * @param source Track source type (default: SOURCE_CAMERA). Use a + * different source (e.g. SOURCE_SCREENSHARE) to publish + * multiple video tracks from the same participant that + * can be independently subscribed to. + * @return Shared pointer to the published video track handle (never null). + * @throws std::runtime_error if the bridge is not connected. + */ + std::shared_ptr + createVideoTrack(const std::string &name, int width, int height, + livekit::TrackSource source); + + // --------------------------------------------------------------- + // Incoming frame callbacks + // --------------------------------------------------------------- + + /** + * Set the 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. + * + * @note Only **one** callback may be registered per (participant, source) + * pair. Calling this again with the same identity and source will + * silently replace the previous callback. + * + * @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 setOnAudioFrameCallback(const std::string &participant_identity, + livekit::TrackSource source, + AudioFrameCallback callback); + + /** + * Register a callback for video frames from a specific remote participant + * and track source. + * + * @note Only **one** callback may be registered per (participant, source) + * pair. Calling this again with the same identity and source will + * silently replace the previous callback. + * + * @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 setOnVideoFrameCallback(const std::string &participant_identity, + livekit::TrackSource source, + VideoFrameCallback callback); + + /** + * Clear the audio frame callback for a specific remote participant + track + * source. + * + * If a reader thread is active for this (identity, source), it is + * stopped and joined. + */ + void clearOnAudioFrameCallback(const std::string &participant_identity, + livekit::TrackSource source); + + /** + * Clear the video frame callback for a specific remote participant + track + * source. + * + * If a reader thread is active for this (identity, source), it is + * stopped and joined. + */ + void clearOnVideoFrameCallback(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). + /// Only one callback can exist per key -- re-registering overwrites. + 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); + + /// Extract the thread for the given callback key. + /// @pre Caller must hold @c mutex_. + std::thread extractReaderThread(const CallbackKey &key); + + /// Start a reader thread for a subscribed track. + /// @return The reader thread for this track. + /// @pre Caller must hold @c mutex_. + std::thread startAudioReader(const CallbackKey &key, + const std::shared_ptr &track, + AudioFrameCallback cb); + /// @copydoc startAudioReader + std::thread startVideoReader(const CallbackKey &key, + const std::shared_ptr &track, + VideoFrameCallback cb); + + mutable std::mutex mutex_; + bool connected_; + bool connecting_; // guards against concurrent connect() calls + bool sdk_initialized_; + + static constexpr int kMaxActiveReaders = 20; + + std::unique_ptr room_; + std::unique_ptr delegate_; + + /// Registered callbacks (may be registered before tracks are subscribed). + std::unordered_map + audio_callbacks_; + /// @copydoc audio_callbacks_ + std::unordered_map + video_callbacks_; + + /// Active reader threads for subscribed tracks. + std::unordered_map + active_readers_; + + /// All tracks created by this bridge. The bridge retains a shared_ptr so + /// it can force-release every track on disconnect() before the room is + /// destroyed, preventing dangling @c participant_ pointers. + std::vector> published_audio_tracks_; + /// @copydoc published_audio_tracks_ + std::vector> published_video_tracks_; + +}; + +} // namespace livekit_bridge diff --git a/bridge/src/bridge_audio_track.cpp b/bridge/src/bridge_audio_track.cpp new file mode 100644 index 00000000..71803fc8 --- /dev/null +++ b/bridge/src/bridge_audio_track.cpp @@ -0,0 +1,130 @@ +/* + * 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. + */ + +/// @file bridge_audio_track.cpp +/// @brief Implementation of BridgeAudioTrack. + +#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 +#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(); } + +bool BridgeAudioTrack::pushFrame(const std::vector &data, + int samples_per_channel, int timeout_ms) { + livekit::AudioFrame frame(std::vector(data.begin(), data.end()), + sample_rate_, num_channels_, samples_per_channel); + + std::lock_guard lock(mutex_); + if (released_) { + return false; + } + + try { + source_->captureFrame(frame, timeout_ms); + } catch (const std::exception &e) { + std::cerr << "[BridgeAudioTrack] captureFrame error: " << e.what() << "\n"; + return false; + } + return true; +} + +bool BridgeAudioTrack::pushFrame(const std::int16_t *data, + int samples_per_channel, int timeout_ms) { + 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); + + std::lock_guard lock(mutex_); + if (released_) { + return false; + } + + try { + source_->captureFrame(frame, timeout_ms); + } catch (const std::exception &e) { + std::cerr << "[BridgeAudioTrack] captureFrame error: " << e.what() << "\n"; + return false; + } + return true; +} + +void BridgeAudioTrack::mute() { + std::lock_guard lock(mutex_); + if (!released_ && track_) { + track_->mute(); + } +} + +void BridgeAudioTrack::unmute() { + std::lock_guard lock(mutex_); + if (!released_ && track_) { + track_->unmute(); + } +} + +bool BridgeAudioTrack::isReleased() const noexcept { + std::lock_guard lock(mutex_); + return released_; +} + +void BridgeAudioTrack::release() { + std::lock_guard lock(mutex_); + 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 + std::cerr << "[BridgeAudioTrack] unpublishTrack error, continuing with " + "cleanup\n"; + } + } + + // 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 00000000..118627c6 --- /dev/null +++ b/bridge/src/bridge_room_delegate.cpp @@ -0,0 +1,53 @@ +/* + * Copyright 2025 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// @file bridge_room_delegate.cpp +/// @brief Implementation of BridgeRoomDelegate event forwarding. + +#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 00000000..bbee0918 --- /dev/null +++ b/bridge/src/bridge_room_delegate.h @@ -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. + */ + +/// @file bridge_room_delegate.h +/// @brief Internal RoomDelegate forwarding SDK events to LiveKitBridge. + +#pragma once + +#include "livekit/room_delegate.h" + +namespace livekit_bridge { + +class LiveKitBridge; + +/** + * Internal RoomDelegate that forwards SDK room events to the LiveKitBridge. + * + * Handles track subscribe/unsubscribe lifecycle. 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) {} + + /// Forwards a track-subscribed event to LiveKitBridge::onTrackSubscribed(). + void onTrackSubscribed(livekit::Room &room, + const livekit::TrackSubscribedEvent &ev) override; + + /// Forwards a track-unsubscribed event to LiveKitBridge::onTrackUnsubscribed(). + 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 00000000..3996e612 --- /dev/null +++ b/bridge/src/bridge_video_track.cpp @@ -0,0 +1,129 @@ +/* + * 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. + */ + +/// @file bridge_video_track.cpp +/// @brief Implementation of BridgeVideoTrack. + +#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 +#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(); } + +bool BridgeVideoTrack::pushFrame(const std::vector &rgba, + std::int64_t timestamp_us) { + livekit::VideoFrame frame( + width_, height_, livekit::VideoBufferType::RGBA, + std::vector(rgba.begin(), rgba.end())); + + std::lock_guard lock(mutex_); + if (released_) { + return false; + } + + try { + source_->captureFrame(frame, timestamp_us); + } catch (const std::exception &e) { + std::cerr << "[BridgeVideoTrack] captureFrame error: " << e.what() << "\n"; + return false; + } + return true; +} + +bool BridgeVideoTrack::pushFrame(const std::uint8_t *rgba, + std::size_t rgba_size, + std::int64_t timestamp_us) { + livekit::VideoFrame frame(width_, height_, livekit::VideoBufferType::RGBA, + std::vector(rgba, rgba + rgba_size)); + + std::lock_guard lock(mutex_); + if (released_) { + return false; + } + + try { + source_->captureFrame(frame, timestamp_us); + } catch (const std::exception &e) { + std::cerr << "[BridgeVideoTrack] captureFrame error: " << e.what() << "\n"; + return false; + } + return true; +} + +void BridgeVideoTrack::mute() { + std::lock_guard lock(mutex_); + if (!released_ && track_) { + track_->mute(); + } +} + +void BridgeVideoTrack::unmute() { + std::lock_guard lock(mutex_); + if (!released_ && track_) { + track_->unmute(); + } +} + +bool BridgeVideoTrack::isReleased() const noexcept { + std::lock_guard lock(mutex_); + return released_; +} + +void BridgeVideoTrack::release() { + std::lock_guard lock(mutex_); + 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 + std::cerr << "[BridgeVideoTrack] unpublishTrack error, continuing with " + "cleanup\n"; + } + } + + // 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 00000000..cfce522b --- /dev/null +++ b/bridge/src/livekit_bridge.cpp @@ -0,0 +1,490 @@ +/* + * 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. + */ + +/// @file livekit_bridge.cpp +/// @brief Implementation of the LiveKitBridge high-level API. + +#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 +#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() + : connected_(false), connecting_(false), sdk_initialized_(false) {} + +LiveKitBridge::~LiveKitBridge() { disconnect(); } + +// --------------------------------------------------------------- +// Connection +// --------------------------------------------------------------- + +bool LiveKitBridge::connect(const std::string &url, const std::string &token, + const livekit::RoomOptions &options) { + // ---- Phase 1: quick check under lock ---- + { + std::lock_guard lock(mutex_); + + if (connected_) { + return true; // already connected + } + + if (connecting_) { + return false; // another thread is already connecting + } + + connecting_ = true; + + // Initialize the LiveKit SDK (idempotent) + if (!sdk_initialized_) { + livekit::initialize(livekit::LogSink::kConsole); + sdk_initialized_ = true; + } + } + + // ---- Phase 2: create room and connect without holding the lock ---- + // This avoids blocking other threads during the network handshake and + // eliminates the risk of deadlock if the SDK delivers delegate callbacks + // synchronously during Connect(). + auto room = std::make_unique(); + assert(room != nullptr); + + bool result = room->Connect(url, token, options); + if (!result) { + std::lock_guard lock(mutex_); + connecting_ = false; + return false; + } + + // ---- Phase 3: commit and attach delegate under lock ---- + // Setting the delegate here (after Connect) ensures that any queued + // onTrackSubscribed events are delivered only after + // room_/delegate_/connected_ are all in a consistent state. + + auto delegate = std::make_unique(*this); + assert(delegate != nullptr); + room->setDelegate(delegate.get()); + { + std::lock_guard lock(mutex_); + room_ = std::move(room); + delegate_ = std::move(delegate); + connected_ = true; + connecting_ = false; + } + 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_) { + std::cerr << "[LiveKitBridge] Attempting to disconnect an already " + "disconnected bridge. Things may not disconnect properly.\n"; + } + + connected_ = false; + connecting_ = false; + + // Release all published tracks while the room/participant are still alive. + // This calls unpublishTrack() on each, ensuring participant_ is valid. + for (auto &track : published_audio_tracks_) { + track->release(); + } + for (auto &track : published_video_tracks_) { + track->release(); + } + published_audio_tracks_.clear(); + published_video_tracks_.clear(); + + // 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.emplace_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, livekit::TrackSource source) { + 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 audio_source = + std::make_shared(sample_rate, num_channels, 0); + + // 2. Create local audio track + auto track = + livekit::LocalAudioTrack::createLocalAudioTrack(name, audio_source); + + // 3. Publish with the caller-specified source + livekit::TrackPublishOptions opts; + opts.source = source; + + auto publication = room_->localParticipant()->publishTrack(track, opts); + + // 4. Wrap in handle and retain a reference + auto bridge_track = std::shared_ptr(new BridgeAudioTrack( + name, sample_rate, num_channels, std::move(audio_source), + std::move(track), std::move(publication), room_->localParticipant())); + published_audio_tracks_.emplace_back(bridge_track); + return bridge_track; +} + +std::shared_ptr +LiveKitBridge::createVideoTrack(const std::string &name, int width, int height, + livekit::TrackSource source) { + std::lock_guard lock(mutex_); + + if (!connected_ || !room_) { + throw std::runtime_error( + "LiveKitBridge::createVideoTrack: not connected to a room"); + } + + // 1. Create video source + auto video_source = std::make_shared(width, height); + + // 2. Create local video track + auto track = + livekit::LocalVideoTrack::createLocalVideoTrack(name, video_source); + + // 3. Publish with the caller-specified source + livekit::TrackPublishOptions opts; + opts.source = source; + + auto publication = room_->localParticipant()->publishTrack(track, opts); + + // 4. Wrap in handle and retain a reference + auto bridge_track = std::shared_ptr(new BridgeVideoTrack( + name, width, height, std::move(video_source), std::move(track), + std::move(publication), room_->localParticipant())); + published_video_tracks_.emplace_back(bridge_track); + return bridge_track; +} + +// --------------------------------------------------------------- +// Incoming frame callbacks +// --------------------------------------------------------------- + +void LiveKitBridge::setOnAudioFrameCallback( + 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::setOnVideoFrameCallback( + 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::clearOnAudioFrameCallback( + 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::clearOnVideoFrameCallback( + 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::thread old_thread; + { + 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()) { + old_thread = startAudioReader(key, track, it->second); + } + } else if (track->kind() == livekit::TrackKind::KIND_VIDEO) { + auto it = video_callbacks_.find(key); + if (it != video_callbacks_.end()) { + old_thread = startVideoReader(key, track, it->second); + } + } + } + // If this key already had a reader (e.g. track was re-subscribed), the old + // reader's stream was closed inside startAudioReader/startVideoReader. We + // join its thread here -- outside the lock -- to guarantee it has finished + // invoking the old callback before we return. + if (old_thread.joinable()) { + old_thread.join(); + } +} + +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; +} + +std::thread +LiveKitBridge::startAudioReader(const CallbackKey &key, + const std::shared_ptr &track, + AudioFrameCallback cb) { + // Caller must hold mutex_. + // Returns the old reader thread (if any) for the caller to join outside + // the lock. + auto old_thread = extractReaderThread(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 old_thread; + } + + 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); + if (active_readers_.size() > kMaxActiveReaders) { + std::cerr << "[LiveKitBridge] More than expected active readers. Need to " + "evaluate how much to expect/support."; + "solution"; + } + return old_thread; +} + +std::thread +LiveKitBridge::startVideoReader(const CallbackKey &key, + const std::shared_ptr &track, + VideoFrameCallback cb) { + // Caller must hold mutex_. + // Returns the old reader thread (if any) for the caller to join outside + // the lock. + auto old_thread = extractReaderThread(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 old_thread; + } + + 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); + if (active_readers_.size() > kMaxActiveReaders) { + std::cerr << "[LiveKitBridge] More than expected active readers. Need to " + "evaluate how much to expect/support."; + } + return old_thread; +} + +} // namespace livekit_bridge diff --git a/bridge/tests/CMakeLists.txt b/bridge/tests/CMakeLists.txt new file mode 100644 index 00000000..258681e2 --- /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 00000000..8e7274e9 --- /dev/null +++ b/bridge/tests/test_bridge_audio_track.cpp @@ -0,0 +1,118 @@ +/* + * 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. + */ + +/// @file test_bridge_audio_track.cpp +/// @brief Unit tests for BridgeAudioTrack. + +#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, PushFrameAfterReleaseReturnsFalse) { + auto track = createNullTrack(); + track.release(); + + std::vector data(960, 0); + + EXPECT_FALSE(track.pushFrame(data, 480)) + << "pushFrame (vector) on a released track should return false"; +} + +TEST_F(BridgeAudioTrackTest, PushFrameRawPointerAfterReleaseReturnsFalse) { + auto track = createNullTrack(); + track.release(); + + std::vector data(960, 0); + + EXPECT_FALSE(track.pushFrame(data.data(), 480)) + << "pushFrame (raw pointer) on a released track should return false"; +} + +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 00000000..08517b02 --- /dev/null +++ b/bridge/tests/test_bridge_video_track.cpp @@ -0,0 +1,114 @@ +/* + * 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. + */ + +/// @file test_bridge_video_track.cpp +/// @brief Unit tests for BridgeVideoTrack. + +#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, PushFrameAfterReleaseReturnsFalse) { + auto track = createNullTrack(); + track.release(); + + std::vector data(1280 * 720 * 4, 0); + + EXPECT_FALSE(track.pushFrame(data)) + << "pushFrame (vector) on a released track should return false"; +} + +TEST_F(BridgeVideoTrackTest, PushFrameRawPointerAfterReleaseReturnsFalse) { + auto track = createNullTrack(); + track.release(); + + std::vector data(1280 * 720 * 4, 0); + + EXPECT_FALSE(track.pushFrame(data.data(), data.size())) + << "pushFrame (raw pointer) on a released track should return false"; +} + +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 00000000..29974f6f --- /dev/null +++ b/bridge/tests/test_callback_key.cpp @@ -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. + */ + +/// @file test_callback_key.cpp +/// @brief Unit tests for LiveKitBridge::CallbackKey hash and equality. + +#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 00000000..38b1d7d3 --- /dev/null +++ b/bridge/tests/test_livekit_bridge.cpp @@ -0,0 +1,185 @@ +/* + * 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. + */ + +/// @file test_livekit_bridge.cpp +/// @brief Unit tests for LiveKitBridge. + +#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, + livekit::TrackSource::SOURCE_MICROPHONE), + std::runtime_error) + << "createAudioTrack should throw when not connected"; +} + +TEST_F(LiveKitBridgeTest, CreateVideoTrackBeforeConnectThrows) { + LiveKitBridge bridge; + + EXPECT_THROW(bridge.createVideoTrack("cam", 1280, 720, + livekit::TrackSource::SOURCE_CAMERA), + 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.setOnAudioFrameCallback("remote-participant", + livekit::TrackSource::SOURCE_MICROPHONE, + [](const livekit::AudioFrame &) {}); + + bridge.clearOnAudioFrameCallback("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.setOnVideoFrameCallback( + "remote-participant", livekit::TrackSource::SOURCE_CAMERA, + [](const livekit::VideoFrame &, std::int64_t) {}); + + bridge.clearOnVideoFrameCallback("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.clearOnAudioFrameCallback("nonexistent", + livekit::TrackSource::SOURCE_MICROPHONE); + bridge.clearOnVideoFrameCallback("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.setOnAudioFrameCallback("alice", + livekit::TrackSource::SOURCE_MICROPHONE, + [](const livekit::AudioFrame &) {}); + + // Register a second callback for the same key -- should overwrite + bridge.setOnAudioFrameCallback( + "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.clearOnAudioFrameCallback( + "alice", livekit::TrackSource::SOURCE_MICROPHONE)); +} + +TEST_F(LiveKitBridgeTest, RegisterCallbacksForMultipleParticipants) { + LiveKitBridge bridge; + + EXPECT_NO_THROW({ + bridge.setOnAudioFrameCallback("alice", + livekit::TrackSource::SOURCE_MICROPHONE, + [](const livekit::AudioFrame &) {}); + + bridge.setOnVideoFrameCallback( + "bob", livekit::TrackSource::SOURCE_CAMERA, + [](const livekit::VideoFrame &, std::int64_t) {}); + + bridge.setOnAudioFrameCallback( + "charlie", livekit::TrackSource::SOURCE_SCREENSHARE_AUDIO, + [](const livekit::AudioFrame &) {}); + }) << "Should be able to register callbacks for multiple participants"; + + // Cleanup + bridge.clearOnAudioFrameCallback("alice", + livekit::TrackSource::SOURCE_MICROPHONE); + bridge.clearOnVideoFrameCallback("bob", livekit::TrackSource::SOURCE_CAMERA); + bridge.clearOnAudioFrameCallback( + "charlie", livekit::TrackSource::SOURCE_SCREENSHARE_AUDIO); +} + +} // namespace test +} // namespace livekit_bridge diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 3d366644..5494a4b5 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -30,21 +30,28 @@ set(EXAMPLES_PRIVATE_INCLUDE_DIRS ${LIVEKIT_BINARY_DIR}/generated ) +# Shared example code (SDL media, wav source, etc.) +set(EXAMPLES_COMMON_DIR ${CMAKE_CURRENT_SOURCE_DIR}/common) + add_executable(SimpleRoom simple_room/main.cpp simple_room/fallback_capture.cpp simple_room/fallback_capture.h - simple_room/sdl_media.cpp - simple_room/sdl_media.h - simple_room/sdl_media_manager.cpp - simple_room/sdl_media_manager.h - simple_room/sdl_video_renderer.cpp - simple_room/sdl_video_renderer.h - simple_room/wav_audio_source.cpp - simple_room/wav_audio_source.h + ${EXAMPLES_COMMON_DIR}/sdl_media.cpp + ${EXAMPLES_COMMON_DIR}/sdl_media.h + ${EXAMPLES_COMMON_DIR}/sdl_media_manager.cpp + ${EXAMPLES_COMMON_DIR}/sdl_media_manager.h + ${EXAMPLES_COMMON_DIR}/sdl_video_renderer.cpp + ${EXAMPLES_COMMON_DIR}/sdl_video_renderer.h + ${EXAMPLES_COMMON_DIR}/wav_audio_source.cpp + ${EXAMPLES_COMMON_DIR}/wav_audio_source.h ) -target_include_directories(SimpleRoom PRIVATE ${EXAMPLES_PRIVATE_INCLUDE_DIRS}) +target_include_directories(SimpleRoom PRIVATE + ${EXAMPLES_PRIVATE_INCLUDE_DIRS} + ${EXAMPLES_COMMON_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/simple_room +) target_link_libraries(SimpleRoom PRIVATE @@ -105,6 +112,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 ) @@ -124,6 +175,56 @@ add_custom_command( $/data ) +# --- bridge_human_robot examples (robot + human; use livekit_bridge and SDL3) --- + +add_executable(BridgeRobot + bridge_human_robot/robot.cpp + ${EXAMPLES_COMMON_DIR}/sdl_media.cpp + ${EXAMPLES_COMMON_DIR}/sdl_media.h +) +target_include_directories(BridgeRobot PRIVATE + ${EXAMPLES_PRIVATE_INCLUDE_DIRS} + ${EXAMPLES_COMMON_DIR} +) +target_link_libraries(BridgeRobot PRIVATE livekit_bridge SDL3::SDL3) + +add_executable(BridgeHuman + bridge_human_robot/human.cpp + ${EXAMPLES_COMMON_DIR}/sdl_media.cpp + ${EXAMPLES_COMMON_DIR}/sdl_media.h +) +target_include_directories(BridgeHuman PRIVATE + ${EXAMPLES_PRIVATE_INCLUDE_DIRS} + ${EXAMPLES_COMMON_DIR} +) +target_link_libraries(BridgeHuman PRIVATE livekit_bridge SDL3::SDL3) + +# Copy SDL3 shared library to bridge_human_robot example output directories +if(UNIX AND NOT APPLE) + foreach(_target BridgeRobot BridgeHuman) + 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 + ) + endforeach() +else() + foreach(_target BridgeRobot BridgeHuman) + add_custom_command(TARGET ${_target} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$" + "$" + COMMENT "Copying SDL3 shared library to ${_target} output directory" + VERBATIM + ) + endforeach() +endif() + # Windows: Copy required DLLs to examples output directory if(WIN32) # Get the livekit library output directory (where DLLs are copied during main build) @@ -135,7 +236,7 @@ if(WIN32) ) # Copy DLLs to each example's output directory - foreach(EXAMPLE SimpleRoom SimpleRpc SimpleDataStream) + foreach(EXAMPLE SimpleRoom SimpleRpc SimpleRobot SimpleHuman SimpleDataStream BridgeRobot BridgeHuman) foreach(DLL ${REQUIRED_DLLS}) add_custom_command(TARGET ${EXAMPLE} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different @@ -146,6 +247,17 @@ if(WIN32) ) endforeach() endforeach() + + # bridge_human_robot examples also need livekit_bridge.dll + foreach(EXAMPLE BridgeRobot BridgeHuman) + add_custom_command(TARGET ${EXAMPLE} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$" + "$/$" + COMMENT "Copying livekit_bridge DLL to ${EXAMPLE} output directory" + VERBATIM + ) + endforeach() endif() # Linux/macOS: Copy shared library to examples output directory @@ -159,7 +271,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 BridgeRobot BridgeHuman) add_custom_command(TARGET ${EXAMPLE} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different "${LIVEKIT_LIB_DIR}/${FFI_SHARED_LIB}" @@ -168,4 +280,15 @@ if(UNIX) VERBATIM ) endforeach() + + # bridge_human_robot examples also need livekit_bridge shared library + foreach(EXAMPLE BridgeRobot BridgeHuman) + add_custom_command(TARGET ${EXAMPLE} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$" + "$/$" + COMMENT "Copying livekit_bridge to ${EXAMPLE} output directory" + VERBATIM + ) + endforeach() endif() \ No newline at end of file diff --git a/examples/bridge_human_robot/human.cpp b/examples/bridge_human_robot/human.cpp new file mode 100644 index 00000000..3a9c8594 --- /dev/null +++ b/examples/bridge_human_robot/human.cpp @@ -0,0 +1,407 @@ +/* + * 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. + */ + +/* + * Human example -- receives audio and video frames from a robot in a + * LiveKit room and renders them using SDL3. + * + * The robot publishes two video tracks and two audio tracks: + * - "robot-cam" (SOURCE_CAMERA) -- webcam or placeholder + * - "robot-sim-frame" (SOURCE_SCREENSHARE) -- simulated diagnostic + * frame + * - "robot-mic" (SOURCE_MICROPHONE) -- real microphone or + * silence + * - "robot-sim-audio" (SOURCE_SCREENSHARE_AUDIO) -- simulated siren tone + * + * Press 'w' to play the webcam feed + real mic, or 's' for sim frame + siren. + * The selection controls both video and audio simultaneously. + * + * Usage: + * human [--no-audio] + * LIVEKIT_URL=... LIVEKIT_TOKEN=... human [--no-audio] + * + * --no-audio Subscribe to audio tracks but suppress local playback. + * + * 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 +#include +#include + +static std::atomic g_running{true}; +static void handleSignal(int) { g_running.store(false); } + +// ---- Video source selection ---- +enum class VideoSource : int { Webcam = 0, SimFrame = 1 }; +static std::atomic g_selected_source{ + static_cast(VideoSource::Webcam)}; + +// ---- Thread-safe video frame slot ---- +// renderFrame() 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; + +/// Store a video frame for the main loop to render. +/// Called from bridge callbacks when their source is the active selection. +static void renderFrame(const livekit::VideoFrame &frame) { + 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; +} + +// ---- 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 ----- + bool no_audio = false; + std::vector positional; + for (int i = 1; i < argc; ++i) { + if (std::string(argv[i]) == "--no-audio") { + no_audio = true; + } else { + positional.push_back(argv[i]); + } + } + + std::string url, token; + if (positional.size() >= 2) { + url = positional[0]; + token = positional[1]; + } 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 [--no-audio] \n" + << " or: LIVEKIT_URL=... LIVEKIT_TOKEN=... human [--no-audio]\n"; + return 1; + } + if (no_audio) { + std::cout << "[human] --no-audio: audio playback disabled.\n"; + } + + 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"; + livekit::RoomOptions options; + options.auto_subscribe = true; + if (!bridge.connect(url, token, options)) { + 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"; + + // Helper: enqueue audio to the speaker (lazily initializes on first call) + auto playAudio = [&speaker, + &speaker_mutex](const livekit::AudioFrame &frame) { + const auto &samples = frame.data(); + if (samples.empty()) + return; + + std::lock_guard lock(speaker_mutex); + + 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()); + }; + + // ----- set audio callbacks ----- + // Real mic (SOURCE_MICROPHONE) -- plays only when 'w' is selected + bridge.setOnAudioFrameCallback( + "robot", livekit::TrackSource::SOURCE_MICROPHONE, + [playAudio, no_audio](const livekit::AudioFrame &frame) { + g_audio_frames.fetch_add(1, std::memory_order_relaxed); + if (!no_audio && g_selected_source.load(std::memory_order_relaxed) == + static_cast(VideoSource::Webcam)) { + playAudio(frame); + } + }); + + // Sim audio / siren (SOURCE_SCREENSHARE_AUDIO) -- plays only when 's' is + // selected + bridge.setOnAudioFrameCallback( + "robot", livekit::TrackSource::SOURCE_SCREENSHARE_AUDIO, + [playAudio, no_audio](const livekit::AudioFrame &frame) { + g_audio_frames.fetch_add(1, std::memory_order_relaxed); + if (!no_audio && g_selected_source.load(std::memory_order_relaxed) == + static_cast(VideoSource::SimFrame)) { + playAudio(frame); + } + }); + + // ----- set video callbacks ----- + // Webcam feed (SOURCE_CAMERA) -- renders only when 'w' is selected + bridge.setOnVideoFrameCallback( + "robot", livekit::TrackSource::SOURCE_CAMERA, + [](const livekit::VideoFrame &frame, std::int64_t /*timestamp_us*/) { + g_video_frames.fetch_add(1, std::memory_order_relaxed); + if (g_selected_source.load(std::memory_order_relaxed) == + static_cast(VideoSource::Webcam)) { + renderFrame(frame); + } + }); + + // Sim frame feed (SOURCE_SCREENSHARE) -- renders only when 's' is selected + bridge.setOnVideoFrameCallback( + "robot", livekit::TrackSource::SOURCE_SCREENSHARE, + [](const livekit::VideoFrame &frame, std::int64_t /*timestamp_us*/) { + g_video_frames.fetch_add(1, std::memory_order_relaxed); + if (g_selected_source.load(std::memory_order_relaxed) == + static_cast(VideoSource::SimFrame)) { + renderFrame(frame); + } + }); + + // ----- Stdin input thread (for switching when the SDL window is not focused) + // ----- + std::thread input_thread([&]() { + std::string line; + while (g_running.load() && std::getline(std::cin, line)) { + if (line == "w" || line == "W") { + g_selected_source.store(static_cast(VideoSource::Webcam), + std::memory_order_relaxed); + std::cout << "[human] Switched to webcam + mic.\n"; + } else if (line == "s" || line == "S") { + g_selected_source.store(static_cast(VideoSource::SimFrame), + std::memory_order_relaxed); + std::cout << "[human] Switched to sim frame + siren.\n"; + } + } + }); + + // ----- Main loop ----- + std::cout + << "[human] Rendering robot feed. Press 'w' for webcam + mic, " + "'s' for sim frame + siren (in this terminal or the SDL window). " + "Ctrl-C or close window to stop.\n"; + + auto last_report = std::chrono::steady_clock::now(); + + // Reused across iterations so the swap gives the producer a pre-allocated + // buffer back, avoiding repeated allocations in steady state. + std::vector local_pixels; + + while (g_running.load()) { + // Pump SDL events (including keyboard input for source selection) + SDL_Event ev; + while (SDL_PollEvent(&ev)) { + if (ev.type == SDL_EVENT_QUIT) { + g_running.store(false); + } else if (ev.type == SDL_EVENT_KEY_DOWN) { + if (ev.key.key == SDLK_W) { + g_selected_source.store(static_cast(VideoSource::Webcam), + std::memory_order_relaxed); + std::cout << "[human] Switched to webcam + mic.\n"; + } else if (ev.key.key == SDLK_S) { + g_selected_source.store(static_cast(VideoSource::SimFrame), + std::memory_order_relaxed); + std::cout << "[human] Switched to sim frame + siren.\n"; + } + } + } + + // Grab the latest video frame under a minimal lock, then render outside it. + int fw = 0, fh = 0; + bool have_frame = false; + { + std::lock_guard lock(g_latest_video.mutex); + if (g_latest_video.dirty && g_latest_video.width > 0 && + g_latest_video.height > 0) { + fw = g_latest_video.width; + fh = g_latest_video.height; + local_pixels.swap(g_latest_video.data); + g_latest_video.dirty = false; + have_frame = true; + } + } + + if (have_frame) { + // 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, + local_pixels.data() + y * srcPitch, srcPitch); + } + SDL_UnlockTexture(texture); + } + } + } + + // 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; + const char *src_name = + g_selected_source.load(std::memory_order_relaxed) == + static_cast(VideoSource::Webcam) + ? "webcam" + : "sim_frame"; + std::cout << "[human] Status: " << g_audio_frames.load() + << " audio frames, " << g_video_frames.load() + << " video frames received (showing: " << src_name << ").\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"; + + // The input thread blocks on std::getline; detach it since there is no + // portable way to interrupt blocking stdin reads. + if (input_thread.joinable()) + input_thread.detach(); + + 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/examples/bridge_human_robot/robot.cpp b/examples/bridge_human_robot/robot.cpp new file mode 100644 index 00000000..6c467e21 --- /dev/null +++ b/examples/bridge_human_robot/robot.cpp @@ -0,0 +1,625 @@ +/* + * 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. + */ + +/* + * 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 +#include +#include +#include + +// ---- Minimal 5x7 bitmap font for rendering text into RGBA buffers ---- +// Each glyph is 5 columns wide, 7 rows tall, stored as 7 bytes (one per row, +// MSB = leftmost pixel). Only printable ASCII 0x20..0x7E are defined. +namespace bitmap_font { + +constexpr int kGlyphW = 5; +constexpr int kGlyphH = 7; + +// clang-format off +static const std::uint8_t kGlyphs[][kGlyphH] = { + // 0x20 ' ' + {0x00,0x00,0x00,0x00,0x00,0x00,0x00}, + // 0x21 '!' + {0x20,0x20,0x20,0x20,0x00,0x20,0x00}, + // 0x22 '"' + {0x50,0x50,0x00,0x00,0x00,0x00,0x00}, + // 0x23 '#' + {0x50,0xF8,0x50,0x50,0xF8,0x50,0x00}, + // 0x24 '$' + {0x20,0x78,0xA0,0x70,0x28,0xF0,0x20}, + // 0x25 '%' + {0xC8,0xC8,0x10,0x20,0x48,0x98,0x00}, + // 0x26 '&' + {0x40,0xA0,0x40,0xA8,0x90,0x68,0x00}, + // 0x27 '\'' + {0x20,0x20,0x00,0x00,0x00,0x00,0x00}, + // 0x28 '(' + {0x10,0x20,0x40,0x40,0x20,0x10,0x00}, + // 0x29 ')' + {0x40,0x20,0x10,0x10,0x20,0x40,0x00}, + // 0x2A '*' + {0x00,0x50,0x20,0xF8,0x20,0x50,0x00}, + // 0x2B '+' + {0x00,0x20,0x20,0xF8,0x20,0x20,0x00}, + // 0x2C ',' + {0x00,0x00,0x00,0x00,0x20,0x20,0x40}, + // 0x2D '-' + {0x00,0x00,0x00,0xF8,0x00,0x00,0x00}, + // 0x2E '.' + {0x00,0x00,0x00,0x00,0x00,0x20,0x00}, + // 0x2F '/' + {0x08,0x08,0x10,0x20,0x40,0x80,0x00}, + // 0x30 '0' + {0x70,0x88,0x98,0xA8,0xC8,0x70,0x00}, + // 0x31 '1' + {0x20,0x60,0x20,0x20,0x20,0x70,0x00}, + // 0x32 '2' + {0x70,0x88,0x08,0x30,0x40,0xF8,0x00}, + // 0x33 '3' + {0x70,0x88,0x30,0x08,0x88,0x70,0x00}, + // 0x34 '4' + {0x10,0x30,0x50,0x90,0xF8,0x10,0x00}, + // 0x35 '5' + {0xF8,0x80,0xF0,0x08,0x08,0xF0,0x00}, + // 0x36 '6' + {0x30,0x40,0xF0,0x88,0x88,0x70,0x00}, + // 0x37 '7' + {0xF8,0x08,0x10,0x20,0x20,0x20,0x00}, + // 0x38 '8' + {0x70,0x88,0x70,0x88,0x88,0x70,0x00}, + // 0x39 '9' + {0x70,0x88,0x88,0x78,0x10,0x60,0x00}, + // 0x3A ':' + {0x00,0x00,0x20,0x00,0x20,0x00,0x00}, + // 0x3B ';' + {0x00,0x00,0x20,0x00,0x20,0x20,0x40}, + // 0x3C '<' + {0x08,0x10,0x20,0x40,0x20,0x10,0x08}, + // 0x3D '=' + {0x00,0x00,0xF8,0x00,0xF8,0x00,0x00}, + // 0x3E '>' + {0x80,0x40,0x20,0x10,0x20,0x40,0x80}, + // 0x3F '?' + {0x70,0x88,0x10,0x20,0x00,0x20,0x00}, + // 0x40 '@' + {0x70,0x88,0xB8,0xB8,0x80,0x70,0x00}, + // 0x41 'A' + {0x70,0x88,0x88,0xF8,0x88,0x88,0x00}, + // 0x42 'B' + {0xF0,0x88,0xF0,0x88,0x88,0xF0,0x00}, + // 0x43 'C' + {0x70,0x88,0x80,0x80,0x88,0x70,0x00}, + // 0x44 'D' + {0xF0,0x88,0x88,0x88,0x88,0xF0,0x00}, + // 0x45 'E' + {0xF8,0x80,0xF0,0x80,0x80,0xF8,0x00}, + // 0x46 'F' + {0xF8,0x80,0xF0,0x80,0x80,0x80,0x00}, + // 0x47 'G' + {0x70,0x88,0x80,0xB8,0x88,0x70,0x00}, + // 0x48 'H' + {0x88,0x88,0xF8,0x88,0x88,0x88,0x00}, + // 0x49 'I' + {0x70,0x20,0x20,0x20,0x20,0x70,0x00}, + // 0x4A 'J' + {0x08,0x08,0x08,0x08,0x88,0x70,0x00}, + // 0x4B 'K' + {0x88,0x90,0xA0,0xC0,0xA0,0x90,0x88}, + // 0x4C 'L' + {0x80,0x80,0x80,0x80,0x80,0xF8,0x00}, + // 0x4D 'M' + {0x88,0xD8,0xA8,0x88,0x88,0x88,0x00}, + // 0x4E 'N' + {0x88,0xC8,0xA8,0x98,0x88,0x88,0x00}, + // 0x4F 'O' + {0x70,0x88,0x88,0x88,0x88,0x70,0x00}, + // 0x50 'P' + {0xF0,0x88,0x88,0xF0,0x80,0x80,0x00}, + // 0x51 'Q' + {0x70,0x88,0x88,0xA8,0x90,0x68,0x00}, + // 0x52 'R' + {0xF0,0x88,0x88,0xF0,0xA0,0x90,0x00}, + // 0x53 'S' + {0x70,0x80,0x70,0x08,0x88,0x70,0x00}, + // 0x54 'T' + {0xF8,0x20,0x20,0x20,0x20,0x20,0x00}, + // 0x55 'U' + {0x88,0x88,0x88,0x88,0x88,0x70,0x00}, + // 0x56 'V' + {0x88,0x88,0x88,0x50,0x50,0x20,0x00}, + // 0x57 'W' + {0x88,0x88,0x88,0xA8,0xA8,0x50,0x00}, + // 0x58 'X' + {0x88,0x50,0x20,0x20,0x50,0x88,0x00}, + // 0x59 'Y' + {0x88,0x50,0x20,0x20,0x20,0x20,0x00}, + // 0x5A 'Z' + {0xF8,0x10,0x20,0x40,0x80,0xF8,0x00}, + // 0x5B '[' + {0x70,0x40,0x40,0x40,0x40,0x70,0x00}, + // 0x5C '\\' + {0x80,0x40,0x20,0x10,0x08,0x08,0x00}, + // 0x5D ']' + {0x70,0x10,0x10,0x10,0x10,0x70,0x00}, + // 0x5E '^' + {0x20,0x50,0x88,0x00,0x00,0x00,0x00}, + // 0x5F '_' + {0x00,0x00,0x00,0x00,0x00,0xF8,0x00}, + // 0x60 '`' + {0x40,0x20,0x00,0x00,0x00,0x00,0x00}, + // 0x61 'a' + {0x00,0x70,0x08,0x78,0x88,0x78,0x00}, + // 0x62 'b' + {0x80,0x80,0xF0,0x88,0x88,0xF0,0x00}, + // 0x63 'c' + {0x00,0x70,0x80,0x80,0x80,0x70,0x00}, + // 0x64 'd' + {0x08,0x08,0x78,0x88,0x88,0x78,0x00}, + // 0x65 'e' + {0x00,0x70,0x88,0xF8,0x80,0x70,0x00}, + // 0x66 'f' + {0x30,0x40,0xF0,0x40,0x40,0x40,0x00}, + // 0x67 'g' + {0x00,0x78,0x88,0x78,0x08,0x70,0x00}, + // 0x68 'h' + {0x80,0x80,0xF0,0x88,0x88,0x88,0x00}, + // 0x69 'i' + {0x20,0x00,0x60,0x20,0x20,0x70,0x00}, + // 0x6A 'j' + {0x10,0x00,0x30,0x10,0x10,0x10,0x60}, + // 0x6B 'k' + {0x80,0x90,0xA0,0xC0,0xA0,0x90,0x00}, + // 0x6C 'l' + {0x60,0x20,0x20,0x20,0x20,0x70,0x00}, + // 0x6D 'm' + {0x00,0xD0,0xA8,0xA8,0x88,0x88,0x00}, + // 0x6E 'n' + {0x00,0xF0,0x88,0x88,0x88,0x88,0x00}, + // 0x6F 'o' + {0x00,0x70,0x88,0x88,0x88,0x70,0x00}, + // 0x70 'p' + {0x00,0xF0,0x88,0xF0,0x80,0x80,0x00}, + // 0x71 'q' + {0x00,0x78,0x88,0x78,0x08,0x08,0x00}, + // 0x72 'r' + {0x00,0xB0,0xC8,0x80,0x80,0x80,0x00}, + // 0x73 's' + {0x00,0x78,0x80,0x70,0x08,0xF0,0x00}, + // 0x74 't' + {0x40,0xF0,0x40,0x40,0x48,0x30,0x00}, + // 0x75 'u' + {0x00,0x88,0x88,0x88,0x98,0x68,0x00}, + // 0x76 'v' + {0x00,0x88,0x88,0x50,0x50,0x20,0x00}, + // 0x77 'w' + {0x00,0x88,0x88,0xA8,0xA8,0x50,0x00}, + // 0x78 'x' + {0x00,0x88,0x50,0x20,0x50,0x88,0x00}, + // 0x79 'y' + {0x00,0x88,0x88,0x78,0x08,0x70,0x00}, + // 0x7A 'z' + {0x00,0xF8,0x10,0x20,0x40,0xF8,0x00}, + // 0x7B '{' + {0x18,0x20,0x60,0x20,0x20,0x18,0x00}, + // 0x7C '|' + {0x20,0x20,0x20,0x20,0x20,0x20,0x00}, + // 0x7D '}' + {0xC0,0x20,0x30,0x20,0x20,0xC0,0x00}, + // 0x7E '~' + {0x00,0x00,0x48,0xB0,0x00,0x00,0x00}, +}; +// clang-format on + +/// Draw a string into an RGBA buffer at the given pixel coordinate. +/// Each character is drawn at `scale` times the native 5x7 size. +static void drawString(std::uint8_t *buf, int buf_w, int buf_h, int x0, int y0, + const std::string &text, int scale, std::uint8_t r, + std::uint8_t g, std::uint8_t b) { + int cx = x0; + for (char ch : text) { + int idx = static_cast(ch) - 0x20; + if (idx < 0 || + idx >= static_cast(sizeof(kGlyphs) / sizeof(kGlyphs[0]))) + idx = 0; // fallback to space + for (int row = 0; row < kGlyphH; ++row) { + std::uint8_t bits = kGlyphs[idx][row]; + for (int col = 0; col < kGlyphW; ++col) { + if (bits & (0x80 >> col)) { + for (int sy = 0; sy < scale; ++sy) { + for (int sx = 0; sx < scale; ++sx) { + int px = cx + col * scale + sx; + int py = y0 + row * scale + sy; + if (px >= 0 && px < buf_w && py >= 0 && py < buf_h) { + int off = (py * buf_w + px) * 4; + buf[off + 0] = r; + buf[off + 1] = g; + buf[off + 2] = b; + buf[off + 3] = 255; + } + } + } + } + } + } + cx += (kGlyphW + 1) * scale; // 1px spacing between characters + } +} + +} // namespace bitmap_font + +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"; + livekit::RoomOptions options; + options.auto_subscribe = true; + if (!bridge.connect(url, token, options)) { + 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; + + constexpr int kSimWidth = 480; + constexpr int kSimHeight = 320; + + auto mic = bridge.createAudioTrack("robot-mic", kSampleRate, kChannels, + livekit::TrackSource::SOURCE_MICROPHONE); + auto sim_audio = + bridge.createAudioTrack("robot-sim-audio", kSampleRate, kChannels, + livekit::TrackSource::SOURCE_SCREENSHARE_AUDIO); + auto cam = bridge.createVideoTrack("robot-cam", kWidth, kHeight, + livekit::TrackSource::SOURCE_CAMERA); + auto sim_cam = + bridge.createVideoTrack("robot-sim-frame", kSimWidth, kSimHeight, + livekit::TrackSource::SOURCE_SCREENSHARE); + std::cout << "[robot] Publishing mic + sim audio (" << kSampleRate << " Hz, " + << kChannels << " ch), cam + sim frame (" << kWidth << "x" + << kHeight << " / " << kSimWidth << "x" << kSimHeight << ").\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*/) { + if (!mic->pushFrame(samples, num_samples_per_channel)) { + std::cerr << "[robot] Mic track released.\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([&]() { + const int kSamplesPerFrame = kSampleRate / 100; + std::vector silence(kSamplesPerFrame * kChannels, 0); + auto next = std::chrono::steady_clock::now(); + while (mic_running.load()) { + if (!mic->pushFrame(silence, kSamplesPerFrame)) { + break; + } + 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) { + 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); + } + if (!cam->pushFrame( + buf.data(), buf.size(), + static_cast(timestampNS / 1000))) { + std::cerr << "[robot] Cam track released.\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()) { + if (!cam->pushFrame(green, ts)) { + break; + } + ts += 33333; + std::this_thread::sleep_for(std::chrono::milliseconds(33)); + } + }); + } + } + + // ----- Sim frame video track (red bg, white text with frame # and time) + // ----- + std::atomic sim_running{true}; + std::thread sim_thread([&]() { + const std::size_t buf_size = kSimWidth * kSimHeight * 4; + std::vector frame(buf_size); + std::uint64_t frame_num = 0; + auto start = std::chrono::steady_clock::now(); + + while (sim_running.load()) { + // Fill with red background + for (int i = 0; i < kSimWidth * kSimHeight; ++i) { + frame[i * 4 + 0] = 200; // R + frame[i * 4 + 1] = 30; // G + frame[i * 4 + 2] = 30; // B + frame[i * 4 + 3] = 255; // A + } + + // Compute elapsed time + auto now = std::chrono::steady_clock::now(); + auto elapsed_ms = + std::chrono::duration_cast(now - start) + .count(); + int secs = static_cast(elapsed_ms / 1000); + int ms = static_cast(elapsed_ms % 1000); + + // Build text lines + std::string line1 = "FRAME " + std::to_string(frame_num); + char time_buf[32]; + std::snprintf(time_buf, sizeof(time_buf), "T=%d.%03ds", secs, ms); + std::string line2(time_buf); + + // Draw white text at scale=4 (each character is 20x28 pixels) + constexpr int kScale = 4; + constexpr int kCharW = (bitmap_font::kGlyphW + 1) * kScale; + int line1_w = static_cast(line1.size()) * kCharW; + int line2_w = static_cast(line2.size()) * kCharW; + int y1 = (kSimHeight / 2) - (bitmap_font::kGlyphH * kScale) - 4; + int y2 = (kSimHeight / 2) + 4; + int x1 = (kSimWidth - line1_w) / 2; + int x2 = (kSimWidth - line2_w) / 2; + + bitmap_font::drawString(frame.data(), kSimWidth, kSimHeight, x1, y1, + line1, kScale, 255, 255, 255); + bitmap_font::drawString(frame.data(), kSimWidth, kSimHeight, x2, y2, + line2, kScale, 255, 255, 255); + + std::int64_t ts = static_cast(elapsed_ms) * 1000; + if (!sim_cam->pushFrame(frame, ts)) { + break; + } + ++frame_num; + std::this_thread::sleep_for(std::chrono::milliseconds(33)); + } + }); + std::cout << "[robot] Sim frame track started.\n"; + + // ----- Sim audio track (siren: sine sweep 600-1200 Hz, 1s period) ----- + std::atomic sim_audio_running{true}; + std::thread sim_audio_thread([&]() { + const int kFrameSamples = kSampleRate / 100; // 10ms frames + constexpr double kLoFreq = 600.0; + constexpr double kHiFreq = 1200.0; + constexpr double kSweepPeriod = 1.0; // seconds per full up-down cycle + constexpr double kAmplitude = 16000.0; + constexpr double kTwoPi = 2.0 * 3.14159265358979323846; + + std::vector buf(kFrameSamples * kChannels); + double phase = 0.0; + std::uint64_t sample_idx = 0; + auto next = std::chrono::steady_clock::now(); + + while (sim_audio_running.load()) { + for (int i = 0; i < kFrameSamples; ++i) { + double t = static_cast(sample_idx) / kSampleRate; + // Triangle sweep between kLoFreq and kHiFreq + double sweep = std::fmod(t / kSweepPeriod, 1.0); + double freq = + (sweep < 0.5) + ? kLoFreq + (kHiFreq - kLoFreq) * (sweep * 2.0) + : kHiFreq - (kHiFreq - kLoFreq) * ((sweep - 0.5) * 2.0); + phase += kTwoPi * freq / kSampleRate; + if (phase > kTwoPi) + phase -= kTwoPi; + auto sample = static_cast(kAmplitude * std::sin(phase)); + for (int ch = 0; ch < kChannels; ++ch) + buf[i * kChannels + ch] = sample; + ++sample_idx; + } + if (!sim_audio->pushFrame(buf, kFrameSamples)) { + break; + } + next += std::chrono::milliseconds(10); + std::this_thread::sleep_until(next); + } + }); + std::cout << "[robot] Sim audio (siren) track started.\n"; + + // ----- 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); + sim_running.store(false); + sim_audio_running.store(false); + if (mic_thread.joinable()) + mic_thread.join(); + if (cam_thread.joinable()) + cam_thread.join(); + if (sim_thread.joinable()) + sim_thread.join(); + if (sim_audio_thread.joinable()) + sim_audio_thread.join(); + sdl_mic.reset(); + sdl_cam.reset(); + + mic.reset(); + sim_audio.reset(); + cam.reset(); + sim_cam.reset(); + bridge.disconnect(); + + SDL_Quit(); + std::cout << "[robot] Done.\n"; + return 0; +} diff --git a/examples/simple_room/sdl_media.cpp b/examples/common/sdl_media.cpp similarity index 100% rename from examples/simple_room/sdl_media.cpp rename to examples/common/sdl_media.cpp diff --git a/examples/simple_room/sdl_media.h b/examples/common/sdl_media.h similarity index 100% rename from examples/simple_room/sdl_media.h rename to examples/common/sdl_media.h diff --git a/examples/simple_room/sdl_media_manager.cpp b/examples/common/sdl_media_manager.cpp similarity index 100% rename from examples/simple_room/sdl_media_manager.cpp rename to examples/common/sdl_media_manager.cpp diff --git a/examples/simple_room/sdl_media_manager.h b/examples/common/sdl_media_manager.h similarity index 100% rename from examples/simple_room/sdl_media_manager.h rename to examples/common/sdl_media_manager.h diff --git a/examples/simple_room/sdl_video_renderer.cpp b/examples/common/sdl_video_renderer.cpp similarity index 100% rename from examples/simple_room/sdl_video_renderer.cpp rename to examples/common/sdl_video_renderer.cpp diff --git a/examples/simple_room/sdl_video_renderer.h b/examples/common/sdl_video_renderer.h similarity index 100% rename from examples/simple_room/sdl_video_renderer.h rename to examples/common/sdl_video_renderer.h diff --git a/examples/simple_room/wav_audio_source.cpp b/examples/common/wav_audio_source.cpp similarity index 100% rename from examples/simple_room/wav_audio_source.cpp rename to examples/common/wav_audio_source.cpp diff --git a/examples/simple_room/wav_audio_source.h b/examples/common/wav_audio_source.h similarity index 100% rename from examples/simple_room/wav_audio_source.h rename to examples/common/wav_audio_source.h diff --git a/examples/simple_robot/human.cpp b/examples/simple_robot/human.cpp new file mode 100644 index 00000000..f55998d7 --- /dev/null +++ b/examples/simple_robot/human.cpp @@ -0,0 +1,264 @@ +/* + * Copyright 2025 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#else +#include +#include +#include +#endif + +#include "json_utils.h" +#include "livekit/livekit.h" +#include "utils.h" + +using namespace livekit; +using namespace std::chrono_literals; + +namespace { + +std::atomic g_running{true}; + +void handleSignal(int) { g_running.store(false); } + +// --- Raw terminal input helpers --- + +#ifndef _WIN32 +struct termios g_orig_termios; +bool g_raw_mode_enabled = false; + +void disableRawMode() { + if (g_raw_mode_enabled) { + tcsetattr(STDIN_FILENO, TCSAFLUSH, &g_orig_termios); + g_raw_mode_enabled = false; + } +} + +void enableRawMode() { + tcgetattr(STDIN_FILENO, &g_orig_termios); + g_raw_mode_enabled = true; + std::atexit(disableRawMode); + + struct termios raw = g_orig_termios; + raw.c_lflag &= ~(ECHO | ICANON); // disable echo and canonical mode + raw.c_cc[VMIN] = 0; // non-blocking read + raw.c_cc[VTIME] = 0; + tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw); +} + +// Returns -1 if no key is available, otherwise the character code. +int readKeyNonBlocking() { + fd_set fds; + FD_ZERO(&fds); + FD_SET(STDIN_FILENO, &fds); + struct timeval tv = {0, 0}; // immediate return + if (select(STDIN_FILENO + 1, &fds, nullptr, nullptr, &tv) > 0) { + unsigned char ch; + if (read(STDIN_FILENO, &ch, 1) == 1) + return ch; + } + return -1; +} +#else +void enableRawMode() { /* Windows _getch() is already unbuffered */ } +void disableRawMode() {} + +int readKeyNonBlocking() { + if (_kbhit()) + return _getch(); + return -1; +} +#endif + +void printUsage(const char *prog) { + std::cerr << "Usage:\n" + << " " << prog << " \n" + << "or:\n" + << " " << prog << " --url= --token=\n\n" + << "Env fallbacks:\n" + << " LIVEKIT_URL, LIVEKIT_TOKEN\n\n" + << "This is the '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 00000000..78a00200 --- /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 00000000..3e8a859e --- /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 00000000..370de15e --- /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 "livekit/livekit.h" +#include "utils.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 00000000..40d02c25 --- /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 00000000..1d1067f9 --- /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 diff --git a/examples/simple_room/fallback_capture.cpp b/examples/simple_room/fallback_capture.cpp index 5d28195f..9d1ea98f 100644 --- a/examples/simple_room/fallback_capture.cpp +++ b/examples/simple_room/fallback_capture.cpp @@ -18,7 +18,6 @@ #include #include -#include #include #include #include diff --git a/include/livekit/room.h b/include/livekit/room.h index 04de1004..b0375840 100644 --- a/include/livekit/room.h +++ b/include/livekit/room.h @@ -76,7 +76,7 @@ struct RoomOptions { // Enable single peer connection mode. When true, uses one RTCPeerConnection // for both publishing and subscribing instead of two separate connections. // Falls back to dual peer connection if the server doesn't support single PC. - bool single_peer_connection = true; + bool single_peer_connection = false; // Optional WebRTC configuration (ICE policy, servers, etc.) std::optional rtc_config;